Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions QueueItKit/view/QueueItViewManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions QueueItKitTests/MockListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class MockListener: QueueListener {
var expectation: XCTestExpectation?

var onQueuePassedCalled = false
var onQueuePassedCallCount = 0
var lastQueuePassedInfo: QueuePassedInfo?

var onQueueDisabledCalled = false
Expand All @@ -23,6 +24,7 @@ class MockListener: QueueListener {

func onQueuePassed(_ info: QueuePassedInfo) {
onQueuePassedCalled = true
onQueuePassedCallCount += 1
lastQueuePassedInfo = info
expectation?.fulfill()
}
Expand Down
84 changes: 82 additions & 2 deletions QueueItKitTests/QueueItViewManagerTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import XCTest
import WebKit
@testable import QueueItKit

class QueueItViewManagerTests: XCTestCase {
Expand Down Expand Up @@ -138,18 +139,97 @@ class QueueItViewManagerTests: XCTestCase {
// Given
let options = QueueItEngineOptions()
let waitingRoomDomain = "queue.mydomain.com"

// When
let viewManagerWithProxy = QueueItViewManager(
queueListener: listener,
options: options,
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 <targetUrl>?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 }
}