diff --git a/QueueItKit/view/QueueItViewManager.swift b/QueueItKit/view/QueueItViewManager.swift index 9d10479..ba88f2d 100644 --- a/QueueItKit/view/QueueItViewManager.swift +++ b/QueueItKit/view/QueueItViewManager.swift @@ -22,6 +22,11 @@ public class QueueItViewManager: NSObject, ObservableObject, WKNavigationDelegat private var userId: String? var isShowQueueCalled: Bool = false + // One-shot guard so onQueuePassed is delivered to the listener only once per + // queue presentation, even if the URI overrider classifies multiple navigation + // steps as `.passed` during the queue-exit redirect chain. Reset in showQueue + // so re-presentation works. + private var hasFiredQueuePassed: Bool = false @MainActor init(queueListener: QueueListener?, options: QueueItEngineOptions, webView: WKWebView?, waitingRoomDomain: String? = nil, queuePathPrefix: String? = nil) { @@ -57,6 +62,7 @@ public class QueueItViewManager: NSObject, ObservableObject, WKNavigationDelegat public func showQueue(_ queueTryPassResult: QueueTryPassResult) { isShowQueueCalled = true + hasFiredQueuePassed = false guard let queueUrl = queueTryPassResult.queueUrl else { QueueItLogger.error(QueueItViewManager.TAG, "Cannot show queue, Queue URL is missing.") self.queueListener?.onError(QueueError(.invalidResponse), errorMessage: "Missing Queue URL for display") @@ -160,6 +166,11 @@ public class QueueItViewManager: NSObject, ObservableObject, WKNavigationDelegat case .passed: QueueItLogger.info(QueueItViewManager.TAG, "Intercept: Queue Passed. Token: \(result.queueItToken ?? "nil")") decisionHandler(.cancel, preferences) + guard !self.hasFiredQueuePassed else { + QueueItLogger.debug(QueueItViewManager.TAG, "Intercept: Queue Passed already fired; suppressing duplicate listener callback.") + return + } + self.hasFiredQueuePassed = true self.hideQueue() let queuePassedInfo = QueuePassedInfo(queueItToken: result.queueItToken) self.queueListener?.onQueuePassed(queuePassedInfo) diff --git a/QueueItKitTests/MockListener.swift b/QueueItKitTests/MockListener.swift index afcc585..633831e 100644 --- a/QueueItKitTests/MockListener.swift +++ b/QueueItKitTests/MockListener.swift @@ -5,6 +5,7 @@ class MockListener: QueueListener { var expectation: XCTestExpectation? var onQueuePassedCalled = false + var onQueuePassedCallCount = 0 var lastQueuePassedInfo: QueuePassedInfo? var onQueueDisabledCalled = false @@ -23,6 +24,7 @@ class MockListener: QueueListener { func onQueuePassed(_ info: QueuePassedInfo) { onQueuePassedCalled = true + onQueuePassedCallCount += 1 lastQueuePassedInfo = info expectation?.fulfill() } diff --git a/QueueItKitTests/QueueItViewManagerTests.swift b/QueueItKitTests/QueueItViewManagerTests.swift index 1e8c0a3..186dad6 100644 --- a/QueueItKitTests/QueueItViewManagerTests.swift +++ b/QueueItKitTests/QueueItViewManagerTests.swift @@ -1,4 +1,5 @@ import XCTest +import WebKit @testable import QueueItKit class QueueItViewManagerTests: XCTestCase { @@ -138,7 +139,7 @@ class QueueItViewManagerTests: XCTestCase { // Given let options = QueueItEngineOptions() let waitingRoomDomain = "queue.mydomain.com" - + // When let viewManagerWithProxy = QueueItViewManager( queueListener: listener, @@ -146,10 +147,89 @@ class QueueItViewManagerTests: XCTestCase { webView: nil, waitingRoomDomain: waitingRoomDomain ) - + // Then XCTAssertNotNil(viewManagerWithProxy) XCTAssertNotNil(viewManagerWithProxy.webView) } + + // MARK: - onQueuePassed dedupe + + /// Three consecutive `.passed` navigation intercepts during a single queue + /// presentation should result in exactly one `onQueuePassed` callback to the + /// listener. Reproduces the case where WKWebView's queue-exit redirect chain + /// hits decidePolicyFor multiple times before hideQueue's about:blank load + /// suppresses further navigations. + @MainActor + func test_decidePolicyFor_passed_firesListenerOnceAcrossMultipleIntercepts() async { + // Given a viewManager configured with a known target URL so the URI + // overrider classifies ?queueittoken=... as `.passed`. + let targetUrl = "https://shop.example.com/checkout" + let queueResult = QueueTryPassResult( + queueItToken: "~rt_queue~initialToken", + queueUrl: "https://example.queue-it.net/queue", + targetUrl: targetUrl, + isPassedThrough: false, + redirectType: .queue, + urlTTLInMinutes: 5 + ) + viewManager?.showQueue(queueResult) + + // When three queue-exit navigations arrive in sequence, each carrying a + // fresh queueittoken (mimicking the live SDK's behavior where every + // redirect step yields a regenerated token). + let tokens = [ + "e_test~ts_1~q_abc~rt_queue~h_aaa", + "e_test~ts_2~q_abc~rt_queue~h_bbb", + "e_test~ts_3~q_abc~rt_queue~h_ccc" + ] + // The delegate method ignores the first arg beyond identity; a throwaway + // WKWebView keeps the test independent of viewManager's own webView property. + let stubWebView = WKWebView() + for token in tokens { + guard let url = URL(string: "\(targetUrl)?queueittoken=\(token)") else { + XCTFail("Could not construct test URL") + return + } + let action = StubMainFrameNavigationAction(url: url) + let expect = expectation(description: "decisionHandler called for \(token)") + viewManager?.webView( + stubWebView, + decidePolicyFor: action, + preferences: WKWebpagePreferences() + ) { _, _ in + expect.fulfill() + } + await fulfillment(of: [expect], timeout: 1.0) + } + + // Then the listener was called exactly once and remembers the token + // from the *first* intercept (subsequent ones are suppressed). + XCTAssertEqual(listener.onQueuePassedCallCount, 1, + "Expected onQueuePassed to fire once across multiple .passed intercepts") + XCTAssertEqual(listener.lastQueuePassedInfo?.queueItToken, tokens.first) + } +} + +// MARK: - Test doubles + +/// Minimal WKNavigationAction stub that supplies a request URL and reports the +/// target frame as the main frame so the ViewManager's intercept logic runs. +private final class StubMainFrameNavigationAction: WKNavigationAction { + private let stubRequest: URLRequest + private let stubFrame: StubMainFrame + + init(url: URL) { + self.stubRequest = URLRequest(url: url) + self.stubFrame = StubMainFrame() + super.init() + } + + override var request: URLRequest { stubRequest } + override var targetFrame: WKFrameInfo? { stubFrame } +} + +private final class StubMainFrame: WKFrameInfo { + override var isMainFrame: Bool { true } }