import pg from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Database } from '../../src/infrastructure/database.js'; import { newUlid, parseUlid } from '../../src/core/ids.js'; import { SyncService } from '../../src/domain/sync/service.js'; import { buildApp } from '../../src/app.js'; import { loadConfig } from '../../src/config.js'; const connectionString = process.env.DATABASE_TEST_URL ?? process.env.TEST_DATABASE_URL; const migrationConnectionString = process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL; const describeDatabase = connectionString ? describe : describe.skip; const cleanupTables = [ 'staff_sync_device_checkpoints', 'staff_sync_command_inbox', 'staff_sync_change_log', 'staff_sync_devices', 'staff_timesheet_entries', 'staff_work_sessions', 'staff_attendance_events', 'staff_employees', 'staff_org_unit_terms', 'staff_org_units', ] as const; describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => { const database = new Database(connectionString!); const tenantId = parseUlid('01J0000000000000000000000A'); const actorId = parseUlid('01J00000000000000000000001'); const orgUnitId = newUlid(); const employeeId = newUlid(); const deviceId = newUlid(); const rawToken = `kstaff_dev_${tenantId}_bootstrap_c0_test_token_secret`; const tokenHash = SyncService.hashToken(rawToken); const syncService = new SyncService(database, actorId); async function cleanupAsMigrator() { const migratorUrl = migrationConnectionString ?? connectionString; if (!migratorUrl) { return; } const migrator = new pg.Client({ connectionString: migratorUrl, application_name: 'krona-staff-bootstrap-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) => { // 1. Insert real org unit (columns: id, tenant_id, code, name, is_active, created_at, updated_at) await client.query( `INSERT INTO staff_org_units (id, tenant_id, code, name, is_active, created_at, updated_at) VALUES ($1, $2, 'bootstrap_point', 'Точка Bootstrap', true, now(), now())`, [orgUnitId, tenantId], ); // 2. Insert employee in staff_employees await client.query( `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status, primary_org_unit_id, created_at, updated_at) VALUES ($1, $2, 'EMP-BOOT-01', 'Сотрудник Bootstrap', 'active', $3, now(), now())`, [employeeId, tenantId, orgUnitId], ); // 3. Insert sync device for HTTP sync testing await client.query( `INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by) VALUES ($1, $2, 'TERM-BOOT-01', 'Терминал Bootstrap', $3, 'kstaff_dev_...', 'active', ARRAY['attendance:sync']::text[], $4, $5)`, [deviceId, tenantId, tokenHash, orgUnitId, actorId], ); }); }); afterAll(async () => { try { await cleanupAsMigrator(); } finally { await database.close(); } }); it('syncService.pull(tenantId, { cursor: null }) succeeds without "column status does not exist" and returns org_unit and employee changes', async () => { const result = await syncService.pull(tenantId, { cursor: null }); expect(result).toBeDefined(); expect(result.has_more).toBe(false); // Verify org_unit change is present with mapped status const orgUnitChange = result.changes.find( (c) => c.entity_type === 'org_unit' && c.entity_id === orgUnitId, ); expect(orgUnitChange).toBeDefined(); expect(orgUnitChange?.payload).toMatchObject({ id: orgUnitId, code: 'bootstrap_point', name: 'Точка Bootstrap', status: 'active', }); // Verify employee change is present const employeeChange = result.changes.find( (c) => c.entity_type === 'employee' && c.entity_id === employeeId, ); expect(employeeChange).toBeDefined(); expect(employeeChange?.payload).toMatchObject({ id: employeeId, personnel_number: 'EMP-BOOT-01', display_name: 'Сотрудник Bootstrap', status: 'active', }); }); it('HTTP /api/v1/staff/sync/pull without cursor returns 200 and includes org_unit and employee in changes', async () => { const config = loadConfig({ DATABASE_URL: connectionString!, STANDALONE_TENANT_ID: tenantId, STANDALONE_ACTOR_ID: actorId, LOG_LEVEL: 'silent', NODE_ENV: 'test', }); const appDatabase = new Database(connectionString!); const app = await buildApp({ config, database: appDatabase }); try { const response = await app.inject({ method: 'POST', url: '/api/v1/staff/sync/pull', headers: { authorization: `Bearer ${rawToken}`, }, payload: { cursor: null }, }); expect(response.statusCode).toBe(200); const body = response.json<{ changes: Array<{ entity_type: string; entity_id: string; payload: Record; }>; next_cursor: string; has_more: boolean; }>(); expect( body.changes.some((c) => c.entity_type === 'org_unit' && c.entity_id === orgUnitId), ).toBe(true); expect( body.changes.some((c) => c.entity_type === 'employee' && c.entity_id === employeeId), ).toBe(true); } finally { await app.close(); } }); });