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.
This commit is contained in:
Jannis Braun
2026-04-28 20:17:41 +02:00
parent 23338419df
commit 45b2eabed3
2 changed files with 17 additions and 11 deletions
@@ -135,23 +135,23 @@ describe('createInvite', () => {
it('rejects empty name', () => { it('rejects empty name', () => {
const adminId = seedAdmin(); 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', () => { it('rejects name longer than 64 chars', () => {
const adminId = seedAdmin(); 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', () => { it('rejects non-positive maxUses', () => {
const adminId = seedAdmin(); const adminId = seedAdmin();
expect(() => createInvite({ name: 'a', maxUses: 0, 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(); expect(() => createInvite({ name: 'a', maxUses: -1, expiresAt: null }, adminId)).toThrow(InviteValidationError);
}); });
it('rejects past expiresAt', () => { it('rejects past expiresAt', () => {
const adminId = seedAdmin(); 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);
}); });
}); });
+12 -6
View File
@@ -154,10 +154,16 @@ function rowToSummary(
* Resolve the username to display for an invite's creator. Returns the live * 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 * username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or
* `null` if the FK is unresolvable (defensive — should not happen in practice). * `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 { function resolveCreatorUsername(
const db = getDb(); creatorId: string,
const u = db.select({ username: schema.users.username, isDeleted: schema.users.isDeleted }) dbHandle: ReturnType<typeof getDb> = getDb(),
): string | null {
const u = dbHandle.select({ username: schema.users.username, isDeleted: schema.users.isDeleted })
.from(schema.users) .from(schema.users)
.where(eq(schema.users.id, creatorId)) .where(eq(schema.users.id, creatorId))
.get(); .get();
@@ -342,13 +348,13 @@ export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSum
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {
// No-op: just return current summary // 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(); 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(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!updated) throw new Error('Failed to read updated invite'); 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(); 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(); const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!updated) throw new Error('Failed to read updated invite'); if (!updated) throw new Error('Failed to read updated invite');
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy)); return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx));
}); });
} }