krona-staff/tests/unit/payroll-auto-calc.test.ts
KAgent 7c038c4b78
Some checks failed
ci / verify (pull_request) Has been cancelled
ci / format (pull_request) Has been cancelled
ci / e2e (pull_request) Has been cancelled
ci / windows (pull_request) Has been cancelled
ci / docker-build (pull_request) Has been cancelled
fix(payroll): prevent duplicate shift accruals across overlapping runs (K5/N4)
2026-09-29 13:29:55 +07:00

753 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFileSync } from 'node:fs';
import type { PoolClient, QueryResult, QueryResultRow } from 'pg';
import { describe, expect, it } from 'vitest';
import type { RequestContext } from '../../src/core/context.js';
import { newUlid } from '../../src/core/ids.js';
import { calculateDay, type DayParticipant } from '../../src/domain/payroll/calculator.js';
import {
cateringPreset,
defaultParams,
findRoleTemplate,
} from '../../src/domain/payroll/presets.js';
import {
approvePayrollRun,
calculatePayrollPeriod,
getEmployeePayAssignment,
saveEmployeePayAssignment,
} from '../../src/domain/payroll/service.js';
const RUB = 100;
const poolRole = findRoleTemplate(cateringPreset, 'pool_member')!;
const percentRole = findRoleTemplate(cateringPreset, 'revenue_percent')!;
function mockContext(overrides?: Partial<RequestContext>): RequestContext {
return {
tenantId: newUlid(),
actorId: newUlid(),
correlationId: newUlid(),
permissions: new Set(['staff:payroll:write', 'staff:payroll:read', 'staff:pay_rule:manage']),
...overrides,
};
}
describe('Owner spreadsheet payroll schemes', () => {
it('calculates 11% pool for 3 cooks (100 ₽/h) + 2.5% admin with 3000 ₽ guarantee on 150k revenue', () => {
const revenueRub = 150_000;
const revenueMinor = revenueRub * RUB;
// 3 cooks working full 12-hour shifts
const cook1: DayParticipant = {
employeeId: 'COOK1',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp, // 10000
};
const cook2: DayParticipant = {
employeeId: 'COOK2',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const cook3: DayParticipant = {
employeeId: 'COOK3',
workedMinutes: 720,
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
// 1 admin working 12 hours with 2.5% revenue + 100 ₽/h and 3000 ₽ guarantee
const admin: DayParticipant = {
employeeId: 'ADMIN',
workedMinutes: 720,
ruleCode: percentRole.code,
rule: percentRole.rule,
params: {
rate_minor: 100 * RUB,
percent_bp: 250, // 2.5%
guarantee_minor: 3_000 * RUB,
},
poolWeightBp: percentRole.poolWeightBp, // 0
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [cook1, cook2, cook3, admin],
});
// Total pool = 11% of 150,000 ₽ = 16,500 ₽ (1,650,000 kopecks)
expect(dayResult.poolAmountMinor).toBe(1_650_000);
// 3 cooks share pool equally: 16,500 / 3 = 5,500 ₽
// Hourly: 12h * 100 ₽/h = 1,200 ₽
// Total per cook: 5,500 + 1,200 = 6,700 ₽ = 670,000 kopecks
const lineCook1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
const lineCook2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
const lineCook3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
expect(lineCook1.poolShareMinor).toBe(550_000);
expect(lineCook1.amountMinor).toBe(670_000);
expect(lineCook2.poolShareMinor).toBe(550_000);
expect(lineCook2.amountMinor).toBe(670_000);
expect(lineCook3.poolShareMinor).toBe(550_000);
expect(lineCook3.amountMinor).toBe(670_000);
// Admin: 2.5% of 150,000 ₽ (3,750 ₽) + hourly (1,200 ₽) = 4,950 ₽ (495,000 kopecks), exceeding 3,000 ₽ guarantee
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
expect(lineAdmin.poolShareMinor).toBe(0);
expect(lineAdmin.amountMinor).toBe(495_000);
// Total daily payroll: 6,700 * 3 + 4,950 = 25,050 ₽ (2,505,000 kopecks)
const totalDay = dayResult.lines.reduce((sum, l) => sum + l.amountMinor, 0);
expect(totalDay).toBe(2_505_000);
});
it('correctly redistributes pool when a cook leaves early (time-slice model)', () => {
const revenueMinor = 150_000 * RUB;
// Cook 1 leaves after 6 hours (half shift)
const cook1: DayParticipant = {
employeeId: 'COOK1',
workedMinutes: 360, // 6h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
// Cook 2 and Cook 3 work full 12h shifts
const cook2: DayParticipant = {
employeeId: 'COOK2',
workedMinutes: 720, // 12h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const cook3: DayParticipant = {
employeeId: 'COOK3',
workedMinutes: 720, // 12h
ruleCode: poolRole.code,
rule: poolRole.rule,
params: { ...defaultParams(poolRole), rate_minor: 100 * RUB },
poolWeightBp: poolRole.poolWeightBp,
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [cook1, cook2, cook3],
});
// Pool = 16,500 ₽ (1,650,000 kopecks)
expect(dayResult.poolAmountMinor).toBe(1_650_000);
// Time-slice redistribution:
// First 6 hours: 8,250 ₽ split among 3 cooks = 2,750 ₽ each
// Last 6 hours: 8,250 ₽ split among remaining 2 cooks = 4,125 ₽ each
// Cook 1 pool share = 2,750 ₽ (275,000 kopecks)
// Cook 2 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
// Cook 3 pool share = 2,750 + 4,125 = 6,875 ₽ (687,500 kopecks)
const line1 = dayResult.lines.find((l) => l.employeeId === 'COOK1')!;
const line2 = dayResult.lines.find((l) => l.employeeId === 'COOK2')!;
const line3 = dayResult.lines.find((l) => l.employeeId === 'COOK3')!;
expect(line1.poolShareMinor).toBe(275_000);
expect(line2.poolShareMinor).toBe(687_500);
expect(line3.poolShareMinor).toBe(687_500);
// The entire pool of 16,500 ₽ is completely distributed without loss
expect(line1.poolShareMinor + line2.poolShareMinor + line3.poolShareMinor).toBe(1_650_000);
// Cook 1: 2,750 + 6h * 100 ₽ = 3,350 ₽ (335,000 kopecks)
// Cook 2 & 3: 6,875 + 12h * 100 ₽ = 8,075 ₽ (807,500 kopecks)
expect(line1.amountMinor).toBe(335_000);
expect(line2.amountMinor).toBe(807_500);
expect(line3.amountMinor).toBe(807_500);
});
it('triggers admin guaranteed minimum when revenue percentage is below threshold', () => {
// Low revenue: 50,000 ₽. 2.5% = 1,250 ₽ + 12h * 100 ₽ = 2,450 ₽, below guarantee of 3,000 ₽
const revenueMinor = 50_000 * RUB;
const admin: DayParticipant = {
employeeId: 'ADMIN',
workedMinutes: 720,
ruleCode: percentRole.code,
rule: percentRole.rule,
params: {
rate_minor: 100 * RUB,
percent_bp: 250,
guarantee_minor: 3_000 * RUB,
},
poolWeightBp: 0,
};
const dayResult = calculateDay({
businessDate: '2026-05-15',
orgUnitId: 'UNIT1',
metrics: { revenue_minor: revenueMinor },
poolRule: cateringPreset.poolRule!,
shiftNormMinutes: cateringPreset.shiftNormMinutes,
roundingUnitMinor: 100,
currencyCode: 'RUB',
participants: [admin],
});
const lineAdmin = dayResult.lines.find((l) => l.employeeId === 'ADMIN')!;
expect(lineAdmin.amountMinor).toBe(3_000 * RUB); // exactly 300,000 kopecks
});
});
describe('Payroll service calculation and warning engine', () => {
function makeMockClient(handlers: {
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
}): PoolClient {
return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params =
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
return Promise.resolve(handlers.query(sql, params));
},
release: () => {},
} as unknown as PoolClient;
}
const defaultRole = cateringPreset.roles.find((r) => r.code === 'pool_member')!;
it('calculates payroll period from confirmed sessions and revenue metrics', async () => {
const executedQueries: string[] = [];
const unitId = newUlid();
const empId = newUlid();
const ruleId = newUlid();
const client = makeMockClient({
query: (sql) => {
executedQueries.push(sql);
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('FROM staff_work_sessions s')) {
// Confirmed work sessions
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
business_date: '2026-05-01',
worked_minutes: 720,
display_name: 'Иван Поваров',
position: 'Повар',
org_unit_name: 'Кухня Центр',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_daily_metrics')) {
return {
rows: [
{
org_unit_id: unitId,
business_date: '2026-05-01',
revenue_minor: '10000000', // 100,000 ₽
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_pay_assignments a')) {
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
pay_rule_id: ruleId,
effective_from: '2026-01-01',
effective_to: null,
params: { rate_minor: 10000 },
pool_weight_bp: 10000,
rule_code: 'pool_member',
rule: defaultRole.rule,
default_pool_weight_bp: 10000,
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_org_unit_terms')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-01',
orgUnitId: unitId,
});
expect(result.status).toBe('calculated');
expect(result.warnings).toHaveLength(0);
expect(result.totals).toHaveLength(1);
const firstTotal = result.totals[0]!;
expect(firstTotal.display_name).toBe('Иван Поваров');
expect(firstTotal.worked_minutes).toBe(720);
// 11% of 100,000 ₽ = 11,000 ₽ pool + 12 * 100 ₽ hourly = 12,200 ₽ = 1,220,000 kopecks
expect(firstTotal.accrued_minor).toBe('1220000');
expect(result.total_accrued_minor).toBe('1220000');
// Check that staff_payroll_lines insertion occurred
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_payroll_lines'))).toBe(true);
// Check outbox and audit log
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_outbox'))).toBe(true);
expect(executedQueries.some((q) => q.includes('INSERT INTO staff_audit_log'))).toBe(true);
});
it('detects warnings for missing revenue, unassigned employees, and empty shifts', async () => {
const unitId = newUlid();
const empId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('FROM staff_work_sessions s')) {
return {
rows: [
{
id: newUlid(),
employee_id: empId,
org_unit_id: unitId,
business_date: '2026-05-02',
worked_minutes: 600,
display_name: 'Петр Безпрофилев',
position: 'Курьер',
org_unit_name: 'Кухня Центр',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
// Return NO metrics (missing revenue) and NO assignments
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-02',
to: '2026-05-02',
});
expect(result.warnings.some((w) => w.kind === 'missing_revenue')).toBe(true);
expect(result.warnings.some((w) => w.kind === 'missing_pay_assignment')).toBe(true);
});
it('warns when no confirmed sessions exist for the period', async () => {
const client = makeMockClient({
query: () => ({ rows: [], rowCount: 0, command: '', oid: 0, fields: [] }),
});
const result = await calculatePayrollPeriod(client, mockContext(), {
from: '2026-06-01',
to: '2026-06-30',
});
expect(result.warnings.some((w) => w.kind === 'no_confirmed_sessions')).toBe(true);
expect(result.totals).toHaveLength(0);
expect(result.total_accrued_minor).toBe('0');
});
it('rejects recalculation if run is already approved', async () => {
const runId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return {
rows: [{ id: runId, status: 'approved' }],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(
calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-31',
}),
).rejects.toThrow('Расчёт за этот период уже утверждён.');
});
it('rejects calculation if overlapping approved run exists (K5/N4)', async () => {
const approvedRunId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (
sql.includes('SELECT id, status FROM staff_payroll_runs') &&
sql.includes("status IN ('approved', 'paid')")
) {
expect(sql).toContain('period_start <= $4');
expect(sql).toContain('period_end >= $3');
return {
rows: [{ id: approvedRunId, status: 'approved' }],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(
calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-15',
to: '2026-06-15',
orgUnitId: newUlid(),
}),
).rejects.toThrow('Расчёт за этот период уже утверждён.');
});
it('updates period_start and period_end in database when recalculating existing draft run (K5/N4)', async () => {
const existingDraftId = newUlid();
let updatedPeriodStart = '';
let updatedPeriodEnd = '';
const client = makeMockClient({
query: (sql, params) => {
if (sql.includes("status IN ('approved', 'paid')")) {
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
}
if (sql.includes('SELECT id, status FROM staff_payroll_runs')) {
return {
rows: [{ id: existingDraftId, status: 'draft' }],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('UPDATE staff_payroll_runs SET')) {
updatedPeriodStart = String(params[2]);
updatedPeriodEnd = String(params[3]);
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await calculatePayrollPeriod(client, mockContext(), {
from: '2026-05-01',
to: '2026-05-31',
});
expect(updatedPeriodStart).toBe('2026-05-01');
expect(updatedPeriodEnd).toBe('2026-05-31');
});
});
describe('Payroll approval and ledger idempotency', () => {
function makeMockClient(handlers: {
query: (sql: string, params: unknown[]) => QueryResult<QueryResultRow>;
}): PoolClient {
return {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params =
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
return Promise.resolve(handlers.query(sql, params));
},
release: () => {},
} as unknown as PoolClient;
}
it('posts accrual lines to ledger with external_key and marks run approved', async () => {
let ledgerInsertQuery = '';
let ledgerInsertParams: unknown[] = [];
const runId = newUlid();
const empId = newUlid();
const client = makeMockClient({
query: (sql, params) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
status: 'calculated',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('FROM staff_payroll_lines')) {
return {
rows: [
{
employee_id: empId,
amount_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('INSERT INTO staff_payroll_ledger')) {
ledgerInsertQuery = sql;
ledgerInsertParams = params;
return { rows: [{ id: newUlid() }], rowCount: 1, command: '', oid: 0, fields: [] };
}
return { rows: [], rowCount: 1, command: '', oid: 0, fields: [] };
},
});
const result = await approvePayrollRun(client, mockContext(), runId);
expect(result.status).toBe('approved');
expect(result.entries_posted).toBe(1);
expect(result.total_accrued_minor).toBe('1220000');
// Verify external_key structure and ON CONFLICT clause
expect(ledgerInsertQuery).toContain(
'ON CONFLICT (tenant_id, external_key) WHERE external_key IS NOT NULL DO NOTHING',
);
expect(ledgerInsertParams).toContain(`payroll_run:${runId}:${empId}`);
});
it('rejects approval if run is not in calculated status', async () => {
const runId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
status: 'approved',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
'Утвердить можно только расчёт в статусе calculated.',
);
});
it('rejects approval if another overlapping run is already approved (K5/N4)', async () => {
const runId = newUlid();
const otherApprovedId = newUlid();
const client = makeMockClient({
query: (sql) => {
if (sql.includes('SELECT id, period_start::text AS period_start')) {
return {
rows: [
{
id: runId,
period_start: '2026-05-01',
period_end: '2026-05-31',
org_unit_id: null,
status: 'calculated',
total_accrued_minor: '1220000',
},
],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
if (sql.includes('SELECT id FROM staff_payroll_runs') && sql.includes('id != $2')) {
return {
rows: [{ id: otherApprovedId }],
rowCount: 1,
command: '',
oid: 0,
fields: [],
};
}
return { rows: [], rowCount: 0, command: '', oid: 0, fields: [] };
},
});
await expect(approvePayrollRun(client, mockContext(), runId)).rejects.toThrow(
'Период расчёта пересекается с другим утверждённым расчётом.',
);
});
});
describe('Pay assignment management', () => {
it('saves and retrieves pay assignment with default fallbacks', async () => {
let savedParamsJson = '';
let closedPreviousVersion = false;
const tenantId = newUlid();
const actorId = newUlid();
const corrId = newUlid();
const empId = newUlid();
const ruleId = newUlid();
const assignId = newUlid();
const client = {
query: (sqlOrConfig: string | { text: string; values: unknown[] }, values?: unknown[]) => {
const sql = typeof sqlOrConfig === 'string' ? sqlOrConfig : sqlOrConfig.text;
const params =
typeof sqlOrConfig === 'string' ? (values ?? []) : (sqlOrConfig.values ?? []);
if (sql.includes('FROM staff_employees')) {
return Promise.resolve({ rows: [{ id: empId }], rowCount: 1 });
}
if (sql.includes('FROM staff_pay_rules WHERE tenant_id')) {
return Promise.resolve({ rows: [{ id: ruleId, pool_weight_bp: 10000 }], rowCount: 1 });
}
if (sql.includes('INSERT INTO staff_pay_assignments')) {
savedParamsJson = String(params[7]);
return Promise.resolve({ rows: [], rowCount: 1 });
}
if (sql.includes('SET effective_to = ($3::date')) {
closedPreviousVersion = true;
expect(String(params[2])).toMatch(/^\d{4}-\d{2}-\d{2}$/);
return Promise.resolve({ rows: [], rowCount: 1 });
}
if (sql.includes('FROM staff_pay_assignments a')) {
return Promise.resolve({
rows: [
{
id: assignId,
employee_id: empId,
org_unit_id: null,
pay_rule_id: ruleId,
effective_from: '2026-05-01',
effective_to: null,
params: { rate_minor: 12000, percent_bp: 0, guarantee_minor: 0 },
pool_weight_bp: 10000,
note: 'Главный повар',
rule_code: 'pool_member',
rule_name: 'Повар (доля от фонда)',
default_pool_weight_bp: 10000,
},
],
rowCount: 1,
});
}
return Promise.resolve({ rows: [], rowCount: 0 });
},
release: () => {},
} as unknown as PoolClient;
const saved = await saveEmployeePayAssignment(client, tenantId, actorId, corrId, empId, {
pay_rule_id: ruleId,
rate_minor: 12000,
note: 'Главный повар',
});
expect(saved.rate_minor).toBe(12000);
expect(saved.pool_weight_bp).toBe(10000);
expect(saved.note).toBe('Главный повар');
expect(savedParamsJson).toContain('"rate_minor":12000');
expect(closedPreviousVersion).toBe(true);
const fetched = await getEmployeePayAssignment(client, tenantId, empId);
expect(fetched).not.toBeNull();
expect(fetched?.rate_minor).toBe(12000);
});
});
describe('Tenant and employee isolation guarantees', () => {
it('enforces tenant_id in every database query in payroll service', () => {
const serviceSrc = readFileSync('src/domain/payroll/service.ts', 'utf8');
// All SELECTs, INSERTs, UPDATEs, DELETEs in service.ts must contain tenant_id
const sqlStatements = serviceSrc.match(/`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*`/g) ?? [];
expect(sqlStatements.length).toBeGreaterThan(5);
for (const sql of sqlStatements) {
expect(sql).toContain('tenant_id');
}
});
it('restricts /payroll query to actor employee or requires manager permissions', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
// Ensure the employee isolation clause is present in GET /payroll
expect(operationsSrc).toContain('($3::boolean OR e.core_user_id = $4 OR e.id = $4)');
expect(operationsSrc).toContain("context.permissions.has('staff:payroll:read')");
expect(operationsSrc).toContain("context.permissions.has('staff:payslip:read_own')");
});
it('restricts pay-assignment access to owner employee or manager', () => {
const operationsSrc = readFileSync('src/operations.ts', 'utf8');
expect(operationsSrc).toContain(
'!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId',
);
});
it('confirms external_key and unique index are declared in 0008_employee_access migration', () => {
const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8');
expect(migrationSql).toContain(
'ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;',
);
expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx');
expect(migrationSql).toContain(
'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;',
);
});
});