Files
waggle-os/packages/server/tests/local/home.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

284 lines
10 KiB
TypeScript

/**
* Home Cockpit route tests (P2 — verify + J08).
*
* Covers the P2 additions to GET /api/home/briefing:
* - personalizeGreeting(): user name spliced into the first greeting clause
* (B8 / PRD §12.1 "greeting with user name") — unit + via inject.
* - applyPriorityRanking(): recency + bounded pending-item boost
* (PRD §12.1 "ranked by recency and priority") — unit.
* - needsReviewCount (J08/D6): personal-mind frames with metadata status
* 'unreviewed' are counted; the count matches what the Memory Center
* "Needs review" view lists.
*
* Scaffolding follows memory-center.test.ts: Fastify inject + MindDB(':memory:')
* + plain-object decorators. localConfig is intentionally absent → the
* buildWorkspaceState call inside the briefing loop throws and is absorbed by
* the route's per-workspace try/catch (pendingCount degrades to 0), and
* emitAuditEvent stays a safe no-op.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { MindDB, IdentityLayer } from '@waggle/core';
import {
homeRoutes,
personalizeGreeting,
applyPriorityRanking,
type RecentWorkspaceCard,
} from '../../src/local/routes/home.js';
import { memoryCenterRoutes } from '../../src/local/routes/memory-center.js';
import {
buildUpcomingSchedules,
type CronScheduleLike,
} from '../../src/local/routes/workspace-context.js';
interface TestWorkspace {
id: string;
name: string;
group: string;
created: string;
status?: string;
teamId?: string;
}
function createTestServer(
db: MindDB,
workspaces: TestWorkspace[] = [],
cronSchedules: CronScheduleLike[] = [],
) {
const server = Fastify({ logger: false });
server.decorate('multiMind', {
personal: db,
getFrameStore: () => undefined,
search: () => [],
workspace: undefined,
setWorkspace: () => {},
});
server.decorate('agentState', {
getWorkspaceMindDb: () => undefined,
activateWorkspaceMind: () => undefined,
listWorkspaces: () => [],
});
server.decorate('workspaceManager', {
list: () => workspaces,
get: (id: string) => workspaces.find((w) => w.id === id),
});
server.decorate('cronStore', {
list: () => cronSchedules,
getExecutionHistory: () => [],
});
// localConfig intentionally absent — see file header.
server.register(homeRoutes);
server.register(memoryCenterRoutes);
return server;
}
const card = (id: string, pendingCount: number, rankTs: number): { card: RecentWorkspaceCard; rankTs: number } => ({
rankTs,
card: { id, name: id, group: 'Personal', lastActive: new Date(rankTs).toISOString(), pendingCount },
});
describe('personalizeGreeting (P2 — B8 name in greeting)', () => {
it('splices the name before the first sentence break', () => {
expect(personalizeGreeting("Good morning. Here's your day", 'Marko'))
.toBe("Good morning, Marko. Here's your day");
});
it('handles the em-dash fresh-state greeting', () => {
expect(personalizeGreeting('Welcome — anything you discuss here will be remembered.', 'Marko'))
.toBe('Welcome, Marko — anything you discuss here will be remembered.');
});
it('passes through without a name', () => {
expect(personalizeGreeting("Good morning. Here's your day"))
.toBe("Good morning. Here's your day");
});
it('passes through greetings with no sentence break', () => {
expect(personalizeGreeting('Hello there', 'Marko')).toBe('Hello there');
});
});
describe('applyPriorityRanking (P2 — recency + pending boost)', () => {
const HOUR = 3_600_000;
const T0 = Date.parse('2026-06-11T12:00:00Z');
it('pending items boost an older workspace past a fresher empty one within the cap window', () => {
const fresh = card('fresh', 0, T0);
const blocked = card('blocked', 3, T0 - 2 * HOUR); // 3 pending → +3h ≥ 2h gap
const ranked = applyPriorityRanking([fresh, blocked]);
expect(ranked.map((c) => c.id)).toEqual(['blocked', 'fresh']);
});
it('the boost is capped — a stale workspace cannot leapfrog on pending count alone', () => {
const fresh = card('fresh', 0, T0);
const stale = card('stale', 50, T0 - 24 * HOUR); // cap 5 → +5h < 24h gap
const ranked = applyPriorityRanking([fresh, stale]);
expect(ranked.map((c) => c.id)).toEqual(['fresh', 'stale']);
});
it('slices to the display cap of 6', () => {
const cards = Array.from({ length: 8 }, (_, i) => card(`w${i}`, 0, T0 - i * HOUR));
expect(applyPriorityRanking(cards)).toHaveLength(6);
});
});
describe('GET /api/home/briefing (P2 — J08 needsReviewCount + greeting)', () => {
let db: MindDB;
let server: ReturnType<typeof Fastify>;
afterEach(async () => {
await server.close();
db.close();
});
function boot(workspaces: TestWorkspace[] = []) {
db = new MindDB(':memory:');
server = createTestServer(db, workspaces);
}
it('empty mind → needsReviewCount 0, isFirstRun true', async () => {
boot();
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
expect(res.statusCode).toBe(200);
const briefing = res.json();
expect(briefing.isFirstRun).toBe(true);
expect(briefing.needsReviewCount).toBe(0);
});
it('counts only personal-mind memories with status unreviewed', async () => {
boot();
// Seed via the same routes the product uses: create (status active) then
// flip one to unreviewed — the C33 harvest lifecycle state.
const m1 = await server.inject({
method: 'POST', url: '/api/memory',
payload: { content: 'Imported fact awaiting review.' },
});
expect(m1.statusCode).toBe(200);
const m2 = await server.inject({
method: 'POST', url: '/api/memory',
payload: { content: 'Already-reviewed fact.' },
});
expect(m2.statusCode).toBe(200);
const patch = await server.inject({
method: 'PATCH', url: `/api/memory/${m1.json().id}`,
payload: { status: 'unreviewed' },
});
expect(patch.statusCode).toBe(200);
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
expect(res.statusCode).toBe(200);
expect(res.json().needsReviewCount).toBe(1);
// The count must agree with what the deep-linked "Needs review" view shows
// — same query the Memory Center fires (status filter + limit=200).
const list = await server.inject({ method: 'GET', url: '/api/memory?status=unreviewed&limit=200' });
expect(list.json().count).toBe(1);
});
it('briefing slices recent workspaces to 6, newest first (route-level ranking wiring)', async () => {
const T0 = Date.parse('2026-06-11T12:00:00Z');
const workspaces = Array.from({ length: 8 }, (_, i) => ({
id: `w${i}`,
name: `Workspace ${i}`,
group: 'Personal',
// w0 oldest … w7 newest
created: new Date(T0 + i * 3_600_000).toISOString(),
}));
boot(workspaces);
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
expect(res.statusCode).toBe(200);
const cards = res.json().recentWorkspaces;
expect(cards).toHaveLength(6);
expect(cards.map((c: { id: string }) => c.id)).toEqual(['w7', 'w6', 'w5', 'w4', 'w3', 'w2']);
});
it('greets by name when identity is seeded (B8)', async () => {
boot([{ id: 'w1', name: 'Alpha', group: 'Personal', created: new Date().toISOString() }]);
new IdentityLayer(db).create({
name: 'Marko', role: 'Founder', department: 'Egzakta',
personality: '', capabilities: '', system_prompt: '',
});
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
expect(res.statusCode).toBe(200);
const briefing = res.json();
expect(briefing.userName).toBe('Marko');
expect(briefing.greeting).toContain(', Marko');
});
it('greeting stays name-free when no identity exists', async () => {
boot([{ id: 'w1', name: 'Alpha', group: 'Personal', created: new Date().toISOString() }]);
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
expect(res.json().userName).toBeUndefined();
expect(res.json().greeting).not.toContain(',');
});
});
describe('upNext schedule aggregation (global jobs dedup + future-only)', () => {
let db: MindDB;
let server: ReturnType<typeof Fastify>;
afterEach(async () => {
await server.close();
db.close();
});
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const PAST = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const globalSchedule = (id: number, name: string, nextRunAt: string): CronScheduleLike => ({
id,
name,
cron_expr: '30 3 * * *',
enabled: 1,
workspace_id: null,
next_run_at: nextRunAt,
} as CronScheduleLike);
const threeWorkspaces: TestWorkspace[] = ['a', 'b', 'c'].map((id, i) => ({
id,
name: `Workspace ${id}`,
group: 'Personal',
created: new Date(Date.parse('2026-06-11T12:00:00Z') + i * 3_600_000).toISOString(),
}));
it('a global schedule surfaces once across multiple workspaces, not once per workspace', async () => {
db = new MindDB(':memory:');
server = createTestServer(db, threeWorkspaces, [
globalSchedule(1, 'Memory compaction', FUTURE),
]);
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
expect(res.statusCode).toBe(200);
const labels = (res.json().upNext as Array<{ label: string }>).map((u) => u.label);
const compactions = labels.filter((l) => l.startsWith('Memory compaction'));
expect(compactions).toHaveLength(1);
});
it('past-due schedules never display as upcoming', async () => {
db = new MindDB(':memory:');
server = createTestServer(db, threeWorkspaces, [
globalSchedule(1, 'Memory compaction', PAST),
globalSchedule(2, 'Harvest sync', FUTURE),
]);
const res = await server.inject({ method: 'GET', url: '/api/home/briefing' });
const labels = (res.json().upNext as Array<{ label: string }>).map((u) => u.label);
expect(labels.some((l) => l.startsWith('Memory compaction'))).toBe(false);
expect(labels.filter((l) => l.startsWith('Harvest sync'))).toHaveLength(1);
});
it('buildUpcomingSchedules unit: filters past, keeps future, scopes by workspace', () => {
const out = buildUpcomingSchedules(
[
globalSchedule(1, 'Past job', PAST),
globalSchedule(2, 'Future job', FUTURE),
{ ...globalSchedule(3, 'Other ws job', FUTURE), workspace_id: 'other' } as CronScheduleLike,
],
'mine',
);
expect(out.some((l) => l.startsWith('Past job'))).toBe(false);
expect(out.some((l) => l.startsWith('Future job'))).toBe(true);
expect(out.some((l) => l.startsWith('Other ws job'))).toBe(false);
});
});