NicknameDirectoryTests.swift (6450B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("Nickname directory") 8 struct NicknameDirectoryTests { 9 10 /// Runs `body` with the directory backed by a per-test temporary file so 11 /// parallel tests never touch the shared App Group container. 12 private func withTemporaryDirectoryFile( 13 _ body: @Sendable () async throws -> Void 14 ) async throws { 15 let url = FileManager.default.temporaryDirectory 16 .appendingPathComponent("nickname-directory-\(UUID().uuidString).json") 17 defer { try? FileManager.default.removeItem(at: url) } 18 try await NicknameDirectory.$testingFileURL.withValue(url) { 19 try await body() 20 } 21 } 22 23 // MARK: - File round trip 24 25 @Test("save/load/entry round-trips through the backing file") 26 func fileRoundTrip() async throws { 27 try await withTemporaryDirectoryFile { 28 #expect(NicknameDirectory.load().isEmpty) 29 let directory = [ 30 "_alice": NicknameDirectory.Entry(nickname: "Mum"), 31 "_bob": NicknameDirectory.Entry(nickname: "Bobby") 32 ] 33 NicknameDirectory.save(directory) 34 #expect(NicknameDirectory.load() == directory) 35 #expect(NicknameDirectory.entry(for: "_alice")?.nickname == "Mum") 36 #expect(NicknameDirectory.entry(for: "_carol") == nil) 37 #expect(NicknameDirectory.entry(for: "") == nil) 38 } 39 } 40 41 @Test("saving an empty directory removes the file") 42 func emptySaveRemovesFile() async throws { 43 try await withTemporaryDirectoryFile { 44 NicknameDirectory.save( 45 ["_alice": NicknameDirectory.Entry(nickname: "Mum")] 46 ) 47 #expect(!NicknameDirectory.load().isEmpty) 48 NicknameDirectory.save([:]) 49 #expect(NicknameDirectory.load().isEmpty) 50 } 51 } 52 53 // MARK: - Rebuild from Core Data 54 55 @Test("rebuildNicknameDirectory mirrors nicknamed friends and skips the rest") 56 func rebuildFromCoreData() async throws { 57 try await withTemporaryDirectoryFile { 58 try await MainActor.run { 59 let persistence = makeTestPersistence() 60 let ctx = persistence.viewContext 61 62 func addFriend( 63 authorID: String, 64 displayName: String?, 65 nickname: String?, 66 isBlocked: Bool = false 67 ) { 68 let friend = FriendEntity(context: ctx) 69 friend.authorID = authorID 70 friend.pairKey = "pair-\(authorID)" 71 friend.friendZoneName = "friend-pair-\(authorID)" 72 friend.friendZoneOwnerName = "_owner" 73 friend.databaseScope = 0 74 friend.createdAt = Date() 75 friend.displayName = displayName 76 friend.nickname = nickname 77 friend.isBlocked = isBlocked 78 } 79 addFriend(authorID: "_alice", displayName: "Alice", nickname: "Mum") 80 addFriend(authorID: "_bob", displayName: nil, nickname: "Bobby") 81 addFriend(authorID: "_carol", displayName: "Carol", nickname: nil) 82 addFriend(authorID: "_dave", displayName: "Dave", nickname: "X", isBlocked: true) 83 try ctx.save() 84 85 FriendEntity.rebuildNicknameDirectory(in: ctx) 86 87 let directory = NicknameDirectory.load() 88 #expect(directory == [ 89 "_alice": NicknameDirectory.Entry(nickname: "Mum"), 90 "_bob": NicknameDirectory.Entry(nickname: "Bobby") 91 ]) 92 93 // Clearing the last nickname empties the directory again. 94 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 95 for friend in (try? ctx.fetch(req)) ?? [] { 96 friend.nickname = nil 97 } 98 try ctx.save() 99 FriendEntity.rebuildNicknameDirectory(in: ctx) 100 #expect(NicknameDirectory.load().isEmpty) 101 } 102 } 103 } 104 105 // MARK: - Invite rows 106 107 @Test("resolvedInviterName prefers the nickname, then the shipped name") 108 @MainActor func resolvedInviterName() throws { 109 let persistence = makeTestPersistence() 110 let ctx = persistence.viewContext 111 112 func addInvite(inviterAuthorID: String, inviterName: String?) -> InviteEntity { 113 let invite = InviteEntity(context: ctx) 114 invite.gameID = UUID() 115 invite.gameTitle = "Saturday Puzzle" 116 invite.inviterAuthorID = inviterAuthorID 117 invite.inviterName = inviterName 118 invite.pingRecordName = "ping-\(UUID().uuidString)" 119 invite.shareURL = "https://example.com/share" 120 invite.createdAt = Date() 121 return invite 122 } 123 124 let friend = FriendEntity(context: ctx) 125 friend.authorID = "_alice" 126 friend.pairKey = "pair-alice" 127 friend.friendZoneName = "friend-pair-alice" 128 friend.friendZoneOwnerName = "_owner" 129 friend.databaseScope = 0 130 friend.createdAt = Date() 131 friend.nickname = "Mum" 132 133 let nicknamed = addInvite(inviterAuthorID: "_alice", inviterName: "Alice") 134 let plain = addInvite(inviterAuthorID: "_bob", inviterName: "Bob") 135 let unnamed = addInvite(inviterAuthorID: "_carol", inviterName: "") 136 try ctx.save() 137 138 #expect(nicknamed.resolvedInviterName == "Mum") 139 #expect(plain.resolvedInviterName == "Bob") 140 #expect(unnamed.resolvedInviterName == nil) 141 } 142 143 // MARK: - Invite banner substitution 144 145 @Test("Invite body prefers the nickname over the inviter's own name") 146 func inviteBodyNickname() { 147 let ping = Ping( 148 recordName: "ping-test-nickname", 149 gameID: UUID(), 150 authorID: "_alice", 151 deviceID: "device-a", 152 playerName: "Alice", 153 puzzleTitle: "Saturday Puzzle", 154 kind: .invite, 155 payload: nil, 156 addressee: "_bob" 157 ) 158 #expect(InviteCoordinator.bodyText(for: ping, nickname: "Mum") 159 == "Mum invited you to the puzzle 'Saturday Puzzle'") 160 #expect(InviteCoordinator.bodyText(for: ping) 161 == "Alice invited you to the puzzle 'Saturday Puzzle'") 162 } 163 }