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>
79 lines
3.0 KiB
TypeScript
79 lines
3.0 KiB
TypeScript
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);
|
|
});
|
|
});
|