dorfteich/apps/api/src/links/links.service.db.test.ts
Claude Fable 5 e957c2a28f
All checks were successful
CD / Build and push images (push) Successful in 4m0s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m21s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m41s
CI / Import/export fidelity gate (push) Successful in 47s
Pond-wide wikilink graph endpoint (#111)
GET /ponds/:pondId/links returns the caller's readable slice of the
wikilink graph in one read: nodes (id, title, slug, labelIds for the
coloring), resolved edges deduplicated per direction (a rename can
leave several slugs pointing at one target), and phantom targets with
their referrer ids. An edge survives only when both endpoints are
readable; a phantom disappears entirely once its last readable referrer
is filtered — a hidden page's existence never leaks through any of the
three collections. Trashed pages and their links are excluded.

Shared PondGraphView types feed the knowledge-graph views (#112/#113).
DB tests cover the owner's full graph, the label-DENY reader slice,
edge dedup, trash exclusion, and labelIds on nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:21:25 +02:00

302 lines
11 KiB
TypeScript

import { randomUUID } from 'node:crypto';
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 { PagesService } from '../pages/pages.service';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { LinksService } from './links.service';
describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let links: LinksService;
let pages: PagesService;
const suffix = uniqueSuffix();
let owner: User;
let outsider: User;
let pondId: string;
const pageIds: string[] = [];
/** Creates a page with an explicit slug (bypassing slug generation). */
async function makePage(slug: string, title = slug): Promise<string> {
const id = randomUUID();
await prisma.page.create({
data: {
id,
pondId,
title,
slug,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: `a${pageIds.length}`,
createdBy: owner.id,
},
});
pageIds.push(id);
return id;
}
/** Simulates what the collab persistence writes for one outgoing link. */
async function link(fromId: string, targetSlug: string, toId: string | null): Promise<void> {
await prisma.pageLink.create({
data: { fromPageId: fromId, toPageId: toId, targetSlug },
});
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
links = app.get(LinksService);
pages = app.get(PagesService);
owner = await prisma.user.create({
data: {
username: `lnk-owner-${suffix}`,
email: `lnk-owner-${suffix}@example.test`,
displayName: 'Link Owner',
},
});
outsider = await prisma.user.create({
data: {
username: `lnk-out-${suffix}`,
email: `lnk-out-${suffix}@example.test`,
displayName: 'Link Outsider',
},
});
const pond = await prisma.pond.create({
data: { slug: `lnk-pond-${suffix}`, name: 'Link Pond', type: 'PERSONAL', ownerId: owner.id },
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
// Headroom for the pages the create test makes.
await prisma.quotaOverride.create({
data: { subjectType: 'USER', subjectId: owner.id, quotaKey: 'additional_ponds', value: 100 },
});
});
afterAll(async () => {
await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: owner.id } });
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('lists backlinks to a page', async () => {
const target = await makePage(`target-${suffix}`);
const source = await makePage(`source-${suffix}`, 'Source Page');
await link(source, `target-${suffix}`, target);
const backlinks = await links.backlinks(owner, target);
expect(backlinks).toEqual([
{ pageId: source, title: 'Source Page', slug: `source-${suffix}`, snippet: '' },
]);
});
it('filters backlink sources to pages the requester may read (#52)', async () => {
// Route-level 404s for invisible ponds are the guard's job (#52, covered
// by the permission e2e pack); the service filters the link *sources*.
const target = await makePage(`hidden-${suffix}`);
const source = await makePage(`hidden-src-${suffix}`, 'Hidden Source');
await link(source, `hidden-${suffix}`, target);
expect(await links.backlinks(outsider, target)).toEqual([]);
});
it('aggregates phantom links by target slug', async () => {
const a = await makePage(`ph-a-${suffix}`, 'A');
const b = await makePage(`ph-b-${suffix}`, 'B');
await link(a, `ghost-${suffix}`, null);
await link(b, `ghost-${suffix}`, null);
const phantoms = await links.phantomLinks(owner, pondId);
const ghost = phantoms.find((p) => p.targetSlug === `ghost-${suffix}`);
expect(ghost?.referencedBy.map((r) => r.title).sort()).toEqual(['A', 'B']);
});
it('resolves phantom links when a page with the target slug is created', async () => {
const source = await makePage(`res-src-${suffix}`, 'Res Source');
await link(source, 'brand-new-page', null);
const created = await pages.create(owner, pondId, { title: 'Brand New Page' });
pageIds.push(created.id);
expect(created.slug).toBe('brand-new-page');
// The phantom now points at the created page — it is a backlink, not phantom.
const backlinks = await links.backlinks(owner, created.id);
expect(backlinks.map((b) => b.pageId)).toContain(source);
const phantoms = await links.phantomLinks(owner, pondId);
expect(phantoms.some((p) => p.targetSlug === 'brand-new-page')).toBe(false);
});
it('re-points phantom links on rename, and id-based backlinks survive a later target rename', async () => {
const source = await makePage(`rn-src-${suffix}`, 'Rename Source');
await link(source, 'wanted-slug', null);
const target = await makePage(`rn-tgt-${suffix}`, 'Rename Target');
// Rename the target to the wanted slug → the phantom resolves.
await pages.update(owner, target, { slug: 'wanted-slug' });
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
// Rename the target's slug again: the backlink stores the id, so it survives.
await pages.update(owner, target, { slug: 'wanted-slug-renamed' });
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
});
});
describe.skipIf(!hasTestDb)('LinksService.pondGraph (db, issue #111)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let links: LinksService;
const suffix = uniqueSuffix();
let owner: User;
let reader: User;
let pondId: string;
let a: string;
let b: string;
let secret: string;
let trashed: string;
async function makePage(slug: string, title = slug): Promise<string> {
const id = randomUUID();
await prisma.page.create({
data: {
id,
pondId,
title,
slug,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: `g${slug}`,
createdBy: owner.id,
},
});
return id;
}
async function link(fromId: string, targetSlug: string, toId: string | null): Promise<void> {
await prisma.pageLink.create({ data: { fromPageId: fromId, toPageId: toId, targetSlug } });
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
links = app.get(LinksService);
owner = await prisma.user.create({
data: {
username: `gr-owner-${suffix}`,
email: `gr-owner-${suffix}@example.test`,
displayName: 'Graph Owner',
},
});
reader = await prisma.user.create({
data: {
username: `gr-reader-${suffix}`,
email: `gr-reader-${suffix}@example.test`,
displayName: 'Graph Reader',
},
});
const pond = await prisma.pond.create({
data: { slug: `gr-pond-${suffix}`, name: 'Graph Pond', type: 'SHARED', ownerId: owner.id },
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
// Grants before any permission resolution warms the pond's cache.
const secretLabel = await prisma.label.create({
data: { pondId, name: 'secret', color: '#334455' },
});
await prisma.roleGrant.createMany({
data: [
{
pondId,
subjectType: 'USER',
subjectId: reader.id,
role: 'READER',
scopeType: 'POND',
effect: 'ALLOW',
createdBy: owner.id,
},
{
pondId,
subjectType: 'USER',
subjectId: reader.id,
role: 'READER',
scopeType: 'LABEL',
scopeId: secretLabel.id,
effect: 'DENY',
createdBy: owner.id,
},
],
});
// A → B (resolved), A → S (resolved, S is secret-labeled), A → T
// (resolved, T gets trashed), B → ghost (phantom), S → ghost-secret
// (phantom whose only referrer is hidden from the reader), plus a
// duplicate A → B row under a second slug (rename leftover).
a = await makePage(`gr-a-${suffix}`, 'GA');
b = await makePage(`gr-b-${suffix}`, 'GB');
secret = await makePage(`gr-s-${suffix}`, 'GS');
trashed = await makePage(`gr-t-${suffix}`, 'GT');
await prisma.pageLabel.create({ data: { pageId: secret, labelId: secretLabel.id } });
await link(a, `gr-b-${suffix}`, b);
await link(a, `gr-b-old-${suffix}`, b);
await link(a, `gr-s-${suffix}`, secret);
await link(a, `gr-t-${suffix}`, trashed);
await link(b, `ghost-${suffix}`, null);
await link(secret, `ghost-secret-${suffix}`, null);
await prisma.page.update({
where: { id: trashed },
data: { deletedAt: new Date(), deletedBy: owner.id },
});
});
afterAll(async () => {
await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageLabel.deleteMany({ where: { page: { pondId } } });
await prisma.label.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, reader.id] } } });
await prisma.$disconnect();
await app.close();
});
it('returns the full readable graph for the owner, deduplicated and without trash', async () => {
const graph = await links.pondGraph(owner, pondId);
expect(graph.nodes.map((n) => n.title).sort()).toEqual(['GA', 'GB', 'GS']);
// The duplicate A→B row (second slug) collapses into one edge; the edge
// to the trashed page is gone with the node.
expect(graph.edges.sort((x, y) => x.to.localeCompare(y.to))).toEqual(
[
{ from: a, to: b },
{ from: a, to: secret },
].sort((x, y) => x.to.localeCompare(y.to)),
);
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([
`ghost-${suffix}`,
`ghost-secret-${suffix}`,
]);
});
it('slices every collection to the reader — no hidden page leaks anywhere', async () => {
const graph = await links.pondGraph(reader, pondId);
// The secret node is gone …
expect(graph.nodes.map((n) => n.title).sort()).toEqual(['GA', 'GB']);
// … the edge into it is dropped even though its source is readable …
expect(graph.edges).toEqual([{ from: a, to: b }]);
// … and the phantom whose only referrer is hidden disappears entirely.
expect(graph.phantoms).toEqual([{ targetSlug: `ghost-${suffix}`, referencedBy: [b] }]);
});
it('carries labelIds on nodes for the graph coloring', async () => {
const graph = await links.pondGraph(owner, pondId);
const secretNode = graph.nodes.find((n) => n.id === secret);
expect(secretNode?.labelIds).toHaveLength(1);
});
});