crossmate

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

commit a0b2c29d8cde71f65d574d2ac5f813b977c73b16
parent 2630a38983f33da7b1726c30488c9f55c07bdb87
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 16:06:25 +0900

Consume unactionable invite Pings before presentation

This commit tightens the stale-invite gate so an inbound .invite Ping
must still be actionable before it can create a local notification. The
previous path could fetch a durable friend-zone Ping, fail to
materialise a pending InviteEntity because the invite was already
tombstoned or lacked a usable share payload, and then still pass the
same Ping to the notification loop. That produced a phantom invite
banner even though the badge and Core Data invite count stayed at zero.

Now consumeStaleInvites shares a single predicate for stale invite
detection, including declined tombstones and malformed payloads, and
deletes those source Pings before applyInvitePings or presentation see
them. The friend model tests cover both cases so a non-actionable invite
can no longer re-notify from the durable Ping alone.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++--------------------
MTests/Unit/Sync/FriendModelTests.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 111 insertions(+), 20 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -2644,26 +2644,11 @@ final class AppServices { let ctx = persistence.container.newBackgroundContext() let staleNames: Set<String> = ctx.performAndWait { - var names: Set<String> = [] - for ping in candidates { - let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - blockedReq.predicate = NSPredicate( - format: "authorID == %@ AND isBlocked == YES", ping.authorID - ) - blockedReq.fetchLimit = 1 - if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { - names.insert(ping.recordName) - continue - } - - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) - gameReq.fetchLimit = 1 - if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { - names.insert(ping.recordName) - } - } - return names + Self.staleInviteRecordNames( + among: candidates, + in: ctx, + currentAuthorID: identity.currentID + ) } guard !staleNames.isEmpty else { return pings } @@ -2679,6 +2664,49 @@ final class AppServices { return pings.filter { !staleNames.contains($0.recordName) } } + static func staleInviteRecordNames( + among pings: [Ping], + in ctx: NSManagedObjectContext, + currentAuthorID: String? + ) -> Set<String> { + var names: Set<String> = [] + for ping in pings where ping.kind == .invite && + ping.authorID != currentAuthorID && + ping.addressee == currentAuthorID { + guard FriendZone.InvitePayload.decode(ping.payload) != nil else { + names.insert(ping.recordName) + continue + } + + let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + blockedReq.predicate = NSPredicate( + format: "authorID == %@ AND isBlocked == YES", ping.authorID + ) + blockedReq.fetchLimit = 1 + if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { + names.insert(ping.recordName) + continue + } + + let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) + inviteReq.fetchLimit = 1 + if let invite = try? ctx.fetch(inviteReq).first, + invite.status != "pending" { + names.insert(ping.recordName) + continue + } + + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) + gameReq.fetchLimit = 1 + if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { + names.insert(ping.recordName) + } + } + return names + } + private func presentPings(_ pings: [Ping]) async { let claimed = claimPingsForHandling(pings) guard !claimed.isEmpty else { return } diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift @@ -78,6 +78,69 @@ struct FriendModelTests { #expect(try ctx.count(for: dupReq) == 1) } + @Test("declined invite tombstone makes the source ping stale") + func declinedInvitePingIsStale() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + + let declined = InviteEntity(context: ctx) + declined.gameID = gameID + declined.inviterAuthorID = "_alice" + declined.shareURL = "https://www.icloud.com/share/def" + declined.pingRecordName = "ping-\(gameID.uuidString)-_alice-device-1" + declined.status = "declined" + declined.createdAt = Date() + try ctx.save() + + let payload = FriendZone.InvitePayload( + gameShareURL: "https://www.icloud.com/share/def" + ).encodedString() + let ping = Ping( + recordName: declined.pingRecordName!, + gameID: gameID, + authorID: "_alice", + deviceID: "device", + playerName: "Alice", + puzzleTitle: "Saturday", + kind: .invite, + payload: payload, + addressee: "_me" + ) + + let stale = AppServices.staleInviteRecordNames( + among: [ping], + in: ctx, + currentAuthorID: "_me" + ) + #expect(stale == [ping.recordName]) + } + + @Test("malformed invite payload makes the source ping stale") + func malformedInvitePayloadIsStale() { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let ping = Ping( + recordName: "ping-\(gameID.uuidString)-_alice-device-1", + gameID: gameID, + authorID: "_alice", + deviceID: "device", + playerName: "Alice", + puzzleTitle: "Saturday", + kind: .invite, + payload: nil, + addressee: "_me" + ) + + let stale = AppServices.staleInviteRecordNames( + among: [ping], + in: ctx, + currentAuthorID: "_me" + ) + #expect(stale == [ping.recordName]) + } + @Test("knownZones predicate excludes a blocked friend zone") func blockedFriendExcludedFromKnownZones() throws { let persistence = makeTestPersistence()