// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import CloudKit import Foundation import SwiftData import TermindApplication import TermindDomain import TermindInfrastructure import TermindPersistence import Testing @Suite @MainActor struct NotificationPreferencesTests { @Test func initialAuthorizationIsRequestedOnceAcrossConcurrentScenesAndReactivation() async { let model = NotificationSettingsModel() let service = InitialAuthorizationService() service.holdAuthorization = true let firstScene = Task { await model.requestInitialAuthorization(using: service) } await service.nextRequest() await model.requestInitialAuthorization(using: service) #expect(service.requests == [true]) service.finishRequest() await firstScene.value await model.requestInitialAuthorization(using: service) #expect(service.requests == [true]) } @Test(arguments: [false, true]) func initialAuthorizationRespectsDisabledNotificationsAndBellOnlyDelivery(bellOnly: Bool) async { let store = NotificationStore() if bellOnly { store.value.assistantMode = .never store.value.sftpMode = .never store.value.terminalDelivery = .bell } else { store.value.isEnabled = false } let model = NotificationSettingsModel(persistence: store) let service = InitialAuthorizationService() await model.requestInitialAuthorization(using: service) #expect(service.requests.isEmpty) model.applyRemoteSnapshot(.init()) await model.requestInitialAuthorization(using: service) #expect(service.requests == [true]) } @Test func failedPreferenceLoadDoesNotRequestAuthorizationUsingDefaults() async { let store = NotificationStore() store.fails = true let model = NotificationSettingsModel(persistence: store) let service = InitialAuthorizationService() await model.requestInitialAuthorization(using: service) #expect(model.storageError != nil) #expect(service.requests.isEmpty) } @Test func defaultsAndIndependentModesAndPermissionRequirements() { var value = NotificationPreferences() #expect(value.isEnabled) #expect(value.assistantMode == .unfocused && value.terminalMode == .unfocused) #expect(value.sftpMode == .unfocused) #expect(value.terminalDelivery == .systemNotifications && value.terminalMinimumSeconds == 5) #expect(value.usesSystemNotifications) value.isEnabled = true value.assistantMode = .never value.sftpMode = .never value.terminalDelivery = .bell #expect(!value.usesSystemNotifications) #expect(value.allowsTerminal(duration: 5, isFocused: false)) #expect(!value.allowsAssistant(isFocused: false)) value.assistantMode = .always value.sftpMode = .always value.terminalMode = .never #expect(value.usesSystemNotifications) #expect(value.allowsAssistant(isFocused: true)) #expect(!value.allowsTerminal(duration: 100, isFocused: false)) } @Test func invalidOrFailedEditsLeaveTheSavedAndLiveValuesUntouched() { let store = NotificationStore() let model = NotificationSettingsModel(persistence: store) #expect(!model.set(-1, for: \.terminalMinimumSeconds)) #expect(model.preferences.terminalMinimumSeconds == 5) #expect(model.set(0, for: \.terminalMinimumSeconds)) #expect(model.set(1, for: \.terminalMinimumSeconds)) store.fails = true #expect(!model.set(false, for: \.isEnabled)) #expect(model.preferences.isEnabled && store.value.isEnabled) #expect(model.storageError != nil) var remote = NotificationPreferences() remote.isEnabled = false model.applyRemoteSnapshot(remote) #expect(model.preferences == remote && model.storageError == nil) #expect(store.value.isEnabled) } @Test func notificationRecordRoundTripsAndRemoteUpdatesNeverEcho() throws { let source = try NotificationFixture() let destination = try NotificationFixture() defer { source.cleanUp() destination.cleanUp() } var value = NotificationPreferences() value.isEnabled = true value.assistantMode = .always value.terminalMode = .unfocused value.terminalDelivery = .bell value.sftpMode = .always value.terminalMinimumSeconds = 7 try source.store.saveNotificationPreferences(value) let key = SettingsRecordKey(kind: .notifications, id: "default") let envelope = try #require(try source.store.journal.envelope(for: key)) let record = try SettingsSyncCoordinator.makeRecord( .init(envelope: envelope), recordID: .init( recordName: key.recordName, zoneID: .init(zoneName: SettingsSyncCoordinator.zoneName))) #expect(record.recordType == "NotificationPreferencesItem") #expect(record.recordType != SettingsSyncCoordinator.recordType) var callbacks = 0 destination.store.didRecordChanges = { _ in callbacks += 1 } let result = try destination.store.applyRemote([SettingsSyncCoordinator.envelope(from: record)]) #expect(result.snapshot.notifications == value) #expect(try destination.store.loadNotificationPreferences() == value) #expect(callbacks == 0) #expect(!result.needsUpload.contains(key)) let older = SettingsEnvelope(key: key, version: .baseline(writerID: UUID()), payload: .notifications(.init())) _ = try destination.store.applyRemote([older]) #expect(try destination.store.loadNotificationPreferences() == value) let deletion = SettingsEnvelope( key: key, version: .next(after: envelope.version, writerID: UUID()), isDeleted: true) _ = try destination.store.applyRemote([deletion]) #expect(try destination.store.loadNotificationPreferences() == NotificationPreferences()) let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any] #expect( Set(encoded?.keys.map { $0 } ?? []) == [ "isEnabled", "assistantMode", "terminalMode", "sftpMode", "terminalDelivery", "terminalMinimumSeconds", ]) } @Test func invalidRemoteDurationIsRejectedAndV41ContainsTheNewEntity() throws { var invalid = NotificationPreferences() invalid.terminalMinimumSeconds = -1 let payload = SettingsRecordPayload.notifications(invalid) let envelope = SettingsEnvelope( key: payload.key, version: .next(after: nil, writerID: UUID()), payload: payload) #expect(!envelope.isValid) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV41.versionIdentifier }) #expect(TermindSchemaV41.models.contains { $0 == PersistentNotificationPreferences.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentNotificationPreferences.self }) } @Test func version40StoreUpgradesAndNotificationPreferencesSurviveReopening() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("Termind.store") try writeLegacyStore(url) var saved = NotificationPreferences() saved.isEnabled = true saved.terminalMinimumSeconds = 12 try saveToUpgradedStore(url, value: saved) let reopened = try TermindPersistence.makeContainer(url: url) let rows = try reopened.mainContext.fetch(FetchDescriptor()) #expect(try rows.first?.load() == saved) let old = try reopened.mainContext.fetch(FetchDescriptor()) #expect(try old.first?.load().automaticallyRenamesWorkspaces == false) } private func writeLegacyStore(_ url: URL) throws { let schema = Schema(versionedSchema: TermindSchemaV40.self) let container = try ModelContainer( for: schema, configurations: [ ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none) ]) container.mainContext.insert(try PersistentWorkspacePreferences(.init(automaticallyRenamesWorkspaces: false))) try container.mainContext.save() } private func saveToUpgradedStore(_ url: URL, value: NotificationPreferences) throws { let container = try TermindPersistence.makeContainer(url: url) container.mainContext.insert(try PersistentNotificationPreferences(value)) try container.mainContext.save() } } @MainActor private final class NotificationStore: NotificationPreferencesPersisting { var value = NotificationPreferences() var fails = false func loadNotificationPreferences() throws -> NotificationPreferences { if fails { throw CocoaError(.fileReadUnknown) } return value } func saveNotificationPreferences(_ value: NotificationPreferences) throws { if fails { throw CocoaError(.fileWriteUnknown) } self.value = value } } @MainActor private final class InitialAuthorizationService: NotificationSending { var requests: [Bool] = [] var holdAuthorization = false private var pending: CheckedContinuation? private let arrivals = AsyncStream.makeStream() func authorization(request: Bool) async -> NotificationAuthorization { requests.append(request) if holdAuthorization { await withCheckedContinuation { pending = $0 arrivals.continuation.yield(()) } } return .notDetermined } func nextRequest() async { var iterator = arrivals.stream.makeAsyncIterator() _ = await iterator.next() } func finishRequest() { pending?.resume() pending = nil } func send(_ notification: AppNotification, ifCurrent: @escaping @MainActor () -> Bool) async {} func remove(_ identifiers: [String]) {} } @MainActor private struct NotificationFixture { let domain = "NotificationPreferencesTests.\(UUID())" let defaults: UserDefaults let container: ModelContainer let store: SettingsSyncPersistence init() throws { defaults = try #require(UserDefaults(suiteName: domain)) container = try TermindPersistence.makeContainer(inMemory: true) store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults) try store.prepare() } func cleanUp() { defaults.removePersistentDomain(forName: domain) } }