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
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>
184 lines
6.9 KiB
TypeScript
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());
|
|
});
|
|
});
|