feat(invites): patchInvite + revokeInvite with txn re-derive + foldUsername helper

Both functions wrap their read-modify-write in a Drizzle better-sqlite3
db.transaction((tx) => ...) with in-txn re-fetch so concurrent admin
mutations are serialized by SQLite's writer lock.

- patchInvite: 404 on missing, 409 on revoked, 400 on maxUses < usedCount,
  allows expiresAt to be moved into the past (effective soft-shut).
- revokeInvite: 404 on missing, 409 on already-revoked (explicit reject,
  not silent no-op).

Also extracts foldUsername() to collapse the duplicated
(username, isDeleted) -> display string fold across resolveCreatorUsername,
listInvites, and listRedemptions (deferred refactor from Task 5 review).

Note: the plan's example used db.transaction(cb)() with an IIFE,
which is the raw better-sqlite3 signature. Drizzle's wrapper
returns the callback's return value directly, so we use the
(tx) => ... form consistent with the rest of the codebase
(userDeletion, federation, channels, etc.).

Tests: 36 invite-service tests pass (27 prior + 9 new).
Full server suite: 40 files / 311 tests pass.
This commit is contained in:
Jannis Braun
2026-04-28 20:11:47 +02:00
parent ce5d4c10c8
commit 23338419df
2 changed files with 197 additions and 15 deletions
@@ -21,7 +21,8 @@ vi.mock('../db/index.js', () => ({
schema,
}));
import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions } from './inviteService.js';
import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js';
import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js';
import { eq } from 'drizzle-orm';
function applyMigrations(db: Database.Database): void {
@@ -295,3 +296,81 @@ describe('listRedemptions', () => {
expect(list[0]?.isDeleted).toBe(false);
});
});
describe('patchInvite', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
});
it('updates name', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'old', maxUses: null, expiresAt: null }, adminId);
const updated = patchInvite(inv.id, { name: 'new' });
expect(updated.name).toBe('new');
});
it('updates maxUses', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId);
const updated = patchInvite(inv.id, { maxUses: 20 });
expect(updated.maxUses).toBe(20);
});
it('rejects maxUses below current usedCount', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: 10, expiresAt: null }, adminId);
testDb.update(schema.inviteLinks).set({ usedCount: 7 }).where(eq(schema.inviteLinks.id, inv.id)).run();
expect(() => patchInvite(inv.id, { maxUses: 5 })).toThrow(InviteValidationError);
});
it('throws InviteNotFoundError when id not found', () => {
expect(() => patchInvite('nonexistent', { name: 'x' })).toThrow(InviteNotFoundError);
});
it('throws InviteStateConflictError when invite is revoked', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, inv.id)).run();
expect(() => patchInvite(inv.id, { name: 'x' })).toThrow(InviteStateConflictError);
});
it('allows expiresAt to be moved to past (effective soft-shut)', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId);
const past = Date.now() - 1000;
const updated = patchInvite(inv.id, { expiresAt: past });
expect(updated.expiresAt).toBe(past);
expect(updated.status).toBe('expired');
});
});
describe('revokeInvite', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
});
it('sets revokedAt and returns revoked summary', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
const revoked = revokeInvite(inv.id);
expect(revoked.status).toBe('revoked');
expect(revoked.revokedAt).toBeGreaterThan(0);
});
it('throws InviteStateConflictError on already-revoked', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
revokeInvite(inv.id);
expect(() => revokeInvite(inv.id)).toThrow(InviteStateConflictError);
});
it('throws InviteNotFoundError on missing id', () => {
expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError);
});
});