246 lines
9.6 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|