godcrm/backend/services/agent-tools/__tests__/send-widget-message.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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/);
});
});