172 lines
5.9 KiB
TypeScript
172 lines
5.9 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 { 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 = newUlid();
|
|
const actorId = newUlid();
|
|
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<string, unknown>;
|
|
}>;
|
|
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();
|
|
}
|
|
});
|
|
});
|