crossmate

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

FriendModelTests.swift (17068B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins the Core Data shape the friend/invite UI and ingest paths rely on:
      9 /// the new `FriendEntity` / `InviteEntity` types exist with the expected
     10 /// attributes, and the predicates used by `GameListView` and
     11 /// `ingestInvitePings` select the right rows.
     12 @Suite("FriendModel")
     13 @MainActor
     14 struct FriendModelTests {
     15 
     16     @Test("isBlocked predicate filters blocked friends")
     17     func blockedFriendPredicate() throws {
     18         let persistence = makeTestPersistence()
     19         let ctx = persistence.viewContext
     20 
     21         let active = FriendEntity(context: ctx)
     22         active.authorID = "_active"
     23         active.pairKey = "k1"
     24         active.isBlocked = false
     25         active.createdAt = Date()
     26 
     27         let blocked = FriendEntity(context: ctx)
     28         blocked.authorID = "_blocked"
     29         blocked.pairKey = "k2"
     30         blocked.isBlocked = true
     31         blocked.createdAt = Date()
     32         try ctx.save()
     33 
     34         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
     35         req.predicate = NSPredicate(format: "isBlocked == NO")
     36         let result = try ctx.fetch(req)
     37         #expect(result.map { $0.authorID } == ["_active"])
     38     }
     39 
     40     @Test("hidden games are excluded by the game-list predicate")
     41     func hiddenGamesExcluded() throws {
     42         let persistence = makeTestPersistence()
     43         let ctx = persistence.viewContext
     44 
     45         for (title, hidden) in [("visible", false), ("hidden", true)] {
     46             let game = GameEntity(context: ctx)
     47             game.id = UUID()
     48             game.title = title
     49             game.puzzleSource = ""
     50             game.createdAt = Date()
     51             game.updatedAt = Date()
     52             game.isHidden = hidden
     53         }
     54         try ctx.save()
     55 
     56         // Exactly the predicate used by GameListView's games fetch.
     57         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     58         req.predicate = GameEntity.visibleInGameListPredicate
     59         #expect(try ctx.fetch(req).map { $0.title } == ["visible"])
     60     }
     61 
     62     @Test("block visibility is re-derived for any game featuring the blocked author")
     63     func blockVisibilityReconcilesSyncedGames() throws {
     64         let persistence = makeTestPersistence()
     65         let ctx = persistence.viewContext
     66 
     67         let blocked = FriendEntity(context: ctx)
     68         blocked.authorID = "_blocked"
     69         blocked.pairKey = "pair-blocked"
     70         blocked.isBlocked = true
     71         blocked.createdAt = Date()
     72 
     73         let shared = GameEntity(context: ctx)
     74         shared.id = UUID()
     75         shared.title = "Shared"
     76         shared.puzzleSource = ""
     77         shared.createdAt = Date()
     78         shared.updatedAt = Date()
     79         shared.databaseScope = 1
     80         shared.ckZoneOwnerName = "_blocked"
     81         shared.isHidden = false
     82 
     83         let owned = GameEntity(context: ctx)
     84         owned.id = UUID()
     85         owned.title = "Owned"
     86         owned.puzzleSource = ""
     87         owned.createdAt = Date()
     88         owned.updatedAt = Date()
     89         owned.databaseScope = 0
     90         owned.ckZoneOwnerName = "_blocked"
     91         owned.isHidden = false
     92 
     93         #expect(GameEntity.reconcileBlockedFriendHiddenGames(
     94             forAuthorIDs: ["_blocked"],
     95             in: ctx
     96         ) == 2)
     97         #expect(shared.isHidden)
     98         #expect(owned.isHidden)
     99 
    100         blocked.isBlocked = false
    101         #expect(GameEntity.reconcileBlockedFriendHiddenGames(
    102             forGameIDs: [shared.id!, owned.id!],
    103             in: ctx
    104         ) == 2)
    105         #expect(!shared.isHidden)
    106         #expect(!owned.isHidden)
    107     }
    108 
    109     @Test("pending-status predicate and pingRecordName dedup work")
    110     func invitePredicates() throws {
    111         let persistence = makeTestPersistence()
    112         let ctx = persistence.viewContext
    113 
    114         let pending = InviteEntity(context: ctx)
    115         pending.gameID = UUID()
    116         pending.gameTitle = "Saturday"
    117         pending.inviterAuthorID = "_alice"
    118         pending.inviterName = "Alice"
    119         pending.shareURL = "https://www.icloud.com/share/abc"
    120         pending.pingRecordName = "ping-1"
    121         pending.status = "pending"
    122         pending.createdAt = Date()
    123 
    124         let declined = InviteEntity(context: ctx)
    125         declined.gameID = UUID()
    126         declined.inviterAuthorID = "_bob"
    127         declined.shareURL = "https://www.icloud.com/share/def"
    128         declined.pingRecordName = "ping-2"
    129         declined.status = "declined"
    130         declined.createdAt = Date()
    131         try ctx.save()
    132 
    133         let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    134         pendingReq.predicate = NSPredicate(format: "status == %@", "pending")
    135         #expect(try ctx.fetch(pendingReq).map { $0.pingRecordName } == ["ping-1"])
    136 
    137         // A declined tombstone is still found by the dedup lookup, so a
    138         // re-fetched Ping won't resurrect it.
    139         let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    140         dupReq.predicate = NSPredicate(format: "pingRecordName == %@", "ping-2")
    141         #expect(try ctx.count(for: dupReq) == 1)
    142     }
    143 
    144     @Test("Retrying an invite Ping creates one row and one notification eligibility")
    145     func retriedInvitePingIsIdempotent() async throws {
    146         let persistence = makeTestPersistence()
    147         let ctx = persistence.viewContext
    148         let gameID = UUID()
    149         let recordName = "ping-\(gameID.uuidString)-_alice-device-1"
    150         let payload = try #require(FriendZone.InvitePayload(
    151             gameShareURL: "https://www.icloud.com/share/retry"
    152         ).encodedString())
    153         let ping = Ping(
    154             recordName: recordName,
    155             gameID: gameID,
    156             authorID: "_alice",
    157             deviceID: "device",
    158             playerName: "Alice",
    159             puzzleTitle: "Saturday",
    160             kind: .invite,
    161             payload: payload,
    162             addressee: "_me"
    163         )
    164 
    165         // Include the same record twice in the first delivery, then replay it
    166         // in a separate ingest as happens after a retry or cold-start fetch.
    167         let first = await InviteCoordinator.storeInvitePings(
    168             [ping, ping],
    169             persistence: persistence
    170         )
    171         let replay = await InviteCoordinator.storeInvitePings(
    172             [ping],
    173             persistence: persistence
    174         )
    175 
    176         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    177         req.predicate = NSPredicate(format: "pingRecordName == %@", recordName)
    178         #expect(try ctx.count(for: req) == 1)
    179         // presentPings uses this inserted-name set as its notification gate.
    180         #expect(first.saveError == nil)
    181         #expect(first.inserted == [recordName])
    182         #expect(replay.saveError == nil)
    183         #expect(replay.inserted.isEmpty)
    184     }
    185 
    186     @Test("declined invite tombstone makes the source ping stale")
    187     func declinedInvitePingIsStale() throws {
    188         let persistence = makeTestPersistence()
    189         let ctx = persistence.viewContext
    190         let gameID = UUID()
    191 
    192         let declined = InviteEntity(context: ctx)
    193         declined.gameID = gameID
    194         declined.inviterAuthorID = "_alice"
    195         declined.shareURL = "https://www.icloud.com/share/def"
    196         declined.pingRecordName = "ping-\(gameID.uuidString)-_alice-device-1"
    197         declined.status = "declined"
    198         declined.createdAt = Date()
    199         try ctx.save()
    200 
    201         let payload = FriendZone.InvitePayload(
    202             gameShareURL: "https://www.icloud.com/share/def"
    203         ).encodedString()
    204         let ping = Ping(
    205             recordName: declined.pingRecordName!,
    206             gameID: gameID,
    207             authorID: "_alice",
    208             deviceID: "device",
    209             playerName: "Alice",
    210             puzzleTitle: "Saturday",
    211             kind: .invite,
    212             payload: payload,
    213             addressee: "_me"
    214         )
    215 
    216         let stale = InviteCoordinator.staleInviteRecordNames(
    217             among: [ping],
    218             in: ctx,
    219             currentAuthorID: "_me"
    220         )
    221         #expect(stale == [ping.recordName])
    222     }
    223 
    224     @Test("malformed invite payload makes the source ping stale")
    225     func malformedInvitePayloadIsStale() {
    226         let persistence = makeTestPersistence()
    227         let ctx = persistence.viewContext
    228         let gameID = UUID()
    229         let ping = Ping(
    230             recordName: "ping-\(gameID.uuidString)-_alice-device-1",
    231             gameID: gameID,
    232             authorID: "_alice",
    233             deviceID: "device",
    234             playerName: "Alice",
    235             puzzleTitle: "Saturday",
    236             kind: .invite,
    237             payload: nil,
    238             addressee: "_me"
    239         )
    240 
    241         let stale = InviteCoordinator.staleInviteRecordNames(
    242             among: [ping],
    243             in: ctx,
    244             currentAuthorID: "_me"
    245         )
    246         #expect(stale == [ping.recordName])
    247     }
    248 
    249     @Test("outbox enumeration excludes a blocked friend")
    250     func blockedFriendExcludedFromKnownZones() throws {
    251         let persistence = makeTestPersistence()
    252         let ctx = persistence.viewContext
    253 
    254         for (suffix, blocked) in [("ok", false), ("no", true)] {
    255             let f = FriendEntity(context: ctx)
    256             f.authorID = "_\(suffix)"
    257             f.pairKey = suffix
    258             f.isBlocked = blocked
    259             f.createdAt = Date()
    260         }
    261         try ctx.save()
    262 
    263         // The scope-1 (outbox) enumeration in CloudZones skips blocked friends
    264         // and derives the zone from pairKey + authorID.
    265         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    266         req.predicate = NSPredicate(format: "isBlocked == NO")
    267         let zoneNames = try ctx.fetch(req).compactMap { friend -> String? in
    268             guard let pairKey = friend.pairKey, let authorID = friend.authorID else { return nil }
    269             return FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID).zoneName
    270         }
    271         #expect(zoneNames == [FriendZone.zoneName(pairKey: "ok")])
    272     }
    273 
    274     @Test("invites are scoped by inviterAuthorID for block cleanup")
    275     func invitesByInviterPredicate() throws {
    276         let persistence = makeTestPersistence()
    277         let ctx = persistence.viewContext
    278 
    279         for (i, inviter) in ["_alice", "_alice", "_bob"].enumerated() {
    280             let invite = InviteEntity(context: ctx)
    281             invite.gameID = UUID()
    282             invite.inviterAuthorID = inviter
    283             invite.shareURL = "https://x/\(i)"
    284             invite.pingRecordName = "ping-\(i)"
    285             invite.status = "pending"
    286             invite.createdAt = Date()
    287         }
    288         try ctx.save()
    289 
    290         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    291         req.predicate = NSPredicate(format: "inviterAuthorID == %@", "_alice")
    292         #expect(try ctx.count(for: req) == 2)
    293     }
    294 
    295     @Test("a pending invite whose game exists locally is detectable for GC")
    296     func staleInviteDetectableForGC() throws {
    297         let persistence = makeTestPersistence()
    298         let ctx = persistence.viewContext
    299         let gameID = UUID()
    300 
    301         let invite = InviteEntity(context: ctx)
    302         invite.gameID = gameID
    303         invite.inviterAuthorID = "_alice"
    304         invite.shareURL = "https://x"
    305         invite.pingRecordName = "ping-1"
    306         invite.status = "pending"
    307         invite.createdAt = Date()
    308 
    309         let game = GameEntity(context: ctx)
    310         game.id = gameID
    311         game.title = "Joined"
    312         game.puzzleSource = ""
    313         game.createdAt = Date()
    314         game.updatedAt = Date()
    315         try ctx.save()
    316 
    317         // The exact GC lookup from applyInvitePings.
    318         let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    319         gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    320         #expect(try ctx.count(for: gReq) == 1)
    321     }
    322 
    323     // MARK: - M3: friend-zone ping authentication
    324 
    325     private func friendZonePing(
    326         gameID: UUID = UUID(),
    327         kind: PingKind = .decline,
    328         authorID: String,
    329         addressee: String,
    330         sourceZoneName: String,
    331         sourceDatabaseScope: DatabaseScope? = .private
    332     ) -> Ping {
    333         Ping(
    334             recordName: "ping-\(gameID.uuidString)-\(authorID)-device",
    335             gameID: gameID,
    336             authorID: authorID,
    337             deviceID: "device",
    338             playerName: "Bob",
    339             puzzleTitle: "Saturday",
    340             kind: kind,
    341             payload: nil,
    342             addressee: addressee,
    343             sourceZoneName: sourceZoneName,
    344             sourceDatabaseScope: sourceDatabaseScope
    345         )
    346     }
    347 
    348     @Test("decline from the decliner's own friend zone authenticates")
    349     func authenticDeclineAccepted() {
    350         let owner = "_owner"
    351         let decliner = "_bob"
    352         let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, decliner))
    353         let ping = friendZonePing(authorID: decliner, addressee: owner, sourceZoneName: zone)
    354         #expect(InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner))
    355     }
    356 
    357     @Test("decline naming a third party from another friend's zone is rejected")
    358     func forgedDeclineRejected() {
    359         let owner = "_owner"
    360         let attacker = "_carol"
    361         let victim = "_bob"
    362         // Carol writes into her own inbox with the owner but forges authorID=Bob.
    363         let carolZone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, attacker))
    364         let ping = friendZonePing(authorID: victim, addressee: owner, sourceZoneName: carolZone)
    365         #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner))
    366     }
    367 
    368     @Test("decline with no source zone fails closed")
    369     func declineMissingZoneRejected() {
    370         let owner = "_owner"
    371         let ping = friendZonePing(authorID: "_bob", addressee: owner, sourceZoneName: "")
    372         #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner))
    373     }
    374 
    375     @Test("decline from a shared-database zone is rejected even with a matching name")
    376     func sharedScopeDeclineRejected() {
    377         let owner = "_owner"
    378         let decliner = "_bob"
    379         // A zone the forger owns can carry the right name, but it only ever
    380         // reaches us through the shared database — our inbox is private.
    381         let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, decliner))
    382         let ping = friendZonePing(
    383             authorID: decliner, addressee: owner,
    384             sourceZoneName: zone, sourceDatabaseScope: .shared
    385         )
    386         #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner))
    387     }
    388 
    389     @Test("decline with an unknown database scope fails closed")
    390     func unknownScopeDeclineRejected() {
    391         let owner = "_owner"
    392         let decliner = "_bob"
    393         let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, decliner))
    394         let ping = friendZonePing(
    395             authorID: decliner, addressee: owner,
    396             sourceZoneName: zone, sourceDatabaseScope: nil
    397         )
    398         #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner))
    399     }
    400 
    401     @Test("invite from the inviter's pairwise zone authenticates")
    402     func authenticInviteAccepted() {
    403         let invitee = "_alice"
    404         let inviter = "_carol"
    405         let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(invitee, inviter))
    406         let ping = friendZonePing(
    407             kind: .invite, authorID: inviter, addressee: invitee, sourceZoneName: zone
    408         )
    409         #expect(InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: invitee))
    410     }
    411 
    412     @Test("invite claiming a different friend's identity is rejected")
    413     func forgedInviteRejected() {
    414         let invitee = "_alice"
    415         let attacker = "_mallory"
    416         let impersonated = "_carol"
    417         // Mallory writes into his own inbox with Alice but claims the invite
    418         // is from Carol; the zone pairs Alice with Mallory, not Carol.
    419         let malloryZone = FriendZone.zoneName(pairKey: FriendZone.pairKey(invitee, attacker))
    420         let ping = friendZonePing(
    421             kind: .invite, authorID: impersonated, addressee: invitee,
    422             sourceZoneName: malloryZone
    423         )
    424         #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: invitee))
    425     }
    426 
    427     // MARK: - M12: invite fast-accept source cap
    428 
    429     @Test("invite fast-accept source keeps ordinary puzzle data")
    430     func fastAcceptPuzzleSourceKeepsOrdinarySource() {
    431         let source = "Title: Fast\n\n\nAB\n\n\nA1. _ ~ AB"
    432         #expect(InviteCoordinator.fastAcceptPuzzleSource(source) == source)
    433     }
    434 
    435     @Test("invite fast-accept source drops empty and oversized payloads")
    436     func fastAcceptPuzzleSourceDropsEmptyAndOversizedSource() {
    437         let exactCap = String(repeating: "A", count: XD.maxSourceBytes)
    438         let oversized = String(repeating: "A", count: XD.maxSourceBytes + 1)
    439 
    440         #expect(InviteCoordinator.fastAcceptPuzzleSource(nil) == nil)
    441         #expect(InviteCoordinator.fastAcceptPuzzleSource("") == nil)
    442         #expect(InviteCoordinator.fastAcceptPuzzleSource(exactCap) == exactCap)
    443         #expect(InviteCoordinator.fastAcceptPuzzleSource(oversized) == nil)
    444     }
    445 }