/** * 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); }); }); });