2026-10-21 04:39:15 +00:00
|
|
|
// 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}`;
|
|
|
|
|
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')) {
|
2026-10-22 09:39:18 +00:00
|
|
|
writeFileSync(stateFile,JSON.stringify(fixture),{mode:0o600});
|
2026-10-21 04:39:15 +00:00
|
|
|
writeFileSync(cookieFile,JSON.stringify([{name:'mega_session',value:user.authStore.token,domain:'localhost',path:'/',httpOnly:true,sameSite:'Lax'}]),{mode:0o600});
|
2026-10-22 09:39:18 +00:00
|
|
|
console.log(JSON.stringify({employeeCode:fixture.employee_code,fixtureId:fixture.id,cookieFile}));fixture=null;
|
2026-10-21 04:39:15 +00:00
|
|
|
} 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);
|
2026-10-22 09:39:18 +00:00
|
|
|
const scan=(employee_no,request_id=randomUUID())=>call('/api/clock/scan',{employee_no,request_id});
|
|
|
|
|
const key=`mechanics:${fixture.id}`;
|
|
|
|
|
const payload={employee_no:fixture.employee_code,request_id:randomUUID()};
|
|
|
|
|
const first=await call('/api/clock/scan',payload);assert.equal(first.status,200,JSON.stringify(first.body));assert.equal(first.body.direction,'in');
|
2026-10-21 04:39:15 +00:00
|
|
|
const retry=await call('/api/clock/scan',payload);assert.equal(retry.status,200);assert.equal(retry.body.id,first.body.id);
|
2026-10-22 09:39:18 +00:00
|
|
|
const repeat=await scan(`OLD BADGE NAME | ${fixture.employee_code}`);assert.equal(repeat.status,409);assert.equal(repeat.body.repeat.direction,'in');
|
|
|
|
|
assert.equal((await scan('NO-SUCH-BADGE-XYZ')).status,404);
|
|
|
|
|
// Age the IN past the repeat window instead of waiting a minute.
|
|
|
|
|
await pb.collection('clock_events').update(first.body.id,{scanned_at:new Date(Date.now()-2*60_000).toISOString()});
|
|
|
|
|
const out=await scan(fixture.employee_code);assert.equal(out.status,200,JSON.stringify(out.body));assert.equal(out.body.direction,'out');assert.equal(out.body.sessionMinutes,2);
|
|
|
|
|
const roster=await call('/api/clock/roster');assert.equal(roster.status,200);assert.equal(roster.body.events.filter(e=>e.employee===key).length,2);
|
2026-10-21 04:39:15 +00:00
|
|
|
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);}
|
2026-10-22 09:39:18 +00:00
|
|
|
const locks=await pb.collection('clock_scan_locks').getFullList({filter:pb.filter('employee = {:id}',{id:key})});assert.equal(locks.length,0);
|
|
|
|
|
console.log('PASS: API auth, scan IN without a site, retry, repeat refused, unknown badge, OUT, audit history, private collection and lock release.');
|
2026-10-21 04:39:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} 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;}}
|