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"
}
}

View File

@@ -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 падающих сьютов).