import Foundation import SwiftData import Testing @testable import Workouts /// Pins the pure decision table of `WriteBacklog` — the depth-1 per-document write /// queue `SyncEngine` drains to disk. Every operation here is a `struct`-level /// mutation with no I/O and no clock of its own, so replacement, retry bookkeeping, /// backoff, and TTL pruning are all deterministic and directly testable. /// `SyncEngineWriteQueueTests` below covers how the engine wires this into the cache /// and the `save`/`delete`/`ingestFromWatch` call sites. struct WriteBacklogTests { private static let t0 = Date(timeIntervalSince1970: 1_700_000_000) /// Mid-month, noon dates in the current time zone — matches /// `WorkoutPathBucketingTests`'s pattern so a derived `relativePath` month bucket /// can never be nudged across a boundary by a real-world UTC offset. private func date(year: Int, month: Int, day: Int = 15, hour: Int = 12) -> Date { var comps = DateComponents() comps.year = year; comps.month = month; comps.day = day; comps.hour = hour return Calendar(identifier: .gregorian).date(from: comps)! } private func workoutDoc(id: String, start: Date, updatedAt: Date, routineName: String? = nil) -> WorkoutDocument { WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: nil, routineName: routineName, start: start, end: nil, status: WorkoutStatus.notStarted.rawValue, createdAt: updatedAt, updatedAt: updatedAt, logs: [], metrics: nil ) } /// Builds a `PendingWrite` with a workout payload and full control over every /// bookkeeping field, so replacement/retry/backoff tests can set up exact /// preconditions directly instead of driving them indirectly through `SyncEngine`. private func pendingWorkout( id: String, start: Date, timestamp: Date, enqueuedAt: Date, routineName: String? = nil, attempts: Int = 0, lastAttemptAt: Date? = nil, faultMessage: String? = nil, stalePaths: Set = [] ) -> PendingWrite { PendingWrite( payload: .workout(workoutDoc(id: id, start: start, updatedAt: timestamp, routineName: routineName)), timestamp: timestamp, stalePaths: stalePaths, enqueuedAt: enqueuedAt, attempts: attempts, lastAttemptAt: lastAttemptAt, faultMessage: faultMessage ) } // MARK: - enqueue /// Enqueuing into an empty slot stores the write verbatim, and `count`/`isEmpty` /// reflect the newly-occupied slot — the base case every replacement test below /// builds on. @Test func enqueueIntoEmptySlotStoresWrite() { var backlog = WriteBacklog() #expect(backlog.isEmpty) #expect(backlog.count == 0) let write = pendingWorkout(id: "01ENQUEUEEMPTY0000000001", start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0) backlog.enqueue(write) #expect(!backlog.isEmpty) #expect(backlog.count == 1) #expect(backlog.pendingWrite(for: write.documentID)?.timestamp == Self.t0) } /// Newer-wins replacement: the incoming document's content takes the slot, but it /// inherits the FIRST entry's retry bookkeeping (`enqueuedAt`, `attempts`, /// `lastAttemptAt`, `faultMessage`) — resetting those on every edit would flap the /// retry banner even though the backlog is failing for environmental reasons, not /// because of what's in the slot. Stale paths union both sides' preexisting sets, /// plus the occupant's now-abandoned target path (a month-rebucketing edit moves a /// workout's file; an earlier attempt may already have landed at the old path). @Test func newerWinsReplacementInheritsBookkeepingAndUnionsStalePaths() throws { var backlog = WriteBacklog() let id = "01NEWERWINS0000000000001" let marchStart = date(year: 2024, month: 3) let aprilStart = date(year: 2024, month: 4) let oldTargetPath = "Workouts/2024/03/\(id).json" let existing = pendingWorkout( id: id, start: marchStart, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(-100), attempts: 2, lastAttemptAt: Self.t0.addingTimeInterval(-10), faultMessage: "disk full", stalePaths: ["Workouts/2024/02/\(id).json"] ) #expect(existing.targetPath == oldTargetPath) backlog.enqueue(existing) let incoming = pendingWorkout( id: id, start: aprilStart, timestamp: Self.t0.addingTimeInterval(10), enqueuedAt: Self.t0, // would be its own enqueuedAt if it were the first — must be overridden stalePaths: ["Workouts/2024/05/\(id).json"] ) backlog.enqueue(incoming) let slot = try #require(backlog.pendingWrite(for: id)) #expect(slot.timestamp == incoming.timestamp) guard case .workout(let doc) = slot.payload else { Issue.record("expected a workout payload"); return } #expect(doc.start == aprilStart) // the newer document content wins the slot #expect(slot.enqueuedAt == existing.enqueuedAt) #expect(slot.attempts == existing.attempts) #expect(slot.lastAttemptAt == existing.lastAttemptAt) #expect(slot.faultMessage == existing.faultMessage) #expect(slot.stalePaths.isSuperset(of: existing.stalePaths)) #expect(slot.stalePaths.isSuperset(of: incoming.stalePaths)) #expect(slot.stalePaths.contains(oldTargetPath)) } /// An incoming write at exactly the occupant's timestamp still wins the slot — /// equal timestamps are treated as the latest local re-save of the same logical /// state, so last-in is correct. @Test func equalTimestampIncomingWinsSlot() throws { var backlog = WriteBacklog() let id = "01EQUALTIMESTAMP000000001" backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Original")) backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Incoming")) let slot = try #require(backlog.pendingWrite(for: id)) guard case .workout(let doc) = slot.payload else { Issue.record("expected a workout payload"); return } #expect(doc.routineName == "Incoming") } /// An incoming write strictly older than the occupant is dropped outright — the /// slot (and its bookkeeping) is left completely unchanged. @Test func strictlyOlderIncomingIsDropped() throws { var backlog = WriteBacklog() let id = "01OLDERDROPPED0000000001" backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, routineName: "Kept")) backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0.addingTimeInterval(-5), enqueuedAt: Self.t0, routineName: "Ignored")) let slot = try #require(backlog.pendingWrite(for: id)) guard case .workout(let doc) = slot.payload else { Issue.record("expected a workout payload"); return } #expect(doc.routineName == "Kept") #expect(slot.timestamp == Self.t0) } // MARK: - resolve /// `resolve(id:ifTimestampAtMost:)` is the replacement-safety property the drain /// relies on: it removes the slot when the just-written version is still current /// (`entry.timestamp <= given`), but a write that slotted in WHILE the attempt was /// in flight (`entry.timestamp > given`) survives — the drain only just wrote the /// OLD content, so the newer queued edit must not be discarded as if it had landed. @Test func resolveRemovesOnlyWhenNoNewerWriteSlottedIn() throws { var backlog = WriteBacklog() let id = "01RESOLVESAFETY0000000001" let firstAttemptTimestamp = Self.t0 backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: firstAttemptTimestamp, enqueuedAt: Self.t0)) let newerTimestamp = Self.t0.addingTimeInterval(30) backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: newerTimestamp, enqueuedAt: Self.t0)) backlog.resolve(id: id, ifTimestampAtMost: firstAttemptTimestamp) let survivor = try #require(backlog.pendingWrite(for: id)) #expect(survivor.timestamp == newerTimestamp) backlog.resolve(id: id, ifTimestampAtMost: newerTimestamp) #expect(backlog.pendingWrite(for: id) == nil) } // MARK: - markFailed /// `markFailed` increments attempts and stamps `lastAttemptAt` on every call, only /// overwrites `faultMessage` when a new one is supplied (an unrecoverable error /// shouldn't be forgotten by a subsequent plain retry failure), `maxAttempts` is /// the max across every slot, and `firstFaultMessage` walks slots oldest-`enqueuedAt` /// first — so once the OLDEST slot also faults, its message takes priority even /// though a newer slot faulted first in wall-clock time. @Test func markFailedTracksAttemptsFaultPriorityAndMax() throws { var backlog = WriteBacklog() let older = "01MARKFAILEDOLDER00000001" // enqueued first let newer = "01MARKFAILEDNEWER00000001" // enqueued second backlog.enqueue(pendingWorkout(id: older, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0)) backlog.enqueue(pendingWorkout(id: newer, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(5))) #expect(backlog.maxAttempts == 0) #expect(backlog.firstFaultMessage == nil) let failAt1 = Self.t0.addingTimeInterval(1) backlog.markFailed(id: older, at: failAt1) var entry = try #require(backlog.pendingWrite(for: older)) #expect(entry.attempts == 1) #expect(entry.lastAttemptAt == failAt1) #expect(entry.faultMessage == nil) // `newer` is the only faulting slot so far — it surfaces even though `older` // was enqueued earlier and hasn't faulted. backlog.markFailed(id: newer, at: Self.t0.addingTimeInterval(2), fault: "disk full") #expect(backlog.firstFaultMessage == "disk full") // Once `older` (the earlier-enqueued slot) also faults, its message takes // priority — `firstFaultMessage` walks entries oldest-first. backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(3), fault: "older fault") #expect(backlog.firstFaultMessage == "older fault") // A later failure with no new fault keeps the existing message rather than // clearing it. backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(4)) entry = try #require(backlog.pendingWrite(for: older)) #expect(entry.attempts == 3) #expect(entry.faultMessage == "older fault") #expect(backlog.maxAttempts == 3) // older: 3 attempts, newer: 1 } // MARK: - Backoff scheduling /// `nextDue`/`earliestNextAttempt` are the drain loop's scheduling surface: a /// fresh entry (never attempted) is due immediately at its `enqueuedAt`, and each /// failure re-anchors the backoff at `retryDelay(afterAttempts:)` from the FAILURE /// time — not cumulative, not from `enqueuedAt`. @Test func nextDueAndEarliestNextAttemptHonorBackoff() throws { var backlog = WriteBacklog() let id = "01BACKOFFDUE0000000000001" let enqueuedAt = Self.t0 backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: enqueuedAt)) // A second, never-failing slot enqueued much later — present throughout so // `earliestNextAttempt` is genuinely picking a minimum across slots, not just // echoing the only one that exists. let freshID = "01BACKOFFFRESH00000000001" backlog.enqueue(pendingWorkout(id: freshID, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(10_000))) var entry = try #require(backlog.pendingWrite(for: id)) #expect(entry.nextAttemptAt == enqueuedAt) #expect(backlog.nextDue(at: enqueuedAt.addingTimeInterval(-1)) == nil) #expect(backlog.nextDue(at: enqueuedAt)?.documentID == id) #expect(backlog.earliestNextAttempt() == enqueuedAt) let firstFailure = Self.t0.addingTimeInterval(100) backlog.markFailed(id: id, at: firstFailure) entry = try #require(backlog.pendingWrite(for: id)) let expectedFirstRetry = firstFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 1)) #expect(entry.nextAttemptAt == expectedFirstRetry) #expect(backlog.nextDue(at: expectedFirstRetry.addingTimeInterval(-0.5)) == nil) #expect(backlog.nextDue(at: expectedFirstRetry)?.documentID == id) let secondFailure = expectedFirstRetry.addingTimeInterval(50) backlog.markFailed(id: id, at: secondFailure) entry = try #require(backlog.pendingWrite(for: id)) let expectedSecondRetry = secondFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 2)) #expect(entry.nextAttemptAt == expectedSecondRetry) #expect(backlog.earliestNextAttempt() == expectedSecondRetry) } /// Locks the exact backoff table: three quick silent retries (2s/4s/8s), then calm /// long retries, capped at 5 minutes from attempt 7 onward. @Test func retryDelayScheduleMatchesBackoffTable() { #expect(WriteBacklog.retryDelay(afterAttempts: 0) == 0) #expect(WriteBacklog.retryDelay(afterAttempts: 1) == 2) #expect(WriteBacklog.retryDelay(afterAttempts: 2) == 4) #expect(WriteBacklog.retryDelay(afterAttempts: 3) == 8) #expect(WriteBacklog.retryDelay(afterAttempts: 4) == 30) #expect(WriteBacklog.retryDelay(afterAttempts: 5) == 60) #expect(WriteBacklog.retryDelay(afterAttempts: 6) == 120) #expect(WriteBacklog.retryDelay(afterAttempts: 7) == 300) #expect(WriteBacklog.retryDelay(afterAttempts: 50) == 300) // capped, not just "7" } // MARK: - pruneExpired /// A persisted backlog loaded on relaunch drops any slot stuck longer than the /// TTL — a run that died with a queued write shouldn't drain stale state into a /// container that's long since moved on — while a younger slot survives untouched. @Test func pruneExpiredDropsOnlyEntriesPastTheTTL() throws { var backlog = WriteBacklog() let now = Self.t0 let ttl: TimeInterval = 60 let youngID = "01PRUNEYOUNG0000000000001" let oldID = "01PRUNEOLD0000000000000001" backlog.enqueue(pendingWorkout(id: youngID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-30))) backlog.enqueue(pendingWorkout(id: oldID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-90))) backlog.pruneExpired(now: now, ttl: ttl) #expect(backlog.pendingWrite(for: youngID) != nil) #expect(backlog.pendingWrite(for: oldID) == nil) #expect(backlog.count == 1) } // MARK: - WriteBacklogFile /// The sidecar file round-trips a non-empty backlog's slots (ids and timestamps /// intact), and saving an EMPTY backlog removes any existing file instead of /// writing `{}` — the sidecar's absence IS "no pending writes," matching what a /// fresh install/relaunch with nothing queued looks like on disk. @Test func fileRoundTripPreservesSlotsAndEmptySaveRemovesFile() throws { let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".json") defer { try? FileManager.default.removeItem(at: url) } var backlog = WriteBacklog() let idA = "01FILEROUNDTRIPA00000001" let idB = "01FILEROUNDTRIPB00000001" backlog.enqueue(pendingWorkout(id: idA, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0)) backlog.enqueue(pendingWorkout(id: idB, start: Self.t0, timestamp: Self.t0.addingTimeInterval(5), enqueuedAt: Self.t0.addingTimeInterval(5))) WriteBacklogFile.save(backlog, to: url) #expect(FileManager.default.fileExists(atPath: url.path)) let loaded = WriteBacklogFile.load(from: url) #expect(loaded.count == 2) #expect(loaded.pendingWrite(for: idA)?.timestamp == Self.t0) #expect(loaded.pendingWrite(for: idA)?.documentID == idA) #expect(loaded.pendingWrite(for: idB)?.timestamp == Self.t0.addingTimeInterval(5)) // Saving an EMPTY backlog over an existing file removes it rather than // writing `{}`. WriteBacklogFile.save(WriteBacklog(), to: url) #expect(!FileManager.default.fileExists(atPath: url.path)) } } // MARK: - SyncEngine integration /// Coverage for `SyncEngine`'s write-queue integration: every `save`/`delete` mirrors /// into the SwiftData cache immediately and enqueues the file write (see /// `WriteBacklogTests` above for the queue's own pure mechanics). None of these tests /// call `connect()` — matching `SyncEngineTests`'s scope note, the engine's `store` /// stays nil, so `kickDrain()` never launches a background drain that could race these /// assertions, and no real iCloud container is ever touched. Each test builds its own /// `SyncEngine` against a fresh in-memory `ModelContainer` and a unique temp /// `backlogURL`, so tests can't contaminate each other or the developer's real /// Application Support directory. struct SyncEngineWriteQueueTests { private static let t0 = Date(timeIntervalSince1970: 1_700_000_000) private func tempBacklogURL() -> URL { FileManager.default.temporaryDirectory.appending(path: "WriteBacklogTests-\(UUID().uuidString).json") } @MainActor private func makeEngine(backlogURL: URL) throws -> (engine: SyncEngine, container: ModelContainer) { let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: schema, configurations: [config]) return (SyncEngine(container: container, backlogURL: backlogURL), container) } private func makeWorkoutDoc(id: String, updatedAt: Date, routineName: String? = "Push Day") -> WorkoutDocument { WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "RT-1", routineName: routineName, start: Self.t0, end: nil, status: WorkoutStatus.notStarted.rawValue, createdAt: Self.t0, updatedAt: updatedAt, logs: [], metrics: nil ) } // MARK: - save(workout:) /// A local save mirrors into the cache immediately (a same-process write doesn't /// reliably wake the metadata observer, and never does in the simulator) and /// queues exactly one write. @MainActor @Test func saveWorkoutMirrorsToCacheAndQueuesWrite() async throws { let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) let id = "01SAVEQUEUE0000000000001" await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0)) let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) #expect(cached.updatedAt == Self.t0) #expect(engine.pendingWriteCount == 1) #expect(engine.writeQueueState == .pending) } /// Depth-1 per-document slot: saving the same id twice keeps exactly one queued /// write, and the cache — which every save mirrors into unconditionally — shows /// the LATEST version, matching what the eventually-drained file will contain. @MainActor @Test func repeatedSaveSameIDStaysDepthOneWithNewestCached() async throws { let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) let id = "01DEPTHONE00000000000001" await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0)) await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60))) #expect(engine.pendingWriteCount == 1) let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) #expect(cached.updatedAt == Self.t0.addingTimeInterval(60)) } // MARK: - ingestFromWatch /// The watch bridge's `updatedAt` intake gate: a strictly-older push is dropped /// (the cache is already ahead of the watch), an equal timestamp is the /// echo/duplicate case and is ignored wholesale even when some other field /// differs, and a strictly-newer push is accepted. `sendMessage` plus a queued /// `transferUserInfo` fallback are unordered, so this gate is what keeps a stale /// racing push from clobbering a newer local save. @MainActor @Test func ingestFromWatchAppliesUpdatedAtGate() async throws { let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) let id = "01INGESTGATE000000000001" await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0, routineName: "Push Day")) // (a) strictly older — dropped, cache unchanged. await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(-60), routineName: "Push Day")) var cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) #expect(cached.updatedAt == Self.t0) // (b) same updatedAt but a changed field — the echo case, ignored entirely // (equal timestamp means duplicate, not a genuine edit). await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0, routineName: "Changed Name")) cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) #expect(cached.updatedAt == Self.t0) #expect(cached.routineName == "Push Day") // (c) strictly newer — accepted. await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60), routineName: "Changed Name")) cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) #expect(cached.updatedAt == Self.t0.addingTimeInterval(60)) #expect(cached.routineName == "Changed Name") } /// A queued-but-unwritten delete vetoes resurrection: a watch push racing the /// delete — even carrying an `updatedAt` newer than the deleted version — must /// not bring the entity back, because the delete's stub simply hasn't landed on /// disk yet (the same veto an already-written tombstone would apply). @MainActor @Test func pendingDeleteVetoesWatchIngestResurrection() async throws { let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) let id = "01DELETEVETO0000000000001" await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0)) let entity = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) await engine.delete(workout: entity) await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(120))) #expect(CacheMapper.fetchWorkout(id: id, in: container.mainContext) == nil) } // MARK: - Restore lifecycle /// `beginRestore()` clears the backlog outright — queued writes reference /// pre-restore state, and draining them into the just-restored tree would /// reintroduce edits the user chose to roll back by restoring a backup. @MainActor @Test func beginRestoreClearsBacklogAndResetsQueueState() async throws { let (engine, _) = try makeEngine(backlogURL: tempBacklogURL()) await engine.save(workout: makeWorkoutDoc(id: "01RESTORECLEAR00000001", updatedAt: Self.t0)) #expect(engine.pendingWriteCount == 1) engine.beginRestore() #expect(engine.pendingWriteCount == 0) #expect(engine.writeQueueState == .idle) engine.endRestore() // balance the begin/end pair, matching every production call site } // MARK: - Persistence /// The backlog sidecar survives across process launches: a second engine pointed /// at the SAME `backlogURL` picks up the first engine's still-queued write on /// `init`, before ever connecting — this is what lets a killed app resume /// draining on next launch instead of silently losing the edit. @MainActor @Test func backlogPersistsAcrossEngineInstancesSharingBacklogURL() async throws { let url = tempBacklogURL() defer { try? FileManager.default.removeItem(at: url) } let (engineA, _) = try makeEngine(backlogURL: url) await engineA.save(workout: makeWorkoutDoc(id: "01PERSISTBACKLOG000001", updatedAt: Self.t0)) #expect(engineA.pendingWriteCount == 1) let (engineB, _) = try makeEngine(backlogURL: url) #expect(engineB.pendingWriteCount == 1) } }