krona-staff/tests/integration/sync-rejection.test.ts

208 lines
7.2 KiB
TypeScript

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);
});
});