d7fa6738e5
F4 [critical] — the anti-join `DELETE … WHERE NOT EXISTS(child)` was still racy under Postgres READ COMMITTED: a reply INSERT holds FOR KEY SHARE on the parent; the DELETE's start snapshot doesn't see the uncommitted child (NOT EXISTS true), blocks on the reply's lock, and when the reply commits the parent was only LOCKED (not modified) so EvalPlanQual does NOT re-check → the DELETE proceeds and CASCADE destroys the just-committed reply. Replaced with a transaction: SELECT the parent FOR UPDATE (conflicts with the reply's FOR KEY SHARE → serializes the concurrent reply), re-check for a child with a FRESH statement in the same tx (a new RC snapshot sees a just-committed reply), delete only if still childless (return 1) else return 0 (caller resolves). The FOR UPDATE lock is held to end-of-tx so no reply can insert between the re-check and the delete. Signature unchanged, so the service + its mocked unit tests are untouched; docstrings updated. F5 [warning] — the client Dismiss button was gated only on canComment, but the server now gates dismiss on owner-or-space-admin, so a non-owner non-admin saw a button the server 403s. `canShowDismiss` now also requires `isOwnerOrAdmin = currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin"` (the same gate the comment delete-menu already uses); threaded into both call sites. F6 [warning] — added a REAL-DB int-spec (apps/server/test/integration/comment-delete-if-childless.int-spec.ts, + a createComment seeder): (a) childless → returns 1, row gone; (b) committed reply → returns 0, parent+reply survive; (c) CONCURRENCY — a second connection inserts a reply (FOR KEY SHARE) and commits mid-operation while deleteCommentIfChildless blocks on FOR UPDATE → asserts it returns 0 and both rows survive (a blind anti-join would lose the reply here). Ran against live Postgres — 3/3 pass. server tsc clean; comment jest 53 + int-spec 3 (live Postgres) pass. client tsc clean; comment vitest 56 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
354 lines
11 KiB
TypeScript
354 lines
11 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 createComment(
|
|
db: Kysely<any>,
|
|
args: {
|
|
workspaceId: string;
|
|
spaceId: string;
|
|
pageId: string;
|
|
creatorId?: string | null;
|
|
parentCommentId?: string | null;
|
|
content?: unknown;
|
|
selection?: string | null;
|
|
suggestedText?: string | null;
|
|
type?: string | null;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
const id = randomUUID();
|
|
const row = await db
|
|
.insertInto('comments')
|
|
.values({
|
|
id,
|
|
workspaceId: args.workspaceId,
|
|
spaceId: args.spaceId,
|
|
pageId: args.pageId,
|
|
creatorId: args.creatorId ?? null,
|
|
parentCommentId: args.parentCommentId ?? null,
|
|
content: (args.content ?? null) as any,
|
|
selection: args.selection ?? null,
|
|
suggestedText: args.suggestedText ?? null,
|
|
type: args.type ?? 'page',
|
|
})
|
|
.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 };
|
|
}
|