import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { PondAccessNotifier } from './pond-access-notifier.service'; describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'teichbesitz mit stil 1'; const owner = { username: `pia-${suffix}`, displayName: `Pia Pond ${suffix}` }; const outsider = { username: `otto-${suffix}`, displayName: `Otto Outside ${suffix}` }; let ownerCookie: string; let outsiderCookie: string; let adminCookie: string; const api = () => request(app.getHttpServer()); async function loginOf(username: string): Promise { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); return sessionCookieOf(res); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); const users = app.get(UsersService); const tokens = app.get(AuthTokensService); // The owner goes through the real verify flow — that is what must // produce the personal pond. const ownerUser = await users.createUser({ username: owner.username, email: `${owner.username}@example.org`, displayName: owner.displayName, password, locale: 'de', }); const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); // additional_ponds defaults to 0 (ADR 0011) — the shared-pond tests // need headroom; the outsider stays at the default for the quota test. await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: ownerUser.id, quotaKey: 'additional_ponds', value: 100, }, }); ownerCookie = await loginOf(owner.username); // The outsider doubles as Site Admin in the trash/restore tests. const outsiderUser = await users.createUser({ username: outsider.username, email: `${outsider.username}@example.org`, displayName: outsider.displayName, password, locale: 'en', }); await users.markEmailVerified(outsiderUser.id); outsiderCookie = await loginOf(outsider.username); }); afterAll(async () => { const users = await prisma.user.findMany({ where: { username: { contains: suffix } }, select: { id: true }, }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } }, }); await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('creates the personal pond on e-mail verification', async () => { const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); const personal = res.body.filter((p: { type: string }) => p.type === 'personal'); expect(personal).toHaveLength(1); expect(personal[0].name).toBe(owner.displayName); expect(personal[0].slug).toMatch(/^pia-pond-/); expect(personal[0].settings.sidebarSort).toBe('alpha'); expect(personal[0].settings.fonts.body.family).toBe('Roboto'); }); it('re-verification does not duplicate the personal pond', async () => { const tokens = app.get(AuthTokensService); const user = await prisma.user.findUniqueOrThrow({ where: { username: owner.username } }); const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token }).expect(204); const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1); }); it('gives a new shared pond a start page and points settings at it (issue #302)', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Startseitenteich ${suffix}` }) .expect(201); const pages = await api() .get(`/api/v1/ponds/${created.body.id}/pages`) .set('Cookie', ownerCookie) .expect(200); expect(pages.body).toHaveLength(1); // The title follows the creator's stored locale — this owner is 'de'. expect(pages.body[0].title).toBe('Startseite'); const pond = await api() .get(`/api/v1/ponds/${created.body.slug}`) .set('Cookie', ownerCookie) .expect(200); expect(pond.body.settings.startPageId).toBe(pages.body[0].id); }); it("titles the start page in the creator's locale (issue #302)", async () => { // The owner is 'de' and got "Startseite" above; an 'en' account must get // the English title. Without both halves the test would pass on a // hardcoded string just as happily. const users = app.get(UsersService); const tokens = app.get(AuthTokensService); const username = `ellie-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `Ellie English ${suffix}`, password, locale: 'en', }); const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token }).expect(204); const pond = await prisma.pond.findFirstOrThrow({ where: { ownerId: user.id, type: 'PERSONAL' }, }); const pages = await prisma.page.findMany({ where: { pondId: pond.id } }); expect(pages.map((page) => page.title)).toEqual(['Home']); }); it('gives the personal pond a start page too (issue #302)', async () => { const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); const personal = res.body.find((p: { type: string }) => p.type === 'personal'); const pages = await api() .get(`/api/v1/ponds/${personal.id}/pages`) .set('Cookie', ownerCookie) .expect(200); expect(pages.body).toHaveLength(1); expect(personal.settings.startPageId).toBe(pages.body[0].id); }); it('changes the start page without losing other settings (issue #302)', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Wechselteich ${suffix}` }) .expect(201); await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ commentPolicy: 'editors' }) .expect(200); const second = await api() .post(`/api/v1/ponds/${created.body.id}/pages`) .set('Cookie', ownerCookie) .send({ title: `Zweite ${suffix}` }) .expect(201); const updated = await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ startPageId: second.body.id }) .expect(200); expect(updated.body.settings.startPageId).toBe(second.body.id); // The neighbouring key must survive the merge — settings hold only // deviations, so an assigning write would silently reset it. expect(updated.body.settings.commentPolicy).toBe('editors'); }); it('clears the start page back to the sort-order default (issue #302)', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Leerteich ${suffix}` }) .expect(201); const cleared = await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ startPageId: null }) .expect(200); expect(cleared.body.settings.startPageId).toBeNull(); }); it('creates shared ponds with deterministic slug suffixes', async () => { const name = `Gartenteich ${suffix}`; const first = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name }) .expect(201); const second = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name }) .expect(201); expect(first.body.slug).toBe(`gartenteich-${suffix}`); expect(second.body.slug).toBe(`gartenteich-${suffix}-2`); expect(first.body.type).toBe('shared'); }); it('renames a pond without changing its slug', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Umbenannt ${suffix}`, description: 'vorher' }) .expect(201); const patched = await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ name: `Neuer Name ${suffix}`, description: 'nachher', sidebarSort: 'created' }) .expect(200); expect(patched.body.name).toBe(`Neuer Name ${suffix}`); expect(patched.body.slug).toBe(created.body.slug); expect(patched.body.description).toBe('nachher'); expect(patched.body.settings.sidebarSort).toBe('created'); const fetched = await api() .get(`/api/v1/ponds/${created.body.slug}`) .set('Cookie', ownerCookie) .expect(200); expect(fetched.body.name).toBe(`Neuer Name ${suffix}`); }); it('saves the pond accent theme without losing other settings (issue #186)', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Akzent ${suffix}` }) .expect(201); // First persist a non-default font, then the theme — the settings // merge must keep both (the jsonb stores only deviations). await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ fonts: { ...created.body.settings.fonts, heading: { family: 'Roboto', weight: 700 } }, }) .expect(200); const themed = await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ theme: { accent: '#2b5f8f' } }) .expect(200); expect(themed.body.settings.theme.accent).toBe('#2b5f8f'); expect(themed.body.settings.fonts.heading.weight).toBe(700); // Back to inherit; malformed hexes never reach the settings. const inherit = await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ theme: { accent: null } }) .expect(200); expect(inherit.body.settings.theme.accent).toBeNull(); await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', ownerCookie) .send({ theme: { accent: 'lila' } }) .expect(400); }); it('hides foreign ponds (list and slug lookup)', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Privatteich ${suffix}` }) .expect(201); const list = await api().get('/api/v1/ponds').set('Cookie', outsiderCookie).expect(200); expect(list.body.map((p: { id: string }) => p.id)).not.toContain(created.body.id); await api().get(`/api/v1/ponds/${created.body.slug}`).set('Cookie', outsiderCookie).expect(404); await api() .patch(`/api/v1/ponds/${created.body.id}`) .set('Cookie', outsiderCookie) .send({ name: 'gekapert' }) .expect(404); }); it('enforces the additional_ponds quota (default 0, override wins)', async () => { const res = await api() .post('/api/v1/ponds') .set('Cookie', outsiderCookie) .send({ name: `Quotateich ${suffix}` }) .expect(403); expect(res.body.code).toBe('quota_exceeded'); expect(res.body.details).toMatchObject({ quotaKey: 'additional_ponds', limit: 0 }); const outsiderUser = await prisma.user.findUniqueOrThrow({ where: { username: outsider.username }, }); await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: outsiderUser.id, quotaKey: 'additional_ponds', value: 1, }, }); await api() .post('/api/v1/ponds') .set('Cookie', outsiderCookie) .send({ name: `Quotateich ${suffix}` }) .expect(201); const second = await api() .post('/api/v1/ponds') .set('Cookie', outsiderCookie) .send({ name: `Quotateich zwei ${suffix}` }) .expect(403); expect(second.body.details.limit).toBe(1); }); it('soft-deletes a shared pond; Site Admin sees trash and restores', async () => { const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Wegwerfteich ${suffix}` }) .expect(201); await api().delete(`/api/v1/ponds/${created.body.id}`).set('Cookie', ownerCookie).expect(204); const list = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); expect(list.body.map((p: { id: string }) => p.id)).not.toContain(created.body.id); await api().get(`/api/v1/ponds/${created.body.slug}`).set('Cookie', ownerCookie).expect(404); // Trash endpoints are Site-Admin-only. await api().get('/api/v1/ponds/trash').set('Cookie', ownerCookie).expect(403); await prisma.user.update({ where: { username: outsider.username }, data: { isSiteAdmin: true }, }); adminCookie = await loginOf(outsider.username); const trash = await api().get('/api/v1/ponds/trash').set('Cookie', adminCookie).expect(200); expect(trash.body.map((p: { id: string }) => p.id)).toContain(created.body.id); await api() .post(`/api/v1/ponds/${created.body.id}/restore`) .set('Cookie', adminCookie) .expect(201); const restored = await api() .get(`/api/v1/ponds/${created.body.slug}`) .set('Cookie', ownerCookie) .expect(200); expect(restored.body.deletedAt).toBeNull(); }); it('notifies the collab server when a pond is trashed (issue #39)', async () => { // The api emits the generic pond-access-change signal on soft-delete; the // collab server listens and terminates the affected live sessions. Here we // assert the api side fires it with the pond id; the NOTIFY→close delivery // is proven end-to-end in the collab access-listener DB test. const notifier = app.get(PondAccessNotifier); const spy = vi.spyOn(notifier, 'notifyAccessChanged'); const created = await api() .post('/api/v1/ponds') .set('Cookie', ownerCookie) .send({ name: `Signalteich ${suffix}` }) .expect(201); await api().delete(`/api/v1/ponds/${created.body.id}`).set('Cookie', ownerCookie).expect(204); expect(spy).toHaveBeenCalledWith(created.body.id); spy.mockRestore(); }); it('refuses to delete the personal pond', async () => { const list = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); const personal = list.body.find((p: { type: string }) => p.type === 'personal'); const res = await api() .delete(`/api/v1/ponds/${personal.id}`) .set('Cookie', ownerCookie) .expect(403); expect(res.body.code).toBe('personal_pond_undeletable'); }); });