fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll

The poll-until-confident loop (prev commit) opened a race: during its ~8s window a
subscriber could scan their card/QR at the reader and exit immediately — but the ANPR
loop kept polling and would ALSO emit a confident read a moment later, exiting the
NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open).

Guard it with the subscriber's open-occurrence count: the bridge identifies the
subscription as soon as a frame reads the bound plate (identity needs no confidence),
baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a
credential closed/opened an occurrence mid-poll, the subscriber already transacted →
abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is
unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite).

+1 test (credential transacts mid-poll → no double-act); 170 server tests green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 23:05:31 +02:00
parent 513566c89e
commit 2a13b95da6
3 changed files with 78 additions and 2 deletions
+29 -2
View File
@@ -86,8 +86,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
}
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
function fakeSubFlow(
match: SubscriptionMatch | null,
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
// credential closing an occurrence mid-poll → count changes).
openCounts: number | number[] = 1,
): SubscriptionFlow {
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
return {
match: vi.fn(() => match),
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
} as unknown as SubscriptionFlow;
}
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
@@ -152,6 +161,10 @@ describe("AnprBridge", () => {
tookMs: 1,
})),
} as unknown as VisionClient;
// Generous window so all 3 escalation attempts run deterministically under suite load
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "2000";
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
@@ -160,6 +173,20 @@ describe("AnprBridge", () => {
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
});
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
const bridge = new AnprBridge(db, vision, sub, silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]); // aborted — the credential already handled it
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });