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; const liveSummary = summary.live_mcp as Record; expect(liveSummary.status).toBe("ok"); expect(liveSummary.channel).toBe("default"); const firstItem = result.items[0] as Record; 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; const liveSummary = summary.live_mcp as Record; 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; const liveSummary = summary.live_mcp as Record; 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; const liveSummary = summary.live_mcp as Record; 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).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; const liveSummary = summary.live_mcp as Record; 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).fa_expected_set_candidate === true)).toBe(true); expect(result.items.some((item) => (item as Record).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; const liveSummary = summary.live_mcp as Record; 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"]); }); });