From ef9e00ce804b0d2b6f1c76b304899e368bc42204 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sat, 26 Sep 2026 15:33:43 +0700 Subject: [PATCH] fix(sync): compute org unit status dynamically in bootstrap pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Таблица staff_org_units не имеет отдельной колонки status, активность определяется полями is_active и deleted_at. Прямой запрос status при начальной загрузке без курсора приводил к 500 ошибке (дефект SYNC-C0). Статус вычисляется через CASE-выражение. Очистка интеграционного теста выполняется ролью мигратора. --- src/domain/sync/service.ts | 34 ++++- tests/integration/sync-bootstrap.test.ts | 172 +++++++++++++++++++++++ tests/unit/sync.test.ts | 69 ++++++++- 3 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 tests/integration/sync-bootstrap.test.ts diff --git a/src/domain/sync/service.ts b/src/domain/sync/service.ts index 2eb9e5b..f9aef94 100644 --- a/src/domain/sync/service.ts +++ b/src/domain/sync/service.ts @@ -334,7 +334,24 @@ export class SyncService { }); } - async pull(tenantId: Ulid, deviceId: Ulid, options: PullOptions = {}): Promise { + async pull(tenantId: Ulid, options?: PullOptions): Promise; + async pull(tenantId: Ulid, deviceId: Ulid, options?: PullOptions): Promise; + async pull( + tenantId: Ulid, + deviceIdOrOptions?: Ulid | PullOptions, + maybeOptions: PullOptions = {}, + ): Promise { + 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, diff --git a/tests/integration/sync-bootstrap.test.ts b/tests/integration/sync-bootstrap.test.ts new file mode 100644 index 0000000..c54007c --- /dev/null +++ b/tests/integration/sync-bootstrap.test.ts @@ -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; + }>; + 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(); + } + }); +}); diff --git a/tests/unit/sync.test.ts b/tests/unit/sync.test.ts index 198fdb1..cdbd814 100644 --- a/tests/unit/sync.test.ts +++ b/tests/unit/sync.test.ts @@ -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);