From 45b2eabed34f2d38228a12028a1e1ad7c7e96a48 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:17:41 +0200 Subject: [PATCH] refactor(invites): thread tx through resolveCreatorUsername + tighten test assertions Inside patchInvite/revokeInvite txn bodies, all reads now go through the tx proxy. Pre-Task-7 hygiene: locks in the consistent pattern that reinstateInvite (Task 7) and redeemInvite (Task 8) will copy. Createinvite test failures now pin to InviteValidationError, catching regressions where the wrong error class would otherwise pass silently. --- .../server/src/utils/inviteService.test.ts | 10 +++++----- packages/server/src/utils/inviteService.ts | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 63713514..56f39865 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -135,23 +135,23 @@ describe('createInvite', () => { it('rejects empty name', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: '', maxUses: null, expiresAt: null }, adminId)).toThrow(InviteValidationError); }); it('rejects name longer than 64 chars', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: 'x'.repeat(65), maxUses: null, expiresAt: null }, adminId)).toThrow(InviteValidationError); }); it('rejects non-positive maxUses', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(); - expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(); + expect(() => createInvite({ name: 'a', maxUses: 0, expiresAt: null }, adminId)).toThrow(InviteValidationError); + expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(InviteValidationError); }); it('rejects past expiresAt', () => { const adminId = seedAdmin(); - expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(); + expect(() => createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() - 1000 }, adminId)).toThrow(InviteValidationError); }); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index b4a8bea5..fe3190e8 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -154,10 +154,16 @@ function rowToSummary( * Resolve the username to display for an invite's creator. Returns the live * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or * `null` if the FK is unresolvable (defensive — should not happen in practice). + * + * Accepts an optional Drizzle handle so callers inside a `db.transaction` + * body can pass the `tx` proxy and keep the read on the same logical txn as + * surrounding writes. Defaults to the outer `getDb()` for non-txn callers. */ -function resolveCreatorUsername(creatorId: string): string | null { - const db = getDb(); - const u = db.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) +function resolveCreatorUsername( + creatorId: string, + dbHandle: ReturnType = getDb(), +): string | null { + const u = dbHandle.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) .from(schema.users) .where(eq(schema.users.id, creatorId)) .get(); @@ -342,13 +348,13 @@ export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSum if (Object.keys(updates).length === 0) { // No-op: just return current summary - return rowToSummary(row, resolveCreatorUsername(row.createdBy)); + return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx)); } tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!updated) throw new Error('Failed to read updated invite'); - return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); }); } @@ -372,6 +378,6 @@ export function revokeInvite(id: string): InviteLinkSummary { tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get(); if (!updated) throw new Error('Failed to read updated invite'); - return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); + return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)); }); }