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