crossmate

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

EngagementCoordinatorTests.swift (19223B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("EngagementCoordinator")
      7 struct EngagementCoordinatorTests {
      8     @Test("room payload encodes v2 room credentials")
      9     func roomPayloadRoundTrip() throws {
     10         let createdAt = Date(timeIntervalSince1970: 100)
     11         let expiresAt = Date(timeIntervalSince1970: 700)
     12         let room = EngagementRoomCredentials(
     13             roomID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
     14             secret: Data(repeating: 7, count: 32).base64URLEncodedString(),
     15             createdAt: createdAt,
     16             expiresAt: expiresAt
     17         )
     18 
     19         let decoded = EngagementRoomCredentials.decode(try room.encoded())
     20 
     21         #expect(decoded == room)
     22         #expect(decoded?.ver == 2)
     23     }
     24 
     25     @Test("addressee matches author-wide and device-specific hails")
     26     func addresseeMatching() {
     27         #expect(EngagementAddressee.parse("alice")?.matches(authorID: "alice", deviceID: "phone") == true)
     28         #expect(EngagementAddressee.parse("alice:phone")?.matches(authorID: "alice", deviceID: "phone") == true)
     29         #expect(EngagementAddressee.parse("alice:pad")?.matches(authorID: "alice", deviceID: "phone") == false)
     30         #expect(EngagementAddressee.parse("bob:phone")?.matches(authorID: "alice", deviceID: "phone") == false)
     31         #expect(EngagementAddressee.parse(nil) == nil)
     32     }
     33 
     34     @Test("socket signatures are stable")
     35     func socketSignature() throws {
     36         let secret = Data(repeating: 1, count: 32).base64URLEncodedString()
     37         let roomID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
     38         let payload = EngagementSocketAuthenticator.signaturePayload(
     39             roomID: roomID,
     40             authorID: "alice",
     41             deviceID: "deviceA",
     42             timestamp: "1000",
     43             nonce: "nonce"
     44         )
     45 
     46         let first = try EngagementSocketAuthenticator.signature(payload: payload, secret: secret)
     47         let second = try EngagementSocketAuthenticator.signature(payload: payload, secret: secret)
     48 
     49         #expect(first == second)
     50         #expect(!first.isEmpty)
     51     }
     52 
     53     @Test("socket URL preserves configured WebSocket endpoint")
     54     @MainActor
     55     func socketURL() throws {
     56         let room = EngagementRoomCredentials(
     57             roomID: UUID(uuidString: "23232323-2323-2323-2323-232323232323")!,
     58             secret: Data(repeating: 2, count: 32).base64URLEncodedString(),
     59             createdAt: Date(timeIntervalSince1970: 100),
     60             expiresAt: Date(timeIntervalSince1970: 700)
     61         )
     62 
     63         let maybeURL = try EngagementHost.socketURL(
     64             room: room,
     65             authorID: "alice",
     66             deviceID: "deviceA",
     67             baseURL: URL(string: "wss://example.org")
     68         )
     69         let url = try #require(maybeURL)
     70 
     71         #expect(url.scheme == "wss")
     72         #expect(url.host() == "example.org")
     73         #expect(url.path == "/rooms/23232323-2323-2323-2323-232323232323/socket")
     74         let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
     75         #expect(queryItems?.contains(URLQueryItem(name: "authorID", value: "alice")) == true)
     76         // The room secret must never travel in the URL; the worker verifies
     77         // the signature against the secret registered at room registration.
     78         #expect(queryItems?.contains { $0.name == "secret" } == false)
     79         #expect(queryItems?.contains { $0.name == "signature" } == true)
     80     }
     81 
     82     @Test("registration request posts the secret in the body over HTTPS")
     83     @MainActor
     84     func registrationRequest() throws {
     85         let room = EngagementRoomCredentials(
     86             roomID: UUID(uuidString: "24242424-2424-2424-2424-242424242424")!,
     87             secret: Data(repeating: 4, count: 32).base64URLEncodedString(),
     88             createdAt: Date(timeIntervalSince1970: 100),
     89             expiresAt: Date(timeIntervalSince1970: 700)
     90         )
     91 
     92         let maybeRequest = try EngagementHost.registrationRequest(
     93             room: room,
     94             baseURL: URL(string: "wss://example.org")
     95         )
     96         let request = try #require(maybeRequest)
     97 
     98         #expect(request.url?.scheme == "https")
     99         #expect(request.url?.host() == "example.org")
    100         #expect(request.url?.path == "/rooms/24242424-2424-2424-2424-242424242424/register")
    101         #expect(request.url?.query() == nil)
    102         #expect(request.httpMethod == "POST")
    103         let body = try JSONDecoder().decode([String: String].self, from: try #require(request.httpBody))
    104         #expect(body == ["secret": room.secret])
    105     }
    106 
    107     @Test("debug message envelope round trips")
    108     func debugMessageRoundTrip() throws {
    109         let sentAt = Date(timeIntervalSince1970: 123)
    110         let message = EngagementMessage(text: "hello", sentAt: sentAt)
    111 
    112         let decoded = try #require(EngagementMessage.decode(try message.encodedData()))
    113 
    114         #expect(decoded.kind == .debugText)
    115         #expect(decoded.text == "hello")
    116         #expect(decoded.sentAt == sentAt)
    117         #expect(decoded.ver == 1)
    118     }
    119 
    120     @Test("cell edit message envelope round trips")
    121     func cellEditMessageRoundTrip() throws {
    122         let edit = RealtimeCellEdit(
    123             gameID: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!,
    124             authorID: "alice",
    125             deviceID: "deviceA",
    126             row: 1,
    127             col: 2,
    128             letter: "Z",
    129             mark: .pencil(checked: nil),
    130             updatedAt: Date(timeIntervalSince1970: 456),
    131             cellAuthorID: "alice"
    132         )
    133         let message = EngagementMessage(cellEdit: edit, sentAt: Date(timeIntervalSince1970: 789))
    134 
    135         let decoded = try #require(EngagementMessage.decode(try message.encodedData()))
    136 
    137         #expect(decoded.kind == .cellEdit)
    138         #expect(decoded.text == "")
    139         #expect(decoded.cellEdit == edit)
    140         #expect(decoded.sentAt == Date(timeIntervalSince1970: 789))
    141     }
    142 
    143     @Test("an oversized frame is rejected before JSON decoding")
    144     func oversizedFrameIsRejected() {
    145         #expect(EngagementMessage.decode(Data(count: EngagementMessage.maxEncodedFrameBytes + 1)) == nil)
    146     }
    147 
    148     @Test("a boundary-size edit batch round trips and an over-count batch is rejected")
    149     func batchEditCountIsBounded() throws {
    150         let atCap = EngagementMessage(cellEdits: makeEdits(count: EngagementMessage.maxBatchEdits))
    151         let atCapData = try atCap.encodedData()
    152         #expect(atCapData.count <= EngagementMessage.maxEncodedFrameBytes)
    153         #expect(EngagementMessage.decode(atCapData)?.cellEdits?.count == EngagementMessage.maxBatchEdits)
    154 
    155         let overCap = EngagementMessage(cellEdits: makeEdits(count: EngagementMessage.maxBatchEdits + 1))
    156         #expect(EngagementMessage.decode(try overCap.encodedData()) == nil)
    157     }
    158 
    159     @Test("frames with oversized strings or auth maps are rejected")
    160     func stringAndAuthBoundsAreEnforced() throws {
    161         func rejected(_ message: EngagementMessage) throws -> Bool {
    162             try EngagementMessage.decode(message.encodedData()) == nil
    163         }
    164 
    165         var longLetter = makeEdits(count: 1)[0]
    166         longLetter.letter = String(repeating: "A", count: EngagementMessage.maxLetterLength + 1)
    167         #expect(try rejected(EngagementMessage(cellEdit: longLetter)))
    168 
    169         var longAuthor = makeEdits(count: 1)[0]
    170         longAuthor.authorID = String(repeating: "a", count: EngagementMessage.maxIdentifierLength + 1)
    171         #expect(try rejected(EngagementMessage(cellEdit: longAuthor)))
    172 
    173         let longText = String(repeating: "x", count: EngagementMessage.maxTextLength + 1)
    174         #expect(try rejected(EngagementMessage(text: longText)))
    175 
    176         let manyTags = Dictionary(uniqueKeysWithValues: (0...EngagementMessage.maxAuthTags).map {
    177             ("author-\($0)", "tag")
    178         })
    179         #expect(try rejected(EngagementMessage(text: "hi", auth: manyTags)))
    180 
    181         let longTag = ["bob": String(repeating: "t", count: EngagementMessage.maxIdentifierLength + 1)]
    182         #expect(try rejected(EngagementMessage(text: "hi", auth: longTag)))
    183     }
    184 
    185     @Test("bulk gestures chunk at the batch cap with order preserved")
    186     func batchChunking() {
    187         let edits = makeEdits(count: EngagementMessage.maxBatchEdits * 2 + 5)
    188         let chunks = EngagementMessage.batchChunks(edits)
    189 
    190         #expect(chunks.map(\.count) == [EngagementMessage.maxBatchEdits, EngagementMessage.maxBatchEdits, 5])
    191         #expect(chunks.flatMap { $0 } == edits)
    192         #expect(EngagementMessage.batchChunks([]).isEmpty)
    193 
    194         let small = makeEdits(count: 3)
    195         #expect(EngagementMessage.batchChunks(small) == [small])
    196     }
    197 
    198     @Test("reconcile connects to the advertised room when a peer is present")
    199     @MainActor
    200     func reconcileConnectsToCreds() async throws {
    201         let gameID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!
    202         let host = MockEngagementHost()
    203         let coordinator = makeCoordinator(host: host)
    204         let room = roomCredentials()
    205 
    206         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true)
    207 
    208         #expect(host.connections.count == 1)
    209         #expect(host.connections.first?.room == room)
    210         #expect(host.connections.first?.authorID == "alice")
    211         #expect(host.connections.first?.deviceID == "deviceA")
    212     }
    213 
    214     @Test("reconcile does not connect without a present peer or without creds")
    215     @MainActor
    216     func reconcileSkipsWithoutPeerOrCreds() async throws {
    217         let gameID = UUID(uuidString: "34343434-3434-3434-3434-343434343434")!
    218         let host = MockEngagementHost()
    219         let coordinator = makeCoordinator(host: host)
    220         let room = roomCredentials()
    221 
    222         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: false)
    223         await coordinator.reconcile(gameID: gameID, creds: nil, hasPeer: true)
    224 
    225         #expect(host.connections.isEmpty)
    226     }
    227 
    228     @Test("send is skipped until the channel opens")
    229     @MainActor
    230     func sendIsSkippedUntilChannelOpens() async throws {
    231         let gameID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")!
    232         let host = MockEngagementHost()
    233         let coordinator = makeCoordinator(host: host)
    234 
    235         await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true)
    236         let engagementID = try #require(host.connections.first?.engagementID)
    237 
    238         await coordinator.sendDebugMessage(gameID: gameID, text: "too early")
    239         #expect(host.sentMessages.isEmpty)
    240 
    241         #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID)
    242         await coordinator.sendDebugMessage(gameID: gameID, text: "now ok")
    243         #expect(host.sentMessages.count == 1)
    244         #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "now ok")
    245     }
    246 
    247     @Test("a channel resolves to its game while connecting and live, and not after close")
    248     @MainActor
    249     func channelGameBinding() async throws {
    250         let gameID = UUID(uuidString: "56565656-5656-5656-5656-565656565656")!
    251         let host = MockEngagementHost()
    252         let coordinator = makeCoordinator(host: host)
    253 
    254         #expect(await coordinator.gameID(for: UUID()) == nil)
    255 
    256         await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true)
    257         let engagementID = try #require(host.connections.first?.engagementID)
    258         #expect(await coordinator.gameID(for: engagementID) == gameID)
    259 
    260         #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID)
    261         #expect(await coordinator.gameID(for: engagementID) == gameID)
    262 
    263         #expect(await coordinator.channelClosed(engagementID: engagementID) == gameID)
    264         #expect(await coordinator.gameID(for: engagementID) == nil)
    265     }
    266 
    267     @Test("reconcile migrates to a rotated room and drops the old connection")
    268     @MainActor
    269     func reconcileMigratesWhenCredsChange() async throws {
    270         let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")!
    271         let host = MockEngagementHost()
    272         let coordinator = makeCoordinator(host: host)
    273         let roomA = roomCredentials(roomID: UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!)
    274         let roomB = roomCredentials(roomID: UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!)
    275 
    276         await coordinator.reconcile(gameID: gameID, creds: roomA, hasPeer: true)
    277         let engagementA = try #require(host.connections.first?.engagementID)
    278         #expect(await coordinator.channelOpened(engagementID: engagementA) == gameID)
    279 
    280         await coordinator.reconcile(gameID: gameID, creds: roomB, hasPeer: true)
    281 
    282         #expect(host.connections.count == 2)
    283         #expect(host.connections.last?.room == roomB)
    284         #expect(host.disconnected == [engagementA])
    285     }
    286 
    287     @Test("reconcile leaves an unchanged room alone")
    288     @MainActor
    289     func reconcileNoOpWhenAlreadyConnected() async throws {
    290         let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")!
    291         let host = MockEngagementHost()
    292         let coordinator = makeCoordinator(host: host)
    293         let room = roomCredentials()
    294 
    295         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true)
    296         let engagementID = try #require(host.connections.first?.engagementID)
    297         #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID)
    298 
    299         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true)
    300 
    301         #expect(host.connections.count == 1)
    302         #expect(host.disconnected.isEmpty)
    303     }
    304 
    305     @Test("reconcile tears down a live channel when the peer leaves")
    306     @MainActor
    307     func reconcileTearsDownWhenPeerLeaves() async throws {
    308         let gameID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")!
    309         let host = MockEngagementHost()
    310         let coordinator = makeCoordinator(host: host)
    311         let room = roomCredentials()
    312 
    313         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true)
    314         let engagementID = try #require(host.connections.first?.engagementID)
    315         #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID)
    316 
    317         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: false)
    318 
    319         #expect(host.disconnected == [engagementID])
    320     }
    321 
    322     @Test("reconcile surfaces a room rejected by the worker")
    323     @MainActor
    324     func reconcileSurfacesRoomRejection() async throws {
    325         let gameID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")!
    326         let host = MockEngagementHost()
    327         host.connectError = EngagementHostError.roomSecretMismatch
    328         let coordinator = makeCoordinator(host: host)
    329 
    330         let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true)
    331 
    332         #expect(outcome == .roomRejected)
    333         #expect(host.connections.isEmpty)
    334     }
    335 
    336     @Test("transient connect failures do not report a rejected room")
    337     @MainActor
    338     func transientConnectFailureIsNotRejection() async throws {
    339         let gameID = UUID(uuidString: "45454545-4545-4545-4545-454545454545")!
    340         let host = MockEngagementHost()
    341         host.connectError = EngagementHostError.registrationFailed(statusCode: 503)
    342         let coordinator = makeCoordinator(host: host)
    343 
    344         let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true)
    345 
    346         #expect(outcome == .reconciled)
    347         #expect(host.connections.isEmpty)
    348     }
    349 
    350     @Test("a stale connecting attempt is swept and retried on the next reconcile")
    351     @MainActor
    352     func staleConnectingDemotesOnReconcile() async throws {
    353         let gameID = UUID(uuidString: "99999999-9999-9999-9999-999999999999")!
    354         let host = MockEngagementHost()
    355         let clock = TestClock(time: Date(timeIntervalSince1970: 10_000))
    356         let coordinator = makeCoordinator(host: host, now: { clock.now }, connectionTimeout: 30)
    357         let room = roomCredentials()
    358 
    359         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true)
    360         let firstEngagementID = try #require(host.connections.first?.engagementID)
    361 
    362         clock.advance(by: 31)
    363         await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true)
    364 
    365         #expect(host.disconnected == [firstEngagementID])
    366         #expect(host.connections.count == 2)
    367     }
    368 
    369     @MainActor
    370     private func makeCoordinator(
    371         host: MockEngagementHost,
    372         now: @escaping @Sendable () -> Date = Date.init,
    373         connectionTimeout: TimeInterval = 30
    374     ) -> EngagementCoordinator {
    375         EngagementCoordinator(
    376             host: host,
    377             localAuthorID: { "alice" },
    378             localDeviceID: "deviceA",
    379             now: now,
    380             connectionTimeout: connectionTimeout
    381         )
    382     }
    383 
    384     private func makeEdits(count: Int) -> [RealtimeCellEdit] {
    385         (0..<count).map { index in
    386             RealtimeCellEdit(
    387                 gameID: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!,
    388                 authorID: "alice",
    389                 deviceID: "deviceA",
    390                 row: index / 128,
    391                 col: index % 128,
    392                 letter: "A",
    393                 mark: .pen(checked: nil),
    394                 updatedAt: Date(timeIntervalSince1970: 456),
    395                 cellAuthorID: "alice"
    396             )
    397         }
    398     }
    399 
    400     private func roomCredentials(
    401         roomID: UUID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")!,
    402         expiresAt: Date = .distantFuture
    403     ) -> EngagementRoomCredentials {
    404         EngagementRoomCredentials(
    405             roomID: roomID,
    406             secret: Data(repeating: 3, count: 32).base64URLEncodedString(),
    407             createdAt: Date(timeIntervalSince1970: 1_000),
    408             expiresAt: expiresAt
    409         )
    410     }
    411 }
    412 
    413 private final class TestClock: @unchecked Sendable {
    414     private let lock = NSLock()
    415     private var current: Date
    416 
    417     init(time: Date) {
    418         self.current = time
    419     }
    420 
    421     var now: Date {
    422         lock.lock()
    423         defer { lock.unlock() }
    424         return current
    425     }
    426 
    427     func advance(by seconds: TimeInterval) {
    428         lock.lock()
    429         defer { lock.unlock() }
    430         current = current.addingTimeInterval(seconds)
    431     }
    432 }
    433 
    434 @MainActor
    435 private final class MockEngagementHost: EngagementTransporting, @unchecked Sendable {
    436     struct Connection: Equatable {
    437         var engagementID: UUID
    438         var room: EngagementRoomCredentials
    439         var authorID: String
    440         var deviceID: String
    441     }
    442 
    443     var connections: [Connection] = []
    444     var disconnected: [UUID] = []
    445     var sentMessages: [(engagementID: UUID, message: Data)] = []
    446     var connectError: Error?
    447 
    448     func connect(
    449         engagementID: UUID,
    450         room: EngagementRoomCredentials,
    451         authorID: String,
    452         deviceID: String
    453     ) async throws {
    454         if let connectError {
    455             throw connectError
    456         }
    457         connections.append(Connection(
    458             engagementID: engagementID,
    459             room: room,
    460             authorID: authorID,
    461             deviceID: deviceID
    462         ))
    463     }
    464 
    465     func send(engagementID: UUID, message: Data) async throws {
    466         sentMessages.append((engagementID, message))
    467     }
    468 
    469     func disconnect(engagementID: UUID) {
    470         disconnected.append(engagementID)
    471     }
    472 }