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 }