crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PendingChangeReapTests.swift (14552B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins down the reap path in `makeRecordZoneChangeBatch`. A pending
      9 /// `.saveRecord` whose record can't be reconstructed (a ping whose durable
     10 /// outbox row is missing/unreadable, or a deleted Core Data entity) must be
     11 /// dropped from the engine's persisted pending changes rather
     12 /// than left queued forever. Without this the device showed a permanent
     13 /// `Pending Changes: 1` that never drained and surfaced no error — every push
     14 /// "succeeded" while silently sending nothing (see the stuck-ping log).
     15 @Suite("PendingChangeReap", .serialized)
     16 @MainActor
     17 struct PendingChangeReapTests {
     18 
     19     private func makeEngine(
     20         persistence: PersistenceController
     21     ) async -> SyncEngine {
     22         let container = CloudContainer.container
     23         let engine = SyncEngine(container: container, persistence: persistence)
     24         await engine.start()
     25         return engine
     26     }
     27 
     28     private func makePrivateGame(
     29         in ctx: NSManagedObjectContext
     30     ) throws -> (UUID, String) {
     31         let id = UUID()
     32         let zoneName = "game-\(id.uuidString)"
     33         let entity = GameEntity(context: ctx)
     34         entity.id = id
     35         entity.title = "Private"
     36         entity.puzzleSource = ""
     37         entity.createdAt = Date()
     38         entity.updatedAt = Date()
     39         entity.ckRecordName = zoneName
     40         entity.ckZoneName = zoneName
     41         entity.databaseScope = 0
     42         try ctx.save()
     43         return (id, zoneName)
     44     }
     45 
     46     @Test("An un-buildable pending save is reaped instead of queued forever")
     47     func unbuildableSaveIsReaped() async throws {
     48         let persistence = makeTestPersistence()
     49         let ctx = persistence.viewContext
     50         let (gameID, zoneName) = try makePrivateGame(in: ctx)
     51         let engine = await makeEngine(persistence: persistence)
     52 
     53         await engine.enqueueGame(ckRecordName: zoneName)
     54         let before = await engine.pendingSaveRecordNames(scope: .private)
     55         #expect(before.contains(zoneName))
     56 
     57         // Delete the backing entity so `buildRecord` can no longer
     58         // reconstruct the `game-` record — the same dead-end the engine hits
     59         // for a ping whose durable outbox row is missing or unreadable.
     60         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     61         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     62         let entity = try #require(try ctx.fetch(req).first)
     63         ctx.delete(entity)
     64         try ctx.save()
     65 
     66         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
     67 
     68         let after = await engine.pendingSaveRecordNames(scope: .private)
     69         #expect(!after.contains(zoneName))
     70     }
     71 
     72     @Test("A buildable pending ping is preserved, not reaped")
     73     func buildablePingIsPreserved() async throws {
     74         let persistence = makeTestPersistence()
     75         let ctx = persistence.viewContext
     76         let (gameID, _) = try makePrivateGame(in: ctx)
     77         let engine = await makeEngine(persistence: persistence)
     78 
     79         await engine.enqueuePing(
     80             kind: .join,
     81             gameID: gameID,
     82             authorID: "_localAuthor",
     83             playerName: "Local"
     84         )
     85         let before = await engine.pendingSaveRecordNames(scope: .private)
     86         let pingName = try #require(before.first { $0.hasPrefix("ping-") })
     87 
     88         // The payload is still in `pendingPings`, so the record builds — the
     89         // reap must not fire just because a record was materialized.
     90         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
     91 
     92         let after = await engine.pendingSaveRecordNames(scope: .private)
     93         #expect(after.contains(pingName))
     94     }
     95 
     96     @Test("Atomic Decision conflict re-enqueues its reconstructable Ping sibling")
     97     func atomicDecisionConflictRecoversPing() async throws {
     98         let persistence = makeTestPersistence()
     99         let engine = await makeEngine(persistence: persistence)
    100         let zoneID = CKRecordZone.ID(
    101             zoneName: "friend-atomic-recovery",
    102             ownerName: "_friend"
    103         )
    104 
    105         let decisionName = RecordSerializer.decisionRecordName(
    106             kind: RecordSerializer.nameDecisionKind,
    107             key: "_localAuthor"
    108         )
    109         let decisionID = CKRecord.ID(recordName: decisionName, zoneID: zoneID)
    110         #expect(await engine.enqueueFriendDecision(
    111             kind: RecordSerializer.nameDecisionKind,
    112             key: "_localAuthor",
    113             payload: "Local",
    114             version: 1,
    115             friendZoneID: zoneID,
    116             friendZoneScope: .shared
    117         ))
    118         try await engine.enqueueFriendZonePing(
    119             kind: .invite,
    120             gameID: UUID(),
    121             gameTitle: "Atomic",
    122             authorID: "_localAuthor",
    123             playerName: "Local",
    124             addressee: "_friend",
    125             friendZoneID: zoneID,
    126             friendZoneScope: .shared,
    127             payload: #"{"gameShareURL":"https://example.com/share"}"#
    128         )
    129 
    130         let pingName = try #require(
    131             await engine.pendingPingRecordNamesForTesting().first
    132         )
    133         let pingID = CKRecord.ID(recordName: pingName, zoneID: zoneID)
    134 
    135         // A sent atomic batch falls out of CKSyncEngine's pending state. The
    136         // Decision's serverRecordChanged error is causal and settles because
    137         // the write-once record already exists; the Ping receives only
    138         // batchRequestFailed and must be added back explicitly afterward.
    139         await engine.recoverAtomicBatchForTesting(
    140             settledDecisionID: decisionID,
    141             batchFailedRecordIDs: [pingID],
    142             scope: .shared
    143         )
    144 
    145         let pending = await engine.pendingSaveRecordNames(scope: .shared)
    146         #expect(!pending.contains(decisionName))
    147         #expect(pending.contains(pingName))
    148         #expect(await engine.pendingPingRecordNamesForTesting() == [pingName])
    149 
    150         // The retry remains materializable rather than being reaped by the
    151         // next record-provider pass.
    152         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .shared)
    153         #expect(await engine.pendingSaveRecordNames(scope: .shared).contains(pingName))
    154     }
    155 
    156     @Test("Pending Ping outbox survives SyncEngine restart")
    157     func pendingPingOutboxSurvivesRestart() async throws {
    158         let persistence = makeTestPersistence()
    159         let firstEngine = await makeEngine(persistence: persistence)
    160         let zoneID = CKRecordZone.ID(
    161             zoneName: "friend-durable-outbox",
    162             ownerName: "_friend"
    163         )
    164         try await firstEngine.enqueueFriendZonePing(
    165             kind: .invite,
    166             gameID: UUID(),
    167             gameTitle: "Durable",
    168             authorID: "_localAuthor",
    169             playerName: "Local",
    170             addressee: "_friend",
    171             friendZoneID: zoneID,
    172             friendZoneScope: .shared,
    173             payload: #"{"gameShareURL":"https://example.com/share"}"#
    174         )
    175         let recordName = try #require(
    176             await firstEngine.pendingPingRecordNamesForTesting().first
    177         )
    178 
    179         // A new engine mirrors an app relaunch. Its CKSyncEngine state may or
    180         // may not have serialized the enqueue before termination, so startup
    181         // restores the outbox row and independently re-adds the stable ID.
    182         let relaunchedEngine = await makeEngine(persistence: persistence)
    183         #expect(await relaunchedEngine.pendingPingRecordNamesForTesting() == [recordName])
    184         #expect(
    185             await relaunchedEngine.pendingSaveRecordNames(scope: .shared)
    186                 .contains(recordName)
    187         )
    188     }
    189 
    190     @Test("Invite delivery advances from queued to CloudKit-confirmed sent")
    191     func inviteDeliveryPhases() async throws {
    192         let persistence = makeTestPersistence()
    193         let engine = await makeEngine(persistence: persistence)
    194         let deliveries = InviteDeliveryStore()
    195         let gameID = UUID()
    196         let friendAuthorID = "_friend"
    197         let delivery = deliveries.delivery(
    198             gameID: gameID,
    199             friendAuthorID: friendAuthorID
    200         )
    201         var rollbackFlags: [Bool] = []
    202         #expect(delivery.phase == .idle)
    203 
    204         await engine.setOnPingDeliveryUpdate { update in
    205             rollbackFlags.append(update.rollbackParticipantOnFailure)
    206             switch update.state {
    207             case .queued:
    208                 deliveries.markQueued(
    209                     recordName: update.recordName,
    210                     gameID: update.gameID,
    211                     friendAuthorID: update.addressee
    212                 )
    213             case .sent:
    214                 deliveries.markSent(
    215                     recordName: update.recordName,
    216                     gameID: update.gameID,
    217                     friendAuthorID: update.addressee
    218                 )
    219             case .failed:
    220                 deliveries.markFailed(
    221                     recordName: update.recordName,
    222                     gameID: update.gameID,
    223                     friendAuthorID: update.addressee,
    224                     failure: update.failure ?? .other
    225                 )
    226             }
    227         }
    228 
    229         let zoneID = CKRecordZone.ID(
    230             zoneName: "friend-delivery-phases",
    231             ownerName: friendAuthorID
    232         )
    233         try await engine.enqueueFriendZonePing(
    234             kind: .invite,
    235             gameID: gameID,
    236             gameTitle: "Phases",
    237             authorID: "_localAuthor",
    238             playerName: "Local",
    239             addressee: friendAuthorID,
    240             friendZoneID: zoneID,
    241             friendZoneScope: .shared,
    242             payload: #"{"gameShareURL":"https://example.com/share"}"#,
    243             rollbackParticipantOnFailure: true
    244         )
    245         let recordName = try #require(
    246             await engine.pendingPingRecordNamesForTesting().first
    247         )
    248         #expect(delivery.phase == .queued)
    249         #expect(rollbackFlags == [true])
    250 
    251         await engine.confirmPendingPingForTesting(recordName: recordName)
    252         #expect(delivery.phase == .sent)
    253         #expect(await engine.pendingPingRecordNamesForTesting().isEmpty)
    254 
    255         // A late/replayed queued update cannot regress a confirmed send.
    256         deliveries.markQueued(
    257             recordName: recordName,
    258             gameID: gameID,
    259             friendAuthorID: friendAuthorID
    260         )
    261         #expect(delivery.phase == .sent)
    262     }
    263 
    264     @Test("Invitation failure detail remains scoped to one game and friend")
    265     func invitationFailureState() {
    266         let deliveries = InviteDeliveryStore()
    267         let gameID = UUID()
    268         let otherGameID = UUID()
    269         let friendAuthorID = "_friend"
    270 
    271         deliveries.markFailed(
    272             recordName: "ping-failed",
    273             gameID: gameID,
    274             friendAuthorID: friendAuthorID,
    275             failure: .quotaExceeded
    276         )
    277         let delivery = deliveries.delivery(
    278             gameID: gameID,
    279             friendAuthorID: friendAuthorID
    280         )
    281         #expect(delivery.phase == .failed)
    282         #expect(delivery.failure == .quotaExceeded)
    283 
    284         // The same friend on a different game is a separate invitation, and the
    285         // announcement each one retracts is keyed the same way.
    286         let other = deliveries.delivery(
    287             gameID: otherGameID,
    288             friendAuthorID: friendAuthorID
    289         )
    290         #expect(other.phase == .idle)
    291         #expect(other.failure == nil)
    292         #expect(
    293             InviteDeliveryStore.failureAnnouncementID(
    294                 gameID: gameID,
    295                 friendAuthorID: friendAuthorID
    296             ) != InviteDeliveryStore.failureAnnouncementID(
    297                 gameID: otherGameID,
    298                 friendAuthorID: friendAuthorID
    299             )
    300         )
    301     }
    302 
    303     @Test("Confirmation timeout leaves the invite queued and non-destructive")
    304     func inviteConfirmationTimeoutStaysQueued() async throws {
    305         let persistence = makeTestPersistence()
    306         let engine = await makeEngine(persistence: persistence)
    307         await engine.setPingConfirmationTimeoutForTesting(.milliseconds(50))
    308         let deliveries = InviteDeliveryStore()
    309         let gameID = UUID()
    310         let friendAuthorID = "_friend"
    311         let delivery = deliveries.delivery(
    312             gameID: gameID,
    313             friendAuthorID: friendAuthorID
    314         )
    315 
    316         await engine.setOnPingDeliveryUpdate { update in
    317             switch update.state {
    318             case .queued:
    319                 deliveries.markQueued(
    320                     recordName: update.recordName,
    321                     gameID: update.gameID,
    322                     friendAuthorID: update.addressee
    323                 )
    324             case .sent:
    325                 deliveries.markSent(
    326                     recordName: update.recordName,
    327                     gameID: update.gameID,
    328                     friendAuthorID: update.addressee
    329                 )
    330             case .failed:
    331                 deliveries.markFailed(
    332                     recordName: update.recordName,
    333                     gameID: update.gameID,
    334                     friendAuthorID: update.addressee,
    335                     failure: update.failure ?? .other
    336                 )
    337             }
    338         }
    339 
    340         let zoneID = CKRecordZone.ID(
    341             zoneName: "friend-timeout",
    342             ownerName: friendAuthorID
    343         )
    344         // The confirmed-send wait gives up after the (shortened) patience
    345         // window and reports `.deliveryPending` rather than blocking forever.
    346         await #expect(throws: SyncEngine.PingOutboxError.deliveryPending) {
    347             try await engine.enqueueFriendZonePing(
    348                 kind: .invite,
    349                 gameID: gameID,
    350                 gameTitle: "Timeout",
    351                 authorID: "_localAuthor",
    352                 playerName: "Local",
    353                 addressee: friendAuthorID,
    354                 friendZoneID: zoneID,
    355                 friendZoneScope: .shared,
    356                 payload: #"{"gameShareURL":"https://example.com/share"}"#,
    357                 rollbackParticipantOnFailure: true,
    358                 waitForServerConfirmation: true
    359             )
    360         }
    361 
    362         // Non-destructive: the Ping is still queued and never marked failed.
    363         let recordName = try #require(
    364             await engine.pendingPingRecordNamesForTesting().first
    365         )
    366         #expect(delivery.phase == .queued)
    367 
    368         // The durable outbox still resolves the true outcome afterwards.
    369         await engine.confirmPendingPingForTesting(recordName: recordName)
    370         #expect(delivery.phase == .sent)
    371         #expect(await engine.pendingPingRecordNamesForTesting().isEmpty)
    372     }
    373 }