dorfteich/apps/collab/src/server.test.ts
Claude Opus 4.8 7d8f331870
All checks were successful
CD / Build and push images (push) Successful in 2m51s
CI / Lint, typecheck, test (push) Successful in 1m55s
CI / Auth e2e pack (push) Successful in 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
Add collab document persistence hooks and content-cache refresh (#35)
The collaboration server becomes the writer of page state (ADR 0003,
realtime-collaboration.md §lifecycle):

- onLoadDocument reconstructs a page's Y.Doc from PostgreSQL by applying
  `pages.ydoc_state` and then every `page_updates` row in order, so a page
  with a long update log loads correctly.
- onStoreDocument persists debounced (2 s, max 30 s): it appends the delta
  since the last flush to `page_updates`, periodically merges the log back
  into `ydoc_state` (inline threshold; the session-aware compaction of idle
  pages remains the separate job, #40), refreshes `page_content_cache`
  (plain text / Markdown / HTML / outline via the shared derivation, #24),
  bumps `pages.updated_at`, and keeps `Attachment.pageId` pointed at the
  embedding page (#31). Each flush runs in one transaction and its duration
  is logged.
- The document size ceiling (MAX_PAGE_DOCUMENT_BYTES) is enforced on store:
  an oversize document is not persisted and the clients are notified with a
  stateless error so they can revert.

Persistence is an injected port (PagePersistence): the Postgres
implementation is covered by a DB-backed test (store/load round-trip,
content-cache refresh, a 1000-entry update log, size-ceiling rejection,
not-found), and the hook wiring — two-client sync, survival across a server
restart, and the size-ceiling stateless notification — by an integration
test using an in-memory fake. The collab package gains its own vitest setup
that provisions an isolated `_collab` test database.

The REST `PUT /pages/:id/state` write path stays in place for now and is
retired (410) together with switching the editor to live collaboration in
#36, so the deployed editor is never left unable to save between the two
deploys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 17:06:41 +02:00

75 lines
2.8 KiB
TypeScript

import { pino } from 'pino';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createCollabServer } from './server.js';
import { freePort } from './testing/free-port.js';
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
import type { CollabHealthReport, DatabaseProbe } from './health.js';
// A silent logger; these tests assert behaviour, not log output.
const logger = pino({ enabled: false });
describe('collab server', () => {
let server: ReturnType<typeof createCollabServer>;
let baseUrl: string;
let wsUrl: string;
const probe = vi.fn<() => Promise<DatabaseProbe>>();
beforeAll(async () => {
server = createCollabServer({
version: 'test-version',
logger,
tokenSecret: 'server-test-secret-32-characters!',
pingDatabase: probe,
persistence: new InMemoryPagePersistence(),
});
// Hocuspocus' listen(port) ignores a falsy port (0 → default 80), so bind
// an explicit OS-assigned free port instead.
const port = await freePort();
await server.listen(port);
baseUrl = `http://127.0.0.1:${port}`;
wsUrl = `ws://127.0.0.1:${port}`;
});
afterAll(async () => {
await server.destroy();
});
it('serves /healthz as 200 with the report when the database is reachable', async () => {
probe.mockResolvedValueOnce({ ok: true });
const res = await fetch(`${baseUrl}/healthz`);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as CollabHealthReport;
expect(body).toMatchObject({ status: 'ok', service: 'collab', version: 'test-version' });
expect(body.checks).toEqual([{ name: 'database', status: 'ok' }]);
});
it('serves /healthz as 503 when the database probe fails', async () => {
probe.mockResolvedValueOnce({ ok: false, detail: 'boom' });
const res = await fetch(`${baseUrl}/healthz`);
expect(res.status).toBe(503);
const body = (await res.json()) as CollabHealthReport;
expect(body.status).toBe('unhealthy');
});
it('leaves non-health HTTP requests to Hocuspocus without probing the db', async () => {
const callsBefore = probe.mock.calls.length;
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(await res.text()).toContain('Hocuspocus');
expect(probe.mock.calls.length).toBe(callsBefore);
});
it('accepts a WebSocket handshake on the collab path', async () => {
const socket = new WebSocket(`${wsUrl}/collab`);
await expect(
new Promise<void>((resolve, reject) => {
socket.addEventListener('open', () => resolve());
socket.addEventListener('error', () => reject(new Error('handshake failed')));
}),
).resolves.toBeUndefined();
socket.close();
});
});