feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
@@ -2,11 +2,11 @@ import { Scenario } from '../types';
export const scenario: Scenario = {
id: 'dimension-lifecycle',
name: 'Dimension lifecycle (create/update/lock/unlock)',
description: 'Create a dimension, update its description, lock it, then unlock it.',
name: 'Dimension lifecycle (archived)',
description: 'Historical pre-migration scenario retained only as an archived reference for the removed dimensions contract.',
tools: ['createDimension', 'updateDimension', 'lockDimension', 'unlockDimension'],
input: {
message: 'Create a dimension named "eval-dim" with description "temporary eval dimension", then update the description to "eval dimension updated", then lock it, then unlock it.',
message: 'Historical reference only: create a dimension named "eval-dim" with description "temporary eval dimension", then update the description to "eval dimension updated", then lock it, then unlock it.',
},
expect: {
toolsCalledSoft: ['createDimension', 'updateDimension', 'lockDimension', 'unlockDimension'],
@@ -2,11 +2,11 @@ import { Scenario } from '../types';
export const scenario: Scenario = {
id: 'get-dimension',
name: 'Get single dimension',
description: 'Fetch detail for a known dimension.',
name: 'Get single dimension (archived)',
description: 'Historical pre-migration scenario retained only as an archived reference for the removed dimensions contract.',
tools: ['getDimension'],
input: {
message: 'Get details for the dimension "ai".',
message: 'Historical reference only: get details for the dimension "ai".',
},
expect: {
toolsCalledSoft: ['getDimension'],
@@ -2,11 +2,11 @@ import { Scenario } from '../types';
export const scenario: Scenario = {
id: 'query-dimensions',
name: 'Query dimensions',
description: 'List available dimensions and basic info.',
name: 'Legacy dimensions query (archived)',
description: 'Historical pre-migration scenario retained only as an archived reference for the removed dimensions contract.',
tools: ['queryDimensions'],
input: {
message: 'List my top dimensions and briefly describe what they represent.',
message: 'Historical reference only: list my top dimensions and briefly describe what they represent.',
},
expect: {
toolsCalledSoft: ['queryDimensions'],
+2 -2
View File
@@ -3,7 +3,7 @@ import { Scenario } from '../types';
export const scenario: Scenario = {
id: 'search-doc-to-lora',
name: 'Search Doc-to-LoRA',
description: 'Free-text graph search should find the Doc-to-LoRA node by title without speculative dimension constraints or unnecessary web search.',
description: 'Free-text graph search should find the Doc-to-LoRA node by title without speculative context constraints or unnecessary web search.',
categories: ['search'],
tools: ['queryNodes'],
input: {
@@ -17,5 +17,5 @@ export const scenario: Scenario = {
maxTotalTokens: 8000,
maxEstimatedCostUsd: 0.08,
},
notes: 'Regression for the failure where free-text search was over-constrained by guessed dimensions and missed the existing Doc-to-LoRA node.',
notes: 'Regression for the failure where free-text search was over-constrained by guessed organization hints and missed the existing Doc-to-LoRA node.',
};
@@ -1,27 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/services/database/sqlite-client', () => ({
getSQLiteClient: vi.fn(),
}));
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
describe('dimensionValidation', () => {
it('returns dimensions missing from the canonical dimensions table', () => {
vi.mocked(getSQLiteClient).mockReturnValue({
query: vi.fn().mockReturnValue({
rows: [{ name: 'ra-h' }, { name: 'ai' }],
}),
} as unknown as ReturnType<typeof getSQLiteClient>);
expect(getUnknownDimensions(['ra-h', 'Building RA-H — Personal Knowledge Graph', 'ai'])).toEqual([
'Building RA-H — Personal Knowledge Graph',
]);
});
it('formats a clear error for unknown dimensions', () => {
expect(formatUnknownDimensionsError(['Building RA-H — Personal Knowledge Graph']))
.toBe('Unknown dimension: "Building RA-H — Personal Knowledge Graph". Create it first or use an existing dimension.');
});
});
+396
View File
@@ -0,0 +1,396 @@
import http from 'node:http';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { AddressInfo } from 'node:net';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
type ContextRecord = {
id: number;
name: string;
description: string | null;
icon: string | null;
};
type NodeRecord = {
id: number;
title: string;
description: string | null;
source: string | null;
link: string | null;
context_id: number | null;
metadata: Record<string, unknown>;
updated_at: string;
created_at: string;
edge_count: number;
};
type RequestLogEntry = {
method: string;
pathname: string;
body: Record<string, unknown> | null;
};
const contexts: ContextRecord[] = [
{ id: 1, name: 'Work', description: 'Work projects and execution context.', icon: 'Briefcase' },
{ id: 2, name: 'Personal', description: 'Personal life and planning context.', icon: 'Heart' },
];
let server: http.Server;
let baseUrl = '';
let nodes: NodeRecord[] = [];
let requestLog: RequestLogEntry[] = [];
let nextNodeId = 1;
function nowIso(): string {
return new Date().toISOString();
}
function resetState() {
nodes = [];
requestLog = [];
nextNodeId = 1;
}
function sendJson(res: http.ServerResponse, statusCode: number, body: unknown) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
async function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8');
return raw ? JSON.parse(raw) : {};
}
function matchesNode(node: NodeRecord, query: string) {
const haystack = [node.title, node.description, node.source, node.link]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query.toLowerCase());
}
async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url || '/', baseUrl);
const pathname = url.pathname;
const method = req.method || 'GET';
const body = method === 'POST' || method === 'PUT' ? await readJsonBody(req) : null;
requestLog.push({ method, pathname, body });
if (method === 'POST' && pathname === '/api/nodes') {
const contextId = typeof body?.context_id === 'number' ? body.context_id : null;
const contextName = typeof body?.context_name === 'string' ? body.context_name.trim() : '';
const resolvedContextId = contextId
?? (contextName ? contexts.find((context) => context.name.toLowerCase() === contextName.toLowerCase())?.id ?? null : null);
if (contextId && !contexts.some((context) => context.id === contextId)) {
return sendJson(res, 400, { success: false, error: 'Context not found.' });
}
if (contextName && resolvedContextId === null) {
return sendJson(res, 400, { success: false, error: 'Context not found.' });
}
const timestamp = nowIso();
const node: NodeRecord = {
id: nextNodeId++,
title: String(body?.title || '').trim(),
description: typeof body?.description === 'string' ? body.description : null,
source: typeof body?.source === 'string' ? body.source : null,
link: typeof body?.link === 'string' ? body.link : null,
context_id: resolvedContextId,
metadata: typeof body?.metadata === 'object' && body.metadata ? body.metadata as Record<string, unknown> : {},
updated_at: timestamp,
created_at: timestamp,
edge_count: 0,
};
nodes.unshift(node);
return sendJson(res, 200, { success: true, data: node, message: `Created node #${node.id}: ${node.title}` });
}
if (method === 'GET' && pathname === '/api/nodes') {
const search = url.searchParams.get('search')?.trim() || '';
const limit = Number(url.searchParams.get('limit') || '10');
const contextIdParam = url.searchParams.get('contextId');
const contextId = contextIdParam ? Number(contextIdParam) : undefined;
let filtered = nodes.slice();
if (search) {
filtered = filtered.filter((node) => matchesNode(node, search));
}
if (contextId !== undefined) {
filtered = filtered.filter((node) => node.context_id === contextId);
}
return sendJson(res, 200, {
success: true,
total: filtered.length,
data: filtered.slice(0, Number.isFinite(limit) ? limit : 10),
});
}
if (method === 'GET' && pathname.startsWith('/api/nodes/')) {
const id = Number(pathname.split('/').pop());
const node = nodes.find((entry) => entry.id === id);
if (!node) {
return sendJson(res, 404, { success: false, error: 'Node not found.' });
}
return sendJson(res, 200, { success: true, node });
}
if (method === 'GET' && pathname === '/api/contexts') {
return sendJson(res, 200, {
success: true,
data: contexts.map((context) => ({
...context,
count: nodes.filter((node) => node.context_id === context.id).length,
})),
});
}
if (method === 'GET' && /^\/api\/contexts\/\d+$/.test(pathname)) {
const id = Number(pathname.split('/').pop());
const context = contexts.find((entry) => entry.id === id);
if (!context) {
return sendJson(res, 404, { success: false, error: 'Context not found.' });
}
return sendJson(res, 200, {
success: true,
data: {
...context,
count: nodes.filter((node) => node.context_id === context.id).length,
},
});
}
if (method === 'GET' && /^\/api\/contexts\/\d+\/nodes$/.test(pathname)) {
const parts = pathname.split('/');
const id = Number(parts[parts.length - 2]);
return sendJson(res, 200, {
success: true,
data: nodes
.filter((node) => node.context_id === id)
.map((node) => ({
id: node.id,
title: node.title,
description: node.description,
link: node.link,
context_id: node.context_id,
updated_at: node.updated_at,
})),
});
}
if (method === 'GET' && pathname === '/api/guides') {
return sendJson(res, 200, {
success: true,
data: [{ name: 'schema' }, { name: 'creating-nodes' }],
});
}
return sendJson(res, 404, { success: false, error: `Unhandled test route: ${method} ${pathname}` });
}
async function withMcpClient<T>(fn: (client: Client) => Promise<T>) {
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(process.cwd(), 'apps', 'mcp-server', 'stdio-server.js')],
cwd: process.cwd(),
env: {
...process.env,
RAH_MCP_TARGET_URL: baseUrl,
} as Record<string, string>,
stderr: 'pipe',
});
const client = new Client({ name: 'ra-h-mcp-contract-test', version: '1.0.0' });
await client.connect(transport);
try {
return await fn(client);
} finally {
await transport.close();
}
}
function getStructured<T>(result: unknown) {
return (result as { structuredContent?: unknown }).structuredContent as T;
}
describe('stdio MCP server contract', () => {
beforeAll(async () => {
server = http.createServer((req, res) => {
void handleRequest(req, res);
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
resetState();
});
it('does not expose dimension tools or dimension fields in the MCP registry', async () => {
await withMcpClient(async (client) => {
const result = await client.listTools();
const toolNames = result.tools.map((tool) => tool.name);
expect(toolNames).not.toEqual(expect.arrayContaining([
'rah_query_dimensions',
'rah_create_dimension',
'rah_update_dimension',
'rah_delete_dimension',
'rah_get_dimension',
]));
const addNodeTool = result.tools.find((tool) => tool.name === 'rah_add_node');
expect(addNodeTool).toBeDefined();
expect(addNodeTool?.inputSchema).toBeTruthy();
expect(JSON.stringify(addNodeTool?.inputSchema)).not.toContain('dimensions');
});
});
it('creates nodes through MCP without context and without legacy taxonomy payloads', async () => {
await withMcpClient(async (client) => {
const result = await client.callTool({
name: 'rah_add_node',
arguments: {
title: 'MCP Contract Test Node',
description: 'Node created through MCP to verify the post-dimensions write contract.',
source: 'Concrete source text proving rah_add_node works without dimensions or automatic context assignment.',
metadata: { source: 'mcp-contract-test' },
},
});
const structured = getStructured<{ nodeId: number; title: string }>(result);
expect(structured.nodeId).toBeGreaterThan(0);
expect(structured.title).toBe('MCP Contract Test Node');
const createRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/nodes');
expect(createRequest?.body).toMatchObject({
title: 'MCP Contract Test Node',
description: 'Node created through MCP to verify the post-dimensions write contract.',
source: 'Concrete source text proving rah_add_node works without dimensions or automatic context assignment.',
metadata: { source: 'mcp-contract-test' },
});
expect(createRequest?.body).not.toHaveProperty('dimensions');
expect(createRequest?.body).not.toHaveProperty('context_id');
expect(nodes[0]?.context_id).toBeNull();
const searchResult = await client.callTool({
name: 'rah_search_nodes',
arguments: {
query: 'contract test node',
limit: 5,
},
});
const searchStructured = getStructured<{ count: number; nodes: Array<Record<string, unknown>> }>(searchResult);
expect(searchStructured.count).toBe(1);
expect(searchStructured.nodes[0]).toMatchObject({
id: structured.nodeId,
title: 'MCP Contract Test Node',
});
expect(searchStructured.nodes[0]).not.toHaveProperty('dimensions');
});
});
it('surfaces invalid explicit contexts instead of inferring a fallback context', async () => {
await withMcpClient(async (client) => {
const result = await client.callTool({
name: 'rah_add_node',
arguments: {
title: 'Bad Context Node',
description: 'This should fail because the context does not exist.',
source: 'Source text for invalid context test.',
context_id: 999,
},
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('Context not found');
expect(nodes).toHaveLength(0);
});
});
it('returns soft-context orientation data without dimension state', async () => {
nodes.push({
id: nextNodeId++,
title: 'Work Hub Node',
description: 'High-signal work hub used to verify MCP context orientation.',
source: 'Hub node source',
link: null,
context_id: 1,
metadata: {},
updated_at: nowIso(),
created_at: nowIso(),
edge_count: 7,
});
await withMcpClient(async (client) => {
const contextsResult = await client.callTool({
name: 'rah_query_contexts',
arguments: {
contextId: 1,
includeNodes: true,
},
});
const contextsStructured = getStructured<{
count: number;
contexts: Array<{ id: number; name: string; nodes?: Array<Record<string, unknown>> }>;
}>(contextsResult);
expect(contextsStructured.count).toBe(1);
expect(contextsStructured.contexts[0]).toMatchObject({
id: 1,
name: 'Work',
});
expect(contextsStructured.contexts[0].nodes?.[0]).toMatchObject({
title: 'Work Hub Node',
context_id: 1,
});
expect(contextsStructured.contexts[0].nodes?.[0]).not.toHaveProperty('dimensions');
const graphContextResult = await client.callTool({
name: 'rah_get_context',
arguments: {},
});
const graphStructured = getStructured<{
stats: { contextCount: number };
contexts: Array<{ id: number; name: string }>;
hubNodes: Array<{ title: string }>;
guides: string[];
}>(graphContextResult);
expect(graphStructured.stats.contextCount).toBe(contexts.length);
expect(graphStructured.contexts).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 1, name: 'Work' }),
expect.objectContaining({ id: 2, name: 'Personal' }),
])
);
expect(graphStructured.hubNodes).toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })])
);
expect(graphStructured.guides).toEqual(expect.arrayContaining(['schema', 'creating-nodes']));
expect(JSON.stringify(graphStructured)).not.toContain('dimensions');
});
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/services/database/nodes', () => ({
nodeService: {
getNodeById: vi.fn(),
},
}));
vi.mock('@/services/database/edges', () => ({
edgeService: {
edgeExists: vi.fn(),
createEdge: vi.fn(),
},
}));
import { nodeService } from '@/services/database/nodes';
import { edgeService } from '@/services/database/edges';
import { createEdgeTool } from '@/tools/database/createEdge';
// Helper to call tool execute with proper AI SDK signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const executeCreateEdge = async (params: any) => {
const result = await createEdgeTool.execute!(params, { toolCallId: 'test', messages: [] });
return result as { success: boolean; error?: string; data?: { id: number } };
};
describe('createEdgeTool', () => {
it('rejects invalid from_node_id', async () => {
const result = await executeCreateEdge({
from_node_id: 0,
to_node_id: 2,
source: 'ai',
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/from_node_id/);
});
it('returns error when edge already exists', async () => {
vi.mocked(nodeService.getNodeById).mockResolvedValueOnce({
id: 1, title: 'From', created_at: '', updated_at: ''
});
vi.mocked(nodeService.getNodeById).mockResolvedValueOnce({
id: 2, title: 'To', created_at: '', updated_at: ''
});
vi.mocked(edgeService.edgeExists).mockResolvedValueOnce(true);
const result = await executeCreateEdge({
from_node_id: 1,
to_node_id: 2,
explanation: 'Source node references the target node directly.',
source: 'ai',
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/already exists/i);
});
it('creates edge when valid and not existing', async () => {
vi.mocked(nodeService.getNodeById).mockResolvedValueOnce({
id: 1, title: 'From', created_at: '', updated_at: ''
});
vi.mocked(nodeService.getNodeById).mockResolvedValueOnce({
id: 2, title: 'To', created_at: '', updated_at: ''
});
vi.mocked(edgeService.edgeExists).mockResolvedValueOnce(false);
vi.mocked(edgeService.createEdge).mockResolvedValueOnce({
id: 10,
from_node_id: 1,
to_node_id: 2,
source: 'helper_name',
context: {},
created_at: '',
});
const result = await executeCreateEdge({
from_node_id: 1,
to_node_id: 2,
explanation: 'Episode belongs to the podcast represented by the target node.',
source: 'ai',
});
expect(result.success).toBe(true);
expect(result.data?.id).toBe(10);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createNodeTool } from '@/tools/database/createNode';
const fetchMock = vi.fn();
global.fetch = fetchMock as typeof fetch;
// Helper to call tool execute with proper AI SDK signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const executeCreateNode = async (params: any) => {
const result = await createNodeTool.execute!(params, { toolCallId: 'test', messages: [] });
return result as { success: boolean; error?: string; data?: { formatted_display: string } };
};
describe('createNodeTool', () => {
beforeEach(() => {
fetchMock.mockReset();
});
it('creates nodes without requiring dimensions', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: { id: 1, title: 'Test' },
}),
});
const result = await executeCreateNode({
title: 'Test',
description: 'Note capturing a concrete test artifact and why it matters.',
});
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalled();
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).not.toHaveProperty('dimensions');
expect(result.data?.formatted_display).toContain('[NODE:1');
});
it('returns error when API fails', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Failed' }),
});
const result = await executeCreateNode({
title: 'Bad',
description: 'Note capturing a failing API case and why the error matters.',
});
expect(result.success).toBe(false);
expect(result.error).toContain('Failed');
});
it('accepts explicit test node descriptions', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: { id: 2, title: 'Eval Edge B' },
}),
});
const result = await executeCreateNode({
title: 'Eval Edge B',
description: 'This is a test node used as the target in an evaluation of edge creation. It matters because it verifies relationship storage and retrieval.',
});
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/services/database/edges', () => ({
edgeService: {
getEdgeById: vi.fn(),
getNodeConnections: vi.fn(),
getEdges: vi.fn(),
},
}));
import { edgeService } from '@/services/database/edges';
import { queryEdgeTool } from '@/tools/database/queryEdge';
// Helper to call tool execute with proper AI SDK signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const executeQueryEdge = async (params: any) => {
const result = await queryEdgeTool.execute!(params, { toolCallId: 'test', messages: [] });
return result as { success: boolean; data?: { edges: any[]; connections?: any[] } };
};
describe('queryEdgeTool', () => {
it('returns empty list when no edges for from_node_id', async () => {
vi.mocked(edgeService.getEdges).mockResolvedValueOnce([]);
const result = await executeQueryEdge({ filters: { from_node_id: 1, limit: 10 } });
expect(result.success).toBe(true);
expect(result.data?.edges).toHaveLength(0);
});
it('returns edges when found for from_node_id', async () => {
vi.mocked(edgeService.getEdges).mockResolvedValueOnce([
{ id: 1, from_node_id: 1, to_node_id: 2, source: 'user', created_at: '' },
]);
const result = await executeQueryEdge({ filters: { from_node_id: 1, limit: 10 } });
expect(result.success).toBe(true);
expect(result.data?.edges).toHaveLength(1);
});
it('returns edge by ID when edge_id provided', async () => {
vi.mocked(edgeService.getEdgeById).mockResolvedValueOnce({
id: 9,
from_node_id: 1,
to_node_id: 2,
source: 'user',
created_at: '',
});
const result = await executeQueryEdge({ filters: { edge_id: 9, limit: 10 } });
expect(result.success).toBe(true);
expect(result.data?.edges).toHaveLength(1);
});
it('returns compact connection payloads for node traversal', async () => {
vi.mocked(edgeService.getNodeConnections).mockResolvedValueOnce([
{
edge: {
id: 42,
from_node_id: 1,
to_node_id: 2,
source: 'user',
created_at: '',
context: {
type: 'related_to',
explanation: 'A'.repeat(300),
confidence: 0.8,
},
},
connected_node: {
id: 2,
title: 'Connected node',
description: 'B'.repeat(250),
notes: 'Hidden notes should not be returned',
chunk: 'Hidden chunk should not be returned',
},
},
] as any);
const result = await executeQueryEdge({ filters: { node_id: 1, limit: 20 } });
expect(result.success).toBe(true);
expect(result.data?.edges).toHaveLength(1);
expect(result.data?.connections).toHaveLength(1);
expect(result.data?.connections?.[0].connected_node.notes).toBeUndefined();
expect(result.data?.connections?.[0].connected_node.chunk).toBeUndefined();
expect(result.data?.connections?.[0].connected_node.description.length).toBeLessThanOrEqual(140);
expect(result.data?.edges?.[0].context.explanation.length).toBeLessThanOrEqual(180);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/services/database/nodes', () => ({
nodeService: {
getNodeById: vi.fn(),
getNodes: vi.fn(),
},
}));
import { nodeService } from '@/services/database/nodes';
import { queryNodesTool } from '@/tools/database/queryNodes';
// Helper to call tool execute with proper AI SDK signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const executeQueryNodes = async (params: any) => {
const result = await queryNodesTool.execute!(params, { toolCallId: 'test', messages: [] });
return result as { success: boolean; data?: { nodes: { id: number }[]; count: number } };
};
describe('queryNodesTool', () => {
it('returns a single node when search is numeric and node exists', async () => {
vi.mocked(nodeService.getNodeById).mockResolvedValueOnce({
id: 123,
title: 'Test Node',
created_at: '',
updated_at: '',
});
const result = await executeQueryNodes({
filters: { search: '123', limit: 5 },
});
expect(result.success).toBe(true);
expect(result.data?.nodes).toHaveLength(1);
expect(result.data?.nodes[0].id).toBe(123);
});
it('returns empty when search is numeric and node is missing', async () => {
vi.mocked(nodeService.getNodeById).mockResolvedValueOnce(null);
const result = await executeQueryNodes({
filters: { search: '999', limit: 5 },
});
expect(result.success).toBe(true);
expect(result.data?.nodes).toHaveLength(0);
expect(result.data?.count).toBe(0);
});
it('respects limit and reports total count', async () => {
vi.mocked(nodeService.getNodes).mockResolvedValueOnce([
{ id: 1, title: 'A', created_at: '', updated_at: '' },
{ id: 2, title: 'B', created_at: '', updated_at: '' },
]);
const result = await executeQueryNodes({
filters: { limit: 1 },
});
expect(result.success).toBe(true);
expect(result.data?.nodes).toHaveLength(1);
expect(result.data?.count).toBe(2);
});
});