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 }