Merge pull request '#115 test(server): integration harness + deferred coverage' (#115) from test/deferred-integration-coverage into develop

This commit is contained in:
claude_code
2026-06-21 14:31:12 +03:00
11 changed files with 685 additions and 93 deletions

View File

@@ -25,6 +25,7 @@
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "pnpm --filter @docmost/editor-ext build",
"test": "jest",
"test:int": "jest --config test/jest-integration.json",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",

View File

@@ -0,0 +1,78 @@
import { Kysely } from 'kysely';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* B — AiAgentRoleRepo: tenant isolation + soft-delete-aware lookups + the
* partial unique index `WHERE deleted_at IS NULL` (migration
* 20260620T120000-ai-agent-roles.ts). Exercises real SQL constraints.
*/
describe('AiAgentRoleRepo isolation + partial unique index [integration]', () => {
let db: Kysely<any>;
let repo: AiAgentRoleRepo;
let w1: string;
let w2: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiAgentRoleRepo(db as any);
w1 = (await createWorkspace(db)).id;
w2 = (await createWorkspace(db)).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('findById / listByWorkspace exclude soft-deleted rows', async () => {
const live = await repo.insert({ workspaceId: w1, name: 'Live', instructions: 'x' });
const dead = await repo.insert({ workspaceId: w1, name: 'Dead', instructions: 'x' });
await repo.softDelete(dead.id, w1);
expect(await repo.findById(live.id, w1)).toBeDefined();
expect(await repo.findById(dead.id, w1)).toBeUndefined();
const names = (await repo.listByWorkspace(w1)).map((r) => r.name);
expect(names).toContain('Live');
expect(names).not.toContain('Dead');
});
it('findById of a W2 role from W1 context returns undefined (tenant isolation)', async () => {
const w2role = await repo.insert({ workspaceId: w2, name: 'W2Role', instructions: 'x' });
expect(await repo.findById(w2role.id, w2)).toBeDefined();
// Same id, wrong workspace context -> not visible.
expect(await repo.findById(w2role.id, w1)).toBeUndefined();
});
it('duplicate (name, workspace) while not-deleted throws 23505 unique violation', async () => {
await repo.insert({ workspaceId: w1, name: 'Dup', instructions: 'x' });
let code: string | undefined;
try {
await repo.insert({ workspaceId: w1, name: 'Dup', instructions: 'x' });
} catch (err: any) {
code = err?.code ?? err?.cause?.code;
}
expect(code).toBe('23505');
});
it('same name is reusable after softDelete (partial unique index WHERE deleted_at IS NULL)', async () => {
const first = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
await repo.softDelete(first.id, w1);
// Now inserting the same name must succeed because the soft-deleted row is
// excluded from the partial unique index.
const second = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
expect(second.id).toBeDefined();
expect(second.id).not.toBe(first.id);
});
it('same name in W1 and W2 is allowed (unique is per-workspace)', async () => {
const a = await repo.insert({ workspaceId: w1, name: 'CrossTenant', instructions: 'x' });
const b = await repo.insert({ workspaceId: w2, name: 'CrossTenant', instructions: 'x' });
expect(a.id).toBeDefined();
expect(b.id).toBeDefined();
expect(a.id).not.toBe(b.id);
});
});

View File

@@ -0,0 +1,96 @@
import { Kysely } from 'kysely';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createUser,
createRole,
createChat,
} from './db';
/**
* E (stretch) — AiChatRepo.findByCreator role-badge LEFT JOIN. The badge
* (roleName/roleEmoji) is populated ONLY when the bound role is live AND
* enabled; a soft-deleted or disabled role resolves to NULL, matching the
* stream's resolveRoleForRequest downgrade. Real SQL join, not a mock.
*/
describe('AiChatRepo.findByCreator role-badge join [integration]', () => {
let db: Kysely<any>;
let repo: AiChatRepo;
let roleRepo: AiAgentRoleRepo;
let workspaceId: string;
let creatorId: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiChatRepo(db as any);
roleRepo = new AiAgentRoleRepo(db as any);
workspaceId = (await createWorkspace(db)).id;
creatorId = (await createUser(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
async function badgeFor(chatId: string) {
const { items } = await repo.findByCreator(creatorId, workspaceId, {
limit: 50,
} as any);
const row = items.find((c: any) => c.id === chatId);
expect(row).toBeDefined();
return { roleName: (row as any).roleName, roleEmoji: (row as any).roleEmoji };
}
it('enabled role -> roleName/roleEmoji populated', async () => {
const role = await createRole(db, {
workspaceId,
name: 'Proofreader',
emoji: '📝',
enabled: true,
});
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBe('Proofreader');
expect(badge.roleEmoji).toBe('📝');
});
it('soft-deleted role -> badge NULL', async () => {
const role = await createRole(db, {
workspaceId,
name: 'Deleted Persona',
emoji: '🗑️',
enabled: true,
});
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
await roleRepo.softDelete(role.id, workspaceId);
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBeNull();
expect(badge.roleEmoji).toBeNull();
});
it('disabled role -> badge NULL (mirrors resolveRoleForRequest downgrade)', async () => {
const role = await createRole(db, {
workspaceId,
name: 'Disabled Persona',
emoji: '🚫',
enabled: false,
});
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBeNull();
expect(badge.roleEmoji).toBeNull();
});
it('chat with no role -> badge NULL', async () => {
const chat = await createChat(db, { workspaceId, creatorId, roleId: null });
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBeNull();
expect(badge.roleEmoji).toBeNull();
});
});

View File

@@ -0,0 +1,194 @@
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 };
}

View File

@@ -0,0 +1,79 @@
import * as path from 'node:path';
import { promises as fs } from 'node:fs';
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { TEST_DATABASE_URL, buildTestDb } from './db';
const MAINTENANCE_URL =
process.env.TEST_MAINTENANCE_DATABASE_URL ??
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost';
const TEST_DB_NAME = 'docmost_test';
// migrate.ts points FileMigrationProvider at src/database/migrations; mirror it.
const migrationFolder = path.resolve(
__dirname,
'../../src/database/migrations',
);
/**
* Jest globalSetup: (re)create the isolated test database and migrate it to
* latest. Mirrors apps/server/src/database/migrate.ts (Kysely Migrator +
* FileMigrationProvider) so the schema is exactly what the app expects.
*/
export default async function globalSetup(): Promise<void> {
// 1. DROP/CREATE the test DB via the maintenance connection. These statements
// cannot run inside a transaction; use the raw postgres client's simple
// query (`.simple()`) so the driver does not wrap them.
const maintenance = postgres(MAINTENANCE_URL, { max: 1, onnotice: () => {} });
try {
await maintenance`DROP DATABASE IF EXISTS docmost_test WITH (FORCE)`.simple();
await maintenance`CREATE DATABASE docmost_test`.simple();
} finally {
await maintenance.end({ timeout: 5 });
}
// 2. Enable pgvector on the fresh DB (migrations create vector columns).
const ext = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => {} });
try {
await ext`CREATE EXTENSION IF NOT EXISTS vector`.simple();
} finally {
await ext.end({ timeout: 5 });
}
// 3. Run all migrations to latest against docmost_test.
const db: Kysely<any> = new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(TEST_DATABASE_URL, { onnotice: () => {} }),
}),
});
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({ fs, path, migrationFolder }),
});
const { error, results } = await migrator.migrateToLatest();
// Fail loud on ANY errored migration, even if Migrator did not also surface a
// top-level `error` — never run the suite against a half-migrated schema.
const failed = (results ?? []).filter((r) => r.status === 'Error');
await db.destroy();
if (error || failed.length > 0) {
const names = failed.map((r) => r.migrationName).join(', ');
throw new Error(
`Test DB migration failed${names ? ` (${names})` : ''}: ${
(error as Error)?.message ?? error ?? 'errored migration result'
}`,
);
}
// 4. Pin the URL for the test workers (db.ts reads it from env).
process.env.TEST_DATABASE_URL = TEST_DATABASE_URL;
// Sanity touch: open + close the shared test Kysely once so a bad connection
// surfaces here rather than mid-suite.
const probe = buildTestDb();
await probe.selectFrom('workspaces').select('id').limit(1).execute();
await probe.destroy();
}

View File

@@ -0,0 +1,11 @@
import { destroyTestDb } from './db';
/**
* Jest globalTeardown: close any pools opened in the setup-process scope so jest
* exits cleanly. The test workers destroy their own connections in afterAll.
* We intentionally LEAVE docmost_test in place for post-mortem debuggability;
* global-setup drops + recreates it on the next run.
*/
export default async function globalTeardown(): Promise<void> {
await destroyTestDb();
}

View File

@@ -0,0 +1,68 @@
import { Kysely } from 'kysely';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
} from './db';
/**
* C — page_template_references FK onDelete('cascade') (migration
* 20260620T131000-page-template-references.ts). Both reference_page_id and
* source_page_id reference pages.id ON DELETE CASCADE; deleting either page
* must remove the reference row.
*/
describe('page_template_references FK cascade [integration]', () => {
let db: Kysely<any>;
let workspaceId: string;
let spaceId: string;
beforeAll(async () => {
db = getTestDb();
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
async function seedRef() {
const source = await createPage(db, { workspaceId, spaceId, title: 'source' });
const reference = await createPage(db, { workspaceId, spaceId, title: 'reference' });
const ref = await db
.insertInto('pageTemplateReferences')
.values({ workspaceId, sourcePageId: source.id, referencePageId: reference.id })
.returning(['id'])
.executeTakeFirstOrThrow();
return { source, reference, refId: ref.id as string };
}
async function refExists(refId: string): Promise<boolean> {
const row = await db
.selectFrom('pageTemplateReferences')
.select('id')
.where('id', '=', refId)
.executeTakeFirst();
return Boolean(row);
}
it('deleting the referenced page cascades the reference row away', async () => {
const { reference, refId } = await seedRef();
expect(await refExists(refId)).toBe(true);
await db.deleteFrom('pages').where('id', '=', reference.id).execute();
expect(await refExists(refId)).toBe(false);
});
it('deleting the source page also cascades the reference row away', async () => {
const { source, refId } = await seedRef();
expect(await refExists(refId)).toBe(true);
await db.deleteFrom('pages').where('id', '=', source.id).execute();
expect(await refExists(refId)).toBe(false);
});
});

View File

@@ -0,0 +1,75 @@
import Redis from 'ioredis';
import { PublicShareWorkspaceLimiter } from 'src/core/ai-chat/public-share-workspace-limiter';
/**
* D — PublicShareWorkspaceLimiter against REAL Redis (logical DB 15, so nothing
* touches dev data). This exercises the actual Lua EVAL — including
* ZREMRANGEBYSCORE eviction and the `ZCARD >= max` boundary — which a FakeRedis
* cannot faithfully reproduce.
*/
describe('PublicShareWorkspaceLimiter vs real Redis [integration]', () => {
let redis: Redis;
beforeAll(async () => {
// db:15 keeps this off the app's db 0, so dev Redis data is never touched.
const url = process.env.TEST_REDIS_URL ?? 'redis://127.0.0.1:6379';
redis = new Redis(url, { db: 15, lazyConnect: false });
// Surface an unreachable/wrong Redis here with a clear error, not mid-test.
await redis.ping();
});
beforeEach(async () => {
await redis.flushdb();
});
afterAll(async () => {
await redis.quit();
});
it('admits the first max calls and denies the next, then re-admits after the window slides', async () => {
let nowMs = 1_000_000;
const now = () => nowMs;
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
const key = 'ws-sliding';
// First 3 admitted.
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
// 4th denied (cap reached; ZCARD >= max).
expect(await limiter.tryConsume(key)).toBe(false);
// Advance time past the window so all 3 entries fall out of the trailing
// windowMs and ZREMRANGEBYSCORE evicts them.
nowMs += 1500;
expect(await limiter.tryConsume(key)).toBe(true);
});
it('counts 3 distinct same-millisecond calls distinctly, then denies the 4th', async () => {
// Fixed `now` => all attempts share the same timestamp. Unique member ids
// (counter + random suffix) keep them distinct in the sorted set so the
// count is not under-reported by score collision.
const now = () => 2_000_000;
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
const key = 'ws-same-ms';
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(false);
// Confirm the sorted set actually holds 3 distinct members at one score.
const card = await redis.zcard('share-ai:ws:' + key);
expect(card).toBe(3);
});
it('keys are isolated per workspace', async () => {
const now = () => 3_000_000;
const limiter = new PublicShareWorkspaceLimiter(redis, 1, 1000, now);
expect(await limiter.tryConsume('ws-a')).toBe(true);
expect(await limiter.tryConsume('ws-a')).toBe(false);
// Different key has its own independent budget.
expect(await limiter.tryConsume('ws-b')).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import { Kysely } from 'kysely';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* A — WorkspaceRepo.updateSetting jsonb-MERGE (the html-embed kill-switch
* write-half). Setting a single top-level key must NOT clobber sibling
* settings namespaces. This is real SQL: the repo does
* `COALESCE(settings,'{}') || jsonb_build_object(key, value)`.
*/
describe('WorkspaceRepo.updateSetting (jsonb merge) [integration]', () => {
let db: Kysely<any>;
let repo: WorkspaceRepo;
beforeAll(() => {
db = getTestDb();
// Repos are plain classes taking @InjectKysely() db — instantiate directly.
repo = new WorkspaceRepo(db as any);
});
afterAll(async () => {
await destroyTestDb();
});
it('persists htmlEmbed:true without clobbering sibling ai/sharing settings', async () => {
const ws = await createWorkspace(db, {
settings: { ai: { chat: true }, sharing: { x: 1 } },
});
const updated = await repo.updateSetting(ws.id, 'htmlEmbed', true);
// Returned row carries the merged settings.
expect(updated.settings).toMatchObject({
htmlEmbed: true,
ai: { chat: true },
sharing: { x: 1 },
});
// Re-read from the DB to confirm it actually persisted (not just returning()).
const row = await db
.selectFrom('workspaces')
.select(['settings'])
.where('id', '=', ws.id)
.executeTakeFirstOrThrow();
expect(row.settings).toEqual({
ai: { chat: true },
sharing: { x: 1 },
htmlEmbed: true,
});
});
it('initializes settings from NULL via COALESCE without error', async () => {
const ws = await createWorkspace(db, { settings: undefined });
const updated = await repo.updateSetting(ws.id, 'htmlEmbed', false);
expect(updated.settings).toEqual({ htmlEmbed: false });
});
});

View File

@@ -0,0 +1,23 @@
{
"moduleFileExtensions": ["js", "json", "ts", "tsx"],
"rootDir": "..",
"testRegex": ".*\\.int-spec\\.ts$",
"testPathIgnorePatterns": ["/node_modules/"],
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
],
"testEnvironment": "node",
"testTimeout": 60000,
"maxWorkers": 1,
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/src/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/src/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/src/ee/$1",
"^src/(.*)$": "<rootDir>/src/$1"
}
}