dorfteich/apps/api/src/watches/watches.e2e.db.test.ts
Claude Fable 5 f4f27cbe78
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m23s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m28s
CI / Import/export fidelity gate (push) Successful in 46s
Add watches: follow pages and ponds with auto-watch preferences (#93)
New watches table (polymorphic target, unique per user+target; page purge
removes its rows via the trash service, and the list endpoint drops
targets the user can no longer read). Endpoints: idempotent PUT/DELETE
/watches/{page|pond}/:id gated by read access (404 hides the target),
GET state for the header toggles, and GET /users/me/watches resolving
names and links. Auto-watch hooks: creating a page and commenting
subscribe the actor, each behind a new user preference
(autoWatchOwnPages / autoWatchOnComment, default on) editable via the
profile PATCH and surfaced as checkboxes in the settings. UI: watch
toggle on the page header and the pond settings header, watch list with
unwatch in the account settings; new watches i18n namespace (de+en).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 22:59:11 +02:00

227 lines
8.8 KiB
TypeScript

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<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
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<string> {
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);
});
});