7d5cd06ff5
- POST /entries/bulk、/records/bulk:任意N筆約3 subrequest(批量冪等查+db.batch()+hydrate),解 Too many subrequests - 部分失敗不整批炸(逐筆驗證剔除+batch錯退化逐句隔離);page_name 冪等 - embedManyOnWrite 批次嵌(單次AI.run+upsert+UPDATE IN) - owner_id 缺→400(validateEntryInput,擋在HTTP路由層;內部無主路徑recipe-stat/value entries不受傷) - Phase 13 tasks;tsc 0、vitest 18/18
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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<string, Row>();
|
|
entryValues: Row[] = [];
|
|
templates = new Map<string, Row>(); // 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<T>() { return { results: db._query(sql, bound) as T[], success: true, meta: {} }; },
|
|
async first<T>() { const r = db._query(sql, bound); return (r[0] ?? null) as T; },
|
|
};
|
|
return stmt;
|
|
}
|
|
|
|
async batch(stmts: { run: () => Promise<unknown> }[]) {
|
|
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/);
|
|
});
|
|
});
|