dorfteich/apps/api/src/search/search.service.db.test.ts
Claude Fable 5 521ea514b4
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#211: classification through feeds, public API, search and the no-JS shell
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00

184 lines
6.9 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { SEARCH_HIGHLIGHT_START } from '@dorfteich/shared';
import { INestApplication } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { SearchProvider } from './search.provider';
describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let search: SearchProvider;
const suffix = uniqueSuffix();
const term = `zeb${suffix}`; // a term unique to this run
let owner: User;
let outsider: User;
let pondId: string;
const pageIds: string[] = [];
/** Creates a page with an indexed content cache, then indexes it. */
async function makePage(title: string, plainText: string): Promise<string> {
const id = randomUUID();
await prisma.page.create({
data: {
id,
pondId,
title,
slug: `p-${id.slice(0, 8)}`,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: `a${pageIds.length}`,
createdBy: owner.id,
contentCache: { create: { plainText, markdown: plainText, html: plainText, outline: [] } },
},
});
pageIds.push(id);
await search.indexPage(id);
return id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
search = app.get(SearchProvider);
owner = await prisma.user.create({
data: {
username: `srch-owner-${suffix}`,
email: `srch-owner-${suffix}@example.test`,
displayName: 'Search Owner',
},
});
outsider = await prisma.user.create({
data: {
username: `srch-out-${suffix}`,
email: `srch-out-${suffix}@example.test`,
displayName: 'Search Outsider',
},
});
const pond = await prisma.pond.create({
data: {
slug: `srch-pond-${suffix}`,
name: 'Search Pond',
type: 'PERSONAL',
ownerId: owner.id,
},
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
});
afterAll(async () => {
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
await prisma.$disconnect();
await app.close();
});
it('ranks a title match above a body match', async () => {
const titleHit = await makePage(`${term} in the title`, 'unrelated body text');
await makePage('unrelated title', `a long story that mentions ${term} in the body only`);
const results = await search.search({ q: term }, owner);
expect(results.length).toBeGreaterThanOrEqual(2);
// Weight A (title) outranks weight C (body).
expect(results[0]!.pageId).toBe(titleHit);
});
it('carries the classification with every hit — a classified snippet is never unmarked (issue #211)', async () => {
const classifiedId = await makePage(`classified ${term} note`, `secret ${term} content`);
await prisma.page.update({
where: { id: classifiedId },
data: { classification: 'VS_NFD' },
});
const results = await search.search({ q: term }, owner);
const classified = results.find((r) => r.pageId === classifiedId);
expect(classified?.classification).toBe('vs_nfd');
// Every other hit carries the field too, as `unclassified`.
const other = results.find((r) => r.pageId !== classifiedId);
expect(other?.classification).toBe('unclassified');
});
it('highlights the match in the snippet', async () => {
const results = await search.search({ q: term }, owner);
const bodyHit = results.find((r) => r.snippet.includes(SEARCH_HIGHLIGHT_START));
expect(bodyHit).toBeDefined();
});
it('matches diacritics-insensitively (Baume finds Bäume)', async () => {
const page = await makePage('Wald', `viele Bäume-${suffix} im Wald`);
const results = await search.search({ q: `Baume-${suffix}` }, owner);
expect(results.map((r) => r.pageId)).toContain(page);
});
it('matches partial words in title and body (M10 follow-up)', async () => {
const titlePage = await makePage(`Quakfrosch${suffix} Titel`, 'nichts weiter');
const bodyPage = await makePage('anderer Titel', `hier lebt ein Teichmolch${suffix}`);
// A mid-word fragment matches nothing via the tsquery — the LIKE branch
// has to find both pages (title and body).
const byTitle = await search.search({ q: `akfrosch${suffix}` }, owner);
expect(byTitle.map((r) => r.pageId)).toContain(titlePage);
const byBody = await search.search({ q: `eichmolch${suffix}` }, owner);
expect(byBody.map((r) => r.pageId)).toContain(bodyPage);
// The outsider still sees nothing through the substring branch.
expect(await search.search({ q: `eichmolch${suffix}` }, outsider)).toEqual([]);
});
it('never returns pages the requester may not read', async () => {
const results = await search.search({ q: term }, outsider);
expect(results).toEqual([]);
});
it('scopes results to a single pond when pondId is given', async () => {
// A second pond of the same owner with a page matching the same term.
const other = await prisma.pond.create({
data: {
slug: `srch-pond2-${suffix}`,
name: 'Second Pond',
type: 'SHARED',
ownerId: owner.id,
},
});
await grantOwnerAdmin(prisma, other.id, owner.id);
const otherPage = await prisma.page.create({
data: {
id: randomUUID(),
pondId: other.id,
title: `${term} elsewhere`,
slug: `q-${suffix}`,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: 'a0',
createdBy: owner.id,
contentCache: { create: { plainText: '', markdown: '', html: '', outline: [] } },
},
});
await search.indexPage(otherPage.id);
// All ponds: the second pond's page is included.
const all = await search.search({ q: term }, owner);
expect(all.map((r) => r.pageId)).toContain(otherPage.id);
// Scoped to the first pond: it is excluded.
const scoped = await search.search({ q: term, pondId }, owner);
expect(scoped.map((r) => r.pondId).every((id) => id === pondId)).toBe(true);
expect(scoped.map((r) => r.pageId)).not.toContain(otherPage.id);
await prisma.page.deleteMany({ where: { pondId: other.id } });
await prisma.pond.deleteMany({ where: { id: other.id } });
});
it('reindexAll rebuilds from the cache and is idempotent', async () => {
const before = await search.search({ q: term }, owner);
await search.reindexAll();
await search.reindexAll();
const after = await search.search({ q: term }, owner);
expect(after.map((r) => r.pageId).sort()).toEqual(before.map((r) => r.pageId).sort());
});
});