419 lines
20 KiB
TypeScript
419 lines
20 KiB
TypeScript
import { readFile, readdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import pg from 'pg';
|
|
import type { QueryResultRow } from 'pg';
|
|
import { ulid } from 'ulid';
|
|
import { rehomeTenant } from '../../scripts/rehome-tenant.mjs';
|
|
|
|
const connectionString = process.env.DATABASE_REHOME_TEST_URL;
|
|
const describeDatabase = connectionString ? describe : describe.skip;
|
|
const confirmation = 'I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE';
|
|
const evidence = {
|
|
backup: {
|
|
format: 'pg_dump -Fc',
|
|
encrypted: true,
|
|
offHost: true,
|
|
sha256: 'a'.repeat(64),
|
|
createdAt: '2026-09-26T08:00:00Z',
|
|
},
|
|
restoreDrill: {
|
|
status: 'passed',
|
|
backupSha256: 'a'.repeat(64),
|
|
restoredAt: '2026-09-26T08:30:00Z',
|
|
verifiedAt: '2026-09-26T08:45:00Z',
|
|
isolatedDatabase: 'krona_staff_restore_check',
|
|
},
|
|
} as const;
|
|
|
|
const tenantTables = [
|
|
'staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log',
|
|
'staff_employees', 'staff_attendance_events', 'staff_timesheet_entries',
|
|
'staff_org_units', 'staff_org_unit_terms', 'staff_metric_definitions',
|
|
'staff_daily_metrics', 'staff_work_items', 'staff_pay_rules', 'staff_pay_assignments',
|
|
'staff_work_sessions', 'staff_payroll_runs', 'staff_payroll_lines', 'staff_payouts',
|
|
'staff_payroll_ledger', 'staff_standalone_employee_access', 'staff_professions',
|
|
'staff_push_subscriptions', 'staff_notification_deliveries', 'staff_sync_devices',
|
|
'staff_sync_change_log', 'staff_sync_command_inbox', 'staff_sync_device_checkpoints',
|
|
];
|
|
|
|
interface IdentityRow extends QueryResultRow {
|
|
database_name: string;
|
|
version_num: number;
|
|
is_superuser: boolean;
|
|
}
|
|
|
|
interface CountRow extends QueryResultRow {
|
|
count: number;
|
|
}
|
|
|
|
interface RoleRow extends QueryResultRow {
|
|
rolsuper: boolean;
|
|
rolbypassrls: boolean;
|
|
}
|
|
|
|
interface PresentRow extends QueryResultRow {
|
|
present: boolean;
|
|
}
|
|
|
|
interface TableNameRow extends QueryResultRow {
|
|
relname: string;
|
|
}
|
|
|
|
interface RowCountRow extends QueryResultRow {
|
|
n: string;
|
|
}
|
|
|
|
interface ForeignKeyRow extends QueryResultRow {
|
|
source_table: string;
|
|
conname: string;
|
|
definition: string;
|
|
convalidated: boolean;
|
|
}
|
|
|
|
interface RehomeAuditRow extends QueryResultRow {
|
|
action: string;
|
|
actor_id: string;
|
|
old_values: { tenantId: string };
|
|
new_values: { tenantId: string; auditRowsAdded: number };
|
|
}
|
|
|
|
async function bootstrapDisposableDatabase(client: pg.Client) {
|
|
if (process.env.DATABASE_REHOME_TEST_CONFIRM !== confirmation) {
|
|
throw new Error(`Set DATABASE_REHOME_TEST_CONFIRM=${confirmation} only for a disposable test database.`);
|
|
}
|
|
const identity = await client.query<IdentityRow>(`
|
|
SELECT current_database() AS database_name,
|
|
current_setting('server_version_num')::integer AS version_num,
|
|
role.rolsuper AS is_superuser
|
|
FROM pg_roles role WHERE role.rolname = current_user
|
|
`);
|
|
const row = identity.rows[0]!;
|
|
if (!row.database_name.endsWith('_rehome_test') || row.version_num < 170000 || !row.is_superuser) {
|
|
throw new Error('Integration tests require a disposable database ending _rehome_test on PostgreSQL 17 and a superuser connection.');
|
|
}
|
|
|
|
const existing = await client.query<CountRow>(`
|
|
SELECT count(*)::integer AS count FROM pg_class c
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND left(c.relname, 6) = 'staff_'
|
|
`);
|
|
const hasStaffSchema = Number(existing.rows[0]!.count) > 0;
|
|
const appRole = await client.query<RoleRow>("SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'");
|
|
if (appRole.rowCount === 0) {
|
|
await client.query('CREATE ROLE krona_staff_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS');
|
|
} else if (appRole.rows[0]!.rolsuper || appRole.rows[0]!.rolbypassrls) {
|
|
throw new Error('The disposable database app role must be NOSUPERUSER and NOBYPASSRLS.');
|
|
}
|
|
if (!hasStaffSchema) {
|
|
const migrations = (await readdir(path.resolve('migrations')))
|
|
.filter((name) => name.endsWith('.up.sql')).sort();
|
|
for (const migration of migrations) {
|
|
await client.query(await readFile(path.resolve('migrations', migration), 'utf8'));
|
|
}
|
|
} else {
|
|
const checks: Array<{ table: string; present: boolean }> = [];
|
|
for (const table of tenantTables) {
|
|
const result = await client.query<PresentRow>('SELECT to_regclass($1) IS NOT NULL AS present', [`public.${table}`]);
|
|
checks.push({ table, present: result.rows[0]!.present });
|
|
}
|
|
const missing = checks.filter((check) => !check.present);
|
|
if (missing.length) throw new Error('The disposable database has an incomplete Staff schema.');
|
|
}
|
|
|
|
const tableNames = await client.query<TableNameRow>(`
|
|
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
|
|
AND left(c.relname, 6) = 'staff_'
|
|
`);
|
|
for (const { relname } of tableNames.rows) {
|
|
const count = await client.query<RowCountRow>(`SELECT count(*)::text AS n FROM public."${relname.replaceAll('"', '""')}"`);
|
|
if (BigInt(count.rows[0]!.n) !== 0n) {
|
|
throw new Error('Use an empty disposable database for the tenant-rehome integration test.');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function listForeignKeyDefinitions(client: pg.Client) {
|
|
const result = await client.query<ForeignKeyRow>(`
|
|
SELECT source.relname AS source_table, con.conname,
|
|
pg_get_constraintdef(con.oid, true) AS definition,
|
|
con.convalidated
|
|
FROM pg_constraint con
|
|
JOIN pg_class source ON source.oid = con.conrelid
|
|
JOIN pg_namespace n ON n.oid = source.relnamespace
|
|
WHERE n.nspname = 'public' AND source.relname = ANY($1::text[])
|
|
AND con.contype = 'f'
|
|
ORDER BY source.relname, con.conname
|
|
`, [tenantTables]);
|
|
return result.rows;
|
|
}
|
|
|
|
describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
|
const client = new pg.Client({ connectionString: connectionString!, application_name: 'krona-staff-rehome-integration-test' });
|
|
let safeToCleanup = false;
|
|
const source = ulid();
|
|
const target = ulid();
|
|
const other = ulid();
|
|
const actor = ulid();
|
|
const ids = Object.fromEntries([
|
|
'org', 'employee', 'profession', 'metric', 'rule', 'run', 'outbox', 'device',
|
|
'attendance', 'timesheet', 'term', 'dailyMetric', 'workItem', 'assignment',
|
|
'session', 'line', 'payout', 'ledger', 'access', 'subscription', 'delivery',
|
|
'change', 'command', 'checkpoint', 'audit', 'inbox', 'idempotency', 'otherProfession',
|
|
].map((key) => [key, ulid()]));
|
|
|
|
beforeAll(async () => {
|
|
await client.connect();
|
|
await bootstrapDisposableDatabase(client);
|
|
safeToCleanup = true;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (safeToCleanup) {
|
|
await client.query('DROP TABLE IF EXISTS public.external_rehome_reference;');
|
|
await client.query('DROP TRIGGER IF EXISTS rehome_test_unknown_trigger ON public.staff_employees;');
|
|
await client.query('DROP FUNCTION IF EXISTS public.rehome_test_trigger_fn();');
|
|
const tables = tenantTables.map((table) => `public."${table}"`).join(', ');
|
|
await client.query(`TRUNCATE TABLE ${tables} CASCADE`);
|
|
}
|
|
await client.end();
|
|
});
|
|
|
|
it('moves every tenant table atomically, preserves keys and totals, and audits one added row', async () => {
|
|
await client.query('BEGIN');
|
|
try {
|
|
await client.query(
|
|
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'unit_test', 'Fixture unit')`,
|
|
[ids.org, source],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'role_test', 'Fixture role')`,
|
|
[ids.profession, source],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_employees (id, tenant_id, profession_id, primary_org_unit_id, personnel_number, display_name)
|
|
VALUES ($1, $2, $3, $4, 'FIXTURE-1', 'Synthetic fixture')`,
|
|
[ids.employee, source, ids.profession, ids.org],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1, $2, 'fixture_metric', 'Fixture metric')`,
|
|
[ids.metric, source],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by)
|
|
VALUES ($1, $2, 'fixture_rule', 1, 'Fixture rule', '{}'::jsonb, '2026-01-01', $3)`,
|
|
[ids.rule, source, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_payroll_runs (id, tenant_id, org_unit_id, period_start, period_end, created_by)
|
|
VALUES ($1, $2, $3, '2026-01-01', '2026-01-31', $4)`,
|
|
[ids.run, source, ids.org, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_outbox
|
|
(id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at)
|
|
VALUES ($1, $2, 'fixture.event', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`,
|
|
[ids.outbox, source, ids.employee, actor, ulid()],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_inbox (tenant_id, event_id, event_type, event_version, correlation_id)
|
|
VALUES ($1, $2, 'fixture.event', 1, $3)`,
|
|
[source, ulid(), ulid()],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at)
|
|
VALUES ($1, 'fixture-key', 'synthetic-hash', now() + interval '1 day')`, [source],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, correlation_id)
|
|
VALUES ($1, $2, $3, 'fixture.created', 'fixture', $4)`,
|
|
[ids.audit, source, actor, ulid()],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_attendance_events
|
|
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, actor_id, correlation_id)
|
|
VALUES ($1, $2, $3, 'check_in', 'manual_entry', 'fixture-attendance', now(), $4, $5)`,
|
|
[ids.attendance, source, ids.employee, actor, ulid()],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by)
|
|
VALUES ($1, $2, $3, '2026-01-01', $4)`, [ulid(), source, ids.employee, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by)
|
|
VALUES ($1, $2, $3, '2026-01-01', $4)`, [ids.term, source, ids.org, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by)
|
|
VALUES ($1, $2, $3, '2026-01-01', 'fixture_metric', 123, $4)`,
|
|
[ids.dailyMetric, source, ids.org, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by)
|
|
VALUES ($1, $2, $3, $4, '2026-01-01', 'fixture_item', $5)`,
|
|
[ids.workItem, source, ids.employee, ids.org, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by)
|
|
VALUES ($1, $2, $3, $4, '2026-01-01', $5)`,
|
|
[ids.assignment, source, ids.employee, ids.rule, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date)
|
|
VALUES ($1, $2, $3, $4, '2026-01-01')`, [ids.session, source, ids.employee, ids.org],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code, amount_minor)
|
|
VALUES ($1, $2, $3, $4, $5, '2026-01-01', $6, 'fixture_rule', 12345)`,
|
|
[ids.line, source, ids.run, ids.employee, ids.org, ids.rule],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by)
|
|
VALUES ($1, $2, $3, '2026-01-01', 200, $4)`, [ids.payout, source, ids.employee, actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_payroll_ledger
|
|
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, correlation_id, created_by)
|
|
VALUES ($1, $2, $3, '2026-01-01', 'accrual', 12345, 'RUB', 'payroll_run', $4, $5),
|
|
($6, $2, $3, '2026-01-01', 'adjustment', 50, 'KZT', 'manual', $4, $5)`,
|
|
[ids.ledger, source, ids.employee, ulid(), actor, ulid()],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_standalone_employee_access
|
|
(id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, 'synth', $6)`,
|
|
[ids.access, source, ids.employee, ulid(), 'a'.repeat(64), actor],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
|
|
VALUES ($1, $2, $3, 'https://push.invalid/fixture', 'synthetic-key', 'synthetic-auth')`,
|
|
[ids.subscription, source, ids.employee],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
|
|
VALUES ($1, $2, $3, $4, 'https://push.invalid/fixture', 'delivered')`,
|
|
[ids.delivery, source, ids.employee, ids.outbox],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by, org_unit_id)
|
|
VALUES ($1, $2, 'fixture-device', 'Synthetic device', $3, 'fixture', $4, $5)`,
|
|
[ids.device, source, 'b'.repeat(64), actor, ids.org],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation)
|
|
VALUES ($1, $2, 'employee', $3, 'upsert')`, [ids.change, source, ids.employee],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_sync_command_inbox
|
|
(id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at)
|
|
VALUES ($1, $2, $3, 'fixture', 'fixture-command', $4, 'applied', now())`,
|
|
[ids.command, source, ids.device, ulid()],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq)
|
|
VALUES ($1, $2, 10)`, [source, ids.device],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'other_fixture', 'Other synthetic role')`,
|
|
[ids.otherProfession, other],
|
|
);
|
|
await client.query('COMMIT');
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
}
|
|
|
|
const oldRole = await client.query<RoleRow>("SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'");
|
|
expect(oldRole.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
|
await client.query('SET ROLE krona_staff_app');
|
|
try {
|
|
await expect(rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }))
|
|
.rejects.toThrow('privileged database administrator');
|
|
} finally {
|
|
await client.query('RESET ROLE');
|
|
}
|
|
|
|
const initialForeignKeys = await listForeignKeyDefinitions(client);
|
|
const dryRun = await rehomeTenant(client, { mode: 'dry-run', source, target });
|
|
expect(Object.keys(dryRun.tables)).toHaveLength(tenantTables.length);
|
|
for (const table of tenantTables) {
|
|
expect(dryRun.tables[table]).toBe(table === 'staff_payroll_ledger' ? '2' : '1');
|
|
}
|
|
|
|
const occupiedTarget = ulid();
|
|
await client.query(
|
|
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'occupied_fixture', 'Occupied synthetic target')`,
|
|
[ulid(), occupiedTarget],
|
|
);
|
|
await expect(rehomeTenant(client, { mode: 'apply', source, target: occupiedTarget, actor, backupEvidence: evidence }))
|
|
.rejects.toThrow('Target tenant already contains staff data');
|
|
|
|
await client.query(`
|
|
CREATE FUNCTION public.rehome_test_trigger_fn() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;
|
|
CREATE TRIGGER rehome_test_unknown_trigger BEFORE UPDATE ON public.staff_employees
|
|
FOR EACH ROW EXECUTE FUNCTION public.rehome_test_trigger_fn();
|
|
`);
|
|
await expect(rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }))
|
|
.rejects.toThrow('Unrecognized user-defined triggers');
|
|
await client.query('DROP TRIGGER rehome_test_unknown_trigger ON public.staff_employees; DROP FUNCTION public.rehome_test_trigger_fn();');
|
|
|
|
await client.query('CREATE TABLE public.external_rehome_reference (employee_id VARCHAR(26) REFERENCES public.staff_employees(id));');
|
|
await expect(rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }))
|
|
.rejects.toThrow('foreign key crosses the staff tenant-table boundary');
|
|
await client.query('DROP TABLE public.external_rehome_reference;');
|
|
|
|
await expect(rehomeTenant(client, {
|
|
mode: 'apply', source, target, actor, backupEvidence: evidence, correlationId: 'invalid',
|
|
})).rejects.toThrow('Correlation ID must be a valid ULID.');
|
|
for (const table of tenantTables) {
|
|
const sourceRows = await client.query<RowCountRow>(`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [source]);
|
|
expect(sourceRows.rows[0]!.n, `${table} survives rollback`).toBe(table === 'staff_payroll_ledger' ? '2' : '1');
|
|
}
|
|
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
|
|
|
|
const result = await rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence });
|
|
expect(result.auditRowsAdded).toBe(1);
|
|
expect(result.tables.staff_audit_log).toBe('2');
|
|
expect(result.payrollLedger).toEqual([
|
|
{ currency: 'KZT', amountMinor: '50' },
|
|
{ currency: 'RUB', amountMinor: '12345' },
|
|
]);
|
|
|
|
for (const table of tenantTables) {
|
|
const sourceRows = await client.query<RowCountRow>(`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [source]);
|
|
expect(sourceRows.rows[0]!.n, `${table} source rows`).toBe('0');
|
|
const targetRows = await client.query<RowCountRow>(`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [target]);
|
|
expect(targetRows.rows[0]!.n, `${table} target rows`).toBe(result.tables[table]);
|
|
}
|
|
const untouchedOther = await client.query<RowCountRow>('SELECT count(*)::text AS n FROM staff_professions WHERE tenant_id = $1', [other]);
|
|
expect(untouchedOther.rows[0]!.n).toBe('1');
|
|
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
|
|
|
|
await client.query('BEGIN');
|
|
try {
|
|
await client.query('SET LOCAL ROLE krona_staff_app');
|
|
await client.query("SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)", [target, actor]);
|
|
const visibleToTarget = await client.query<RowCountRow>('SELECT count(*)::text AS n FROM staff_employees');
|
|
expect(visibleToTarget.rows[0]!.n).toBe('1');
|
|
await client.query("SELECT set_config('app.tenant_id', $1, true)", [source]);
|
|
const visibleToOldTenant = await client.query<RowCountRow>('SELECT count(*)::text AS n FROM staff_employees');
|
|
expect(visibleToOldTenant.rows[0]!.n).toBe('0');
|
|
await client.query('COMMIT');
|
|
} catch (error) {
|
|
await client.query('ROLLBACK').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
|
|
const rehomeAudit = await client.query<RehomeAuditRow>(
|
|
`SELECT action, actor_id, old_values, new_values
|
|
FROM staff_audit_log WHERE tenant_id = $1 AND action = 'tenant.rehomed'`, [target],
|
|
);
|
|
expect(rehomeAudit.rows).toHaveLength(1);
|
|
expect(rehomeAudit.rows[0]!.actor_id).toBe(actor);
|
|
expect(rehomeAudit.rows[0]!.old_values.tenantId).toBe(source);
|
|
expect(rehomeAudit.rows[0]!.new_values.tenantId).toBe(target);
|
|
expect(rehomeAudit.rows[0]!.new_values.auditRowsAdded).toBe(1);
|
|
});
|
|
});
|