import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'seiten voller notizen 1'; const owner = { username: `pia-pages-${suffix}`, displayName: `Pia Pages ${suffix}` }; const outsider = { username: `otto-pages-${suffix}`, displayName: `Otto Outside ${suffix}` }; let ownerCookie: string; let outsiderCookie: string; let pondId: 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); const ownerUser = await users.createUser({ username: owner.username, email: `${owner.username}@example.org`, displayName: owner.displayName, password, locale: 'en', }); const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); ownerCookie = await loginOf(owner.username); 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); const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; }); afterAll(async () => { const users = await prisma.user.findMany({ where: { username: { contains: suffix } }, select: { id: true }, }); await prisma.page.deleteMany({ where: { pond: { owner: { username: { contains: suffix } } } }, }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('creates a page with an empty Yjs state and a title-derived slug', async () => { const res = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Welcome ${suffix}` }) .expect(201); expect(res.body.slug).toBe(`welcome-${suffix}`); expect(res.body.pondId).toBe(pondId); const fetched = await api() .get(`/api/v1/pages/${res.body.id}`) .set('Cookie', ownerCookie) .expect(200); expect(typeof fetched.body.state).toBe('string'); expect(Buffer.from(fetched.body.state, 'base64').length).toBeGreaterThan(0); }); it('gives duplicate titles deterministic slug suffixes within the same pond', async () => { const title = `Duplicate ${suffix}`; const first = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title }) .expect(201); const second = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title }) .expect(201); expect(first.body.slug).toBe(`duplicate-${suffix}`); expect(second.body.slug).toBe(`duplicate-${suffix}-2`); }); it('exports the page as a downloadable Markdown file (issue #30)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Export Me ${suffix}` }) .expect(201); // The content cache is now refreshed by the collab server on store (#35), // not by a REST write, so seed it directly to exercise the export path. await prisma.pageContentCache.upsert({ where: { pageId: created.body.id }, create: { pageId: created.body.id, plainText: `markdown export ${suffix}`, markdown: `markdown export ${suffix}`, html: `

markdown export ${suffix}

`, outline: [], }, update: { markdown: `markdown export ${suffix}` }, }); const res = await api() .get(`/api/v1/pages/${created.body.id}/export/markdown`) .set('Cookie', ownerCookie) .expect(200); expect(res.headers['content-type']).toContain('text/markdown'); expect(res.headers['content-disposition']).toBe( `attachment; filename="${created.body.slug}.md"`, ); expect(res.text).toBe(`markdown export ${suffix}`); await api() .get(`/api/v1/pages/${created.body.id}/export/markdown`) .set('Cookie', outsiderCookie) .expect(404); }); it('retires the REST state-write path with 410 (state now flows through collab, #36)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Retired ${suffix}` }) .expect(201); const res = await api() .put(`/api/v1/pages/${created.body.id}/state`) .set('Cookie', ownerCookie) .send({ state: 'AAAA' }) .expect(410); expect(res.body.code).toBe('rest_state_write_retired'); }); it('keeps the slug stable on a title-only rename; validates explicit slug changes', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Original ${suffix}` }) .expect(201); const renamed = await api() .patch(`/api/v1/pages/${created.body.id}`) .set('Cookie', ownerCookie) .send({ title: `Renamed ${suffix}` }) .expect(200); expect(renamed.body.title).toBe(`Renamed ${suffix}`); expect(renamed.body.slug).toBe(created.body.slug); const other = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Taken ${suffix}` }) .expect(201); const conflict = await api() .patch(`/api/v1/pages/${created.body.id}`) .set('Cookie', ownerCookie) .send({ slug: other.body.slug }) .expect(409); expect(conflict.body.code).toBe('slug_taken'); const changed = await api() .patch(`/api/v1/pages/${created.body.id}`) .set('Cookie', ownerCookie) .send({ slug: `custom-slug-${suffix}` }) .expect(200); expect(changed.body.slug).toBe(`custom-slug-${suffix}`); }); it('soft-deletes a page', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Trashed ${suffix}` }) .expect(201); await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(404); }); it('resolves a page by pond id + slug (issue #25 editor route)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Slug Lookup ${suffix}` }) .expect(201); const bySlug = await api() .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) .set('Cookie', ownerCookie) .expect(200); expect(bySlug.body.id).toBe(created.body.id); expect(typeof bySlug.body.state).toBe('string'); await api() .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) .set('Cookie', outsiderCookie) .expect(404); await api() .get(`/api/v1/ponds/${pondId}/pages/does-not-exist-${suffix}`) .set('Cookie', ownerCookie) .expect(404); }); it('lists pages sorted by the pond sidebar sort mode (issue #26)', async () => { const titles = [`Zebra ${suffix}`, `Apple ${suffix}`, `Mango ${suffix}`]; const ids: string[] = []; for (const title of titles) { const res = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title }) .expect(201); ids.push(res.body.id as string); } const [zebraId, appleId, mangoId] = ids; // Default sort mode is 'alpha'. const alphaList = await api() .get(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .expect(200); const alphaOrder = (alphaList.body as { id: string }[]) .map((p) => p.id) .filter((id) => ids.includes(id)); expect(alphaOrder).toEqual([appleId, mangoId, zebraId]); await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', ownerCookie) .send({ sidebarSort: 'created' }) .expect(200); const createdList = await api() .get(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .expect(200); const createdOrder = (createdList.body as { id: string }[]) .map((p) => p.id) .filter((id) => ids.includes(id)); expect(createdOrder).toEqual([zebraId, appleId, mangoId]); // Sort mode is a pond setting, not per-caller: the outsider would see // 'created' order too, but has no access to this pond in the first place. await api().get(`/api/v1/ponds/${pondId}/pages`).set('Cookie', outsiderCookie).expect(404); await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', ownerCookie) .send({ sidebarSort: 'alpha' }) .expect(200); }); it('excludes soft-deleted pages from the list', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Deleted From List ${suffix}` }) .expect(201); await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); const list = await api() .get(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .expect(200); expect((list.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(false); }); it('hides pages in foreign ponds (404, not 403)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Private ${suffix}` }) .expect(201); await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', outsiderCookie).expect(404); await api() .patch(`/api/v1/pages/${created.body.id}`) .set('Cookie', outsiderCookie) .send({ title: 'hijacked' }) .expect(404); await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', outsiderCookie) .send({ title: 'sneaky' }) .expect(404); }); });