dorfteich/apps/api/src/pages/task-overview.e2e.db.test.ts
Claude Fable 5 e164370691 #154: Aufgabenübersicht als Kern-Block (Seite + Unterseiten)
Neuer Block-Atom task_overview (Markdown-Fence dorfteich-tasks,
HTML-Placeholder). Shared extractTaskRows liest Task-Zeilen mit Text,
Mentions (#150) und Start-/Zieldaten (#152); TasksService sammelt zur
Lesezeit den Teilbaum (rekursiv via collectSubtreeIds, canAccessPage-
Filter je Quellseite) aus Basis-State + page_updates-Log — KEINE
abgeleitete Tabelle nötig (Teilbäume sind klein, kein Drift). Neuer
auth-Endpoint GET /read/:pond/:slug/tasks; die öffentliche Ansicht
expandiert den Placeholder serverseitig zur statischen Tabelle
(Instanz-Sprache). NodeView mit Live-Tabelle und Rückschreib-Checkboxen
(optimistisch, Override bis der debounced Collab-Persist nachzieht);
Einfügen über die Block-Auswahl (eingebauter Eintrag). Unit- + DB-Tests,
neuer CI-Pack tasks.spec (voller Loop inkl. Rückschreiben end-to-end),
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 01:20:30 +02:00

171 lines
5.7 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { markdownToDoc } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { docToState } from './yjs-content';
/**
* The task overview collection (issue #154): tasks of the page and its live
* subtree, permission-filtered per source page; the public rendering expands
* the placeholder into a static table.
*/
describe.skipIf(!hasTestDb)('task overview endpoint (e2e, issue #154)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'uebersicht zeigt alles 1';
let ownerId: string;
let ownerCookie: string;
let pondSlug: string;
let pondId: string;
const api = () => request(app.getHttpServer());
async function makePage(
slug: string,
title: string,
markdown: string,
parentId: string | null = null,
): Promise<string> {
const doc = markdownToDoc(markdown);
const page = await prisma.page.create({
data: {
pondId,
slug,
title,
parentId,
createdBy: ownerId,
sortKey: 'a0',
ydocState: docToState(doc),
contentCache: {
create: { plainText: markdown, markdown, html: '', outline: [] },
},
},
});
return page.id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `overview-owner-${suffix}`;
const owner = await users.createUser({
username,
email: `${username}@example.test`,
displayName: 'Overview Owner',
password,
locale: 'en',
});
ownerId = owner.id;
await users.markEmailVerified(ownerId);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
pondSlug = `overview-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Overview Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'EDITOR',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } });
await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } });
await prisma.page.deleteMany({ where: { pond: { ownerId } } });
await prisma.pond.deleteMany({ where: { ownerId } });
await prisma.session.deleteMany({ where: { userId: ownerId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('collects tasks of the page and its subtree with mentions and dates', async () => {
const rootId = await makePage(
`plan-${suffix}`,
'Plan',
'- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```',
);
await makePage(`kabel-${suffix}`, 'Kabel', '- [x] Kabel prüfen <<2026-07-01', rootId);
// A sibling outside the subtree contributes nothing.
await makePage(`anders-${suffix}`, 'Anders', '- [ ] Fremde Aufgabe');
const res = await api()
.get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`)
.set('Cookie', ownerCookie)
.expect(200);
const pages = res.body as {
title: string;
tasks: { text: string; checked: boolean; dueDate: string | null }[];
}[];
expect(pages.map((p) => p.title)).toEqual(['Plan', 'Kabel']);
expect(pages[0]!.tasks[0]).toMatchObject({
text: 'Bühne buchen',
checked: false,
dueDate: '2026-08-01',
});
expect(pages[1]!.tasks[0]).toMatchObject({ text: 'Kabel prüfen', checked: true });
const allTexts = pages.flatMap((p) => p.tasks.map((t) => t.text));
expect(allTexts).not.toContain('Fremde Aufgabe');
});
it('requires a session and hides unreadable pages', async () => {
await api().get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`).expect(401);
});
it('expands the placeholder into a static table in the public rendering', async () => {
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
// Raw grant rows bypass the permission cache — drop the pond's entry.
app.get(PondPermissionCache).invalidate(pondId);
// The public body comes from the content cache — regenerate it with the
// real renderer so the placeholder div is present.
const { docToHtml } = await import('@dorfteich/shared');
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```');
await prisma.pageContentCache.updateMany({
where: { page: { pondId, slug: `plan-${suffix}` } },
data: { html: docToHtml(doc) },
});
const res = await api().get(`/api/v1/public/${pondSlug}/plan-${suffix}/content`).expect(200);
const html = (res.body as { html: string }).html;
expect(html).toContain('dt-task-overview-table');
expect(html).toContain('Bühne buchen');
expect(html).toContain('Kabel prüfen');
expect(html).not.toContain('data-task-overview');
});
});