maestro/src/bridge/memory-api.test.ts
2026-06-03 05:08:00 +00:00

246 lines
9.6 KiB
TypeScript

/**
* memory-api.test.ts — unit tests for /api/local/memory router
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createMemoryApi } from './memory-api.js';
import { upsertMemoryEntry } from '../user-folder/memory.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const USER_A = 'user-a';
/**
* App with req.user injected (authenticated).
*/
function makeApp(userId: string, dataDir: string): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use('/api/local/memory', createMemoryApi({ dataDir }));
return app;
}
/**
* App with no req.user — simulates missing auth.
*/
function makeUnauthApp(dataDir: string): express.Application {
const app = express();
app.use(express.json());
// No req.user set; authActive defaults to true inside the router
app.use('/api/local/memory', createMemoryApi({ dataDir }));
return app;
}
// ── Setup / Teardown ───────────────────────────────────────────────────────────
describe('Memory API', () => {
let tmpDir: string;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'memory-api-test-'));
app = makeApp(USER_A, tmpDir);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
// ── GET /entries ─────────────────────────────────────────────────────────────
describe('GET /entries', () => {
it('returns empty entries and null index when no memory exists', async () => {
const res = await request(app).get('/api/local/memory/entries');
expect(res.status).toBe(200);
expect(res.body.entries).toEqual([]);
expect(res.body.index).toBeNull();
});
it('returns parsed entries and index when entries exist', async () => {
// Seed directly via the memory helper
upsertMemoryEntry(tmpDir, USER_A, {
name: 'my-fact',
type: 'user',
description: 'A test fact',
body: 'some body content',
});
const res = await request(app).get('/api/local/memory/entries');
expect(res.status).toBe(200);
const entries = res.body.entries as Array<{ name: string; description: string; type: string; body: string }>;
expect(entries).toHaveLength(1);
expect(entries[0]!.name).toBe('my-fact');
expect(entries[0]!.description).toBe('A test fact');
expect(entries[0]!.type).toBe('user');
expect(entries[0]!.body.trim()).toBe('some body content');
// Index should contain the entry line
expect(typeof res.body.index).toBe('string');
expect(res.body.index).toContain('my-fact');
});
it('returns 401 when the request is unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp).get('/api/local/memory/entries');
expect(res.status).toBe(401);
});
});
// ── PUT /entries/:name ────────────────────────────────────────────────────────
describe('PUT /entries/:name', () => {
it('creates an entry and writes it to disk', async () => {
const res = await request(app)
.put('/api/local/memory/entries/my-note')
.send({ description: 'A useful note', type: 'reference', body: 'Details here.' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.name).toBe('my-note');
// Verify file is on disk
const factPath = join(tmpDir, USER_A, 'memory', 'my-note.md');
expect(existsSync(factPath)).toBe(true);
// Verify GET returns it
const getRes = await request(app).get('/api/local/memory/entries');
expect(getRes.status).toBe(200);
const names = (getRes.body.entries as Array<{ name: string }>).map(e => e.name);
expect(names).toContain('my-note');
});
it('updates an existing entry (upsert)', async () => {
// Create first
await request(app)
.put('/api/local/memory/entries/upsert-me')
.send({ description: 'Old description', type: 'user', body: 'old body' });
// Update
const res = await request(app)
.put('/api/local/memory/entries/upsert-me')
.send({ description: 'New description', type: 'feedback', body: 'new body' });
expect(res.status).toBe(200);
const getRes = await request(app).get('/api/local/memory/entries');
const entry = (getRes.body.entries as Array<{ name: string; description: string; type: string }>)
.find(e => e.name === 'upsert-me');
expect(entry).toBeDefined();
expect(entry!.description).toBe('New description');
expect(entry!.type).toBe('feedback');
});
it('returns 400 + rejected_bad_name for a name that is too long', async () => {
// Build a 65-char name using only URL-safe chars to avoid Express routing weirdness.
// isValidMemoryName rejects names longer than 64 chars.
const longName = 'a'.repeat(64) + 'b'; // 65 chars, all alphanumeric
const res = await request(app)
.put(`/api/local/memory/entries/${longName}`)
.send({ description: 'desc', type: 'user', body: 'body' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_bad_name');
});
it('returns 400 + rejected_unknown_type for invalid type', async () => {
const res = await request(app)
.put('/api/local/memory/entries/valid-name')
.send({ description: 'A description', type: 'bogus-type', body: 'body content' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_unknown_type');
});
it('returns 400 + rejected_body_too_large when body exceeds maxEntryBodyBytes', async () => {
// Default maxEntryBodyBytes is 8192; send 9000 bytes
const bigBody = 'x'.repeat(9000);
const res = await request(app)
.put('/api/local/memory/entries/big-entry')
.send({ description: 'too big', type: 'user', body: bigBody });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_body_too_large');
});
it('returns 400 + rejected_bad_description for multi-line description', async () => {
const res = await request(app)
.put('/api/local/memory/entries/multi-line-desc')
.send({ description: 'line one\nline two', type: 'user', body: 'body' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_bad_description');
});
it('accepts all valid types', async () => {
for (const type of ['user', 'feedback', 'project', 'reference'] as const) {
const res = await request(app)
.put(`/api/local/memory/entries/type-test-${type}`)
.send({ description: `type is ${type}`, type, body: 'body' });
expect(res.status).toBe(200);
}
});
it('returns 401 when unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp)
.put('/api/local/memory/entries/my-note')
.send({ description: 'desc', type: 'user', body: 'body' });
expect(res.status).toBe(401);
});
});
// ── DELETE /entries/:name ─────────────────────────────────────────────────────
describe('DELETE /entries/:name', () => {
it('removes an entry and updates the index', async () => {
// Seed entry
upsertMemoryEntry(tmpDir, USER_A, {
name: 'delete-me',
type: 'project',
description: 'Temporary fact',
body: 'will be deleted',
});
const res = await request(app).delete('/api/local/memory/entries/delete-me');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.name).toBe('delete-me');
// Fact file should be gone from memory dir (moved to trash)
const factPath = join(tmpDir, USER_A, 'memory', 'delete-me.md');
expect(existsSync(factPath)).toBe(false);
// GET should return empty entries
const getRes = await request(app).get('/api/local/memory/entries');
const names = (getRes.body.entries as Array<{ name: string }>).map(e => e.name);
expect(names).not.toContain('delete-me');
});
it('returns 404 for a nonexistent entry', async () => {
const res = await request(app).delete('/api/local/memory/entries/does-not-exist');
expect(res.status).toBe(404);
});
it('returns 404 for an invalid name (do not leak existence)', async () => {
// Name exceeding 64 chars → treated as 404 to not reveal system structure
const res = await request(app).delete(`/api/local/memory/entries/${'z'.repeat(65)}`);
expect(res.status).toBe(404);
});
it('returns 401 when unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp).delete('/api/local/memory/entries/some-entry');
expect(res.status).toBe(401);
});
});
});