- 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>
104 lines
6.8 KiB
TypeScript
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);
|
|
});
|