dorfteich/apps/api/src/search/search.service.db.test.ts
Claude Fable 5 83fa23bbf9
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m35s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m44s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 46s
Polish round 2: content footer, dismissable menus, manual versions, substring search, icon actions in settings (M10 follow-up)
- content footer: the collab status is an icon (wifi/off/refresh, localized
  tooltip + visually-hidden text, class/data-status hooks kept for e2e) on
  the left, the legal links right-aligned; read mode drops the editor
  frame and its inner padding, edit mode keeps it
- menus (page overflow, user, notifications bell, pond switcher) close on
  outside click and Escape via a shared useDismissable hook; the bell got
  its missing tooltip
- side panels (labels, history) stack vertically in one column
- edit mode gains a Save-version icon (prompt for the name, POST
  /pages/:id/versions); the history panel lists contributors by display
  name — more than three collapse to two plus an expandable ellipsis
  (PageVersionView.contributors resolved server-side, deleted users drop
  out)
- search finds partial words via a LIKE fallback next to the tsquery
  (FTS matches still rank first; regression-pinned in the db pack), and
  the recent-searches list has a clear button
- pond owners create labels directly in the label picker (plus a
  permanent link to the full manager); add/remove/delete buttons across
  the pond settings (members, access rules, labels, files) and the
  watch/unwatch toggles in pond/user settings are icon buttons now —
  class hooks and accessible names unchanged for the e2e packs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 07:13:34 +02:00

170 lines
6.2 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('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());
});
});