Files
gitmost/apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts
T
claude code agent 227 baa41d66ad test(infra): coverage-gate + acceptInvitation atomicity int-spec + turn-end unit (#324)
Tail of #244. Three items:

1. Coverage-gate (main). develop had no coverage tooling at all. Added
   @vitest/coverage-v8@4.1.6 (pinned to the vitest already in use) to the three
   vitest packages — git-sync, editor-ext (which also gains its missing direct
   `vitest` devDep), apps/client — and enabled v8 coverage with per-package
   thresholds (no root vitest config exists, so per-package is the only
   meaningful scope). v8 provider is chosen deliberately: istanbul broke on the
   ESM `@docmost/editor-ext` barrel; v8 collects native runtime coverage and
   never re-parses ESM. `enabled: true` wires the gate into the plain `test`
   script, so `pnpm -r test` (the CI entrypoint) enforces it without a manual
   `--coverage`. Thresholds set ~4-5 pts below measured current coverage so the
   gate PASSES today and FAILS on regression (verified: forcing lines=95 on
   editor-ext exits 1). `all: false` — coverage counts test-touched files;
   documented in the configs (with `all: true` the many untested type/barrel
   files would sink the % and make the gate meaningless).
   Measured→threshold (S/B/F/L): git-sync 91.78/79.16/76.76/92.46 → 88/75/72/88;
   editor-ext 58.58/48.1/64.96/58.91 → 54/44/60/54; client 59.93/58/48.47/59.39
   → 55/53/44/55. All exit 0.

2. acceptInvitation atomicity int-spec. New
   apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts
   (+ createDefaultGroup/createInvitation seeders in test/integration/db.ts per
   its convention). Wires the real WorkspaceInvitationService with real
   User/Group/GroupUser repos against the test Kysely, stubbing only the
   post-commit collaborators. Asserts the invariant protected by
   users_email_workspace_id_unique: (a) two CONCURRENT accepts → exactly one
   fulfilled, one BadRequestException('Invitation already accepted'), membership
   count == 1, invitation consumed; (b) repeated sequential accept → still one
   membership; (c) the survivor is in the workspace default group (whole-tx, no
   torn state). Ran against real Postgres+Redis: 3/3 pass.

3. turn-end decision unit test. `decideTurnEnd` does not exist as a symbol; the
   turn-end logic lives in chat-thread.tsx's onFinish handler. Added a focused
   block to the existing chat-thread.test.tsx (matching its hoisted-mock style):
   clean finish → flush queued (continue); abort/disconnect/error → queue
   preserved (end) with the correct notice; parent notified on every terminal
   outcome. 8 passed (3 existing + 5 new).

Verified: git-sync 712, editor-ext 247, client 888 (all with the gate, exit 0);
int-spec 3/3 (real Postgres); tsc --noEmit clean for client + server;
pnpm install --frozen-lockfile consistent (lockfile additive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:37:28 +03:00

219 lines
8.3 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { Kysely } from 'kysely';
import { Workspace } from '@docmost/db/types/entity.types';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createUser,
createDefaultGroup,
createInvitation,
} from './db';
/**
* acceptInvitation atomicity (issue #324, tail of #244).
*
* acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a
* single tx: inserts the invited user, adds them to the default group, and
* deletes the invitation. Two accepts of the SAME invitation therefore race to
* insert a user with the same (email, workspaceId) — which the
* `users_email_workspace_id_unique` constraint forbids. The service catches that
* violation and reports "Invitation already accepted".
*
* These specs pin the INVARIANT that path protects: no matter how many times the
* invitation is accepted (concurrently or repeatedly), the workspace ends up
* with exactly ONE membership for the invited email and the invitation is
* consumed exactly once — never a duplicate user and never a half-applied state.
*
* The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo)
* against the test Kysely; only the peripheral collaborators that acceptInvitation
* touches AFTER the transaction (mail, session token, billing, audit, env) are
* stubbed, so the exercised DB write path is the production one.
*/
describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => {
let db: Kysely<any>;
let service: WorkspaceInvitationService;
// Count the memberships (user rows) for an email within a workspace — the
// quantity the atomicity guarantee is about.
async function membershipCount(
workspaceId: string,
email: string,
): Promise<number> {
const rows = await db
.selectFrom('users')
.select('id')
.where('workspaceId', '=', workspaceId)
.where('email', '=', email.toLowerCase())
.execute();
return rows.length;
}
async function invitationExists(invitationId: string): Promise<boolean> {
const row = await db
.selectFrom('workspaceInvitations')
.select('id')
.where('id', '=', invitationId)
.executeTakeFirst();
return !!row;
}
beforeAll(() => {
db = getTestDb();
const userRepo = new UserRepo(db as any);
const groupRepo = new GroupRepo(db as any);
const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo);
// Collaborators used only on the post-commit success tail; safe to stub.
const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) };
const domainService = {} as any;
const tokenService = {} as any;
const sessionService = {
createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'),
};
const billingQueue = { add: jest.fn().mockResolvedValue(undefined) };
const environmentService = { isCloud: () => false };
const auditService = { log: jest.fn() };
service = new WorkspaceInvitationService(
userRepo,
groupUserRepo,
mailService as any,
domainService,
tokenService,
sessionService as any,
db as any,
billingQueue as any,
environmentService as any,
auditService as any,
);
});
afterAll(async () => {
await destroyTestDb();
});
// A workspace with its default group, an inviter, and a pending invitation.
async function seedInvite(): Promise<{
workspace: Workspace;
invitationId: string;
token: string;
email: string;
}> {
const { id: workspaceId } = await createWorkspace(db);
await createDefaultGroup(db, workspaceId);
const inviter = await createUser(db, workspaceId);
// Distinct address per invite so specs never collide across the suite.
const email = `invitee-${workspaceId.slice(0, 8)}@example.test`;
const invite = await createInvitation(db, {
workspaceId,
email,
invitedById: inviter.id,
});
// acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa
// off the workspace; a minimal plain object is sufficient.
const workspace = {
id: workspaceId,
hostname: `host-${workspaceId.slice(0, 8)}`,
enforceSso: false,
enforceMfa: false,
emailDomains: [] as string[],
} as unknown as Workspace;
return { workspace, invitationId: invite.id, token: invite.token, email };
}
it('concurrent accepts create a single membership and consume the invitation once', async () => {
const { workspace, invitationId, token, email } = await seedInvite();
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
// Fire two accepts of the SAME invitation at once. They race to insert the
// same (email, workspaceId); the unique constraint lets exactly one win.
const results = await Promise.allSettled([
service.acceptInvitation({ ...dto }, workspace),
service.acceptInvitation({ ...dto }, workspace),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
const rejected = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected',
);
// Exactly one accept succeeds; the other is rejected.
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
// The loser fails via the caught unique-constraint path with the specific
// "already accepted" message — not a half-state / generic failure.
expect(rejected[0].reason).toBeInstanceOf(BadRequestException);
expect(rejected[0].reason.message).toBe('Invitation already accepted');
// Invariant: exactly one membership, and the invitation is gone.
expect(await membershipCount(workspace.id, email)).toBe(1);
expect(await invitationExists(invitationId)).toBe(false);
});
it('a repeated (sequential) accept does not create a duplicate membership', async () => {
const { workspace, invitationId, token, email } = await seedInvite();
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
// First accept succeeds and returns an auth token.
const first = await service.acceptInvitation({ ...dto }, workspace);
expect(first?.authToken).toBe('test-auth-token');
expect(await membershipCount(workspace.id, email)).toBe(1);
expect(await invitationExists(invitationId)).toBe(false);
// Re-accepting the (now consumed) invitation must be rejected and must NOT
// add a second membership. The invitation row is gone, so this hits the
// "Invitation not found" guard rather than the unique-constraint path.
await expect(
service.acceptInvitation({ ...dto }, workspace),
).rejects.toBeInstanceOf(BadRequestException);
expect(await membershipCount(workspace.id, email)).toBe(1);
});
it('the single created membership is added to the default group (no partial state)', async () => {
const { workspace, invitationId, token, email } = await seedInvite();
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
await Promise.allSettled([
service.acceptInvitation({ ...dto }, workspace),
service.acceptInvitation({ ...dto }, workspace),
]);
// Resolve the one surviving user and assert the whole tx applied: they exist
// AND are in the workspace default group (the mid-transaction step), proving
// the winning accept committed as a whole rather than leaving a torn state.
const user = await db
.selectFrom('users')
.select(['id'])
.where('workspaceId', '=', workspace.id)
.where('email', '=', email.toLowerCase())
.executeTakeFirstOrThrow();
const defaultGroup = await db
.selectFrom('groups')
.select(['id'])
.where('workspaceId', '=', workspace.id)
.where('isDefault', '=', true)
.executeTakeFirstOrThrow();
const membership = await db
.selectFrom('groupUsers')
.select(['userId'])
.where('groupId', '=', defaultGroup.id)
.where('userId', '=', user.id)
.execute();
expect(membership).toHaveLength(1);
});
});