import { INestApplication } from '@nestjs/common'; import type { WatchListView, WatchStateView } from '@dorfteich/shared'; import { PrismaClient, User } from '@prisma/client'; 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'; /** * Watches end to end (issue #93): per-user round-trip, read-gated targets, * preference-gated auto-watch for own pages and comments, the settings * list with unwatch for both target types, and purge cleanup. */ describe.skipIf(!hasTestDb)('watches (e2e, issue #93)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'beobachten heisst kuemmern 1'; const users: Record = {}; const cookies: Record = {}; let pondId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const service = app.get(UsersService); const username = `wa-${handle}-${suffix}`; const user = await service.createUser({ username, email: `${username}@example.org`, displayName: `Wa ${handle}`, password, locale: 'en', }); await service.markEmailVerified(user.id); users[handle] = user; cookies[handle] = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); } async function createPage(cookie: string, title: string): Promise { const res = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookie) .send({ title }) .expect(201); return (res.body as { id: string }).id; } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); for (const handle of ['owner', 'member', 'outsider']) await makeUser(handle); const pond = await prisma.pond.create({ data: { slug: `wa-pond-${suffix}`, name: 'Watch Pond', type: 'SHARED', ownerId: users.owner!.id, }, }); pondId = pond.id; for (const [handle, role] of [ ['owner', 'POND_ADMIN'], ['member', 'EDITOR'], ] as const) { await prisma.roleGrant.create({ data: { pondId, subjectType: 'USER', subjectId: users[handle]!.id, role, scopeType: 'POND', effect: 'ALLOW', createdBy: users.owner!.id, }, }); } }); afterAll(async () => { const ids = Object.values(users).map((u) => u.id); await prisma.watch.deleteMany({ where: { userId: { in: ids } } }); await prisma.comment.deleteMany({ where: { page: { pondId } } }); await prisma.roleGrant.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } }); await prisma.session.deleteMany({ where: { userId: { in: ids } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('round-trips watch state per user for pages and ponds', async () => { const pageId = await createPage(cookies.owner!, 'Watched page'); // Page creation auto-watched it for the owner; drop that to test manually. await api().delete(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.owner!).expect(200); await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); await api().put(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.member!).expect(200); const memberState = ( await api().get(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200) ).body as WatchStateView; expect(memberState.watched).toBe(true); // Per-user: the owner is not subscribed just because the member is. const ownerState = ( await api().get(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.owner!).expect(200) ).body as WatchStateView; expect(ownerState.watched).toBe(false); // Watching needs read access: the outsider gets a 404, never a row. await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.outsider!).expect(404); await api().put(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.outsider!).expect(404); }); it('auto-watches own pages and commented pages, gated by the preferences', async () => { // Default preferences: creating a page subscribes its author … const created = await createPage(cookies.member!, 'Auto watched'); expect( ( (await api() .get(`/api/v1/watches/page/${created}`) .set('Cookie', cookies.member!) .expect(200)) as { body: WatchStateView } ).body.watched, ).toBe(true); // … and commenting subscribes the commenter. await api() .post(`/api/v1/pages/${created}/comments`) .set('Cookie', cookies.owner!) .send({ body: 'watching this now' }) .expect(201); expect( ( (await api() .get(`/api/v1/watches/page/${created}`) .set('Cookie', cookies.owner!) .expect(200)) as { body: WatchStateView } ).body.watched, ).toBe(true); // Disabling the preferences stops both behaviors. await api() .patch('/api/v1/users/me') .set('Cookie', cookies.member!) .send({ autoWatchOwnPages: false, autoWatchOnComment: false }) .expect(200); const second = await createPage(cookies.member!, 'Not auto watched'); expect( ( (await api() .get(`/api/v1/watches/page/${second}`) .set('Cookie', cookies.member!) .expect(200)) as { body: WatchStateView } ).body.watched, ).toBe(false); await api() .post(`/api/v1/pages/${second}/comments`) .set('Cookie', cookies.member!) .send({ body: 'no subscription please' }) .expect(201); expect( ( (await api() .get(`/api/v1/watches/page/${second}`) .set('Cookie', cookies.member!) .expect(200)) as { body: WatchStateView } ).body.watched, ).toBe(false); }); it('lists own watches with names and unwatches both types from settings', async () => { const pageId = await createPage(cookies.owner!, 'Listed page'); await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); await api().put(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.member!).expect(200); const list = ( await api().get('/api/v1/users/me/watches').set('Cookie', cookies.member!).expect(200) ).body as WatchListView; const pageEntry = list.watches.find((w) => w.targetId === pageId); const pondEntry = list.watches.find((w) => w.targetId === pondId); expect(pageEntry).toMatchObject({ targetType: 'page', name: 'Listed page' }); expect(pageEntry?.slug).toBeTruthy(); expect(pondEntry).toMatchObject({ targetType: 'pond', name: 'Watch Pond', slug: null }); // Unwatch both types (the settings list's action). await api().delete(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); await api().delete(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.member!).expect(200); const after = ( await api().get('/api/v1/users/me/watches').set('Cookie', cookies.member!).expect(200) ).body as WatchListView; expect(after.watches.find((w) => w.targetId === pageId)).toBeUndefined(); expect(after.watches.find((w) => w.targetId === pondId)).toBeUndefined(); }); it('drops unreadable targets from the list and cleans up on purge', async () => { const pageId = await createPage(cookies.owner!, 'Vanishing page'); await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); // Trash hides it from the list (target no longer live) … await prisma.page.update({ where: { id: pageId }, data: { deletedAt: new Date(), deletedBy: users.owner!.id }, }); const list = ( await api().get('/api/v1/users/me/watches').set('Cookie', cookies.member!).expect(200) ).body as WatchListView; expect(list.watches.find((w) => w.targetId === pageId)).toBeUndefined(); // … and purge removes the rows entirely (trash service hook). await api().delete(`/api/v1/pages/${pageId}/purge`).set('Cookie', cookies.owner!).expect(204); expect(await prisma.watch.count({ where: { targetId: pageId } })).toBe(0); }); });