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

173 lines
6.8 KiB
TypeScript

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 type { SyncCommand } from '../../src/domain/sync/types.js';
const connectionString = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_TEST_URL;
const describeDatabase = connectionString ? describe : describe.skip;
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
const database = new Database(connectionString!);
const tenantId = parseUlid('01J0000000000000000000000A');
const actorId = parseUlid('01J00000000000000000000001');
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,
};
beforeAll(async () => {
await database.tenantTransaction(tenantId, actorId, async (client) => {
// Create employee
await client.query(
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status, created_by)
VALUES ($1, $2, 'EMP-REJ-01', 'Тестовый Сотрудник', 'active', $3)`,
[employeeId, tenantId, actorId],
);
// 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 () => {
await database.tenantTransaction(tenantId, actorId, async (client) => {
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM staff_work_sessions WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM staff_timesheet_entries WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM staff_employees WHERE tenant_id = $1', [tenantId]);
});
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);
});
});