dorfteich/apps/api/src/pages/task-toggle.e2e.db.test.ts
Claude Fable 5 3f7190ebcc #153: Stabile Task-IDs + Toggle-Rückschreibpfad über den Collab-Server
task_item bekommt ein optionales id-Attr (default null — Bestandsdocs
bleiben gültig), durchgereicht in toDOM/parseDOM und dem Lese-HTML;
der Editor vergibt/entdoppelt IDs lazy per appendTransaction
(TaskItemIds-Extension, auch gegen Copy/Paste). Neuer Kanal
TASK_TOGGLE_CHANNEL; POST /pages/:id/tasks/:taskId {checked} prüft
Schreibrecht, registriert den Toggler als pending contributor und
feuert pg_notify; neuer collab task-toggle-listener (Struktur =
restore-listener) öffnet eine DirectConnection und flippt das
checked-Attribut in einer Transaktion — offene Editoren konvergieren,
unbekannte taskId = geloggter No-op. DB-Test (NOTIFY-Payload,
Attribution, 403/404/400).

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

155 lines
5.1 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import { Client } from 'pg';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Task-toggle request path (issue #153): the api checks write permission,
* records the toggler as a pending contributor, and emits the NOTIFY the
* collab server consumes. The Yjs application itself lives in the collab
* listener (verified through the collab e2e stack).
*/
describe.skipIf(!hasTestDb)('task toggle endpoint (e2e, issue #153)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'aufgaben sind erledigt 1';
let editorId: string;
let editorCookie: string;
let readerCookie: string;
let pageId: string;
const notifies: TaskToggleRequest[] = [];
let listenClient: Client;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const mkUser = async (handle: string) => {
const username = `task-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.test`,
displayName: `Task ${handle}`,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
const cookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
return { id: user.id, cookie };
};
const editor = await mkUser('editor');
editorId = editor.id;
editorCookie = editor.cookie;
const reader = await mkUser('reader');
readerCookie = reader.cookie;
const pond = await prisma.pond.create({
data: { slug: `task-pond-${suffix}`, name: 'Task Pond', type: 'SHARED', ownerId: editorId },
});
const page = await prisma.page.create({
data: {
pondId: pond.id,
slug: `tasks-${suffix}`,
title: 'Tasks',
createdBy: editorId,
sortKey: 'a0',
ydocState: new Uint8Array(),
},
});
pageId = page.id;
for (const [userId, role] of [
[editorId, 'EDITOR'],
[reader.id, 'READER'],
] as const) {
await prisma.roleGrant.create({
data: {
pondId: pond.id,
subjectType: 'USER',
subjectId: userId,
role,
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: editorId,
},
});
}
listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL });
await listenClient.connect();
listenClient.on('notification', (message) => {
if (message.channel === TASK_TOGGLE_CHANNEL && message.payload) {
notifies.push(JSON.parse(message.payload) as TaskToggleRequest);
}
});
await listenClient.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`);
});
afterAll(async () => {
await listenClient.end().catch(() => undefined);
await prisma.pagePendingContributor.deleteMany({ where: { pageId } });
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: editorId } } });
await prisma.page.deleteMany({ where: { pond: { ownerId: editorId } } });
await prisma.pond.deleteMany({ where: { ownerId: editorId } });
await prisma.session.deleteMany({ where: { user: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('emits the toggle NOTIFY and records the pending contributor', async () => {
await api()
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
.set('Cookie', editorCookie)
.send({ checked: true })
.expect(202);
await expect.poll(() => notifies.length, { timeout: 5000 }).toBeGreaterThan(0);
expect(notifies[0]).toEqual({
pageId,
taskId: 'abc123defg',
checked: true,
userId: editorId,
});
const pending = await prisma.pagePendingContributor.findMany({ where: { pageId } });
expect(pending.map((row) => row.userId)).toContain(editorId);
});
it('refuses read-only users and hides unknown pages', async () => {
// A reader may see the page, so the write refusal is a 403 (#60).
await api()
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
.set('Cookie', readerCookie)
.send({ checked: true })
.expect(403);
await api()
.post(`/api/v1/pages/00000000-0000-4000-8000-000000000000/tasks/x`)
.set('Cookie', editorCookie)
.send({ checked: true })
.expect(404);
await api()
.post(`/api/v1/pages/${pageId}/tasks/abc123defg`)
.set('Cookie', editorCookie)
.send({ checked: 'yes' })
.expect(400);
});
});