Files
yattee/YatteeTests/CloudKitSyncTests.swift

221 lines
8.5 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("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)
}
}
}