// Mock all heavy dependencies so the module can be loaded in isolation
jest.mock("@skyramp/skyramp", () => ({}));
jest.mock("simple-git", () => ({ simpleGit: jest.fn() }));
jest.mock("../../services/AnalyticsService.js", () => ({
    AnalyticsService: { pushMCPToolEvent: jest.fn() },
}));
jest.mock("../../prompts/test-recommendation/test-recommendation-prompt.js", () => ({
    buildRecommendationPrompt: jest.fn(),
}));
jest.mock("../../prompts/test-recommendation/recommendationSections.js", () => ({
    MAX_RECOMMENDATIONS: 10,
    MAX_TESTS_TO_GENERATE: 3,
}));
jest.mock("../../prompts/test-recommendation/analysisOutputPrompt.js", () => ({
    buildAnalysisOutputText: jest.fn(),
}));
jest.mock("../../services/TestDiscoveryService.js", () => ({
    TestDiscoveryService: jest.fn(),
}));
jest.mock("../../utils/branchDiff.js", () => ({
    computeBranchDiff: jest.fn(),
}));
jest.mock("../../utils/routeParsers.js", () => ({
    classifyEndpointsByChangedFiles: jest.fn(),
    extractResourceFromPath: jest.fn(),
    parseFileEndpoints: jest.fn(),
}));
jest.mock("../../utils/repoScanner.js", () => ({
    scanAllRepoEndpoints: jest.fn(),
    scanRelatedEndpoints: jest.fn(),
    grepRouterMountingContext: jest.fn(),
    findCandidateRouteFiles: jest.fn(),
}));
jest.mock("../../utils/projectMetadata.js", () => ({
    detectProjectMetadata: jest.fn(),
}));
jest.mock("../../utils/scenarioDrafting.js", () => ({
    draftScenariosFromEndpoints: jest.fn(),
}));
jest.mock("../../utils/trace-parser.js", () => ({
    parseTraceFile: jest.fn(),
    discoverTraceFiles: jest.fn(),
    discoverPlaywrightZips: jest.fn(),
}));
jest.mock("../../utils/pr-comment-parser.js", () => ({
    parsePRComments: jest.fn(),
}));
jest.mock("../../utils/AnalysisStateManager.js", () => ({
    StateManager: jest.fn().mockImplementation(() => ({
        writeData: jest.fn(),
    })),
    registerSession: jest.fn(),
    storeSessionData: jest.fn(),
}));
jest.mock("../../utils/workspaceAuth.js", () => ({
    parseWorkspaceAuthType: jest.fn(),
}));
jest.mock("../../utils/logger.js", () => ({
    logger: { info: jest.fn(), debug: jest.fn(), error: jest.fn(), warn: jest.fn(), warning: jest.fn() },
}));
jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
    McpServer: jest.fn(),
}));
jest.mock("@modelcontextprotocol/sdk/types.js", () => ({}));
jest.mock("@modelcontextprotocol/sdk/shared/protocol.js", () => ({}));
import { z } from "zod";
import { analyzeChangesInputSchema, isSecurityRelevantDiff } from "./analyzeChangesTool.js";
const schema = z.object(analyzeChangesInputSchema);
describe("isSecurityRelevantDiff", () => {
    it("matches auth-specific middleware and admin-key signals", () => {
        expect(isSecurityRelevantDiff("const authMiddleware = requireAuth();")).toBe(true);
        expect(isSecurityRelevantDiff("require_admin_key(request)")).toBe(true);
        expect(isSecurityRelevantDiff("headers['x-admin-key']")).toBe(true);
        expect(isSecurityRelevantDiff("require-role for destructive delete")).toBe(true);
    });
    it("does not match generic middleware-only refactors", () => {
        expect(isSecurityRelevantDiff("refactor logging middleware order")).toBe(false);
        expect(isSecurityRelevantDiff("move compression middleware to server setup")).toBe(false);
    });
});
describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
    it("accepts a valid absolute path", () => {
        const result = schema.safeParse({
            repositoryPath: "/repo",
            stateOutputFile: "/tmp/analyze-changes-state.json",
        });
        expect(result.success).toBe(true);
    });
    it("rejects a relative path for stateOutputFile", () => {
        // stateOutputFile must be absolute so the caller can guarantee the file location.
        // Relative paths are silently ambiguous and should be rejected.
        const result = schema.safeParse({
            repositoryPath: "/repo",
            stateOutputFile: "relative/path/state.json",
        });
        expect(result.success).toBe(false);
    });
    it("accepts absence of stateOutputFile (optional field)", () => {
        const result = schema.safeParse({ repositoryPath: "/repo" });
        expect(result.success).toBe(true);
    });
});
describe("automatic old files cleanup", () => {
    let StateManager;
    let cleanupOldFilesSpy;
    beforeEach(() => {
        jest.clearAllMocks();
        StateManager = require("../../utils/AnalysisStateManager.js").StateManager;
        cleanupOldFilesSpy = jest.fn().mockResolvedValue(0);
        StateManager.cleanupOldFiles = cleanupOldFilesSpy;
    });
    it("calls cleanupOldFiles with default temp dir when no stateOutputFile provided", async () => {
        // This test verifies the cleanup call is made without stateDir when using default location
        expect(StateManager.cleanupOldFiles).toBeDefined();
        await StateManager.cleanupOldFiles(24, undefined);
        expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined);
    });
    it("calls cleanupOldFiles with custom dir when stateOutputFile is provided", async () => {
        // This test verifies the cleanup call is made with the directory of stateOutputFile
        const customPath = "/custom/dir";
        await StateManager.cleanupOldFiles(24, customPath);
        expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, customPath);
    });
    it("calls cleanupOldFiles with empty stateTypes to restrict to diff files only", async () => {
        // analyzeTestHealthTool passes [] so state files (skyramp-analysis-*, skyramp-recommendation-*)
        // are never deleted — the caller still needs args.stateFile for skyramp_actions.
        await StateManager.cleanupOldFiles(24, undefined, []);
        expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined, []);
    });
    it("continues execution if cleanup fails", async () => {
        // Cleanup failures should not crash the analyze flow
        cleanupOldFilesSpy.mockRejectedValue(new Error("permission denied"));
        await expect(StateManager.cleanupOldFiles(24).catch(() => {
            // In the real code, this is caught and logged as a warning
            return Promise.resolve();
        })).resolves.toBeUndefined();
    });
});
// ─────────────────────────────────────────────────────────────────────────────
// filterEndpointsBySpec — spec filtering + merge logic (Step 4c / 4c-merge)
// ─────────────────────────────────────────────────────────────────────────────
import { filterEndpointsBySpec } from "./analyzeChangesTool.js";
describe("filterEndpointsBySpec", () => {
    const makeEp = (path, methods = ["GET"]) => ({ path, methods, sourceFile: "" });
    it("removes scanned endpoints that are not in the spec and not diff-changed", () => {
        const scanned = [makeEp("/api/v1/users"), makeEp("/api/v1/phantom")];
        const specPaths = new Set(["/api/v1/users"]);
        const specItems = { "/api/v1/users": { get: {} } };
        const diffChanged = new Set();
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        expect(result.map(e => e.path)).toContain("/api/v1/users");
        expect(result.map(e => e.path)).not.toContain("/api/v1/phantom");
    });
    it("returns the endpoint list unchanged when specPaths is empty (early-return guard)", () => {
        const scanned = [makeEp("/api/v1/new-feature")];
        const specPaths = new Set(); // spec lags — new endpoint not there yet
        const specItems = {};
        const diffChanged = new Set(["/api/v1/new-feature"]);
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        // specPaths is empty → early-return guard triggers → input returned as-is without filtering
        expect(result.map(e => e.path)).toContain("/api/v1/new-feature");
    });
    it("preserves a diff-changed endpoint that is absent from a non-empty spec (spec-lag path)", () => {
        // This is the real spec-lag scenario: spec exists (non-empty) but the new endpoint
        // added in this PR hasn't been documented yet. filterEndpointsBySpec must keep it.
        const scanned = [makeEp("/api/v1/new-feature")];
        const specPaths = new Set(["/api/v1/users"]); // spec has other paths but not new-feature
        const specItems = { "/api/v1/users": { get: {} } };
        const diffChanged = new Set(["/api/v1/new-feature"]);
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        expect(result.map(e => e.path)).toContain("/api/v1/new-feature");
    });
    it("preserves spec-lagging diff-changed path when spec has other paths", () => {
        const scanned = [makeEp("/api/v1/users"), makeEp("/api/v1/new-feature")];
        const specPaths = new Set(["/api/v1/users"]); // spec has users but not new-feature
        const specItems = { "/api/v1/users": { get: {} } };
        const diffChanged = new Set(["/api/v1/new-feature"]);
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        expect(result.map(e => e.path)).toContain("/api/v1/users");
        expect(result.map(e => e.path)).toContain("/api/v1/new-feature");
    });
    it("normalises Express :param paths to {param} when matching OpenAPI spec paths", () => {
        const scanned = [makeEp("/api/v1/users/:id")];
        const specPaths = new Set(["/api/v1/users/{id}"]);
        const specItems = { "/api/v1/users/{id}": { get: {} } };
        const diffChanged = new Set();
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        const paths = result.map(e => e.path);
        // Express-style path is kept because it normalises to the spec path
        expect(paths).toContain("/api/v1/users/:id");
        // The spec form must NOT be added as a duplicate — would produce two entries for the same route
        expect(paths).not.toContain("/api/v1/users/{id}");
        expect(paths).toHaveLength(1);
    });
    it("merges spec-only paths that the static scan missed", () => {
        const scanned = [makeEp("/api/v1/users")];
        const specPaths = new Set(["/api/v1/users", "/api/v1/products"]);
        const specItems = {
            "/api/v1/users": { get: {} },
            "/api/v1/products": { get: {}, post: {} },
        };
        const diffChanged = new Set();
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        const paths = result.map(e => e.path);
        expect(paths).toContain("/api/v1/users");
        expect(paths).toContain("/api/v1/products");
        const products = result.find(e => e.path === "/api/v1/products");
        expect(products?.methods).toEqual(expect.arrayContaining(["GET", "POST"]));
    });
    it("does not add spec-only paths that have no HTTP verb operations", () => {
        const scanned = [];
        const specPaths = new Set(["/api/v1/empty-path"]);
        const specItems = { "/api/v1/empty-path": { "x-custom-field": "value" } }; // no HTTP verbs
        const diffChanged = new Set();
        const result = filterEndpointsBySpec(scanned, specPaths, specItems, diffChanged);
        expect(result.map(e => e.path)).not.toContain("/api/v1/empty-path");
    });
    it("returns the original array unchanged when specPaths is empty", () => {
        const scanned = [makeEp("/api/v1/anything")];
        const result = filterEndpointsBySpec(scanned, new Set(), {}, new Set());
        expect(result).toEqual(scanned);
    });
});
// ─────────────────────────────────────────────────────────────────────────────
// isGraphQLFile — unit tests
// ─────────────────────────────────────────────────────────────────────────────
import { filterUnsupportedGraphQLEndpoints, isGraphQLFile } from "./analyzeChangesTool.js";
import * as os from "os";
import * as path from "path";
import * as fsSync from "fs";
describe("isGraphQLFile", () => {
    const tmpDir = os.tmpdir();
    it("returns true for .graphql extension without reading content", async () => {
        const result = await isGraphQLFile("schema.graphql", "/any/repo");
        expect(result).toBe(true);
    });
    it("returns true for .gql extension without reading content", async () => {
        const result = await isGraphQLFile("types.gql", "/any/repo");
        expect(result).toBe(true);
    });
    it("returns true for a top-level graphql directory non-code artifact", async () => {
        const result = await isGraphQLFile("graphql/schema", "/any/repo");
        expect(result).toBe(true);
    });
    it("returns true for a file containing a GraphQL type Query block", async () => {
        const file = path.join(tmpDir, "graphql-test-query.ts");
        fsSync.writeFileSync(file, 'type Query {\n  hello: String\n}\n');
        const result = await isGraphQLFile(path.basename(file), tmpDir);
        expect(result).toBe(true);
    });
    it("returns true for a file containing a GraphQL schema block", async () => {
        const file = path.join(tmpDir, "graphql-test-schema.ts");
        fsSync.writeFileSync(file, 'schema {\n  query: Query\n}\n');
        const result = await isGraphQLFile(path.basename(file), tmpDir);
        expect(result).toBe(true);
    });
    it("returns true for a GraphQL resolver implementation file", async () => {
        const file = path.join(tmpDir, "users.resolver.ts");
        fsSync.writeFileSync(file, 'import { Resolver, Query } from "@nestjs/graphql";\n@Resolver()\nclass UsersResolver { @Query() users() { return []; } }\n');
        const result = await isGraphQLFile(path.basename(file), tmpDir);
        expect(result).toBe(true);
    });
    it("returns false for a Nest REST controller using @Query params", async () => {
        const file = path.join(tmpDir, "users.controller.ts");
        fsSync.writeFileSync(file, 'import { Controller, Get, Query } from "@nestjs/common";\n@Controller("users")\nclass UsersController { @Get() list(@Query("page") page: string) { return page; } }\n');
        const result = await isGraphQLFile(path.basename(file), tmpDir);
        expect(result).toBe(false);
    });
    it("returns false for a regular TypeScript route file", async () => {
        const file = path.join(tmpDir, "graphql-test-route.ts");
        fsSync.writeFileSync(file, 'import express from "express";\nrouter.get("/users", handler);\n');
        const result = await isGraphQLFile(path.basename(file), tmpDir);
        expect(result).toBe(false);
    });
    it("returns false for frontend Apollo gql query templates", async () => {
        const file = path.join(tmpDir, "usersQuery.tsx");
        fsSync.writeFileSync(file, 'import { gql } from "@apollo/client";\nexport const USERS_QUERY = gql`query Users { users { id } }`;\n');
        const result = await isGraphQLFile(path.basename(file), tmpDir);
        expect(result).toBe(false);
    });
    it("returns false (does not throw) when the file does not exist", async () => {
        const result = await isGraphQLFile("nonexistent-file.ts", tmpDir);
        expect(result).toBe(false);
    });
});
describe("filterUnsupportedGraphQLEndpoints", () => {
    it("memoizes GraphQL source-file checks within one filter pass", async () => {
        const endpoints = [
            { path: "/users", methods: ["GET"], sourceFile: "src/routes/users.ts" },
            { path: "/users/{id}", methods: ["GET"], sourceFile: "src/routes/users.ts" },
            { path: "/orders", methods: ["GET"], sourceFile: "src/routes/orders.ts" },
            { path: "/graphql", methods: ["POST"], sourceFile: "src/routes/graphql.ts" },
            { path: "/openapi-only", methods: ["GET"], sourceFile: "" },
        ];
        const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
        const result = await filterUnsupportedGraphQLEndpoints(endpoints, "/repo");
        expect(result.map((endpoint) => endpoint.path)).toEqual(["/users", "/users/{id}", "/orders", "/openapi-only"]);
        expect(readSpy).toHaveBeenCalledTimes(2);
        expect(readSpy.mock.calls.map(([file]) => String(file))).toEqual([
            path.join("/repo", "src/routes/users.ts"),
            path.join("/repo", "src/routes/orders.ts"),
        ]);
        readSpy.mockRestore();
    });
});
// ─────────────────────────────────────────────────────────────────────────────
// analyzeChangesTool handler — GraphQL-only early return (handler-level)
// ─────────────────────────────────────────────────────────────────────────────
import { registerAnalyzeChangesTool, buildTraceFileEntry } from "./analyzeChangesTool.js";
import { computeBranchDiff } from "../../utils/branchDiff.js";
import { classifyEndpointsByChangedFiles } from "../../utils/routeParsers.js";
import { scanRelatedEndpoints } from "../../utils/repoScanner.js";
import * as fsModule from "fs";
/** Register the tool against a minimal mock server and return the captured handler.
 *  The handler takes (params, extra) — extra is pre-filled with a no-op sendNotification. */
function captureAnalyzeHandler() {
    let capturedHandler;
    const mockServer = {
        registerTool: jest.fn((_name, _meta, handler) => {
            capturedHandler = handler;
        }),
    };
    registerAnalyzeChangesTool(mockServer);
    const mockExtra = { sendNotification: jest.fn().mockResolvedValue(undefined), _meta: {} };
    return (params) => capturedHandler(params, mockExtra);
}
describe("analyzeChangesTool handler — GraphQL-only early return", () => {
    const baseParams = {
        repositoryPath: "/fake/repo",
        scope: "branch_diff",
    };
    beforeEach(() => {
        jest.clearAllMocks();
        // Minimal diffData: two .graphql files changed, no endpoints classified
        computeBranchDiff.mockResolvedValue({
            currentBranch: "feature",
            baseBranch: "main",
            changedFiles: ["schema.graphql", "mutations.graphql"],
            deletedFiles: [],
            diffContent: "",
        });
        scanRelatedEndpoints.mockReturnValue([]);
        classifyEndpointsByChangedFiles.mockReturnValue({
            changedEndpoints: [],
            newEndpoints: [],
            removedEndpoints: [],
            unmatchedFiles: [],
            affectedServices: [],
        });
    });
    it("returns the GraphQL-only early-return message when all changed files are .graphql", async () => {
        const handler = captureAnalyzeHandler();
        const result = await handler(baseParams);
        expect(result.isError).toBeFalsy();
        expect(result.content[0].text).toContain("GraphQL-only diff detected");
        expect(result.content[0].text).toContain("schema.graphql");
        expect(result.content[0].text).toContain("REST API testing");
    });
    it("returns the GraphQL-only message when only GraphQL resolver endpoints are scanned", async () => {
        computeBranchDiff.mockResolvedValue({
            currentBranch: "feature",
            baseBranch: "main",
            changedFiles: ["src/graphql/users.resolver.ts"],
            deletedFiles: [],
            diffContent: "",
        });
        scanRelatedEndpoints.mockReturnValue([
            { path: "/users", methods: ["GET"], sourceFile: "src/graphql/users.resolver.ts" },
        ]);
        classifyEndpointsByChangedFiles.mockReturnValue({
            changedEndpoints: [],
            newEndpoints: [],
            removedEndpoints: [],
            unmatchedFiles: [],
            affectedServices: [],
        });
        const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('import { Resolver, Query } from "@nestjs/graphql";\n@Resolver()\nclass UsersResolver { @Query() users() { return []; } }');
        const handler = captureAnalyzeHandler();
        const result = await handler(baseParams);
        expect(classifyEndpointsByChangedFiles).toHaveBeenCalledWith(expect.anything(), [], []);
        expect(result.content[0].text).toContain("GraphQL-only diff detected");
        expect(result.content[0].text).toContain("endpoint implementation");
        readSpy.mockRestore();
    });
    it("does NOT early-return when .graphql files are mixed with REST route files", async () => {
        computeBranchDiff.mockResolvedValue({
            currentBranch: "feature",
            baseBranch: "main",
            changedFiles: ["schema.graphql", "src/routes/users.ts"],
            deletedFiles: [],
            diffContent: "",
        });
        // users.ts is not a GraphQL file — isGraphQLFile will read it and return false
        const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
        const handler = captureAnalyzeHandler();
        const result = await handler(baseParams);
        // Should not early-return with GraphQL message
        expect(result.content[0].text).not.toContain("GraphQL-only diff detected");
        readSpy.mockRestore();
    });
    it("does NOT early-return when scope is full_repo (only fires for PR diffs)", async () => {
        const { scanAllRepoEndpoints } = require("../../utils/repoScanner.js");
        scanAllRepoEndpoints.mockReturnValue([]);
        const handler = captureAnalyzeHandler();
        const result = await handler({ ...baseParams, scope: "full_repo" });
        expect(result.content[0].text).not.toContain("GraphQL-only diff detected");
    });
});
// ─────────────────────────────────────────────────────────────────────────────
// buildTraceFileEntry — traceFiles shape mapping
// ─────────────────────────────────────────────────────────────────────────────
describe("buildTraceFileEntry", () => {
    it("maps TraceParseResult to a TraceFile", () => {
        const parsed = {
            format: "har",
            entries: [],
            userFlows: [
                { flowId: "login-flow", entries: [], durationMs: 0 },
                { flowId: "checkout-flow", entries: [], durationMs: 0 },
            ],
        };
        const entry = buildTraceFileEntry("/repo/trace.json", parsed);
        expect(entry).toEqual({
            path: "/repo/trace.json",
            format: "har",
            analyzed: true,
            userFlows: ["login-flow", "checkout-flow"],
        });
    });
    it("returns an empty userFlows array when the trace has no flows", () => {
        const parsed = {
            format: "har",
            entries: [],
            userFlows: [],
        };
        const entry = buildTraceFileEntry("/empty.json", parsed);
        expect(entry.userFlows).toEqual([]);
    });
});
