fix(api): wrap critical mutations in transactions and fix TOCTOU race conditions

- applyProjectScenario: wrap assignment loop in db.$transaction to prevent partial updates
- vacation approve/reject: fix TOCTOU race via updateMany with status-guard in WHERE + CONFLICT on count=0
- vacation cancel: wrap vacation.update + entitlement.updateMany in $transaction
- batchApprove: collect mutations, wrap in $transaction, dispatch SSE/notifications after commit
- Fix dead-code bug in createHappyPathDb where $transaction was assigned after return
- Add atomicity and concurrency tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 08:34:59 +02:00
parent b103e79e92
commit 1d6d75ecf6
6 changed files with 245 additions and 129 deletions
@@ -71,7 +71,7 @@ export function createHappyPathDb() {
return null;
});
return {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "MANAGER" }),
},
@@ -111,6 +111,11 @@ export function createHappyPathDb() {
},
vacation: {
findUnique: vacationFindUnique,
findUniqueOrThrow: vi.fn().mockImplementation(async (args?: any) => {
const result = await vacationFindUnique({ where: { id: args?.where?.id } });
if (!result) throw new Error("Vacation not found");
return result;
}),
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
@@ -147,6 +152,7 @@ export function createHappyPathDb() {
approvedById: args?.data?.approvedById ?? existing?.approvedById ?? null,
};
}),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
notification: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
@@ -158,9 +164,9 @@ export function createHappyPathDb() {
webhook: {
findMany: vi.fn().mockResolvedValue([]),
},
};
} as any;
(db as Record<string, unknown>).$transaction = vi.fn(
db.$transaction = vi.fn(
async (callback: (tx: typeof db) => Promise<unknown>) => callback(db),
);