crossmate

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

FriendModelTests.swift (7799B)


      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.friendZoneName = "friend-k1"
     25         active.friendZoneOwnerName = CKCurrentUserDefaultName
     26         active.databaseScope = 0
     27         active.isBlocked = false
     28         active.createdAt = Date()
     29 
     30         let blocked = FriendEntity(context: ctx)
     31         blocked.authorID = "_blocked"
     32         blocked.pairKey = "k2"
     33         blocked.friendZoneName = "friend-k2"
     34         blocked.friendZoneOwnerName = CKCurrentUserDefaultName
     35         blocked.databaseScope = 1
     36         blocked.isBlocked = true
     37         blocked.createdAt = Date()
     38         try ctx.save()
     39 
     40         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
     41         req.predicate = NSPredicate(format: "isBlocked == NO")
     42         let result = try ctx.fetch(req)
     43         #expect(result.map { $0.authorID } == ["_active"])
     44     }
     45 
     46     @Test("pending-status predicate and pingRecordName dedup work")
     47     func invitePredicates() throws {
     48         let persistence = makeTestPersistence()
     49         let ctx = persistence.viewContext
     50 
     51         let pending = InviteEntity(context: ctx)
     52         pending.gameID = UUID()
     53         pending.gameTitle = "Saturday"
     54         pending.inviterAuthorID = "_alice"
     55         pending.inviterName = "Alice"
     56         pending.shareURL = "https://www.icloud.com/share/abc"
     57         pending.pingRecordName = "ping-1"
     58         pending.status = "pending"
     59         pending.createdAt = Date()
     60 
     61         let declined = InviteEntity(context: ctx)
     62         declined.gameID = UUID()
     63         declined.inviterAuthorID = "_bob"
     64         declined.shareURL = "https://www.icloud.com/share/def"
     65         declined.pingRecordName = "ping-2"
     66         declined.status = "declined"
     67         declined.createdAt = Date()
     68         try ctx.save()
     69 
     70         let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
     71         pendingReq.predicate = NSPredicate(format: "status == %@", "pending")
     72         #expect(try ctx.fetch(pendingReq).map { $0.pingRecordName } == ["ping-1"])
     73 
     74         // A declined tombstone is still found by the dedup lookup, so a
     75         // re-fetched Ping won't resurrect it.
     76         let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
     77         dupReq.predicate = NSPredicate(format: "pingRecordName == %@", "ping-2")
     78         #expect(try ctx.count(for: dupReq) == 1)
     79     }
     80 
     81     @Test("declined invite tombstone makes the source ping stale")
     82     func declinedInvitePingIsStale() throws {
     83         let persistence = makeTestPersistence()
     84         let ctx = persistence.viewContext
     85         let gameID = UUID()
     86 
     87         let declined = InviteEntity(context: ctx)
     88         declined.gameID = gameID
     89         declined.inviterAuthorID = "_alice"
     90         declined.shareURL = "https://www.icloud.com/share/def"
     91         declined.pingRecordName = "ping-\(gameID.uuidString)-_alice-device-1"
     92         declined.status = "declined"
     93         declined.createdAt = Date()
     94         try ctx.save()
     95 
     96         let payload = FriendZone.InvitePayload(
     97             gameShareURL: "https://www.icloud.com/share/def"
     98         ).encodedString()
     99         let ping = Ping(
    100             recordName: declined.pingRecordName!,
    101             gameID: gameID,
    102             authorID: "_alice",
    103             deviceID: "device",
    104             playerName: "Alice",
    105             puzzleTitle: "Saturday",
    106             kind: .invite,
    107             payload: payload,
    108             addressee: "_me"
    109         )
    110 
    111         let stale = InviteCoordinator.staleInviteRecordNames(
    112             among: [ping],
    113             in: ctx,
    114             currentAuthorID: "_me"
    115         )
    116         #expect(stale == [ping.recordName])
    117     }
    118 
    119     @Test("malformed invite payload makes the source ping stale")
    120     func malformedInvitePayloadIsStale() {
    121         let persistence = makeTestPersistence()
    122         let ctx = persistence.viewContext
    123         let gameID = UUID()
    124         let ping = Ping(
    125             recordName: "ping-\(gameID.uuidString)-_alice-device-1",
    126             gameID: gameID,
    127             authorID: "_alice",
    128             deviceID: "device",
    129             playerName: "Alice",
    130             puzzleTitle: "Saturday",
    131             kind: .invite,
    132             payload: nil,
    133             addressee: "_me"
    134         )
    135 
    136         let stale = InviteCoordinator.staleInviteRecordNames(
    137             among: [ping],
    138             in: ctx,
    139             currentAuthorID: "_me"
    140         )
    141         #expect(stale == [ping.recordName])
    142     }
    143 
    144     @Test("knownZones predicate excludes a blocked friend zone")
    145     func blockedFriendExcludedFromKnownZones() throws {
    146         let persistence = makeTestPersistence()
    147         let ctx = persistence.viewContext
    148 
    149         for (suffix, blocked) in [("ok", false), ("no", true)] {
    150             let f = FriendEntity(context: ctx)
    151             f.authorID = "_\(suffix)"
    152             f.pairKey = suffix
    153             f.friendZoneName = "friend-\(suffix)"
    154             f.friendZoneOwnerName = "_owner"
    155             f.databaseScope = 1
    156             f.isBlocked = blocked
    157             f.createdAt = Date()
    158         }
    159         try ctx.save()
    160 
    161         // Exactly the predicate used by SyncEngine.knownZones.
    162         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    163         req.predicate = NSPredicate(format: "databaseScope == %d AND isBlocked == NO", 1)
    164         #expect(try ctx.fetch(req).map { $0.friendZoneName } == ["friend-ok"])
    165     }
    166 
    167     @Test("invites are scoped by inviterAuthorID for block cleanup")
    168     func invitesByInviterPredicate() throws {
    169         let persistence = makeTestPersistence()
    170         let ctx = persistence.viewContext
    171 
    172         for (i, inviter) in ["_alice", "_alice", "_bob"].enumerated() {
    173             let invite = InviteEntity(context: ctx)
    174             invite.gameID = UUID()
    175             invite.inviterAuthorID = inviter
    176             invite.shareURL = "https://x/\(i)"
    177             invite.pingRecordName = "ping-\(i)"
    178             invite.status = "pending"
    179             invite.createdAt = Date()
    180         }
    181         try ctx.save()
    182 
    183         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    184         req.predicate = NSPredicate(format: "inviterAuthorID == %@", "_alice")
    185         #expect(try ctx.count(for: req) == 2)
    186     }
    187 
    188     @Test("a pending invite whose game exists locally is detectable for GC")
    189     func staleInviteDetectableForGC() throws {
    190         let persistence = makeTestPersistence()
    191         let ctx = persistence.viewContext
    192         let gameID = UUID()
    193 
    194         let invite = InviteEntity(context: ctx)
    195         invite.gameID = gameID
    196         invite.inviterAuthorID = "_alice"
    197         invite.shareURL = "https://x"
    198         invite.pingRecordName = "ping-1"
    199         invite.status = "pending"
    200         invite.createdAt = Date()
    201 
    202         let game = GameEntity(context: ctx)
    203         game.id = gameID
    204         game.title = "Joined"
    205         game.puzzleSource = ""
    206         game.createdAt = Date()
    207         game.updatedAt = Date()
    208         try ctx.save()
    209 
    210         // The exact GC lookup from applyInvitePings.
    211         let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    212         gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    213         #expect(try ctx.count(for: gReq) == 1)
    214     }
    215 }