- 新迁移 20261011010000_loan_lender_employee_unique:(legal_entity_id, employee_id) 唯一,归档计入、无员工出借人不冲突;生产只读排查重复 0 组 - 触到约束时返回原有 loan.employee_lender_conflict,不报 500 或误报请求重复;员工行锁与代码检查保留 - 测试改为断言数据库拒绝模糊旧数据,补空员工共存与归档占用;变异 12 项含去索引与数据库兜底两种运行 - PRD 补本迁移回退说明;独立只读复核无阻断,发布窗口需重跑重复计数(AC59) Co-Authored-By: Claude Opus 5.5 <[email protected]>
82 行
6.4 KiB
JavaScript
82 行
6.4 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { readFileSync } = require('node:fs');
|
|
const path = require('node:path');
|
|
const Module = require('node:module');
|
|
|
|
// Mutate only the isolated fixture process. Repository files and the production
|
|
// module cache remain unchanged; all route exports are restored after each case.
|
|
module.exports = async function employeeMutations(h, runFixture) {
|
|
const filename = h.req.resolve('./src/services/loanLenderEmployee');
|
|
const source = readFileSync(filename, 'utf8');
|
|
const live = h.req('./src/services/loanLenderEmployee'); const original = { ...live };
|
|
const mutations = [
|
|
['bank_permission', 'if (!fastify.hasPermission(request.employee, "employee.bank.read") &&\n !fastify.hasPermission(request.employee, "employee.bank.manage"))', 'if (false)'],
|
|
['source_version', 'if (source.sourceVersion !== expectedSourceVersion)', 'if (false)'],
|
|
['source_version_name', ' employee.name, employee.legal_entity_id,', ' employee.legal_entity_id,'],
|
|
// Restores the pre-fix fingerprint (updated_at selected and hashed).
|
|
['source_version_updated_at', [['status: true, archived_at: true,\n', 'status: true, archived_at: true, updated_at: true,\n'],
|
|
['"loan-employee-source-v2", legalEntityId, employee.id,', '"loan-employee-source-v2", legalEntityId, employee.id, employee.updated_at,']]],
|
|
// The code check is proven with the database constraint removed; with it in
|
|
// place the same mutant must be fully absorbed, reporting the same conflict.
|
|
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { withoutConstraint: true }],
|
|
['legacy_duplicate_db_backstop', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { expectAbsorbed: true }],
|
|
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
|
|
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
|
|
['in_flight', 'await assertLenderNotInFlight(tx, id);', ''],
|
|
['lender_versions', 'assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);\n assertCurrentVersion(lender.account_version, input.expectedAccountVersion);', ''],
|
|
['reuse_request_binding', 'if (!previousRequest) await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REUSED", lender.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
|
|
['required_audit', 'await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
|
|
];
|
|
const results = [];
|
|
const INDEX = 'loan_lenders_legal_entity_id_employee_id_key';
|
|
for (const [name, from, to, options = {}] of mutations) {
|
|
const edits = Array.isArray(from) ? from : [[from, to]];
|
|
let mutated = source;
|
|
for (const [target, replacement] of edits) {
|
|
assert.equal(source.split(target).length, 2, `mutation ${name} must have exactly one target`);
|
|
mutated = mutated.replace(target, replacement);
|
|
}
|
|
const mutant = new Module(filename, module); mutant.filename = filename;
|
|
mutant.paths = Module._nodeModulePaths(path.dirname(filename));
|
|
mutant._compile(mutated, filename);
|
|
let failure;
|
|
Object.assign(live, mutant.exports);
|
|
if (options.withoutConstraint) await h.prisma.$executeRawUnsafe(`DROP INDEX "${INDEX}"`);
|
|
try { await runFixture(h); } catch (error) { failure = error; }
|
|
finally {
|
|
Object.assign(live, original);
|
|
if (options.withoutConstraint) {
|
|
// Detach the duplicates this mutant let through, then restore the index.
|
|
await h.prisma.$executeRawUnsafe(`UPDATE loan_lenders SET employee_id = NULL WHERE id IN (SELECT id FROM (
|
|
SELECT id, row_number() OVER (PARTITION BY legal_entity_id, employee_id ORDER BY created_at, id) AS n
|
|
FROM loan_lenders WHERE employee_id IS NOT NULL) ranked WHERE n > 1)`);
|
|
await h.prisma.$executeRawUnsafe(`CREATE UNIQUE INDEX "${INDEX}" ON loan_lenders (legal_entity_id, employee_id)`);
|
|
}
|
|
}
|
|
if (options.expectAbsorbed) {
|
|
assert.ok(!failure, `mutation ${name} was not absorbed by the database constraint: ${failure?.message?.split('\n')[0]}`);
|
|
results.push({ name, absorbedByDatabase: true, witness: 'database_constraint_backstop' });
|
|
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
|
|
continue;
|
|
}
|
|
assert.ok(failure, `mutation ${name} survived`);
|
|
// A generic environment, SQL or parsing failure is not a killed guard.
|
|
const wrongAcceptance = failure.code === 'ERR_ASSERTION' && failure.actual === 200 && [403, 409, 500].includes(failure.expected);
|
|
const duplicateIdentity = name === 'employee_lock' && failure.code === 'ERR_ASSERTION' &&
|
|
typeof failure.actual === 'string' && typeof failure.expected === 'string' &&
|
|
/^[0-9a-f-]{36}$/.test(failure.actual) && /^[0-9a-f-]{36}$/.test(failure.expected) && failure.actual !== failure.expected;
|
|
const missingLock = name === 'employee_lock' && (failure.message === 'synthetic employee operation did not actually wait for the row lock' ||
|
|
failure.code === 'EMPLOYEE_SOURCE_LOCK_NOT_HELD');
|
|
// Login-only change reported as an employee source change (two differing versions).
|
|
const loginFalseAlarm = name === 'source_version_updated_at' && failure.code === 'ERR_ASSERTION' &&
|
|
/^[0-9a-f]{64}$/.test(String(failure.actual)) && /^[0-9a-f]{64}$/.test(String(failure.expected)) && failure.actual !== failure.expected;
|
|
const fkDeadlock = name === 'employee_lock_fk_compatibility' && failure.code === 'EMPLOYEE_SOURCE_FK_DEADLOCK';
|
|
const hint = failure.code === 'ERR_ASSERTION' ? String(failure.message).split('\n')[0] : failure.name;
|
|
assert.ok(wrongAcceptance || duplicateIdentity || missingLock || fkDeadlock || loginFalseAlarm, `mutation ${name} failed outside its guard: ${hint} ${failure.code ?? ''}`);
|
|
results.push({ name, caught: true, witness: wrongAcceptance ? 'unsafe_http_acceptance' : loginFalseAlarm ? 'login_reported_as_source_change' : duplicateIdentity ? 'duplicate_employee_lender' : fkDeadlock ? 'observed_postgres_deadlock' : 'missing_row_lock' });
|
|
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
|
|
}
|
|
console.log(JSON.stringify({ stage: 'loan_employee_mutations', status: 'PASS', count: results.length, sourceFilesChanged: false }));
|
|
return results;
|
|
};
|