// 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')) { writeFileSync(stateFile,JSON.stringify(fixture),{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,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 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'); const retry=await call('/api/clock/scan',payload);assert.equal(retry.status,200);assert.equal(retry.body.id,first.body.id); 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); 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: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.'); } } } 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;}}