120 lines
8.1 KiB
TypeScript
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);
|
|
});
|