All checks were successful
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m0s
CI / Auth e2e pack (push) Successful in 2m10s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
The editor now edits over the collaboration server instead of REST — the moment Dorfteich becomes collaborative (ADR 0003, realtime-collaboration.md). Web: - New `useCollabProvider` hook binds a page's Y.Doc to a HocuspocusProvider. The document loads and persists through the collab server (#35); there is no REST autosave and no REST seed (a REST seed would fork the doc lineage and duplicate content). The collab token is fetched lazily on every (re)connect via an async token function, so an expired token is replaced transparently and a permission change takes effect on the next reconnect. - Connection-state UI replaces the save indicator: connecting / connected ("Live") / reconnecting / offline, driven by provider status + navigator online state. Read-only (`ro`) tokens make the editor non-editable with a reason; an oversize-document stateless error (#35) surfaces a banner. - Removed `use-page-autosave.ts` and `yjs-base64.ts` (no longer used). API: - `PUT /pages/:id/state` is retired and returns 410 `rest_state_write_retired` (the criterion deferred here from #35). Collab is the sole writer of page state; the read paths remain. Removed the now-dead `saveState` service. e2e / CI: - The e2e static server proxies the `/collab` WebSocket upgrade (mirrors Caddy); vite dev gains a `/collab` ws proxy. The auth-e2e CI job starts the collab server and runs a new collab pack. - New `collab.spec.ts`: two browsers converge on one page (the milestone headline), and offline edits continue locally and sync on reconnect. The read-only live assertion is a `test.fixme` until real read-only grants exist — under interim access seeing and modifying coincide, so no `ro` token is issued yet (that arrives with #53). Reworked the api/trash tests and the content editor-basics test off the retired REST write path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
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<string> {
|
|
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: `<p>markdown export ${suffix}</p>`,
|
|
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);
|
|
});
|
|
});
|