mega-plant/lib/clock.test.ts
David df53130861 Add Workforce pages, Plant Clock tab bar and oil & diesel book-out
- Workforce: Employees register, per-employee dashboards, Attendance table
- Plant Clock: scan-only clock, bottom tab bar (Employees / Oil & Diesel / Job cards)
- Oil & Diesel stock: diesel tank (650 L, opening 589 L), level overwrite dialog,
  QR on each card, book-out screen with searchable vehicle picker,
  filterable "Booked out" table (today/week/month/all/range)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:39:18 +02:00

104 lines
6.8 KiB
TypeScript

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>={}):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);
});