mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Extracted media-source video IDs are full file paths, so closing a long-named WebDAV video built a watch record name longer than 255 characters and CKRecord.ID threw a fatal CKException. Scoped record names now fall back to a SHA-256 digest of the ID when the verbatim form would exceed the limit. The sync engine's local lookups for materializing pending saves and applying remote deletions recognise the digest form and match it against all entities of that type instead of the indexed ID lookup. Short IDs keep their existing names, so records already in iCloud are unaffected.
287 lines
12 KiB
Swift
287 lines
12 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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Record Names
|
|
|
|
@Suite("Record Names")
|
|
struct RecordNameTests {
|
|
|
|
private static let zone = CKRecordZone(zoneName: RecordType.zoneName)
|
|
private static let extracted = SourceScope.from(
|
|
sourceRawValue: "extracted", globalProvider: nil, instanceURLString: nil, externalExtractor: "media_source_webdav"
|
|
)
|
|
|
|
/// A WebDAV media-source ID: the full path, far past CloudKit's limit.
|
|
private static let longVideoID = String(repeating: "Ted Lasso S04E05 Riches of Embarrassment 1080p ATVP WEB-DL/", count: 6) + "episode.mkv"
|
|
|
|
@Test("Short IDs keep their verbatim record name")
|
|
func shortIDVerbatim() {
|
|
let name = SyncableRecordType.watchEntry(videoID: "abc123", scope: Self.extracted).recordID(in: Self.zone).recordName
|
|
#expect(name == "watch-abc123@extracted:media_source_webdav")
|
|
#expect(SyncableRecordType.isHashedBareID(SyncableRecordType.extractBareID(from: String(name.dropFirst(6)))) == false)
|
|
}
|
|
|
|
@Test("Over-long IDs fall back to a digest that fits CloudKit's limit")
|
|
func longIDHashed() {
|
|
let verbatim = "watch-\(Self.longVideoID)\(Self.extracted.recordNameSuffix)"
|
|
#expect(verbatim.utf8.count > SyncableRecordType.maxRecordNameLength)
|
|
|
|
let recordID = SyncableRecordType.watchEntry(videoID: Self.longVideoID, scope: Self.extracted).recordID(in: Self.zone)
|
|
let name = recordID.recordName
|
|
#expect(name.utf8.count <= SyncableRecordType.maxRecordNameLength)
|
|
#expect(name.hasPrefix("watch-sha256-"))
|
|
#expect(name.hasSuffix("@extracted:media_source_webdav"))
|
|
|
|
let bareID = SyncableRecordType.extractBareID(from: String(name.dropFirst(6)))
|
|
#expect(SyncableRecordType.isHashedBareID(bareID))
|
|
#expect(SyncableRecordType.entityID(Self.longVideoID, matchesBareID: bareID))
|
|
#expect(SyncableRecordType.entityID(Self.longVideoID + "x", matchesBareID: bareID) == false)
|
|
}
|
|
|
|
@Test("Digest names are stable and distinct per ID and scope")
|
|
func digestStable() {
|
|
let a = SyncableRecordType.bookmark(videoID: Self.longVideoID, scope: Self.extracted).recordID(in: Self.zone).recordName
|
|
let b = SyncableRecordType.bookmark(videoID: Self.longVideoID, scope: Self.extracted).recordID(in: Self.zone).recordName
|
|
let other = SyncableRecordType.bookmark(videoID: Self.longVideoID + "2", scope: Self.extracted).recordID(in: Self.zone).recordName
|
|
#expect(a == b)
|
|
#expect(a != other)
|
|
}
|
|
|
|
@Test("The mapper round-trips a long-ID watch entry without throwing")
|
|
@MainActor
|
|
func mapperLongID() {
|
|
let entry = WatchEntry(
|
|
videoID: Self.longVideoID,
|
|
sourceRawValue: "extracted",
|
|
title: "Video",
|
|
authorName: "Channel",
|
|
authorID: "ch1",
|
|
duration: 10,
|
|
watchedSeconds: 5,
|
|
isLive: false
|
|
)
|
|
entry.externalExtractor = "media_source_webdav"
|
|
let record = CloudKitSyncTests.makeMapper().toCKRecord(watchEntry: entry)
|
|
#expect(record.recordID.recordName.hasPrefix("watch-sha256-"))
|
|
#expect(record["videoID"] as? String == Self.longVideoID)
|
|
}
|
|
}
|
|
}
|