baa41d66ad
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>
320 lines
9.8 KiB
TypeScript
320 lines
9.8 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { CamelCasePlugin, Kysely } from 'kysely';
|
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
|
import * as postgres from 'postgres';
|
|
|
|
/**
|
|
* db.ts — THE canonical place to seed prerequisite rows for integration tests.
|
|
*
|
|
* Seeders here use minimal, explicit `insertInto(...).values(...)` calls and are
|
|
* DELIBERATELY decoupled from the app's repo `insert*` methods. Those repo
|
|
* methods carry side effects integration specs do not want — password hashing,
|
|
* validation, default/derived columns, event emission — so reproducing only the
|
|
* columns a test needs keeps the fixtures small, fast and predictable.
|
|
*
|
|
* CONVENTIONS:
|
|
* - New entity seeders go HERE (a `createX(db, ...)` helper) rather than as raw
|
|
* `insertInto` calls scattered across spec files, so the schema knowledge
|
|
* lives in one place.
|
|
* - Each seeder inserts only the NOT NULL / uniquely-constrained columns plus
|
|
* whatever the consuming tests assert on; everything else is left to DB
|
|
* defaults.
|
|
* - Plain `randomUUID()` (v4) is fine for FK integrity; the app uses uuid v7,
|
|
* but tests never depend on id ordering.
|
|
*
|
|
* TRADE-OFF: because the column/constraint knowledge below is mirrored from the
|
|
* Kysely schema rather than derived from it, a migration that changes a NOT NULL
|
|
* column or a unique constraint can make an insert here fail. When that happens
|
|
* the fix is to update the relevant seeder, not the spec that calls it.
|
|
*/
|
|
|
|
/**
|
|
* Isolated test database connection string. The dev DB is `docmost`; tests run
|
|
* against a dedicated `docmost_test` that global-setup drops + recreates +
|
|
* migrates so nothing here touches dev data. Overridable via env (global-setup
|
|
* also sets it so the value is consistent across the run).
|
|
*/
|
|
export const TEST_DATABASE_URL =
|
|
process.env.TEST_DATABASE_URL ??
|
|
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
|
|
|
|
/**
|
|
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
|
|
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
|
|
* parsing (to:20 / from:[20,1700] / serialize toString / parse parseInt). The
|
|
* repos rely on camelCase columns + bigint-as-number, so the test Kysely must
|
|
* match or queries break.
|
|
*/
|
|
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
|
|
return new Kysely<any>({
|
|
dialect: new PostgresJSDialect({
|
|
postgres: postgres(url, {
|
|
max: 5,
|
|
onnotice: () => {},
|
|
types: {
|
|
bigint: {
|
|
to: 20,
|
|
from: [20, 1700],
|
|
serialize: (value: number) => value.toString(),
|
|
parse: (value: string) => Number.parseInt(value),
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
plugins: [new CamelCasePlugin()],
|
|
});
|
|
}
|
|
|
|
let singleton: Kysely<any> | undefined;
|
|
|
|
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
|
|
export function getTestDb(): Kysely<any> {
|
|
if (!singleton) {
|
|
singleton = buildTestDb();
|
|
}
|
|
return singleton;
|
|
}
|
|
|
|
export async function destroyTestDb(): Promise<void> {
|
|
if (singleton) {
|
|
await singleton.destroy();
|
|
singleton = undefined;
|
|
}
|
|
}
|
|
|
|
// --- Seeding helpers ---------------------------------------------------------
|
|
// Each helper inserts a minimal valid row (only the columns the tests need plus
|
|
// the NOT NULL / uniquely-constrained ones) and returns the generated id. See
|
|
// the module doc comment above for why these bypass the app's repo layer.
|
|
|
|
// Short, human-readable suffix derived from a row's uuid. Used to build unique
|
|
// names/slugs/hostnames for seeded rows so unique constraints never collide.
|
|
const shortId = (id: string): string => id.slice(0, 8);
|
|
|
|
export async function createWorkspace(
|
|
db: Kysely<any>,
|
|
overrides: { settings?: unknown; name?: string } = {},
|
|
): Promise<{ id: string; settings: any }> {
|
|
const id = randomUUID();
|
|
const suffix = shortId(id);
|
|
const row = await db
|
|
.insertInto('workspaces')
|
|
.values({
|
|
id,
|
|
name: overrides.name ?? `ws-${suffix}`,
|
|
// hostname is uniquely constrained; keep it unique per workspace.
|
|
hostname: `host-${suffix}`,
|
|
settings:
|
|
overrides.settings === undefined ? null : (overrides.settings as any),
|
|
})
|
|
.returning(['id', 'settings'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string, settings: row.settings };
|
|
}
|
|
|
|
export async function createUser(
|
|
db: Kysely<any>,
|
|
workspaceId: string,
|
|
overrides: { email?: string; name?: string } = {},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const suffix = shortId(id);
|
|
const row = await db
|
|
.insertInto('users')
|
|
.values({
|
|
id,
|
|
email: overrides.email ?? `user-${suffix}@example.test`,
|
|
name: overrides.name ?? `user-${suffix}`,
|
|
workspaceId,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|
|
|
|
// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup`
|
|
// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a
|
|
// workspace under test must have exactly one for the accept path to complete.
|
|
export async function createDefaultGroup(
|
|
db: Kysely<any>,
|
|
workspaceId: string,
|
|
overrides: { name?: string } = {},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const suffix = shortId(id);
|
|
const row = await db
|
|
.insertInto('groups')
|
|
.values({
|
|
id,
|
|
// name is unique per workspace + NOT NULL.
|
|
name: overrides.name ?? `group-${suffix}`,
|
|
isDefault: true,
|
|
workspaceId,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|
|
|
|
// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a
|
|
// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a
|
|
// spec needs to drive acceptInvitation (id + token + the invited email).
|
|
export async function createInvitation(
|
|
db: Kysely<any>,
|
|
args: {
|
|
workspaceId: string;
|
|
email: string;
|
|
invitedById?: string | null;
|
|
role?: string;
|
|
token?: string;
|
|
groupIds?: string[] | null;
|
|
},
|
|
): Promise<{ id: string; token: string; email: string }> {
|
|
const id = randomUUID();
|
|
const token = args.token ?? `tok-${shortId(id)}`;
|
|
const row = await db
|
|
.insertInto('workspaceInvitations')
|
|
.values({
|
|
id,
|
|
email: args.email,
|
|
role: args.role ?? 'member',
|
|
token,
|
|
groupIds: (args.groupIds ?? null) as any,
|
|
invitedById: args.invitedById ?? null,
|
|
workspaceId: args.workspaceId,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string, token, email: args.email };
|
|
}
|
|
|
|
export async function createSpace(
|
|
db: Kysely<any>,
|
|
workspaceId: string,
|
|
overrides: { slug?: string; name?: string } = {},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const suffix = shortId(id);
|
|
const row = await db
|
|
.insertInto('spaces')
|
|
.values({
|
|
id,
|
|
name: overrides.name ?? `space-${suffix}`,
|
|
// slug is unique per workspace + NOT NULL.
|
|
slug: overrides.slug ?? `space-${suffix}`,
|
|
workspaceId,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|
|
|
|
export async function createPage(
|
|
db: Kysely<any>,
|
|
args: { workspaceId: string; spaceId: string; title?: string },
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const suffix = shortId(id);
|
|
const row = await db
|
|
.insertInto('pages')
|
|
.values({
|
|
id,
|
|
// slug_id is NOT NULL + globally unique.
|
|
slugId: `slug-${suffix}`,
|
|
title: args.title ?? `page-${suffix}`,
|
|
spaceId: args.spaceId,
|
|
workspaceId: args.workspaceId,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|
|
|
|
export async function createRole(
|
|
db: Kysely<any>,
|
|
args: {
|
|
workspaceId: string;
|
|
creatorId?: string | null;
|
|
name: string;
|
|
emoji?: string | null;
|
|
instructions?: string;
|
|
enabled?: boolean;
|
|
deletedAt?: Date | null;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const row = await db
|
|
.insertInto('aiAgentRoles')
|
|
.values({
|
|
id,
|
|
workspaceId: args.workspaceId,
|
|
creatorId: args.creatorId ?? null,
|
|
name: args.name,
|
|
emoji: args.emoji ?? null,
|
|
instructions: args.instructions ?? 'be helpful',
|
|
enabled: args.enabled ?? true,
|
|
deletedAt: args.deletedAt ?? null,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|
|
|
|
export async function createChat(
|
|
db: Kysely<any>,
|
|
args: {
|
|
workspaceId: string;
|
|
creatorId: string;
|
|
roleId?: string | null;
|
|
title?: string;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const row = await db
|
|
.insertInto('aiChats')
|
|
.values({
|
|
id,
|
|
workspaceId: args.workspaceId,
|
|
creatorId: args.creatorId,
|
|
roleId: args.roleId ?? null,
|
|
title: args.title ?? `chat-${shortId(id)}`,
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|
|
|
|
export async function createMessage(
|
|
db: Kysely<any>,
|
|
args: {
|
|
workspaceId: string;
|
|
chatId: string;
|
|
userId?: string | null;
|
|
role?: string;
|
|
content?: string | null;
|
|
status?: string | null;
|
|
metadata?: unknown;
|
|
// Explicit timestamp so a test can control message ORDER (the default DB
|
|
// now() can tie within a millisecond, and the v4 id is not time-ordered).
|
|
createdAt?: Date;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const row = await db
|
|
.insertInto('aiChatMessages')
|
|
.values({
|
|
id,
|
|
workspaceId: args.workspaceId,
|
|
chatId: args.chatId,
|
|
userId: args.userId ?? null,
|
|
role: args.role ?? 'assistant',
|
|
content: args.content ?? null,
|
|
status: args.status ?? null,
|
|
metadata: (args.metadata ?? null) as any,
|
|
...(args.createdAt ? { createdAt: args.createdAt } : {}),
|
|
})
|
|
.returning(['id'])
|
|
.executeTakeFirstOrThrow();
|
|
return { id: row.id as string };
|
|
}
|