commit 3a7415885dc2df40e6e8de425de775eca5ca7b5b
parent 056e55c54cfd2c51605453ad782f2e595243af8a
Author: Michael Camilleri <[email protected]>
Date: Tue, 21 Jul 2026 10:42:32 +0900
Keep retried invite Pings idempotent
Retried invite Pings must not produce duplicate rows in 'Invited' or
repeat their notification. This commit makes the durable insertion
result an explicit ingest contract and ignores repeated record names
within the same delivery. Regression coverage exercises both duplicate
input in one callback and a later cold-start-style ingest.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
2 files changed, 65 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -223,11 +223,33 @@ final class InviteCoordinator {
}
guard !invites.isEmpty else { return [] }
+ let (inserted, saveError) = await Self.storeInvitePings(
+ invites,
+ persistence: persistence
+ )
+ if let saveError {
+ eventLog.note("InviteCoordinator: applyInvitePings save failed — \(saveError)", level: "error")
+ }
+ return inserted
+ }
+
+ /// Persists already-validated invite Pings and reports only rows inserted
+ /// by this ingest. Keeping the inserted-name result tied to the durable
+ /// save is what makes a replay idempotent for both the Invited section and
+ /// its banner: `presentPings` only notifies for names returned here.
+ /// Internal so the retry contract can be covered without constructing the
+ /// coordinator's unrelated CloudKit collaborators.
+ static func storeInvitePings(
+ _ invites: [Ping],
+ persistence: PersistenceController
+ ) async -> (inserted: Set<String>, saveError: Error?) {
let ctx = persistence.container.newBackgroundContext()
- let (inserted, saveError): (Set<String>, Error?) = await ctx.perform {
+ return await ctx.perform {
var insertedPingRecordNames: Set<String> = []
+ var handledPingRecordNames: Set<String> = []
for ping in invites {
guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
+ guard handledPingRecordNames.insert(ping.recordName).inserted else { continue }
let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
@@ -273,10 +295,6 @@ final class InviteCoordinator {
}
return (insertedPingRecordNames, nil)
}
- if let saveError {
- eventLog.note("InviteCoordinator: applyInvitePings save failed — \(saveError)", level: "error")
- }
- return inserted
}
/// Drops the pending invite row(s) for `gameID`. Called when the game's
diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift
@@ -142,6 +142,48 @@ struct FriendModelTests {
#expect(try ctx.count(for: dupReq) == 1)
}
+ @Test("Retrying an invite Ping creates one row and one notification eligibility")
+ func retriedInvitePingIsIdempotent() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = UUID()
+ let recordName = "ping-\(gameID.uuidString)-_alice-device-1"
+ let payload = try #require(FriendZone.InvitePayload(
+ gameShareURL: "https://www.icloud.com/share/retry"
+ ).encodedString())
+ let ping = Ping(
+ recordName: recordName,
+ gameID: gameID,
+ authorID: "_alice",
+ deviceID: "device",
+ playerName: "Alice",
+ puzzleTitle: "Saturday",
+ kind: .invite,
+ payload: payload,
+ addressee: "_me"
+ )
+
+ // Include the same record twice in the first delivery, then replay it
+ // in a separate ingest as happens after a retry or cold-start fetch.
+ let first = await InviteCoordinator.storeInvitePings(
+ [ping, ping],
+ persistence: persistence
+ )
+ let replay = await InviteCoordinator.storeInvitePings(
+ [ping],
+ persistence: persistence
+ )
+
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(format: "pingRecordName == %@", recordName)
+ #expect(try ctx.count(for: req) == 1)
+ // presentPings uses this inserted-name set as its notification gate.
+ #expect(first.saveError == nil)
+ #expect(first.inserted == [recordName])
+ #expect(replay.saveError == nil)
+ #expect(replay.inserted.isEmpty)
+ }
+
@Test("declined invite tombstone makes the source ping stale")
func declinedInvitePingIsStale() throws {
let persistence = makeTestPersistence()