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
171 lines
5.7 KiB
TypeScript
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');
|
|
});
|
|
});
|