import pg from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Database } from '../../src/infrastructure/database.js'; import { newUlid } from '../../src/core/ids.js'; import { SyncService } from '../../src/domain/sync/service.js'; import type { SyncCommand } from '../../src/domain/sync/types.js'; const connectionString = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_TEST_URL; const migrationConnectionString = process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL; const describeDatabase = connectionString ? describe : describe.skip; const cleanupTables = [ 'staff_sync_command_inbox', 'staff_sync_change_log', 'staff_sync_devices', 'staff_work_sessions', 'staff_timesheet_entries', 'staff_employees', ] as const; describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => { const database = new Database(connectionString!); const tenantId = newUlid(); const actorId = newUlid(); const deviceId = newUlid(); const employeeId = newUlid(); const sessionId = newUlid(); const timesheetId = newUlid(); const rawToken = `kstaff_dev_${tenantId}_test_rejection_token_secret`; const tokenHash = SyncService.hashToken(rawToken); const syncService = new SyncService(database, actorId); const testDevice = { id: deviceId, tenant_id: tenantId, device_code: 'TERM-REJECT-TEST', name: 'Rejection Test Terminal', token_hash: tokenHash, token_hint: 'kstaff_dev_...', status: 'active' as const, allowed_scopes: ['attendance:sync'], org_unit_id: null, registered_at: new Date().toISOString(), last_seen_at: null, revoked_at: null, created_by: actorId, }; async function cleanupAsMigrator() { if (!migrationConnectionString) { return; } const migrator = new pg.Client({ connectionString: migrationConnectionString, application_name: 'krona-staff-test-cleanup', }); await migrator.connect(); try { await migrator.query('BEGIN'); try { await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]); for (const table of cleanupTables) { await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenantId]); } await migrator.query('COMMIT'); } catch (error) { await migrator.query('ROLLBACK'); throw error; } } finally { await migrator.end(); } } beforeAll(async () => { await cleanupAsMigrator(); await database.tenantTransaction(tenantId, actorId, async (client) => { // Create employee await client.query( `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status) VALUES ($1, $2, 'EMP-REJ-01', 'Тестовый Сотрудник', 'active')`, [employeeId, tenantId], ); // Create sync device await client.query( `INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ deviceId, tenantId, testDevice.device_code, testDevice.name, tokenHash, testDevice.token_hint, 'active', ['attendance:sync'], actorId, ], ); }); }); afterAll(async () => { try { await cleanupAsMigrator(); } finally { await database.close(); } }); it('rejects upsert_work_session and creates NO records in staff_work_sessions or change log', async () => { // S1 scenario: device attempts to create a confirmed work session with 2880 minutes const command: SyncCommand = { command_type: 'upsert_work_session', schema_version: 1, idempotency_key: 'cmd-live-s1-work-session', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { id: sessionId, employee_id: employeeId, business_date: '2026-09-26', status: 'confirmed', worked_minutes: 2880, revision: 1, }, }; const results = await syncService.push(tenantId, testDevice, [command]); expect(results).toHaveLength(1); expect(results[0]?.status).toBe('rejected'); expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); expect(results[0]?.error_message).toContain('Команда не поддерживается'); // Verify 0 rows in staff_work_sessions const sessionRes = await database.tenantTransaction(tenantId, actorId, (client) => client.query<{ cnt: number }>( 'SELECT count(*)::int as cnt FROM staff_work_sessions WHERE tenant_id = $1', [tenantId], ), ); expect(sessionRes.rows[0]?.cnt).toBe(0); // Verify 0 rows in staff_sync_change_log for work_session const changeRes = await database.tenantTransaction(tenantId, actorId, (client) => client.query<{ cnt: number }>( "SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'work_session'", [tenantId], ), ); expect(changeRes.rows[0]?.cnt).toBe(0); // Verify recorded in staff_sync_command_inbox as rejected const inboxRes = await database.tenantTransaction(tenantId, actorId, (client) => client.query<{ status: string; error_code: string }>( 'SELECT status, error_code FROM staff_sync_command_inbox WHERE tenant_id = $1 AND idempotency_key = $2', [tenantId, command.idempotency_key], ), ); expect(inboxRes.rows[0]?.status).toBe('rejected'); expect(inboxRes.rows[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); }); it('rejects upsert_timesheet_entry and creates NO records in staff_timesheet_entries or change log', async () => { const command: SyncCommand = { command_type: 'upsert_timesheet_entry', schema_version: 1, idempotency_key: 'cmd-live-timesheet-entry', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { id: timesheetId, employee_id: employeeId, work_date: '2026-09-26', minutes_worked: 480, status: 'approved', revision: 1, }, }; const results = await syncService.push(tenantId, testDevice, [command]); expect(results).toHaveLength(1); expect(results[0]?.status).toBe('rejected'); expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); expect(results[0]?.error_message).toContain('Команда не поддерживается'); // Verify 0 rows in staff_timesheet_entries const tsRes = await database.tenantTransaction(tenantId, actorId, (client) => client.query<{ cnt: number }>( 'SELECT count(*)::int as cnt FROM staff_timesheet_entries WHERE tenant_id = $1', [tenantId], ), ); expect(tsRes.rows[0]?.cnt).toBe(0); // Verify 0 rows in staff_sync_change_log for timesheet_entry const changeRes = await database.tenantTransaction(tenantId, actorId, (client) => client.query<{ cnt: number }>( "SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'timesheet_entry'", [tenantId], ), ); expect(changeRes.rows[0]?.cnt).toBe(0); }); });