import { describe, expect, it, vi } from 'vitest'; import { buildApp } from '../../src/app.js'; import { loadConfig } from '../../src/config.js'; const base = { DATABASE_URL: 'postgres://localhost/staff', STANDALONE_TENANT_ID: '01J00000000000000000000000', STANDALONE_ACTOR_ID: '01J00000000000000000000001', LOG_LEVEL: 'silent', NODE_ENV: 'test', } as const; const managerToken = 'm'.repeat(64); const terminalToken = 't'.repeat(64); const tokenConfig = { STANDALONE_MANAGER_TOKEN: managerToken, STANDALONE_TERMINAL_TOKEN: terminalToken, } as const; function fakeDatabase(rows: Array>>) { const calls: Array<[string, readonly unknown[] | undefined]> = []; const query = async (sql: string, parameters?: readonly unknown[]) => { calls.push([sql, parameters]); const resultRows = rows.shift() ?? []; return { rows: resultRows, rowCount: resultRows.length }; }; return { calls, database: { ping: vi.fn(async () => undefined), close: vi.fn(async () => undefined), tenantTransaction: async ( _tenant: string, _actor: string, run: (client: { query: typeof query }) => Promise, ) => run({ query }), } as never, }; } describe('attendance portal', () => { it('requires authentication when standalone tokens are configured', async () => { const { database } = fakeDatabase([]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const response = await app.inject('/api/v1/staff/portal'); expect(response.statusCode).toBe(401); await app.close(); }); it('creates an HttpOnly manager session and accepts its secure cookie', async () => { const { database } = fakeDatabase([[], [], []]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const login = await app.inject({ method: 'POST', url: '/api/v1/staff/session', payload: { access_token: managerToken }, }); const setCookie = login.headers['set-cookie']; expect(login.statusCode).toBe(200); expect(setCookie).toContain('__Host-krona_staff_session='); expect(setCookie).toContain('HttpOnly'); expect(setCookie).toContain('Secure'); expect(setCookie).toContain('SameSite=Strict'); const response = await app.inject({ url: '/api/v1/staff/portal', headers: { cookie: String(setCookie).split(';')[0] }, }); expect(response.statusCode).toBe(200); expect(response.json<{ viewer: { role: string } }>().viewer.role).toBe('manager'); await app.close(); }); it('does not allow the terminal key to create a browser session', async () => { const { database } = fakeDatabase([]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/session', payload: { access_token: terminalToken }, }); expect(response.statusCode).toBe(403); expect(response.headers['set-cookie']).toBeUndefined(); await app.close(); }); it('scopes an employee portal to the linked Core user', async () => { const { database, calls } = fakeDatabase([[], [], []]); const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database, }); const response = await app.inject('/api/v1/staff/portal'); expect(response.statusCode).toBe(200); const body = response.json<{ viewer: { role: string } }>(); expect(body.viewer.role).toBe('employee'); expect(calls).toHaveLength(4); expect( calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID), ).toBe(true); await app.close(); }); it('writes a terminal event and its outbox notification atomically', async () => { const eventId = '01J00000000000000000000002'; const employeeId = '01J00000000000000000000003'; const { database, calls } = fakeDatabase([[{ id: employeeId }], [{ id: eventId }], []]); const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database, }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/attendance/events', payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId, }, }); expect(response.statusCode).toBe(201); expect(calls).toHaveLength(3); expect(calls[0]?.[0]).toContain("status = 'active'"); expect(calls[2]?.[0]).toContain('staff_outbox'); expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in'); await app.close(); }); it('rejects an unknown terminal employee without writing attendance or outbox', async () => { const eventId = '01J00000000000000000000002'; const employeeId = '01J00000000000000000000003'; const { database, calls } = fakeDatabase([[]]); const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database, }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/attendance/events', payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId, }, }); expect(response.statusCode).toBe(404); expect(calls).toHaveLength(1); expect(calls[0]?.[0]).toContain("status = 'active'"); expect(calls[0]?.[0]).not.toContain('INSERT INTO staff_employees'); await app.close(); }); it('does not give an employee manager access to the timesheet', async () => { const { database } = fakeDatabase([]); const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database, }); const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' }, }); expect(response.statusCode).toBe(403); await app.close(); }); it('allows a manager to create an employee and writes an audit record', async () => { const employeeId = '01J00000000000000000000004'; const { database, calls } = fakeDatabase([ [ { id: employeeId, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active', }, ], ]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/employees', headers: { authorization: `Bearer ${managerToken}` }, payload: { personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', }, }); expect(response.statusCode).toBe(201); expect(calls).toHaveLength(2); expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees'); expect(calls[1]?.[0]).toContain('staff_audit_log'); await app.close(); }); it('does not allow an employee to create another employee', async () => { const { database } = fakeDatabase([]); const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database, }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/employees', payload: { personnel_number: 'A-18', display_name: 'Посторонний' }, }); expect(response.statusCode).toBe(403); await app.close(); }); it('updates an employee with before and after values in the audit trail', async () => { const employeeId = '01J00000000000000000000004'; const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active', }; const after = { ...before, position: 'Старший администратор' }; const { database, calls } = fakeDatabase([[before], [], [after], []]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const response = await app.inject({ method: 'PATCH', url: `/api/v1/staff/employees/${employeeId}`, headers: { authorization: `Bearer ${managerToken}` }, payload: { position: 'Старший администратор' }, }); expect(response.statusCode).toBe(200); expect(calls).toHaveLength(4); expect(calls[0]?.[0]).toContain('FOR UPDATE'); expect(calls[3]?.[1]?.[4]).toEqual(before); expect(calls[3]?.[1]?.[5]).toEqual(after); await app.close(); }); it('reconciles terminal events into a reviewable work session', async () => { const employeeId = '01J00000000000000000000004'; const orgUnitId = '01J00000000000000000000005'; const sessionId = '01J00000000000000000000006'; const attendanceRows = [ { id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z', }, { id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z', }, ]; const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1, }; const { database, calls } = fakeDatabase([attendanceRows, [session], []]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/work-sessions/reconcile', headers: { authorization: `Bearer ${managerToken}` }, payload: { date_from: '2026-09-24', date_to: '2026-09-24' }, }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0, }); expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions'); expect(calls[1]?.[1]?.[7]).toBe(480); await app.close(); }); it('requires a reason when confirmed hours are corrected', async () => { const sessionId = '01J00000000000000000000006'; const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1, }; const { database, calls } = fakeDatabase([[current]]); const app = await buildApp({ config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), database, }); const response = await app.inject({ method: 'PATCH', url: `/api/v1/staff/work-sessions/${sessionId}`, headers: { authorization: `Bearer ${managerToken}` }, payload: { status: 'confirmed', worked_minutes: 450 }, }); expect(response.statusCode).toBe(400); expect(calls).toHaveLength(1); await app.close(); }); });