import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createQrLogin } from '../../public/js/skland/scan.js'; const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; function fakeClock() { let time = 0, id = 0; const tasks = new Map(); return { now: () => time, timers: { setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; }, clearTimeout: (key) => tasks.delete(key), }, count: () => tasks.size, async advance(ms) { const target = time + ms; for (;;) { const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0]; if (!next || next[1].at > target) break; time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush(); } time = target; await flush(); }, }; } function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) { const clock = fakeClock(), calls = [], images = [], pending = [], errors = []; let confirmed = 0, starts = 0; const scan = createQrLogin({ ...clock, request: async (path) => { calls.push({ path, at: clock.now() }); return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll(); }, encode: async (url) => `image:${url}`, onQr: (value) => images.push(value), onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); }, onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); }, }); return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed }; } test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => { const f = fixture(); f.scan.setActive(true); await f.clock.advance(0); assert.deepEqual(f.images, ['image:QR1']); await f.clock.advance(9999); assert.equal(f.starts(), 1); await f.clock.advance(1); assert.equal(f.starts(), 2); assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]); await f.clock.advance(10000); assert.equal(f.starts(), 3); f.scan.stop(); assert.equal(f.clock.count(), 0); }); test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => { let attempts = 0; const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => { if (++attempts === 1) throw new Error('status offline'); }); f.scan.setActive(true); await f.clock.advance(2000); assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline'); await f.clock.advance(10000); assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0); f.scan.stop(); }); test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => { const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true })); f.scan.setActive(true); await f.clock.advance(2000); assert.equal(f.pending.at(-1), true); await f.clock.advance(59999); assert.equal(f.starts(), 1); await f.clock.advance(1); assert.equal(f.starts(), 2); assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000); assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval'); f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too'); f.scan.stop(); }); test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => { let f; f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 })); f.scan.setActive(true); await f.clock.advance(10000); assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true); await f.clock.advance(59999); assert.equal(f.starts(), 1); await f.clock.advance(1); assert.equal(f.starts(), 2); f.scan.stop(); }); test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => { const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true })); f.scan.setActive(true); await f.clock.advance(0); f.scan.setActive(false); assert.equal(f.clock.count(), 0); await f.clock.advance(50000); assert.equal(f.calls.length, 1); f.scan.setActive(true); await f.clock.advance(0); assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true); f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2); }); test('confirmation wins over a manual refresh queued during an in-flight poll', async () => { let resolvePoll; const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; })); f.scan.setActive(true); await f.clock.advance(2000); f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll'); resolvePoll({ ok: true, state: 'confirmed' }); await flush(); assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0); await f.clock.advance(120000); assert.equal(f.starts(), 1); f.scan.stop(); }); test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => { let resolvePoll; const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; })); stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop(); resolvePoll({ ok: true, state: 'confirmed' }); await flush(); assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0); const retry = fixture(() => { throw new Error('offline'); }); retry.scan.setActive(true); await retry.clock.advance(2000); assert.equal(retry.errors[0].message, 'offline'); await retry.clock.advance(9999); assert.equal(retry.starts(), 1); await retry.clock.advance(1); assert.equal(retry.starts(), 2); retry.scan.stop(); });