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 { 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()); }); });