173 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
});
|