Files
gitmost/apps/server/test/integration/db.ts
claude code agent 227 04f05626ad test(server): integration harness + deferred coverage vs real Postgres/Redis
Builds the deferred integration tests from docs/backlog/feature-test-coverage-
deferred.md that needed real infra (a test Postgres + real Redis) which the repo
lacked. Runs against an isolated, auto-created docmost_test database and Redis
logical DB 15 — never the dev data.

Harness (apps/server/test/integration/, run via new `pnpm --filter server test:int`
=> jest --config test/jest-integration.json; default unit `jest` is untouched and
excludes these via the *.int-spec.ts name + rootDir):
- db.ts: buildTestDb() mirrors database.module.ts exactly (PostgresJSDialect,
  CamelCasePlugin, bigint to:20/from:[20,1700] parsing) + minimal seed helpers.
- global-setup.ts: DROP/CREATE docmost_test, CREATE EXTENSION vector, migrate to
  latest via Kysely Migrator (fails loud on any errored migration).
- global-teardown.ts: closes the pool.

Coverage (5 suites, 16 tests, all green against live PG+Redis):
- WorkspaceRepo.updateSetting: jsonb-merge persists htmlEmbed without clobbering
  sibling ai/sharing namespaces (the kill-switch write half).
- AiAgentRoleRepo: soft-delete exclusion, cross-workspace tenant isolation,
  duplicate (name,workspace) -> 23505, name reusable after softDelete (partial
  unique index WHERE deleted_at IS NULL), same name across workspaces allowed.
- page_template_references: deleting either source or referenced page cascades
  the link row (onDelete cascade) — real FK, not mocked.
- PublicShareWorkspaceLimiter vs REAL Redis: real ioredis EVAL of the sliding-
  window Lua — max boundary (3 admit / 4th deny), re-admit after the window
  slides, same-ms distinct members. Catches Lua bugs a FakeRedis cannot.
- AiChatRepo.findByCreator: role-badge join (enabled->badge; soft-deleted or
  disabled role -> null).

Review: APPROVE; applied its two hardening suggestions (fail loud on errored
migration result even without a top-level error; TEST_REDIS_URL override + ping
preflight). tsc clean; unit run excludes int-spec (verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:02:55 +03:00

195 lines
5.5 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { CamelCasePlugin, Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
/**
* 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 ---------------------------------------------------------
// Insert minimal valid rows (only the columns the tests need + NOT NULL ones).
// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7).
export async function createWorkspace(
db: Kysely<any>,
overrides: { settings?: unknown; name?: string } = {},
): Promise<{ id: string; settings: any }> {
const id = randomUUID();
const row = await db
.insertInto('workspaces')
.values({
id,
name: overrides.name ?? `ws-${id.slice(0, 8)}`,
// hostname is uniquely constrained; keep it unique per workspace.
hostname: `host-${id.slice(0, 8)}`,
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 row = await db
.insertInto('users')
.values({
id,
email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`,
name: overrides.name ?? `user-${id.slice(0, 8)}`,
workspaceId,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}
export async function createSpace(
db: Kysely<any>,
workspaceId: string,
overrides: { slug?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('spaces')
.values({
id,
name: overrides.name ?? `space-${id.slice(0, 8)}`,
// slug is unique per workspace + NOT NULL.
slug: overrides.slug ?? `space-${id.slice(0, 8)}`,
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 row = await db
.insertInto('pages')
.values({
id,
// slug_id is NOT NULL + globally unique.
slugId: `slug-${id.slice(0, 8)}`,
title: args.title ?? `page-${id.slice(0, 8)}`,
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-${id.slice(0, 8)}`,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}