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
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
75 lines
2.8 KiB
TypeScript
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();
|
|
});
|
|
});
|