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