From 04f05626ad21ecd43dd4f96dc91b6a156faae514 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 07:02:55 +0300 Subject: [PATCH] test(server): integration harness + deferred coverage vs real Postgres/Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/package.json | 1 + .../ai-agent-roles-repo.int-spec.ts | 78 +++++++ .../ai-chat-repo-find-by-creator.int-spec.ts | 96 +++++++++ apps/server/test/integration/db.ts | 194 ++++++++++++++++++ apps/server/test/integration/global-setup.ts | 79 +++++++ .../test/integration/global-teardown.ts | 11 + ...ge-template-references-cascade.int-spec.ts | 68 ++++++ ...public-share-workspace-limiter.int-spec.ts | 75 +++++++ .../workspace-repo-update-setting.int-spec.ts | 60 ++++++ apps/server/test/jest-integration.json | 23 +++ .../backlog/feature-test-coverage-deferred.md | 93 --------- 11 files changed, 685 insertions(+), 93 deletions(-) create mode 100644 apps/server/test/integration/ai-agent-roles-repo.int-spec.ts create mode 100644 apps/server/test/integration/ai-chat-repo-find-by-creator.int-spec.ts create mode 100644 apps/server/test/integration/db.ts create mode 100644 apps/server/test/integration/global-setup.ts create mode 100644 apps/server/test/integration/global-teardown.ts create mode 100644 apps/server/test/integration/page-template-references-cascade.int-spec.ts create mode 100644 apps/server/test/integration/public-share-workspace-limiter.int-spec.ts create mode 100644 apps/server/test/integration/workspace-repo-update-setting.int-spec.ts create mode 100644 apps/server/test/jest-integration.json delete mode 100644 docs/backlog/feature-test-coverage-deferred.md diff --git a/apps/server/package.json b/apps/server/package.json index 03cb57bf..6ee1931b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/test/integration/ai-agent-roles-repo.int-spec.ts b/apps/server/test/integration/ai-agent-roles-repo.int-spec.ts new file mode 100644 index 00000000..9129dd75 --- /dev/null +++ b/apps/server/test/integration/ai-agent-roles-repo.int-spec.ts @@ -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; + 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); + }); +}); diff --git a/apps/server/test/integration/ai-chat-repo-find-by-creator.int-spec.ts b/apps/server/test/integration/ai-chat-repo-find-by-creator.int-spec.ts new file mode 100644 index 00000000..56d632f2 --- /dev/null +++ b/apps/server/test/integration/ai-chat-repo-find-by-creator.int-spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/server/test/integration/db.ts b/apps/server/test/integration/db.ts new file mode 100644 index 00000000..bb4001c8 --- /dev/null +++ b/apps/server/test/integration/db.ts @@ -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 { + return new Kysely({ + 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 | undefined; + +/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */ +export function getTestDb(): Kysely { + if (!singleton) { + singleton = buildTestDb(); + } + return singleton; +} + +export async function destroyTestDb(): Promise { + 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, + 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, + 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, + 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, + 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, + 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, + 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 }; +} diff --git a/apps/server/test/integration/global-setup.ts b/apps/server/test/integration/global-setup.ts new file mode 100644 index 00000000..ab63a28a --- /dev/null +++ b/apps/server/test/integration/global-setup.ts @@ -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 { + // 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 = new Kysely({ + 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(); +} diff --git a/apps/server/test/integration/global-teardown.ts b/apps/server/test/integration/global-teardown.ts new file mode 100644 index 00000000..2b2f74a3 --- /dev/null +++ b/apps/server/test/integration/global-teardown.ts @@ -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 { + await destroyTestDb(); +} diff --git a/apps/server/test/integration/page-template-references-cascade.int-spec.ts b/apps/server/test/integration/page-template-references-cascade.int-spec.ts new file mode 100644 index 00000000..63c6917c --- /dev/null +++ b/apps/server/test/integration/page-template-references-cascade.int-spec.ts @@ -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; + 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 { + 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); + }); +}); diff --git a/apps/server/test/integration/public-share-workspace-limiter.int-spec.ts b/apps/server/test/integration/public-share-workspace-limiter.int-spec.ts new file mode 100644 index 00000000..fc0f77c7 --- /dev/null +++ b/apps/server/test/integration/public-share-workspace-limiter.int-spec.ts @@ -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); + }); +}); diff --git a/apps/server/test/integration/workspace-repo-update-setting.int-spec.ts b/apps/server/test/integration/workspace-repo-update-setting.int-spec.ts new file mode 100644 index 00000000..f4589e1b --- /dev/null +++ b/apps/server/test/integration/workspace-repo-update-setting.int-spec.ts @@ -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; + 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 }); + }); +}); diff --git a/apps/server/test/jest-integration.json b/apps/server/test/jest-integration.json new file mode 100644 index 00000000..1f42191e --- /dev/null +++ b/apps/server/test/jest-integration.json @@ -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": "/test/integration/global-setup.ts", + "globalTeardown": "/test/integration/global-teardown.ts", + "moduleNameMapper": { + "^@docmost/db/(.*)$": "/src/database/$1", + "^@docmost/transactional/(.*)$": "/src/integrations/transactional/$1", + "^@docmost/ee/(.*)$": "/src/ee/$1", + "^src/(.*)$": "/src/$1" + } +} diff --git a/docs/backlog/feature-test-coverage-deferred.md b/docs/backlog/feature-test-coverage-deferred.md deleted file mode 100644 index 410357a4..00000000 --- a/docs/backlog/feature-test-coverage-deferred.md +++ /dev/null @@ -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 падающих сьютов).