import { describe, it, expect } from 'vitest' import { parseHeatSyncLog, normalizeWebhookEvents, cardMatchesEvent, type DoorIngestEvent, } from '@/lib/door-log-logic' function ev(p: Partial): DoorIngestEvent { return { result: 'granted', cardNumber: null, cardUid: null, occurredAt: null, detail: '', dedupeKey: '', ...p } } describe('parseHeatSyncLog', () => { it('parses a granted event and reconstructs a small card number', () => { const log = '
\nG: 12345\ng: 0\n
' const ev = parseHeatSyncLog(log) expect(ev).toHaveLength(1) expect(ev[0].result).toBe('granted') expect(ev[0].cardNumber).toBe('12345') expect(ev[0].cardUid).toBeNull() }) it('reconstructs a card number split across the divisor (high*32767 + low)', () => { // 40000 = 1*32767 + 7233 const log = 'G: 7233\ng: 1' const ev = parseHeatSyncLog(log) expect(ev[0].cardNumber).toBe('40000') }) it('parses a denied event from D/d', () => { const log = 'D: 100\nd: 0' const ev = parseHeatSyncLog(log) expect(ev[0].result).toBe('denied') expect(ev[0].cardNumber).toBe('100') }) it('stamps best-effort H:M:E time-of-day when present', () => { const log = 'H: 9\nM: 5\nE: 3\nG: 50\ng: 0' const ev = parseHeatSyncLog(log) expect(ev[0].detail).toContain('09:05:03') expect(ev[0].dedupeKey).toContain('09:05:03') }) it('ignores empty ring slots (NUL keys) and unrelated keys', () => { const log = ': 0\nU: 1\nG: 7\ng: 0\n: 0' const ev = parseHeatSyncLog(log) expect(ev).toHaveLength(1) expect(ev[0].cardNumber).toBe('7') }) it('does not ingest raw reads (R/r)', () => { const log = 'R: 999\nr: 0' expect(parseHeatSyncLog(log)).toHaveLength(0) }) it('gives distinct dedupe keys to events in different slots, stable per slot', () => { const log = 'G: 1\ng: 0\nU: 1\nG: 1\ng: 0' const ev = parseHeatSyncLog(log) expect(ev).toHaveLength(2) expect(ev[0].dedupeKey).not.toBe(ev[1].dedupeKey) // Re-parsing the identical dump yields identical keys (re-poll dedupes). const again = parseHeatSyncLog(log) expect(again.map(e => e.dedupeKey)).toEqual(ev.map(e => e.dedupeKey)) }) it('caps the number of emitted events', () => { const log = Array.from({ length: 50 }, () => 'G: 1\ng: 0').join('\n') expect(parseHeatSyncLog(log, { maxEvents: 10 })).toHaveLength(10) }) it('returns nothing for an empty or junk body', () => { expect(parseHeatSyncLog('
\n
')).toHaveLength(0) expect(parseHeatSyncLog('not a log at all')).toHaveLength(0) }) it('ignores pathological over-long digit runs without throwing (hostile body)', () => { // Number('9'.repeat(400)) === Infinity and BigInt(Infinity) throws; the // 15-digit cap means this line never matches and is skipped. const log = `G: ${'9'.repeat(400)}\ng: 0\nG: 42\ng: 0` expect(() => parseHeatSyncLog(log)).not.toThrow() const ev = parseHeatSyncLog(log) expect(ev).toHaveLength(1) expect(ev[0].cardNumber).toBe('42') }) it('treats a high-half as 0 when missing within the window', () => { const log = 'G: 7' const ev = parseHeatSyncLog(log) expect(ev[0].cardNumber).toBe('7') }) it('does not pair a G with a distant unrelated g (window bound)', () => { // A g far past the window must not be borrowed as this G's high half. const log = 'G: 5\nU: 1\nU: 1\nU: 1\nU: 1\ng: 9' const ev = parseHeatSyncLog(log) expect(ev[0].cardNumber).toBe('5') }) it('does not inherit a previous event time-of-day (resets H/M/E per event)', () => { // H/M/E stamp only the event that immediately follows them. A later grant // with no time of its own must NOT reuse the first event's 09:05:03. const log = 'H: 9\nM: 5\nE: 3\nG: 50\ng: 0\nG: 60\ng: 0' const ev = parseHeatSyncLog(log) expect(ev).toHaveLength(2) expect(ev[0].detail).toContain('09:05:03') expect(ev[0].dedupeKey).toContain('09:05:03') expect(ev[1].detail).not.toContain('09:05:03') expect(ev[1].dedupeKey).not.toContain('09:05:03') // The stale time must not leak into the second event's dedupe key either. expect(ev[1].dedupeKey).toBe('hs:5:G:60:') }) it('pairs each high-half token at most once (no high-half reuse)', () => { // Two adjacent grants each own a distinct g; the first g must not be // borrowed by the second grant. G:5<->g:1 and G:6<->g:2. const log = 'G: 5\nG: 6\ng: 1\ng: 2' const ev = parseHeatSyncLog(log) expect(ev).toHaveLength(2) // 5 + 1*32767 = 32772 expect(ev[0].cardNumber).toBe('32772') // 6 + 2*32767 = 65540 (reusing g:1 would wrongly give 6 + 1*32767 = 32773) expect(ev[1].cardNumber).toBe('65540') }) }) describe('normalizeWebhookEvents', () => { it('keys on the caller-supplied event id', () => { const ev = normalizeWebhookEvents([{ id: 'abc', card_uid: '9c40', result: 'granted' }]) expect(ev[0].dedupeKey).toBe('wh:abc') expect(ev[0].cardUid).toBe('9c40') expect(ev[0].result).toBe('granted') }) it('defaults a missing result to unknown', () => { const ev = normalizeWebhookEvents([{ id: '1', card_number: '40000' }]) expect(ev[0].result).toBe('unknown') expect(ev[0].cardNumber).toBe('40000') }) it('passes through an ISO occurred_at', () => { const ev = normalizeWebhookEvents([{ id: '1', card_uid: 'x', occurred_at: '2026-05-20T10:00:00Z' }]) expect(ev[0].occurredAt).toBe('2026-05-20T10:00:00Z') }) }) describe('cardMatchesEvent', () => { it('matches a hex-stored uid to a decimal poll number', () => { // 0x9c40 === 40000 expect(cardMatchesEvent('9c40', ev({ cardNumber: '40000' }))).toBe(true) expect(cardMatchesEvent('9C40', ev({ cardNumber: '40000' }))).toBe(true) expect(cardMatchesEvent('0x9c40', ev({ cardNumber: '40000' }))).toBe(true) }) it('does NOT match a different card by coincidental decimal string (the fixed false positive)', () => { // stored "16" is hex 0x16 = 22; a poll of decimal card 16 must NOT match it. expect(cardMatchesEvent('16', ev({ cardNumber: '16' }))).toBe(false) // stored "16" DOES match a poll of decimal 22 (its real number). expect(cardMatchesEvent('16', ev({ cardNumber: '22' }))).toBe(true) }) it('matches an all-digit hex uid to its decimal number', () => { // stored "10" is hex 0x10 = 16; a poll of 16 is the same card. expect(cardMatchesEvent('10', ev({ cardNumber: '16' }))).toBe(true) }) it('matches a webhook card_uid by exact (case-insensitive) string', () => { expect(cardMatchesEvent('9c40', ev({ cardUid: '9C40' }))).toBe(true) }) it('matches a webhook all-digit card_uid as a decimal number (hex-stored)', () => { expect(cardMatchesEvent('9c40', ev({ cardUid: '40000' }))).toBe(true) }) it('matches a webhook card_number field', () => { expect(cardMatchesEvent('9c40', ev({ cardUid: null, cardNumber: '40000' }))).toBe(true) }) it('does not match different cards or empty input', () => { expect(cardMatchesEvent('9c40', ev({ cardNumber: '40001' }))).toBe(false) expect(cardMatchesEvent('', ev({ cardNumber: '40000' }))).toBe(false) expect(cardMatchesEvent('9c40', ev({}))).toBe(false) }) it('does not match a non-hex stored uid against a number', () => { // 'xyz' is not a HeatSync hex uid; only an exact webhook uid string matches. expect(cardMatchesEvent('xyz', ev({ cardNumber: '123' }))).toBe(false) expect(cardMatchesEvent('xyz', ev({ cardUid: 'xyz' }))).toBe(true) }) })