mega-plant/lib/clock.test.ts

120 lines
8.1 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clockAction, workDate, missedClockIns, ClockError, lookupClock, saveClock, type ClockStore, type ClockEvent, type ClockEmployee } from './clock';
const employee: ClockEmployee = { id: 'operators:abcdefghijklmno', name: 'Worker One', employeeNo: 'MCW001', role: 'Operator', asset: '' };
const site = 'abcdefghijklmno';
const now = '2026-10-21T08:00:00.000Z';
const input = { employee_no:'MCW001', site, expected_employee_id:employee.id, expected_event_id:null as string|null, expected_direction:'in' as 'in'|'out', expected_work_date:'2026-10-21', request_id:'12345678-1234-1234-1234-123456789abc', allow_recent:false, location:null };
function fixture() {
const events: ClockEvent[] = [];
let locked = false;
let failSave = false;
const store: ClockStore = {
employees:async()=>[employee], siteExists:async(id)=>id===site || id==='othersiteabcdef',
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>={}):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 same-site IN closes; old IN and transfer start IN',()=>{
assert.equal(clockAction(null,site,now),'in');
assert.equal(clockAction(prior(),site,now),'out');
assert.equal(clockAction(prior({scanned_at:'2026-10-20T08:00:00Z'}),site,now),'in');
assert.equal(clockAction(prior(), 'othersiteabcdef',now),'in');
});
test('lookup rejects ambiguous badge codes and matches without case sensitivity',async()=>{
const {store}=fixture();
assert.equal((await lookupClock(store,'mcw001',site,now)).employee.id,employee.id);
store.employees=async()=>[employee,{...employee,id:'mechanics:abcdefghijklmno'}];
await assert.rejects(()=>lookupClock(store,'MCW001',site,now),/Duplicate employee code/);
});
test('lookup errors never invent an empty scan history',async()=>{
const {store}=fixture();store.latest=async()=>{throw new Error('offline');};
await assert.rejects(()=>lookupClock(store,'MCW001',site,now),/offline/);
});
test('save once, safely retry exact request after later scans without another event',async()=>{
const {store,events}=fixture();
const saved=await saveClock(store,input,'userabcdefghijk',now);
assert.equal(saved.direction,'in');
events.push(prior({id:'anotheroutabcde',direction:'out'}));
const retry=await saveClock(store,input,'userabcdefghijk','2026-10-21T09:00:00Z');
assert.equal(retry.id,saved.id);assert.equal(events.length,2);
await assert.rejects(()=>saveClock(store,{...input,site:'othersiteabcdef'},'userabcdefghijk',now),/another confirmation/);
});
test('stale identity, event, date and direction are rejected',async()=>{
const {store,events}=fixture();
await assert.rejects(()=>saveClock(store,{...input,expected_employee_id:'mechanics:abcdefghijklmno'},'userabcdefghijk',now),/badge changed/i);
await assert.rejects(()=>saveClock(store,{...input,expected_work_date:'2026-10-20'},'userabcdefghijk',now),/work day/i);
await assert.rejects(()=>saveClock(store,{...input,expected_direction:'out'},'userabcdefghijk',now),/action changed/i);
events.push(prior());
await assert.rejects(()=>saveClock(store,input,'userabcdefghijk',now),/another scan/i);
});
test('recent scan requires override; sub-second scans cannot be overridden',async()=>{
const {store,events}=fixture();events.push(prior({scanned_at:'2026-10-21T07:59:30Z'}));
const payload={...input,expected_event_id:events[0].id,expected_direction:'out' as const};
await assert.rejects(()=>saveClock(store,payload,'userabcdefghijk',now),/recent/i);
assert.equal((await saveClock(store,{...payload,allow_recent:true},'userabcdefghijk',now)).direction,'out');
const second=events.at(-1)!;
await assert.rejects(()=>saveClock(store,{...input,request_id:'another-request-123456',expected_event_id:second.id,allow_recent:true},'userabcdefghijk',now),/just scanned/i);
});
test('OUT records elapsed minutes and fresh GPS; transfer does not invent OUT',async()=>{
const {store,events}=fixture();events.push(prior());
const result=await saveClock(store,{...input,expected_event_id:events[0].id,expected_direction:'out',location:{latitude:0,longitude:0,accuracy:5}},'userabcdefghijk',now);
assert.equal(result.session_minutes,120);assert.equal(result.latitude,0);assert.equal(result.has_location,true);
const f=fixture();f.events.push(prior());
const transfer=await saveClock(f.store,{...input,site:'othersiteabcdef',expected_event_id:f.events[0].id},'userabcdefghijk',now);
assert.equal(transfer.direction,'in');assert.equal(f.events.length,2);
});
test('one of two concurrent confirmations saves; failed saves release the lock',async()=>{
const {store,events,setFail}=fixture();
const results=await Promise.allSettled([saveClock(store,input,'userabcdefghijk',now),saveClock(store,{...input,request_id:'another-request-123456'},'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 remains visible after a new day or site transfer',()=>{
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]);
const transfer=prior({id:'transfereventab',site:'othersiteabcdef',scanned_at:'2026-10-21T07:00:00Z'});
assert.deepEqual(missedClockIns([today,transfer],now).map(e=>e.id),[today.id]);
assert.equal(missedClockIns([today,prior({direction:'out',scanned_at:now})],now).length,0);
});
test('invalid input fails before accessing storage',async()=>{
const {store}=fixture();
for(const body of [null,[],{}, {...input,location:{latitude:91,longitude:0,accuracy:2}}, {...input,allow_recent:'yes'}]) {
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 lookupClock(store,'OLD PRINTED NAME | MCW001',site,now);
assert.equal(found.employee.name,employee.name);
});