mega-plant/scripts/verify-clock.mjs

67 lines
5.6 KiB
JavaScript

// Reversible integration check against the running local app and configured PB.
// Creates one temporary mechanic, only writes clock events for that fixture,
// and deletes all fixture events/locks/employee in finally. Never clocks real staff.
// --prepare-browser leaves the fixture until --cleanup-browser, and writes a
// short-lived user cookie to a private temp file for browser checks.
import PocketBase from 'pocketbase';
import assert from 'node:assert/strict';
import { writeFileSync, readFileSync, rmSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
const stateFile='/tmp/plant-clock-browser-fixture.json';
const cookieFile='/tmp/plant-clock-browser-cookie.json';
const base=process.env.MEGA_AUTH_BASE_URL||'http://localhost:6007';
const pb=new PocketBase(process.env.NEXT_PUBLIC_PB_URL);pb.autoCancellation(false);
pb.beforeSend=(url,options)=>({url,options:{...options,signal:AbortSignal.timeout(15000)}});
let fixture;
async function cleanup() {
if(!fixture)return;
const key=`mechanics:${fixture.id}`;
for(const collection of ['clock_events','clock_scan_locks']) {
const records=await pb.collection(collection).getFullList({filter:pb.filter('employee = {:key}',{key})});
for(const record of records)await pb.collection(collection).delete(record.id);
}
await pb.collection('mechanics').delete(fixture.id);fixture=null;
}
try {
await pb.collection('_superusers').authWithPassword(process.env.PB_ADMIN,process.env.PB_PASS);
if(process.argv.includes('--cleanup-browser')) {
fixture=JSON.parse(readFileSync(stateFile,'utf8'));await cleanup();
rmSync(stateFile,{force:true});rmSync(cookieFile,{force:true});console.log('Browser fixture and temporary cookie removed.');
} else {
const state=await pb.collection('user_onboarding').getFirstListItem('password_changed = true && disabled = false');
const user=await pb.collection('users').impersonate(state.user,3600);
const cookie=`mega_session=${user.authStore.token}`;
const sites=await pb.collection('sites').getList(1,1,{sort:'name'});
const site=sites.items[0].id;
fixture=await pb.collection('mechanics').create({name:'CLOCK TEST — temporary worker',employee_code:`TEST${Date.now()}`,role:'Mechanic',source:'Temporary clock integration test; removed after verification'});
if(process.argv.includes('--prepare-browser')) {
writeFileSync(stateFile,JSON.stringify({...fixture,site}),{mode:0o600});
writeFileSync(cookieFile,JSON.stringify([{name:'mega_session',value:user.authStore.token,domain:'localhost',path:'/',httpOnly:true,sameSite:'Lax'}]),{mode:0o600});
console.log(JSON.stringify({employeeCode:fixture.employee_code,site,fixtureId:fixture.id,cookieFile}));fixture=null;
} else {
const call=async(path,body)=>{
const response=await fetch(base+path,{method:body?'POST':'GET',headers:{Cookie:cookie,...(body?{'Content-Type':'application/json',Origin:base}:{})},...(body?{body:JSON.stringify(body)}:{})});
return {status:response.status,body:await response.json()};
};
assert.equal((await fetch(base+'/api/clock/roster')).status,401);
const lookupPath=`/api/clock/employee-lookup?employee_no=${fixture.employee_code}&site=${site}`;
const lookup=await call(lookupPath);assert.equal(lookup.status,200);assert.equal(lookup.body.nextDirection,'in');
const printedBadge=await call(`/api/clock/employee-lookup?employee_no=${encodeURIComponent(`OLD BADGE NAME | ${fixture.employee_code}`)}&site=${site}`);assert.equal(printedBadge.status,200);assert.equal(printedBadge.body.employee.id,lookup.body.employee.id);
const payload={employee_no:fixture.employee_code,site,expected_employee_id:lookup.body.employee.id,expected_event_id:null,expected_direction:'in',expected_work_date:lookup.body.workDate,request_id:randomUUID(),allow_recent:false,location:{latitude:0,longitude:0,accuracy:8}};
const first=await call('/api/clock/scan',payload);assert.equal(first.status,200,JSON.stringify(first.body));assert.equal(first.body.direction,'in');assert.equal(first.body.hasLocation,true);
const retry=await call('/api/clock/scan',payload);assert.equal(retry.status,200);assert.equal(retry.body.id,first.body.id);
assert.equal((await call('/api/clock/scan',{...payload,request_id:randomUUID()})).status,409);
const next=await call(lookupPath);assert.equal(next.body.nextDirection,'out');
await new Promise(resolve=>setTimeout(resolve,1100));
const outPayload={...payload,expected_event_id:first.body.id,expected_direction:'out',request_id:randomUUID(),location:null};
assert.equal((await call('/api/clock/scan',outPayload)).status,409);
const out=await call('/api/clock/scan',{...outPayload,allow_recent:true});assert.equal(out.status,200,JSON.stringify(out.body));assert.equal(out.body.direction,'out');
const roster=await call('/api/clock/roster');assert.equal(roster.status,200);assert.equal(roster.body.events.filter(e=>e.employee===lookup.body.employee.id).length,2);
const anonymous=new PocketBase(process.env.NEXT_PUBLIC_PB_URL);
try {assert.equal((await anonymous.collection('clock_events').getFullList()).length,0);} catch(e) {assert.equal(e.status,403);}
const locks=await pb.collection('clock_scan_locks').getFullList({filter:pb.filter('employee = {:id}',{id:lookup.body.employee.id})});assert.equal(locks.length,0);
console.log('PASS: API auth, lookup, IN, retry, stale scan, recent warning, OUT, audit history, private collection and lock release.');
}
}
} catch(e) {console.error('Clock integration failed:',e.status??'',e.message);process.exitCode=1;}
finally {try{await cleanup();}catch(e){console.error('Fixture cleanup failed:',e.message);process.exitCode=1;}}