import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { bulkCreateEntries, validateEntryInput } from '../src/actions/entry-crud'; import { bulkCreateRecords } from '../src/actions/record-crud'; import { entryRoutes } from '../src/routes/entries'; import { recordRoutes } from '../src/routes/records'; import type { Bindings } from '../src/types'; // ── In-memory fake D1 ──────────────────────────────────────────────────────── // Interprets only the statement shapes the bulk/CRUD paths issue. Backed by real // Maps so idempotency (page_name), batch writes, and IN(...) hydration behave for real. interface Row { [k: string]: unknown } function collapse(sql: string): string { return sql.replace(/\s+/g, ' ').trim(); } class FakeDB { entries = new Map(); entryValues: Row[] = []; templates = new Map(); // keyed by id; name lookup scans seedTemplate(t: { id: string; name: string; slots: string[] }) { this.templates.set(t.id, { id: t.id, name: t.name, slots_json: JSON.stringify(t.slots), description: null, created_by: 'system' }); } prepare(sql: string) { const db = this; let bound: unknown[] = []; const stmt = { bind(...args: unknown[]) { bound = args; return stmt; }, async run() { db._exec(sql, bound); return { success: true, meta: {} }; }, async all() { return { results: db._query(sql, bound) as T[], success: true, meta: {} }; }, async first() { const r = db._query(sql, bound); return (r[0] ?? null) as T; }, }; return stmt; } async batch(stmts: { run: () => Promise }[]) { const out: unknown[] = []; for (const s of stmts) out.push(await s.run()); return out; } _exec(rawSql: string, bound: unknown[]) { const sql = collapse(rawSql); let m = sql.match(/^INSERT INTO (\w+) \(([^)]*)\) VALUES/i); if (m) { const table = m[1]; const cols = m[2].split(',').map((s) => s.trim()); const row: Row = {}; cols.forEach((c, i) => { row[c] = bound[i]; }); if (table === 'entries') { this.entries.set(row.id as string, { content: null, entry_type: null, owner_id: null, parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1, ...row, }); } else if (table === 'entry_values') { this.entryValues.push(row); } return; } m = sql.match(/^UPDATE entries SET (.*) WHERE id = \?$/i); if (m) { const setClause = m[1]; const assigns = setClause.split(',').map((s) => s.trim()); const id = bound[bound.length - 1] as string; const row = this.entries.get(id); let bi = 0; for (const a of assigns) { const col = a.split('=')[0].trim(); if (/=\s*unixepoch\(\)/i.test(a)) { if (row) row[col] = 2; } else { if (row) row[col] = bound[bi]; bi++; } } return; } // ignore anything else in exec } _query(rawSql: string, bound: unknown[]): Row[] { const sql = collapse(rawSql); if (/FROM templates/i.test(sql)) { const all = [...this.templates.values()]; if (/IN \(/i.test(sql)) { const keys = new Set(bound.map(String)); return all.filter((t) => keys.has(String(t.id)) || keys.has(String(t.name))); } // getTemplate: WHERE id = ? OR name = ? LIMIT 1 const key = String(bound[0]); return all.filter((t) => String(t.id) === key || String(t.name) === key).slice(0, 1); } if (/FROM entries/i.test(sql)) { const all = [...this.entries.values()]; if (/SELECT id, page_name/i.test(sql)) { const set = new Set(bound.map(String)); return all.filter((e) => e.page_name != null && set.has(String(e.page_name))).map((e) => ({ id: e.id, page_name: e.page_name })); } if (/WHERE id IN \(/i.test(sql)) { const set = new Set(bound.map(String)); return all.filter((e) => set.has(String(e.id))); } if (/WHERE id = \?/i.test(sql)) { return all.filter((e) => String(e.id) === String(bound[0])); } } return []; } } function makeEnv(db: FakeDB): Bindings { return { DB: asDb(db), ENVIRONMENT: 'test' } as Bindings; } const asDb = (db: FakeDB): D1Database => db as unknown as D1Database; // ── validateEntryInput ─────────────────────────────────────────────────────── describe('validateEntryInput (owner_id 機制強制)', () => { it('rejects missing owner_id', () => { expect(validateEntryInput({ entry_type: 'block' })).toMatch(/owner_id required/); }); it('rejects missing entry_type', () => { expect(validateEntryInput({ owner_id: 'leo' })).toBe('entry_type required'); }); it('rejects non-object', () => { expect(validateEntryInput(null)).toMatch(/must be an object/); }); it('accepts a complete entry', () => { expect(validateEntryInput({ entry_type: 'block', owner_id: 'leo' })).toBeNull(); }); }); // ── bulkCreateEntries ──────────────────────────────────────────────────────── describe('bulkCreateEntries', () => { it('creates many entries in one shot; partial failure does not abort the batch', async () => { const db = new FakeDB(); const r = await bulkCreateEntries(asDb(db), [ { entry_type: 'block', owner_id: 'leo', content: 'a' }, { entry_type: 'block', owner_id: 'leo', content: 'b' }, { entry_type: 'block', content: 'no-owner' } as never, // missing owner_id → failed ]); expect(r.created).toBe(2); expect(r.failed).toBe(1); expect(r.results[2].status).toBe('failed'); expect(r.results[2].error).toMatch(/owner_id required/); expect(r.entries.length).toBe(2); expect(db.entries.size).toBe(2); // the bad one was never written }); it('is idempotent by page_name: same key re-entry updates, does not duplicate', async () => { const db = new FakeDB(); const first = await bulkCreateEntries(asDb(db), [ { entry_type: 'agent-skill', owner_id: 'registry', page_name: 'skill-x', content: 'v1' }, ]); expect(first.created).toBe(1); expect(db.entries.size).toBe(1); const second = await bulkCreateEntries(asDb(db), [ { entry_type: 'agent-skill', owner_id: 'registry', page_name: 'skill-x', content: 'v2' }, ]); expect(second.updated).toBe(1); expect(second.created).toBe(0); expect(db.entries.size).toBe(1); // no duplicate row const row = [...db.entries.values()][0]; expect(row.content).toBe('v2'); // content flipped to the new value }); it('accepts an empty array (no-op)', async () => { const db = new FakeDB(); const r = await bulkCreateEntries(asDb(db), []); expect(r).toMatchObject({ created: 0, updated: 0, failed: 0 }); }); }); // ── bulkCreateRecords ──────────────────────────────────────────────────────── describe('bulkCreateRecords', () => { it('creates records via a template; owner_id missing → that record fails', async () => { const db = new FakeDB(); db.seedTemplate({ id: 'tpl-1', name: 'triplet', slots: ['subject', 'predicate', 'object'] }); const r = await bulkCreateRecords(asDb(db), [ { template: 'triplet', owner_id: 'leo', values: { subject: 'cat', predicate: 'is', object: 'animal' } }, { template: 'triplet', owner_id: 'leo', values: { subject: 'dog', predicate: 'is', object: 'animal' } }, { template: 'triplet', values: { subject: 'x', predicate: 'y', object: 'z' } } as never, // no owner ]); expect(r.created).toBe(2); expect(r.failed).toBe(1); expect(r.records.length).toBe(2); // each created record wrote 3 value entries + 3 entry_values expect(db.entryValues.length).toBe(6); expect(db.entries.size).toBe(6); // value entries carry the record owner (tenant isolation) for (const e of db.entries.values()) expect(e.owner_id).toBe('leo'); }); it('unknown template → failed, not thrown', async () => { const db = new FakeDB(); const r = await bulkCreateRecords(asDb(db), [{ template: 'nope', owner_id: 'leo', values: { a: 'b' } }]); expect(r.failed).toBe(1); expect(r.results[0].error).toMatch(/template not found/); }); }); // ── Route-level owner_id enforcement (D27/D28) ─────────────────────────────── describe('POST /entries owner_id enforcement (route 層擋死)', () => { const app = new Hono<{ Bindings: Bindings }>(); app.route('/entries', entryRoutes); app.route('/records', recordRoutes); it('POST /entries without owner_id → 400', async () => { const db = new FakeDB(); const res = await app.request('/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entry_type: 'block', content: 'x' }), }, makeEnv(db)); expect(res.status).toBe(400); expect((await res.json() as { error: string }).error).toMatch(/owner_id required/); expect(db.entries.size).toBe(0); }); it('POST /entries with owner_id → 200 created', async () => { const db = new FakeDB(); const res = await app.request('/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entry_type: 'block', owner_id: 'leo', content: 'x' }), }, makeEnv(db)); expect(res.status).toBe(200); expect((await res.json() as { success: boolean }).success).toBe(true); expect(db.entries.size).toBe(1); }); it('POST /entries/bulk mixed → 200 with per-item breakdown', async () => { const db = new FakeDB(); const res = await app.request('/entries/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entries: [ { entry_type: 'block', owner_id: 'leo', content: 'a' }, { entry_type: 'block', content: 'b' }, // no owner ] }), }, makeEnv(db)); expect(res.status).toBe(200); const body = await res.json() as { created: number; failed: number }; expect(body.created).toBe(1); expect(body.failed).toBe(1); }); it('POST /records without owner_id → 400', async () => { const db = new FakeDB(); db.seedTemplate({ id: 'tpl-1', name: 'triplet', slots: ['s'] }); const res = await app.request('/records', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ template: 'triplet', values: { s: 'x' } }), }, makeEnv(db)); expect(res.status).toBe(400); expect((await res.json() as { error: string }).error).toMatch(/owner_id required/); }); });