import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { spawnSync } from 'node:child_process'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; test('runtime rejects SQLite user data backend outside node tests', () => { const script = ` import { createUserDataSpaceService } from './user-data-space-service.mjs'; try { createUserDataSpaceService({ workspaceRoot: '/tmp/runtime-sqlite-denied', backend: 'sqlite' }); process.exit(0); } catch (error) { console.error(error.code); process.exit(7); } `; const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env, NODE_TEST_CONTEXT: '' }, }); assert.equal(result.status, 7); assert.match(result.stderr, /sqlite_runtime_disabled/); }); function createTempWorkspace() { return fs.mkdtempSync(path.join(os.tmpdir(), 'user-data-space-')); } test('createUserDataSpaceService supports agent SQL and dataset registry', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE form_registrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT, status TEXT DEFAULT 'pending', note TEXT, created_at TEXT, deleted_at TEXT );`); const dataset = await service.upsertDataset({ name: 'registrations', table: 'form_registrations', description: '活动报名数据', actions: ['read', 'insert'], columns: { read: ['id', 'name', 'phone', 'status', 'note', 'created_at'], insert: ['name', 'phone', 'status', 'note'], }, limits: { maxRowsPerRead: 50, maxInsertBytes: 4096 }, }); assert.equal(dataset.name, 'registrations'); assert.deepEqual(dataset.actions, ['read', 'insert']); const inserted = await service.insertDatasetRow('registrations', { name: '张三', phone: '13800000000', status: 'pending', note: '第一场', }); assert.equal(inserted.row.name, '张三'); const listed = service.readDatasetRows('registrations', { limit: 10 }); assert.equal(listed.rows.length, 1); assert.equal(listed.rows[0].phone, '13800000000'); const stats = service.getDatasetStats('registrations'); assert.equal(stats.total, 1); const schema = service.getDatasetSchema('registrations'); assert.equal(schema.dataset.name, 'registrations'); assert.ok(schema.tableColumns.some((column) => column.name === 'name')); }); test('dataset insert rejects unauthorized columns', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, secret_code TEXT );`); await service.upsertDataset({ name: 'public_names', table: 'secrets', actions: ['insert'], columns: { insert: ['name'] }, }); await assert.rejects( () => service.insertDatasetRow('public_names', { name: '公开项', secret_code: 'hidden' }), (error) => error.code === 'columns_not_allowed', ); }); test('dataset read rejects missing dataset', () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); assert.throws( () => service.readDatasetRows('missing'), (error) => error.code === 'dataset_not_found', ); }); test('rejectDangerousSql blocks attach and dot commands', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await assert.rejects( () => service.querySql("ATTACH DATABASE '/tmp/other.sqlite' AS other; SELECT 1"), /不允许/, ); await assert.rejects(() => service.executeSql('.open /tmp/other.sqlite'), /dot command/); }); test('user-data-space-service supports update and soft delete', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, status TEXT DEFAULT 'open', updated_at TEXT, updated_by_label TEXT, deleted_at TEXT, deleted_by TEXT );`); await service.upsertDataset({ name: 'tasks', table: 'tasks', actions: ['read', 'insert', 'update', 'soft_delete'], columns: { read: ['id', 'title', 'status', 'updated_at'], insert: ['title', 'status'], update: ['status'], soft_delete: ['id'], }, }); const inserted = await service.insertDatasetRow('tasks', { title: '整理资料', status: 'open' }); const updated = await service.updateRowForDataset(service.getDataset('tasks'), inserted.row.id, { status: 'done', }, { updatedByLabel: 'owner' }); assert.equal(updated.row.status, 'done'); const deleted = await service.softDeleteRowForDataset(service.getDataset('tasks'), inserted.row.id, { deletedBy: 'owner', }); assert.equal(deleted.deleted, true); const listed = service.readDatasetRows('tasks', { limit: 10 }); assert.equal(listed.rows.length, 0); }); test('exportDatasetRows supports json and csv output', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT );`); await service.upsertDataset({ name: 'contacts', table: 'contacts', actions: ['read', 'insert'], columns: { read: ['id', 'name', 'phone'], insert: ['name', 'phone'], }, }); await service.insertDatasetRow('contacts', { name: '李四', phone: '13900000002' }); const json = service.exportDatasetRows('contacts', { format: 'json' }); assert.equal(json.rowCount, 1); assert.equal(json.rows[0].name, '李四'); const csv = service.exportDatasetRows('contacts', { format: 'csv' }); assert.match(csv.content, /name,phone/); assert.match(csv.content, /李四/); }); test('restoreSoftDeletedRow clears deleted_at', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, deleted_at TEXT, deleted_by TEXT );`); await service.upsertDataset({ name: 'items', table: 'items', actions: ['read', 'insert', 'soft_delete'], columns: { read: ['id', 'title', 'deleted_at'], insert: ['title'], soft_delete: ['id'], }, }); const inserted = await service.insertDatasetRow('items', { title: '可恢复项' }); await service.softDeleteRowForDataset(service.getDataset('items'), inserted.row.id, { deletedBy: 'test' }); const hidden = service.readDatasetRows('items', { limit: 10 }); assert.equal(hidden.rows.length, 0); const restored = await service.restoreSoftDeletedRow('items', inserted.row.id); assert.equal(restored.restored, true); const visible = service.readDatasetRows('items', { limit: 10 }); assert.equal(visible.rows.length, 1); assert.equal(visible.rows[0].title, '可恢复项'); }); test('readRowsForDataset applies rowScope where clause', async () => { const workspaceRoot = createTempWorkspace(); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE scoped_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, created_by_user_id TEXT );`); await service.upsertDataset({ name: 'scoped_items', table: 'scoped_items', actions: ['read', 'insert'], columns: { read: ['id', 'title', 'created_by_user_id'], insert: ['title'], }, }); await service.insertRowForDataset(service.getDataset('scoped_items'), { title: 'A' }, { rowScope: { ownerColumn: 'created_by_user_id', visitorUserId: 'user-a' }, }); await service.insertRowForDataset(service.getDataset('scoped_items'), { title: 'B' }, { rowScope: { ownerColumn: 'created_by_user_id', visitorUserId: 'user-b' }, }); const scoped = service.readRowsForDataset(service.getDataset('scoped_items'), { limit: 10, rowScope: { whereClause: `"created_by_user_id" = 'user-a'` }, }); assert.equal(scoped.rows.length, 1); assert.equal(scoped.rows[0].title, 'A'); });