fix(sync): compute org unit status dynamically in bootstrap pull
Таблица staff_org_units не имеет отдельной колонки status, активность определяется полями is_active и deleted_at. Прямой запрос status при начальной загрузке без курсора приводил к 500 ошибке (дефект SYNC-C0). Статус вычисляется через CASE-выражение. Очистка интеграционного теста выполняется ролью мигратора.
This commit is contained in:
parent
6c5bf367dc
commit
1a77a8264b
3 changed files with 270 additions and 5 deletions
|
|
@ -334,7 +334,24 @@ export class SyncService {
|
|||
});
|
||||
}
|
||||
|
||||
async pull(tenantId: Ulid, deviceId: Ulid, options: PullOptions = {}): Promise<SyncPullResult> {
|
||||
async pull(tenantId: Ulid, options?: PullOptions): Promise<SyncPullResult>;
|
||||
async pull(tenantId: Ulid, deviceId: Ulid, options?: PullOptions): Promise<SyncPullResult>;
|
||||
async pull(
|
||||
tenantId: Ulid,
|
||||
deviceIdOrOptions?: Ulid | PullOptions,
|
||||
maybeOptions: PullOptions = {},
|
||||
): Promise<SyncPullResult> {
|
||||
let deviceId: Ulid;
|
||||
let options: PullOptions;
|
||||
|
||||
if (typeof deviceIdOrOptions === 'string') {
|
||||
deviceId = deviceIdOrOptions;
|
||||
options = maybeOptions ?? {};
|
||||
} else {
|
||||
deviceId = this.systemActorId;
|
||||
options = deviceIdOrOptions ?? {};
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
|
||||
|
||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||
|
|
@ -408,9 +425,18 @@ export class SyncService {
|
|||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>(`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, [
|
||||
tenantId,
|
||||
]);
|
||||
}>(
|
||||
`SELECT id, code, name,
|
||||
CASE
|
||||
WHEN deleted_at IS NOT NULL THEN 'archived'
|
||||
WHEN NOT is_active THEN 'inactive'
|
||||
ELSE 'active'
|
||||
END AS status
|
||||
FROM staff_org_units
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY id`,
|
||||
[tenantId],
|
||||
);
|
||||
for (const row of orgRes.rows) {
|
||||
const payload: SafeOrgUnitPayload = {
|
||||
id: row.id,
|
||||
|
|
|
|||
172
tests/integration/sync-bootstrap.test.ts
Normal file
172
tests/integration/sync-bootstrap.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
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<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -519,7 +519,7 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
|||
const orgId = newUlid();
|
||||
const profId = newUlid();
|
||||
|
||||
const { database } = createMockDb((sql) => {
|
||||
const { database, queries } = createMockDb((sql) => {
|
||||
if (sql.includes('MAX(cursor_seq)')) {
|
||||
return { rows: [{ max_seq: '50' }] };
|
||||
}
|
||||
|
|
@ -553,6 +553,23 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
|||
expect(result.next_cursor).toBe(encodeCursor(tenantId, 50n));
|
||||
expect(result.has_more).toBe(false);
|
||||
|
||||
// Verify staff_org_units query uses CASE expression for status
|
||||
const orgQuery = queries.find((q) => q.sql.includes('FROM staff_org_units'));
|
||||
expect(orgQuery).toBeDefined();
|
||||
expect(orgQuery!.sql).toMatch(
|
||||
/CASE[\s\S]+WHEN deleted_at IS NOT NULL THEN 'archived'[\s\S]+WHEN NOT is_active THEN 'inactive'[\s\S]+ELSE 'active'[\s\S]+END AS status/,
|
||||
);
|
||||
|
||||
// Verify SafeOrgUnitPayload
|
||||
const orgChange = result.changes.find((c) => c.entity_type === 'org_unit');
|
||||
expect(orgChange).toBeDefined();
|
||||
expect(orgChange!.payload).toEqual({
|
||||
id: orgId,
|
||||
code: 'KITCHEN',
|
||||
name: 'Кухня',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Verify employee payload has NO sensitive fields
|
||||
const empChange = result.changes.find((c) => c.entity_type === 'employee');
|
||||
expect(empChange).toBeDefined();
|
||||
|
|
@ -567,6 +584,56 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
|||
expect(payload).not.toHaveProperty('template');
|
||||
});
|
||||
|
||||
it('bootstrap sync selects staff_org_units status via CASE expression and forms SafeOrgUnitPayload', async () => {
|
||||
const orgActiveId = newUlid();
|
||||
const orgInactiveId = newUlid();
|
||||
const orgArchivedId = newUlid();
|
||||
|
||||
const { database, queries } = createMockDb((sql) => {
|
||||
if (sql.includes('MAX(cursor_seq)')) {
|
||||
return { rows: [{ max_seq: '10' }] };
|
||||
}
|
||||
if (sql.includes('FROM staff_org_units')) {
|
||||
return {
|
||||
rows: [
|
||||
{ id: orgActiveId, code: 'point_active', name: 'Точка Активная', status: 'active' },
|
||||
{
|
||||
id: orgInactiveId,
|
||||
code: 'point_inactive',
|
||||
name: 'Точка Неактивная',
|
||||
status: 'inactive',
|
||||
},
|
||||
{
|
||||
id: orgArchivedId,
|
||||
code: 'point_archived',
|
||||
name: 'Точка В Архиве',
|
||||
status: 'archived',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const result = await service.pull(tenantId, { cursor: null, entity_types: ['org_unit'] });
|
||||
|
||||
const orgQuery = queries.find((q) => q.sql.includes('FROM staff_org_units'));
|
||||
expect(orgQuery).toBeDefined();
|
||||
expect(orgQuery!.sql).toContain('CASE');
|
||||
expect(orgQuery!.sql).toContain("WHEN deleted_at IS NOT NULL THEN 'archived'");
|
||||
expect(orgQuery!.sql).toContain("WHEN NOT is_active THEN 'inactive'");
|
||||
expect(orgQuery!.sql).toContain("ELSE 'active'");
|
||||
expect(orgQuery!.sql).toContain('END AS status');
|
||||
|
||||
expect(result.changes).toHaveLength(3);
|
||||
expect(result.changes.map((c) => c.payload)).toEqual([
|
||||
{ id: orgActiveId, code: 'point_active', name: 'Точка Активная', status: 'active' },
|
||||
{ id: orgInactiveId, code: 'point_inactive', name: 'Точка Неактивная', status: 'inactive' },
|
||||
{ id: orgArchivedId, code: 'point_archived', name: 'Точка В Архиве', status: 'archived' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('incremental pull returns changes strictly after cursor', async () => {
|
||||
const cursor = encodeCursor(tenantId, 10n);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue