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
155 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|