Files
waggle-os/packages/server/tests/dreams-routes.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

138 lines
5.9 KiB
TypeScript

/**
* /api/dreams — response shape, days clamp, lazy narrative polish
* (single-flight, quiet-night skip, LLM-failure fallback) (DREAM-DIARY spec).
*
* Polish behavior uses a bare Fastify instance with the `polish` test seam;
* one full-server test confirms the route is wired into buildLocalServer.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { MindDB } from '@waggle/core';
import { buildLocalServer } from '../src/local/index.js';
import { injectWithAuth } from './test-utils.js';
import { DreamJournal } from '../src/local/dream-journal.js';
import { dreamRoutes, type NarrativePolish } from '../src/local/routes/dreams.js';
async function waitFor(predicate: () => boolean, ms = 2000): Promise<void> {
const deadline = Date.now() + ms;
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor timed out');
await new Promise(r => setTimeout(r, 5));
}
}
describe('dreams routes (bare instance + polish seam)', () => {
let dir: string;
let server: FastifyInstance;
let journal: DreamJournal;
let polish: ReturnType<typeof vi.fn>;
let resolvePolish: ((v: string) => void) | null;
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-dreams-rt-'));
journal = new DreamJournal(dir);
resolvePolish = null;
polish = vi.fn(() => new Promise<string>(resolve => { resolvePolish = resolve; }));
server = Fastify();
server.decorate('dreamJournal', journal);
await server.register(dreamRoutes, { polish: polish as unknown as NarrativePolish });
await server.ready();
});
afterEach(async () => {
await server.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it('returns newest-first entries with summary and events', async () => {
journal.record('memory_compact', { temporaryPruned: 3, deprecatedPruned: 0, pframesMerged: 1 }, new Date('2026-07-08T03:00:00'));
journal.record('index_reconcile', { ftsFixed: 2, vecFixed: 0 }, new Date('2026-07-09T03:00:00'));
const res = await server.inject({ method: 'GET', url: '/api/dreams?days=7' });
expect(res.statusCode).toBe(200);
const body = res.json() as Array<{ date: string; summary: string; events: unknown[] }>;
expect(body.map(d => d.date)).toEqual(['2026-07-09', '2026-07-08']);
expect(body[0].summary).toContain('repaired 2 search-index entries');
expect(body[1].events).toHaveLength(2 - 1);
});
it('clamps the days query to a sane range', async () => {
const res = await server.inject({ method: 'GET', url: '/api/dreams?days=99999' });
expect(res.statusCode).toBe(200);
const bad = await server.inject({ method: 'GET', url: '/api/dreams?days=banana' });
expect(bad.statusCode).toBe(200); // falls back to default 7
});
it('rejects non-local origins', async () => {
const res = await server.inject({
method: 'GET', url: '/api/dreams', headers: { origin: 'https://evil.example.com' },
});
expect(res.statusCode).toBe(403);
});
it('schedules ONE polish per active day even under polling (single-flight)', async () => {
journal.record('memory_compact', { temporaryPruned: 5, deprecatedPruned: 0, pframesMerged: 0 }, new Date('2026-07-09T03:00:00'));
await server.inject({ method: 'GET', url: '/api/dreams' });
await server.inject({ method: 'GET', url: '/api/dreams' });
await server.inject({ method: 'GET', url: '/api/dreams' });
expect(polish).toHaveBeenCalledTimes(1);
// Complete the in-flight polish → persisted → no further calls.
resolvePolish?.('While you slept I tidied five memories.');
await waitFor(() => journal.read('2026-07-09')?.narrative !== undefined);
await server.inject({ method: 'GET', url: '/api/dreams' });
expect(polish).toHaveBeenCalledTimes(1);
expect(journal.read('2026-07-09')?.narrative).toContain('tidied five memories');
});
it('skips the LLM entirely for quiet nights', async () => {
journal.record('memory_compact', { temporaryPruned: 0, deprecatedPruned: 0, pframesMerged: 0 }, new Date('2026-07-09T03:00:00'));
await server.inject({ method: 'GET', url: '/api/dreams' });
expect(polish).not.toHaveBeenCalled();
});
it('keeps the deterministic summary when the polish fails, and can retry later', async () => {
journal.record('index_reconcile', { ftsFixed: 4, vecFixed: 0 }, new Date('2026-07-09T03:00:00'));
polish.mockRejectedValueOnce(new Error('proxy down'));
const res = await server.inject({ method: 'GET', url: '/api/dreams' });
const body = res.json() as Array<{ summary: string; narrative?: string }>;
expect(body[0].summary).toContain('repaired 4 search-index entries');
await waitFor(() => polish.mock.calls.length === 1);
await new Promise(r => setTimeout(r, 20)); // let the rejection settle + clear the guard
expect(journal.read('2026-07-09')?.narrative).toBeUndefined();
// Guard cleared → a later read may try again.
await server.inject({ method: 'GET', url: '/api/dreams' });
expect(polish.mock.calls.length).toBe(2);
});
});
describe('dreams route wiring in buildLocalServer', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-dreams-full-'));
const mind = new MindDB(path.join(tmpDir, 'personal.mind'));
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('exposes GET /api/dreams with the decorated journal (empty at first)', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/dreams' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
});
});