Fix push-навигация: stale pendingChatRoute вызывал переход в чужой чат при переключении табов
This commit is contained in:
231
RosettaTests/PendingChatRouteTests.swift
Normal file
231
RosettaTests/PendingChatRouteTests.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user