Files
mobile-ios/RosettaTests/PendingChatRouteTests.swift

232 lines
8.2 KiB
Swift

import Testing
@testable import Rosetta
// MARK: - Pending Chat Route Expiry Tests
/// Tests for the push notification chat navigation fix.
/// Bug: stale `pendingChatRoute` was consumed on tab switches (`.onAppear`)
/// instead of only on cold start / foreground resume.
/// Fix: `consumeFreshPendingRoute()` checks timestamp < 3 seconds.
struct PendingChatRouteExpiryTests {
private let routeA = ChatRoute(publicKey: "02aaa", title: "Alice", username: "", verified: 0)
private let routeB = ChatRoute(publicKey: "02bbb", title: "Bob", username: "", verified: 0)
private func clearStatics() {
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = nil
}
// MARK: - Fresh Route Consumption
@Test("Fresh route (just set) is consumed successfully")
func freshRouteConsumed() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date()
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result != nil)
#expect(result?.publicKey == "02aaa")
#expect(result?.title == "Alice")
}
@Test("After consumption, both statics are nil")
func staticsNilAfterConsumption() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date()
_ = AppDelegate.consumeFreshPendingRoute()
#expect(AppDelegate.pendingChatRoute == nil)
#expect(AppDelegate.pendingChatRouteTimestamp == nil)
}
@Test("Second consumption returns nil (idempotent)")
func doubleConsumptionReturnsNil() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date()
let first = AppDelegate.consumeFreshPendingRoute()
let second = AppDelegate.consumeFreshPendingRoute()
#expect(first != nil)
#expect(second == nil)
}
// MARK: - Stale Route Rejection
@Test("Stale route (5s old) is rejected and cleared")
func staleRouteRejected() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-5)
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result == nil)
// Both statics must be cleared even on rejection
#expect(AppDelegate.pendingChatRoute == nil)
#expect(AppDelegate.pendingChatRouteTimestamp == nil)
}
@Test("Route exactly at expiry boundary (3s) is rejected")
func routeAtExpiryBoundary() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-3.0)
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result == nil)
}
@Test("Route just before expiry (2.9s) is consumed")
func routeJustBeforeExpiry() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-2.9)
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result != nil)
#expect(result?.publicKey == "02aaa")
}
@Test("Very old route (60s) is rejected")
func veryOldRouteRejected() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-60)
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result == nil)
}
// MARK: - Missing Data
@Test("Route without timestamp is rejected and cleared")
func routeWithoutTimestamp() {
clearStatics()
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = nil
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result == nil)
#expect(AppDelegate.pendingChatRoute == nil)
}
@Test("Timestamp without route is rejected and cleared")
func timestampWithoutRoute() {
clearStatics()
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = Date()
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result == nil)
#expect(AppDelegate.pendingChatRouteTimestamp == nil)
}
@Test("Both nil — returns nil, no crash")
func bothNil() {
clearStatics()
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result == nil)
}
// MARK: - Route Overwrite (rapid notification taps)
@Test("Second notification overwrites first — only second is consumed")
func overwriteWithSecondNotification() {
clearStatics()
// First notification
AppDelegate.pendingChatRoute = routeA
AppDelegate.pendingChatRouteTimestamp = Date()
// Second notification immediately after (overwrites)
AppDelegate.pendingChatRoute = routeB
AppDelegate.pendingChatRouteTimestamp = Date()
let result = AppDelegate.consumeFreshPendingRoute()
#expect(result != nil)
#expect(result?.publicKey == "02bbb")
#expect(result?.title == "Bob")
}
// MARK: - Expiry Constant
@Test("Expiry constant is 3 seconds")
func expiryConstantValue() {
#expect(AppDelegate.pendingRouteExpirySeconds == 3.0)
}
}
// MARK: - Tab Switch Scenario Tests
/// Simulates the exact bug scenario:
/// 1. Route set from notification tap
/// 2. .onReceive fails to consume (simulated by skipping)
/// 3. Tab switch triggers .onAppear stale route must NOT navigate
struct TabSwitchScenarioTests {
private let route = ChatRoute(publicKey: "02ccc", title: "Charlie", username: "", verified: 0)
private func clearStatics() {
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = nil
}
@Test("Stale route from 10s ago is NOT consumed on tab switch")
func staleRouteOnTabSwitch() {
clearStatics()
// Simulate: notification tapped 10 seconds ago, .onReceive missed it
AppDelegate.pendingChatRoute = route
AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-10)
// Simulate: .onAppear fires on tab switch
let consumed = AppDelegate.consumeFreshPendingRoute()
#expect(consumed == nil, "Stale route must NOT be consumed on tab switch")
#expect(AppDelegate.pendingChatRoute == nil, "Stale route must be cleared")
}
@Test("Fresh route from notification tap IS consumed on cold start .onAppear")
func freshRouteOnColdStart() {
clearStatics()
// Simulate: notification tapped just now, app is launching
AppDelegate.pendingChatRoute = route
AppDelegate.pendingChatRouteTimestamp = Date()
// Simulate: .onAppear fires on cold start (< 3s since notification tap)
let consumed = AppDelegate.consumeFreshPendingRoute()
#expect(consumed != nil, "Fresh route must be consumed on cold start")
#expect(consumed?.publicKey == "02ccc")
}
@Test("Fresh route consumed by .onReceive → didBecomeActive finds nil")
func onReceiveConsumesThenDidBecomeActiveNoOp() {
clearStatics()
// Simulate: notification posted + .onReceive fires (primary path)
AppDelegate.pendingChatRoute = route
AppDelegate.pendingChatRouteTimestamp = Date()
// .onReceive consumes (simulated by direct clear, same as ChatListView line 110-111)
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = nil
// didBecomeActiveNotification fires after
let consumed = AppDelegate.consumeFreshPendingRoute()
#expect(consumed == nil, "didBecomeActive must be no-op after .onReceive consumed")
}
@Test("Route set → first consumeFresh consumes → second consumeFresh returns nil")
func sequentialConsumption() {
clearStatics()
AppDelegate.pendingChatRoute = route
AppDelegate.pendingChatRouteTimestamp = Date()
// First call: .onAppear on cold start
let first = AppDelegate.consumeFreshPendingRoute()
// Second call: didBecomeActiveNotification a moment later
let second = AppDelegate.consumeFreshPendingRoute()
#expect(first != nil)
#expect(second == nil, "Only the first caller should get the route")
}
}