Files
yattee/YatteeTests/CloudKitSyncTests.swift
Arkadiusz Fal bb6e98f9d2 Say what a timestamp tie actually does to the toggle
The tie test claimed the local record only repeated the server value, but
it fixtures opposing toggles stamped at the same instant, so name it for
the rule it proves. Note beside the strategy why that tie needs no
deterministic tiebreaker: no writer stamps these settings with a fixed
epoch date, unlike accounts and data contexts.
2026-09-08 23:49:43 +02:00

221 lines
8.4 KiB
Swift

//
// CloudKitSyncTests.swift
// YatteeTests
//
// Tests for CloudKit record mapping and conflict resolution.
//
import CloudKit
import Foundation
import Testing
@testable import Yattee
@Suite("CloudKit Sync")
struct CloudKitSyncTests {
@MainActor
private static func makeMapper() -> CloudKitRecordMapper {
CloudKitRecordMapper(zone: CKRecordZone(zoneName: RecordType.zoneName))
}
@MainActor
private static func makeEntry(
videoID: String = "video1",
watchedSeconds: TimeInterval = 0,
duration: TimeInterval = 0,
isLive: Bool = false,
updatedAt: Date = Date(timeIntervalSince1970: 1_000)
) -> WatchEntry {
let entry = WatchEntry(
videoID: videoID,
sourceRawValue: "global",
title: "Video",
authorName: "Channel",
authorID: "ch1",
duration: duration,
watchedSeconds: watchedSeconds,
isLive: isLive
)
entry.updatedAt = updatedAt
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")
struct RecordMapperTests {
@Test("isLive survives the record round-trip")
@MainActor
func liveFlagRoundTrip() throws {
let mapper = CloudKitSyncTests.makeMapper()
let record = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(isLive: true))
#expect(record["isLive"] as? Int64 == 1)
let decoded = try mapper.toWatchEntry(from: record)
#expect(decoded.isLive)
}
@Test("Records from older clients without isLive read as not live")
@MainActor
func missingLiveFlagReadsFalse() throws {
let mapper = CloudKitSyncTests.makeMapper()
let record = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(isLive: true))
// Simulate a record written before the field existed
record["isLive"] = nil
let decoded = try mapper.toWatchEntry(from: record)
#expect(!decoded.isLive)
}
}
// MARK: - Conflict Resolution
@Suite("Watch Entry Conflicts")
struct WatchEntryConflictTests {
@Test("Newer local live watch keeps its isLive flag over an old server record")
@MainActor
func localLiveWinsOverStaleServer() async throws {
let mapper = CloudKitSyncTests.makeMapper()
let local = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(
isLive: true,
updatedAt: Date(timeIntervalSince1970: 2_000)
))
let server = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(
watchedSeconds: 1_234,
duration: 3_600,
updatedAt: Date(timeIntervalSince1970: 1_000)
))
// Old clients never wrote the field at all
server["isLive"] = nil
let resolver = CloudKitConflictResolver()
let resolved = await resolver.resolveWatchEntryConflict(local: local, server: server)
#expect(resolved["isLive"] as? Int64 == 1)
#expect(resolved["watchedSeconds"] as? Double == 0)
}
@Test("Newer local VOD heal clears a stale live flag on the server")
@MainActor
func localVODHealClearsServerLiveFlag() async throws {
let mapper = CloudKitSyncTests.makeMapper()
let local = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(
watchedSeconds: 300,
duration: 1_200,
updatedAt: Date(timeIntervalSince1970: 2_000)
))
let server = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(
isLive: true,
updatedAt: Date(timeIntervalSince1970: 1_000)
))
let resolver = CloudKitConflictResolver()
let resolved = await resolver.resolveWatchEntryConflict(local: local, server: server)
#expect(resolved["isLive"] as? Int64 == 0)
#expect(resolved["watchedSeconds"] as? Double == 300)
}
@Test("Newer server record keeps its isLive flag")
@MainActor
func newerServerLiveFlagWins() async throws {
let mapper = CloudKitSyncTests.makeMapper()
let local = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(
watchedSeconds: 500,
duration: 3_600,
updatedAt: Date(timeIntervalSince1970: 1_000)
))
let server = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(
isLive: true,
updatedAt: Date(timeIntervalSince1970: 2_000)
))
let resolver = CloudKitConflictResolver()
let resolved = await resolver.resolveWatchEntryConflict(local: local, server: server)
#expect(resolved["isLive"] as? Int64 == 1)
#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("Equal timestamps keep the server value")
@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)
}
}
}