312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import fs from "fs";
|
||
import os from "os";
|
||
import path from "path";
|
||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
|
||
const MCP_FLAG = "FEATURE_ASSISTANT_MCP_RUNTIME_V1";
|
||
const MCP_PROXY = "ASSISTANT_MCP_PROXY_URL";
|
||
const MCP_CHANNEL = "ASSISTANT_MCP_CHANNEL";
|
||
const ORIGINAL_ENV = {
|
||
[MCP_FLAG]: process.env[MCP_FLAG],
|
||
[MCP_PROXY]: process.env[MCP_PROXY],
|
||
[MCP_CHANNEL]: process.env[MCP_CHANNEL]
|
||
};
|
||
const TEMP_DIRS: string[] = [];
|
||
|
||
function restoreEnv(): void {
|
||
for (const key of [MCP_FLAG, MCP_PROXY, MCP_CHANNEL] as const) {
|
||
const original = ORIGINAL_ENV[key];
|
||
if (original === undefined) {
|
||
delete process.env[key];
|
||
} else {
|
||
process.env[key] = original;
|
||
}
|
||
}
|
||
}
|
||
|
||
function cleanupTempDirs(): void {
|
||
for (const dir of TEMP_DIRS.splice(0)) {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function createSnapshotRoot(): string {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "assistant-mcp-bridge-"));
|
||
TEMP_DIRS.push(root);
|
||
return root;
|
||
}
|
||
|
||
describe.sequential("assistant MCP runtime bridge", () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
restoreEnv();
|
||
cleanupTempDirs();
|
||
vi.resetModules();
|
||
});
|
||
|
||
it("does not call MCP when runtime flag is disabled", async () => {
|
||
process.env[MCP_FLAG] = "0";
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Почему по счету 60.01 долг остался?");
|
||
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
expect(result.summary.live_mcp).toBeUndefined();
|
||
});
|
||
|
||
it("uses MCP live probe for hybrid route when runtime flag is enabled", async () => {
|
||
process.env[MCP_FLAG] = "1";
|
||
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
|
||
process.env[MCP_CHANNEL] = "default";
|
||
|
||
const payload = JSON.stringify({
|
||
success: true,
|
||
data: [
|
||
{
|
||
Период: "2026-03-01T00:00:00",
|
||
Регистратор: "Списание с расчетного счета 0001",
|
||
СчетДт: "60.01",
|
||
СчетКт: "51",
|
||
Сумма: 15000
|
||
},
|
||
{
|
||
Период: "2026-03-02T00:00:00",
|
||
Регистратор: "Операция бухгалтерская 0002",
|
||
СчетДт: "91.02",
|
||
СчетКт: "51",
|
||
Сумма: 900
|
||
}
|
||
]
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Проверь 60.01 и 60.02: оплата есть, долг остался");
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(result.status).toBe("ok");
|
||
expect(result.items.length).toBeGreaterThan(0);
|
||
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const liveSummary = summary.live_mcp as Record<string, unknown>;
|
||
expect(liveSummary.status).toBe("ok");
|
||
expect(liveSummary.channel).toBe("default");
|
||
|
||
const firstItem = result.items[0] as Record<string, unknown>;
|
||
expect(firstItem.source_layer).toBe("mcp_live_probe");
|
||
});
|
||
|
||
it("keeps snapshot fallback when MCP responds with error", async () => {
|
||
process.env[MCP_FLAG] = "1";
|
||
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
|
||
process.env[MCP_CHANNEL] = "default";
|
||
|
||
const payload = JSON.stringify({
|
||
success: false,
|
||
data: null,
|
||
error: "channel_not_connected"
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Проверь 60.01 остаток");
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const liveSummary = summary.live_mcp as Record<string, unknown>;
|
||
expect(liveSummary.status).toBe("error");
|
||
expect(result.limitations.some((item) => /live/i.test(item))).toBe(true);
|
||
});
|
||
|
||
it("does not inject out-of-scope rows when account filter matched zero", async () => {
|
||
process.env[MCP_FLAG] = "1";
|
||
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
|
||
process.env[MCP_CHANNEL] = "default";
|
||
|
||
const payload = JSON.stringify({
|
||
success: true,
|
||
data: [
|
||
{
|
||
Период: "2026-03-01T00:00:00",
|
||
Регистратор: "Операция бухгалтерская 0002",
|
||
СчетДт: "91.02",
|
||
СчетКт: "51",
|
||
Сумма: 900
|
||
}
|
||
]
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Check account 60 only");
|
||
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const liveSummary = summary.live_mcp as Record<string, unknown>;
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(Array.isArray(liveSummary.account_scope)).toBe(true);
|
||
expect((liveSummary.account_scope as string[]).some((item) => String(item).includes("60"))).toBe(true);
|
||
expect(liveSummary.matched_rows).toBe(0);
|
||
expect(liveSummary.returned_rows).toBe(0);
|
||
expect(result.items).toHaveLength(0);
|
||
});
|
||
|
||
it("uses claim-bound live call sequence for RBP lifecycle query", async () => {
|
||
process.env[MCP_FLAG] = "1";
|
||
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
|
||
process.env[MCP_CHANNEL] = "default";
|
||
|
||
const payload = JSON.stringify({
|
||
success: true,
|
||
data: [
|
||
{
|
||
Период: "2020-07-31T00:00:00",
|
||
Регистратор: "Списание РБП за Июль 2020",
|
||
СчетДт: "20.01",
|
||
СчетКт: "97.01",
|
||
Сумма: 5000
|
||
},
|
||
{
|
||
Период: "2020-07-31T00:00:00",
|
||
Регистратор: "Закрытие месяца Июль 2020",
|
||
СчетДт: "97.01",
|
||
СчетКт: "20.01",
|
||
Сумма: 5000
|
||
}
|
||
]
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime(
|
||
"hybrid_store_plus_live",
|
||
"31 июля прошло Списание РБП за июль 2020 на 5000. Есть ли хвост РБП?"
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const liveSummary = summary.live_mcp as Record<string, unknown>;
|
||
expect(liveSummary.claim_type).toBe("prove_rbp_tail_state");
|
||
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
|
||
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
|
||
expect(Array.isArray(liveSummary.executed_live_calls)).toBe(true);
|
||
expect((liveSummary.executed_live_calls as unknown[]).length).toBe(4);
|
||
const rbpCallLimits = fetchMock.mock.calls.map(([, requestInit]) => {
|
||
const init = requestInit as { body?: string };
|
||
return Number(JSON.parse(String(init.body ?? "{}")).limit ?? 0);
|
||
});
|
||
expect(rbpCallLimits).toEqual([96, 96, 96, 128]);
|
||
expect(liveSummary.matched_rows).toBeGreaterThan(0);
|
||
expect(result.items.some((item) => Array.isArray((item as Record<string, unknown>).relation_pattern_hits))).toBe(true);
|
||
});
|
||
|
||
it("uses claim-bound live call sequence for fixed-asset amortization coverage query", async () => {
|
||
process.env[MCP_FLAG] = "1";
|
||
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
|
||
process.env[MCP_CHANNEL] = "default";
|
||
|
||
const payload = JSON.stringify({
|
||
success: true,
|
||
data: [
|
||
{
|
||
period: "2020-07-31T00:00:00",
|
||
registrator: "Начисление амортизации Июль 2020",
|
||
account_dt: "20.01",
|
||
account_kt: "02.01",
|
||
amount: 2471.52
|
||
},
|
||
{
|
||
period: "2020-07-31T00:00:00",
|
||
registrator: "Начисление амортизации Июль 2020",
|
||
account_dt: "20.01",
|
||
account_kt: "02.01",
|
||
amount: 2465.28
|
||
}
|
||
]
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime(
|
||
"hybrid_store_plus_live",
|
||
"31 июля начислена амортизация тремя суммами — 2 471,52, 2 465,28 и 849,83. Есть риск, что объект ОС не попал в амортизацию?"
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const liveSummary = summary.live_mcp as Record<string, unknown>;
|
||
expect(liveSummary.claim_type).toBe("prove_fixed_asset_amortization_coverage");
|
||
expect(liveSummary.source_profile).toBe("claim_bound_fa_live_path");
|
||
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
|
||
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
|
||
expect(Array.isArray(liveSummary.executed_live_calls)).toBe(true);
|
||
expect((liveSummary.executed_live_calls as unknown[]).length).toBe(4);
|
||
const faCallLimits = fetchMock.mock.calls.map(([, requestInit]) => {
|
||
const init = requestInit as { body?: string };
|
||
return Number(JSON.parse(String(init.body ?? "{}")).limit ?? 0);
|
||
});
|
||
expect(faCallLimits).toEqual([96, 96, 128, 128]);
|
||
expect(liveSummary.matched_rows).toBeGreaterThan(0);
|
||
expect(result.items.some((item) => (item as Record<string, unknown>).fa_expected_set_candidate === true)).toBe(true);
|
||
expect(result.items.some((item) => (item as Record<string, unknown>).fa_actual_set_candidate === true)).toBe(true);
|
||
});
|
||
|
||
it("uses claim-bound VAT live path instead of supplier-tail generic probe for VAT chain query", async () => {
|
||
process.env[MCP_FLAG] = "1";
|
||
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
|
||
process.env[MCP_CHANNEL] = "default";
|
||
|
||
const payload = JSON.stringify({
|
||
success: true,
|
||
data: [
|
||
{
|
||
period: "2020-07-15T00:00:00",
|
||
registrator: "Реализация товаров 0001",
|
||
account_dt: "62.01",
|
||
account_kt: "90.01",
|
||
amount: 1400
|
||
},
|
||
{
|
||
period: "2020-07-15T00:00:00",
|
||
registrator: "Счет-фактура выданный 0001",
|
||
account_dt: "90.03",
|
||
account_kt: "68.02",
|
||
amount: 233.33
|
||
}
|
||
]
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
|
||
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
|
||
const result = await dataLayer.executeRouteRuntime(
|
||
"hybrid_store_plus_live",
|
||
"По поставщику и счету-фактуре проверь НДС-цепочку: есть ли выпадение между документом, регистром и книгой покупок?"
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const liveSummary = summary.live_mcp as Record<string, unknown>;
|
||
expect(liveSummary.claim_type).toBe("prove_vat_chain_completeness");
|
||
expect(liveSummary.query_subject).toBe("vat_chain_conflict");
|
||
expect(liveSummary.source_profile).toBe("claim_bound_vat_live_path");
|
||
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
|
||
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
|
||
expect(Array.isArray(liveSummary.account_scope)).toBe(true);
|
||
expect(liveSummary.account_scope).toEqual(["19", "68"]);
|
||
});
|
||
});
|