import { test } from 'node:test'; import assert from 'node:assert/strict'; import { clockAction, workDate, missedClockIns, ClockError, ClockRepeat, saveClock, type ClockStore, type ClockEvent, type ClockEmployee } from './clock'; const employee: ClockEmployee = { id: 'operators:abcdefghijklmno', name: 'Worker One', employeeNo: 'MCW001', role: 'Operator', asset: '' }; const now = '2026-10-21T08:00:00.000Z'; const input = { employee_no:'MCW001', request_id:'12345678-1234-1234-1234-123456789abc' }; const req = (n:number) => `request-${String(n).padStart(12,'0')}`; function fixture() { const events: ClockEvent[] = []; let locked = false; let failSave = false; const store: ClockStore = { employees:async()=>[employee], latest:async(id)=>events.filter(e=>e.employee===id).at(-1)??null, event:async(id)=>events.find(e=>e.id===id)??null, lock:async()=>{if(locked)throw new ClockError('Busy',423);locked=true;return 'lock';}, unlock:async()=>{locked=false;}, create:async(event)=>{if(failSave)throw new Error('offline');events.push(event);return event;}, }; return {store,events,setFail:(v:boolean)=>{failSave=v;}}; } const prior = (overrides:Partial={}):ClockEvent => ({id:'prioreventabcde',employee:employee.id,employee_no:'MCW001',employee_name:employee.name,site:'',direction:'in',scanned_at:'2026-10-21T06:00:00Z',actor:'userabcdefghijk',previous_event:'',session_minutes:0,has_location:false,...overrides}); test('South African work date handles UTC midnight boundary',()=>{ assert.equal(workDate('2026-10-20T22:30:00Z'),'2026-10-21'); }); test('first scan IN; same-day IN closes with OUT; old IN or after OUT starts IN',()=>{ assert.equal(clockAction(null,now),'in'); assert.equal(clockAction(prior(),now),'out'); assert.equal(clockAction(prior({scanned_at:'2026-10-20T08:00:00Z'}),now),'in'); assert.equal(clockAction(prior({direction:'out'}),now),'in'); }); test('a scan needs no site: IN, then OUT with elapsed minutes, then IN again',async()=>{ const {store,events}=fixture(); const first=await saveClock(store,{...input,request_id:req(1)},'userabcdefghijk','2026-10-21T06:00:00Z'); assert.equal(first.direction,'in');assert.equal(first.site,''); const out=await saveClock(store,{...input,request_id:req(2)},'userabcdefghijk','2026-10-21T10:30:00Z'); assert.equal(out.direction,'out');assert.equal(out.session_minutes,270);assert.equal(out.previous_event,first.id); const back=await saveClock(store,{...input,request_id:req(3)},'userabcdefghijk','2026-10-21T11:15:00Z'); assert.equal(back.direction,'in');assert.equal(events.length,3); }); test('badge codes match without case sensitivity; duplicates and unknowns are refused',async()=>{ const {store}=fixture(); assert.equal((await saveClock(store,{...input,employee_no:'mcw001'},'userabcdefghijk',now)).employee,employee.id); await assert.rejects(()=>saveClock(store,{...input,employee_no:'NOPE1'},'userabcdefghijk',now),e=>e instanceof ClockError && e.status===404); store.employees=async()=>[employee,{...employee,id:'mechanics:abcdefghijklmno'}]; await assert.rejects(()=>saveClock(store,{...input,request_id:req(9)},'userabcdefghijk',now),/Duplicate employee code/); }); test('a repeat read within a minute is refused and names the scan that counts',async()=>{ const {store,events}=fixture();events.push(prior({scanned_at:'2026-10-21T07:59:30Z'})); await assert.rejects(()=>saveClock(store,input,'userabcdefghijk',now),e=>e instanceof ClockRepeat && e.last.id==='prioreventabcde' && e.status===409); assert.equal(events.length,1); assert.equal((await saveClock(store,input,'userabcdefghijk','2026-10-21T08:01:00Z')).direction,'out'); }); test('retrying the exact request returns the saved event, never a second one',async()=>{ const {store,events}=fixture(); const saved=await saveClock(store,input,'userabcdefghijk',now); const retry=await saveClock(store,input,'userabcdefghijk','2026-10-21T09:00:00Z'); assert.equal(retry.id,saved.id);assert.equal(events.length,1); await assert.rejects(()=>saveClock(store,input,'otheruserabcdef',now),/another request/); }); test('one of two concurrent scans saves; failed saves release the lock',async()=>{ const {store,events}=fixture(); const results=await Promise.allSettled([saveClock(store,input,'userabcdefghijk',now),saveClock(store,{...input,request_id:req(5)},'userabcdefghijk',now)]); assert.equal(results.filter(r=>r.status==='fulfilled').length,1);assert.equal(events.length,1); const f=fixture();f.setFail(true);await assert.rejects(()=>saveClock(f.store,input,'userabcdefghijk',now),/offline/); f.setFail(false);assert.equal((await saveClock(f.store,input,'userabcdefghijk',now)).direction,'in'); }); test('missed OUT stays visible after a new day; a same-day OUT closes it',()=>{ const old=prior({scanned_at:'2026-10-20T06:00:00Z'}); const today=prior({id:'todayeventabcde'}); assert.deepEqual(missedClockIns([old,today],now).map(e=>e.id),[old.id]); assert.equal(missedClockIns([today,prior({id:'outeventabcdefg',direction:'out',scanned_at:now})],now).length,0); }); test('invalid input fails before accessing storage',async()=>{ const {store}=fixture();store.employees=async()=>{throw new Error('storage touched');}; for(const body of [null,[],{},{employee_no:'MCW001'},{employee_no:'',request_id:req(1)},{employee_no:'MCW001',request_id:'short'}]) { await assert.rejects(()=>saveClock(store,body,'userabcdefghijk',now),e=>e instanceof ClockError && e.status===400); } }); test('origin validation uses the client Host header when Next binds to 0.0.0.0',async()=>{ const { clockOriginAllowed }=await import('./clock-origin'); assert.equal(clockOriginAllowed(new Headers({origin:'http://localhost:6007',host:'localhost:6007'})),true); assert.equal(clockOriginAllowed(new Headers({origin:'https://plant.example',host:'plant.example'})),true); assert.equal(clockOriginAllowed(new Headers({origin:'https://evil.example',host:'plant.example'})),false); assert.equal(clockOriginAllowed(new Headers({host:'plant.example'})),false); assert.equal(clockOriginAllowed(new Headers({origin:'null',host:'plant.example'})),false); }); test('an identical retry that reaches the lock after the first save returns the saved event',async()=>{ const first=fixture();const saved=await saveClock(first.store,input,'userabcdefghijk',now); const second=fixture();const acquire=second.store.lock; second.store.lock=async employee=>{const lock=await acquire(employee);second.events.push(saved);return lock;}; const retried=await saveClock(second.store,input,'userabcdefghijk',now); assert.equal(retried.id,saved.id);assert.equal(second.events.length,1); }); test('existing printed name-and-code badge resolves by employee code and uses current roster name',async()=>{ const {store}=fixture(); const found=await saveClock(store,{...input,employee_no:'OLD PRINTED NAME | MCW001'},'userabcdefghijk',now); assert.equal(found.employee_name,employee.name); });