crossmate

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

AppDelegatePushBufferTests.swift (6077B)


      1 import Foundation
      2 import Testing
      3 import UIKit
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("App delegate push buffering")
      8 @MainActor
      9 struct AppDelegatePushBufferTests {
     10     private actor StartupProbe {
     11         private(set) var started = false
     12         private(set) var finished = false
     13         private(set) var callbackReturned = false
     14         private var continuation: CheckedContinuation<Void, Never>?
     15         private var resumeRequested = false
     16 
     17         func pause() async {
     18             started = true
     19             if resumeRequested {
     20                 finished = true
     21                 return
     22             }
     23             await withCheckedContinuation { continuation = $0 }
     24             finished = true
     25         }
     26 
     27         func resume() {
     28             if let continuation {
     29                 continuation.resume()
     30                 self.continuation = nil
     31             } else {
     32                 resumeRequested = true
     33             }
     34         }
     35 
     36         func noteCallbackReturned() {
     37             callbackReturned = true
     38         }
     39     }
     40 
     41     @Test("An APNs token that beats handler installation is replayed once")
     42     func tokenReplayedOnce() {
     43         let delegate = AppDelegate()
     44         let token = Data([0x01, 0x02, 0x03])
     45         delegate.application(
     46             UIApplication.shared,
     47             didRegisterForRemoteNotificationsWithDeviceToken: token
     48         )
     49 
     50         var received: [Data] = []
     51         delegate.onAPNsToken = { received.append($0) }
     52         #expect(received == [token])
     53 
     54         // The buffer is consumed by the replay — reinstalling the handler
     55         // must not deliver the token again.
     56         delegate.onAPNsToken = { received.append($0) }
     57         #expect(received == [token])
     58     }
     59 
     60     @Test("The APNs token goes straight through once the handler is installed")
     61     func tokenDeliveredDirectly() {
     62         let delegate = AppDelegate()
     63         var received: [Data] = []
     64         delegate.onAPNsToken = { received.append($0) }
     65 
     66         let token = Data([0x0a, 0x0b])
     67         delegate.application(
     68             UIApplication.shared,
     69             didRegisterForRemoteNotificationsWithDeviceToken: token
     70         )
     71         #expect(received == [token])
     72     }
     73 
     74     @Test("A registration failure that beats handler installation is replayed")
     75     func registrationFailureReplayed() {
     76         let delegate = AppDelegate()
     77         delegate.application(
     78             UIApplication.shared,
     79             didFailToRegisterForRemoteNotificationsWithError: NSError(domain: "test", code: 3)
     80         )
     81 
     82         var messages: [String] = []
     83         delegate.onAPNsRegistrationResult = { messages.append($0) }
     84         #expect(messages.count == 1)
     85         #expect(messages.first?.contains("FAILED") == true)
     86     }
     87 
     88     @Test("Pre-start remote notifications are buffered, coalesced by source, and drained in order")
     89     func remoteNotificationsBufferedAndDrained() async {
     90         let delegate = AppDelegate()
     91         let firstGame = UUID()
     92         let secondGame = UUID()
     93 
     94         // Two wakes from the same source coalesce to the newer one (which
     95         // carries the presence deadline); a different game stays a separate
     96         // entry.
     97         _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [
     98             "kind": "presence",
     99             "gameID": firstGame.uuidString,
    100             "senderDeviceID": "alice-device",
    101         ])
    102         _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [
    103             "kind": "presence",
    104             "gameID": firstGame.uuidString,
    105             "senderDeviceID": "alice-device",
    106             "presenceUntil": "2026-07-19T10:00:00Z",
    107         ])
    108         _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [
    109             "kind": "presence",
    110             "gameID": secondGame.uuidString,
    111             "senderDeviceID": "bob-device",
    112         ])
    113 
    114         var drained: [(gameID: UUID?, presenceUntil: Date?)] = []
    115         delegate.onRemoteNotification = { _, _, _, gameID, _, _, presenceUntil, _ in
    116             drained.append((gameID, presenceUntil))
    117         }
    118         for _ in 0..<1000 where drained.count < 2 { await Task.yield() }
    119 
    120         #expect(drained.count == 2)
    121         #expect(drained.first?.gameID == firstGame)
    122         #expect(drained.first?.presenceUntil != nil)
    123         #expect(drained.last?.gameID == secondGame)
    124 
    125         // The buffer is empty after the drain — a later push is delivered
    126         // directly, not re-queued.
    127         _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [
    128             "kind": "presence",
    129             "gameID": firstGame.uuidString,
    130             "senderDeviceID": "alice-device",
    131         ])
    132         #expect(drained.count == 3)
    133     }
    134 
    135     @Test("A background wake waits for racing startup and the buffered drain")
    136     func backgroundWakeAwaitsStartupAndDrain() async {
    137         let delegate = AppDelegate()
    138         let startup = StartupProbe()
    139         let gameID = UUID()
    140         var received: [UUID?] = []
    141 
    142         delegate.isInstalledApplicationDelegateForTesting = true
    143         delegate.startServicesForTesting = {
    144             await startup.pause()
    145             delegate.onRemoteNotification = { _, _, _, gameID, _, _, _, _ in
    146                 received.append(gameID)
    147             }
    148         }
    149 
    150         let resultTask = Task { @MainActor in
    151             let result = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [
    152                 "kind": "presence",
    153                 "gameID": gameID.uuidString,
    154                 "senderDeviceID": "alice-device",
    155             ])
    156             await startup.noteCallbackReturned()
    157             return result
    158         }
    159 
    160         for _ in 0..<1000 where !(await startup.started) { await Task.yield() }
    161         #expect(await startup.started)
    162         #expect(!(await startup.finished))
    163         #expect(!(await startup.callbackReturned))
    164         #expect(received.isEmpty)
    165 
    166         await startup.resume()
    167         #expect(await resultTask.value == .newData)
    168         #expect(await startup.finished)
    169         #expect(received == [gameID])
    170     }
    171 }