From e0f6b5e6d4ddea67814ae85625277c894f4f954b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 8 Sep 2026 23:27:06 +0200 Subject: [PATCH] Let the newer channel notification toggle win its conflict --- .../CloudKit/CloudKitConflictResolver.swift | 31 ++++++++ .../CloudKit/CloudKitSyncEngine.swift | 2 + YatteeTests/CloudKitSyncTests.swift | 79 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/Yattee/Services/CloudKit/CloudKitConflictResolver.swift b/Yattee/Services/CloudKit/CloudKitConflictResolver.swift index d0a5862e..c62ffe1f 100644 --- a/Yattee/Services/CloudKit/CloudKitConflictResolver.swift +++ b/Yattee/Services/CloudKit/CloudKitConflictResolver.swift @@ -333,6 +333,37 @@ actor CloudKitConflictResolver { return resolved } + // MARK: - ChannelNotificationSettings Conflict Resolution + + /// Resolves a conflict between local and server channel notification settings. + /// + /// Strategy: + /// - Last write wins based on updatedAt timestamp — the same rule + /// `DataManager.upsertChannelNotificationSettings` applies on arrival, so a + /// record that wins the upload is never refused by the other device. + /// - Preserve server recordChangeTag + func resolveChannelNotificationSettingsConflict( + local: CKRecord, + server: CKRecord + ) -> CKRecord { + let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast + let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast + + // IMPORTANT: Always start with server record to preserve recordChangeTag + let resolved = server + + // If local is newer, copy its fields to server record + if localUpdated > serverUpdated { + resolved["notificationsEnabled"] = local["notificationsEnabled"] + resolved["updatedAt"] = local["updatedAt"] + resolved["sourceRawValue"] = local["sourceRawValue"] + resolved["instanceURLString"] = local["instanceURLString"] + resolved["globalProvider"] = local["globalProvider"] + } + + return resolved + } + // MARK: - Account Conflict Resolution /// Resolves a conflict between local and server account records. diff --git a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift index 75a390ad..ed5551be 100644 --- a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift +++ b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift @@ -2112,6 +2112,8 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate { return await conflictResolver.resolveRecentPlaylistConflict(local: local, server: server) case RecordType.controlsPreset: return await conflictResolver.resolveLayoutPresetConflict(local: local, server: server) + case RecordType.channelNotificationSettings: + return await conflictResolver.resolveChannelNotificationSettingsConflict(local: local, server: server) case RecordType.account, RecordType.dataContext, RecordType.legacyAccountMigration, RecordType.accountDeletion: guard let accountSync else { return server } diff --git a/YatteeTests/CloudKitSyncTests.swift b/YatteeTests/CloudKitSyncTests.swift index cd32ca72..a1917a14 100644 --- a/YatteeTests/CloudKitSyncTests.swift +++ b/YatteeTests/CloudKitSyncTests.swift @@ -40,6 +40,22 @@ struct CloudKitSyncTests { return entry } + @MainActor + private static func makeNotificationSettings( + channelID: String = "channel1", + notificationsEnabled: Bool = false, + updatedAt: Date = Date(timeIntervalSince1970: 1_000) + ) -> ChannelNotificationSettings { + let settings = ChannelNotificationSettings( + channelID: channelID, + notificationsEnabled: notificationsEnabled, + sourceRawValue: "global", + globalProvider: "youtube" + ) + settings.updatedAt = updatedAt + return settings + } + // MARK: - Record Mapper @Suite("Record Mapper") @@ -138,4 +154,67 @@ struct CloudKitSyncTests { #expect(resolved["watchedSeconds"] as? Double == 0) } } + + @Suite("Channel Notification Settings Conflicts") + struct ChannelNotificationSettingsConflictTests { + @Test("Newer local toggle wins over the older server value") + @MainActor + func newerLocalToggleWins() async throws { + let mapper = CloudKitSyncTests.makeMapper() + let local = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings( + notificationsEnabled: false, + updatedAt: Date(timeIntervalSince1970: 2_000) + )) + let server = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings( + notificationsEnabled: true, + updatedAt: Date(timeIntervalSince1970: 1_000) + )) + + let resolver = CloudKitConflictResolver() + let resolved = await resolver.resolveChannelNotificationSettingsConflict(local: local, server: server) + + #expect(resolved["notificationsEnabled"] as? Int64 == 0) + #expect(resolved["updatedAt"] as? Date == Date(timeIntervalSince1970: 2_000)) + } + + @Test("Newer server toggle keeps its value") + @MainActor + func newerServerToggleWins() async throws { + let mapper = CloudKitSyncTests.makeMapper() + let local = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings( + notificationsEnabled: false, + updatedAt: Date(timeIntervalSince1970: 1_000) + )) + let server = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings( + notificationsEnabled: true, + updatedAt: Date(timeIntervalSince1970: 2_000) + )) + + let resolver = CloudKitConflictResolver() + let resolved = await resolver.resolveChannelNotificationSettingsConflict(local: local, server: server) + + #expect(resolved["notificationsEnabled"] as? Int64 == 1) + #expect(resolved["updatedAt"] as? Date == Date(timeIntervalSince1970: 2_000)) + } + + @Test("A local record that only repeats the server value leaves it untouched") + @MainActor + func equalTimestampsKeepServerValue() async throws { + let mapper = CloudKitSyncTests.makeMapper() + let stamp = Date(timeIntervalSince1970: 1_000) + let local = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings( + notificationsEnabled: false, + updatedAt: stamp + )) + let server = mapper.toCKRecord(channelNotificationSettings: CloudKitSyncTests.makeNotificationSettings( + notificationsEnabled: true, + updatedAt: stamp + )) + + let resolver = CloudKitConflictResolver() + let resolved = await resolver.resolveChannelNotificationSettingsConflict(local: local, server: server) + + #expect(resolved["notificationsEnabled"] as? Int64 == 1) + } + } }