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>
This commit is contained in:
@@ -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",
|
||||
|
||||
78
apps/server/test/integration/ai-agent-roles-repo.int-spec.ts
Normal file
78
apps/server/test/integration/ai-agent-roles-repo.int-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
194
apps/server/test/integration/db.ts
Normal file
194
apps/server/test/integration/db.ts
Normal 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 };
|
||||
}
|
||||
79
apps/server/test/integration/global-setup.ts
Normal file
79
apps/server/test/integration/global-setup.ts
Normal 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();
|
||||
}
|
||||
11
apps/server/test/integration/global-teardown.ts
Normal file
11
apps/server/test/integration/global-teardown.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
23
apps/server/test/jest-integration.json
Normal file
23
apps/server/test/jest-integration.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
|
||||
|
||||
## Контекст
|
||||
|
||||
PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
|
||||
основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
|
||||
client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
|
||||
тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
|
||||
editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
|
||||
|
||||
Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
|
||||
тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
|
||||
сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
|
||||
почему, чтобы не потерять.
|
||||
|
||||
---
|
||||
|
||||
## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
|
||||
|
||||
Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
|
||||
исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
|
||||
(testcontainers или сервис в pipeline) + хелпер миграций.
|
||||
|
||||
- **`AiAgentRoleRepo` — изоляция и индексы.**
|
||||
`apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
|
||||
Проверить против реальной БД: `findById`/`listByWorkspace` исключают
|
||||
soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
|
||||
(tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
|
||||
переиспользуемо после softDelete (partial unique index
|
||||
`WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
|
||||
одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
|
||||
сейчас только предполагается unit-моками.
|
||||
|
||||
- **`AiChatRepo.findByCreator` — join role-badge.**
|
||||
`apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
|
||||
Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
|
||||
бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
|
||||
`resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
|
||||
ambiguous column после join). Не проверяемо чистым unit-ом.
|
||||
|
||||
- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
|
||||
`apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
|
||||
`apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
|
||||
Сейчас покрыта только форма вызова сервиса
|
||||
(`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
|
||||
персистится через jsonb-merge **не затирая** соседние настройки (ai,
|
||||
sharing). Это и есть «kill-switch пишется» — критично, что write-половина
|
||||
тоггла не ломает остальной settings-namespace.
|
||||
|
||||
- **FK `page_template_references` onDelete('cascade').**
|
||||
Миграция `20260620T131000-page-template-references.ts`. Проверить, что
|
||||
удаление source/reference-страницы каскадит строки ссылок.
|
||||
|
||||
## 2. HTTP / e2e-харнес (его нет в apps/server)
|
||||
|
||||
- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
|
||||
per-workspace cap держит.**
|
||||
Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
|
||||
(`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
|
||||
из Fastify `trustProxy` → `X-Forwarded-For`. Единственный оправданный e2e
|
||||
(named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
|
||||
лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
|
||||
поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
|
||||
|
||||
- **Достоверность Lua-окна cost-cap против реального Redis.**
|
||||
`apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
|
||||
(`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
|
||||
`FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
|
||||
(`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
|
||||
против реального/testcontainers Redis.
|
||||
|
||||
## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
|
||||
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
|
||||
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
|
||||
сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
|
||||
цикл external-MCP клиентов (закрытие при throw/onFinish), и
|
||||
**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
|
||||
требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
|
||||
`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
|
||||
287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Сопутствующие НЕ-тестовые находки
|
||||
|
||||
Вынесены в отдельные issues (всплыли во время написания тестов):
|
||||
|
||||
- #52 — ai-roles: нет серверной валидации модели роли + дрейф enum драйверов.
|
||||
- #53 — ws: `invalidateSpaceRestrictionCache` без вызывающих (30с stale-окно).
|
||||
- #54 — page-embed: серверный guard глубины/циклов раскрытия.
|
||||
- #55 — transclusion: cycle-guard в `collectPageEmbedsFromPmJson`.
|
||||
- #56 — test-infra: jest DI + lib0 ESM (16 падающих сьютов).
|
||||
Reference in New Issue
Block a user