Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
175 lines
6.7 KiB
JavaScript
175 lines
6.7 KiB
JavaScript
// @vitest-environment node
|
|
/**
|
|
* ADR-0031 WP-23 — send_widget_message MCP wrapper tests.
|
|
*
|
|
* Validates the contract: the wrapper resolves table/row metadata, builds a
|
|
* row_reference attachment, and delegates to send_chat_message. Heavy DB
|
|
* dependencies are mocked so the suite runs without a fixture.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// Mock the database connection module BEFORE importing the SUT.
|
|
const dbGet = vi.fn();
|
|
const dbRun = vi.fn();
|
|
const dbAll = vi.fn();
|
|
const isPostgres = vi.fn(() => false);
|
|
const sqlNow = vi.fn(() => "datetime('now')");
|
|
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: (...args) => dbGet(...args),
|
|
dbRun: (...args) => dbRun(...args),
|
|
dbAll: (...args) => dbAll(...args),
|
|
isPostgres: (...args) => isPostgres(...args),
|
|
sqlNow: (...args) => sqlNow(...args),
|
|
}));
|
|
|
|
vi.mock('../../../utils/logger.js', () => ({
|
|
apiLogger: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('../../chat/invocation-dispatcher.js', () => ({
|
|
dispatchInvocationsFromContent: vi.fn(),
|
|
hasInvocationTokens: vi.fn(() => false),
|
|
}));
|
|
|
|
const { chatToolHandlers } = await import('../chat-tools.js');
|
|
|
|
beforeEach(() => {
|
|
dbGet.mockReset();
|
|
dbRun.mockReset();
|
|
});
|
|
|
|
describe('send_widget_message — input validation', () => {
|
|
it('rejects missing conversation_id', async () => {
|
|
const res = await chatToolHandlers.send_widget_message({ table_id: 1, row_id: 1 }, 1);
|
|
expect(res).toEqual({ error: expect.stringContaining('conversation_id') });
|
|
expect(dbGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects missing table_id', async () => {
|
|
const res = await chatToolHandlers.send_widget_message({ conversation_id: 1, row_id: 1 }, 1);
|
|
expect(res).toEqual({ error: expect.stringContaining('table_id') });
|
|
});
|
|
|
|
it('rejects missing row_id', async () => {
|
|
const res = await chatToolHandlers.send_widget_message({ conversation_id: 1, table_id: 1 }, 1);
|
|
expect(res).toEqual({ error: expect.stringContaining('row_id') });
|
|
});
|
|
|
|
it('rejects invalid style', async () => {
|
|
const res = await chatToolHandlers.send_widget_message(
|
|
{ conversation_id: 1, table_id: 1, row_id: 1, style: 'banner' }, 1
|
|
);
|
|
expect(res).toEqual({ error: expect.stringContaining('style') });
|
|
});
|
|
|
|
it('errors when table is missing', async () => {
|
|
dbGet.mockResolvedValueOnce(null); // universal_tables lookup → not found
|
|
const res = await chatToolHandlers.send_widget_message(
|
|
{ conversation_id: 1, table_id: 999, row_id: 1 }, 1
|
|
);
|
|
expect(res).toEqual({ error: expect.stringMatching(/Table 999 not found/) });
|
|
});
|
|
|
|
it('errors when row is missing', async () => {
|
|
dbGet
|
|
.mockResolvedValueOnce({ id: 2197, name: 'Реестр документов', display_name: 'ADRs', icon: '📡' })
|
|
.mockResolvedValueOnce(null); // table_rows lookup → not found
|
|
const res = await chatToolHandlers.send_widget_message(
|
|
{ conversation_id: 1, table_id: 2197, row_id: 99999 }, 1
|
|
);
|
|
expect(res).toEqual({ error: expect.stringMatching(/Row 99999 not found/) });
|
|
});
|
|
});
|
|
|
|
describe('send_widget_message — happy path', () => {
|
|
it('resolves metadata, builds row_reference attachment, and posts via INSERT', async () => {
|
|
dbGet
|
|
// 1. universal_tables lookup
|
|
.mockResolvedValueOnce({ id: 2197, name: '_registry', display_name: 'ADRs', icon: '📡' })
|
|
// 2. table_rows lookup
|
|
.mockResolvedValueOnce({
|
|
id: 139259,
|
|
data: JSON.stringify({ name: 'ADR-0031 — Row-Mutation Events', slug: 'adr-0031', icon: '📡' }),
|
|
})
|
|
// 3. send_chat_message — conversations lookup
|
|
.mockResolvedValueOnce({ id: 42, space_id: 11 });
|
|
|
|
dbRun
|
|
// 4. INSERT INTO messages
|
|
.mockResolvedValueOnce({ lastInsertRowid: 555 })
|
|
// 5. UPDATE conversations
|
|
.mockResolvedValueOnce({});
|
|
|
|
const result = await chatToolHandlers.send_widget_message(
|
|
{ conversation_id: 42, table_id: 2197, row_id: 139259, style: 'card', note: 'doc ready' },
|
|
1
|
|
);
|
|
|
|
expect(result).toEqual({ success: true, message_id: 555, message: 'Message sent' });
|
|
|
|
// Verify the INSERT was called with row_reference attachment carrying resolved metadata.
|
|
const insertCall = dbRun.mock.calls.find(([sql]) => /INSERT INTO messages/.test(sql));
|
|
expect(insertCall).toBeTruthy();
|
|
const params = insertCall[1];
|
|
// params order: [conversation_id, role, content, content_type, sender_id, attachmentsJson]
|
|
expect(params[0]).toBe(42);
|
|
expect(params[2]).toBe('doc ready'); // content = note
|
|
expect(params[3]).toBe('text'); // content_type = 'text' (chips ride on text msgs)
|
|
const atts = JSON.parse(params[5]);
|
|
expect(atts).toHaveLength(1);
|
|
expect(atts[0]).toMatchObject({
|
|
type: 'row_reference',
|
|
rowReference: {
|
|
table_id: 2197,
|
|
row_id: 139259,
|
|
table_name: 'ADRs',
|
|
table_icon: '📡',
|
|
row_title: 'ADR-0031 — Row-Mutation Events',
|
|
style: 'card',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('falls back to "Row #N" when no display column matches', async () => {
|
|
dbGet
|
|
.mockResolvedValueOnce({ id: 100, name: 'misc', display_name: null, icon: null })
|
|
.mockResolvedValueOnce({ id: 7, data: JSON.stringify({ foo: 'bar' }) })
|
|
.mockResolvedValueOnce({ id: 1, space_id: null });
|
|
dbRun
|
|
.mockResolvedValueOnce({ lastInsertRowid: 1 })
|
|
.mockResolvedValueOnce({});
|
|
|
|
await chatToolHandlers.send_widget_message(
|
|
{ conversation_id: 1, table_id: 100, row_id: 7 }, 1
|
|
);
|
|
|
|
const insertCall = dbRun.mock.calls.find(([sql]) => /INSERT INTO messages/.test(sql));
|
|
const atts = JSON.parse(insertCall[1][5]);
|
|
expect(atts[0].rowReference.row_title).toBe('Row #7');
|
|
expect(atts[0].rowReference.table_name).toBe('misc');
|
|
// No table_icon when icon is null.
|
|
expect(atts[0].rowReference.table_icon).toBeUndefined();
|
|
// Default style = 'chip'.
|
|
expect(atts[0].rowReference.style).toBe('chip');
|
|
});
|
|
});
|
|
|
|
describe('create_document return shape (ADR-0031 WP-23)', () => {
|
|
it('exposes registry_table_id alongside document_id (compile-time check)', async () => {
|
|
// We intentionally avoid running the full create_document path here (too
|
|
// many DB dependencies). The shape is asserted by inspecting the source —
|
|
// this test is a guard against future refactors that drop the field.
|
|
const fs = await import('node:fs');
|
|
const path = await import('node:path');
|
|
const src = fs.readFileSync(
|
|
path.resolve(__dirname, '../document-tools.js'),
|
|
'utf8'
|
|
);
|
|
// Find the create_document return object and verify registry_table_id is present.
|
|
const match = src.match(/async create_document[\s\S]*?return \{[\s\S]*?\};/);
|
|
expect(match).toBeTruthy();
|
|
expect(match[0]).toMatch(/registry_table_id/);
|
|
});
|
|
});
|