import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import { makeTmpPath, cleanupTmpPath } from "./fixtures.js"; import { discoverServices, parseMavenArtifactId, resolveSpringAppName, deriveServiceAliases } from "../service_discovery.js"; describe("discoverServices", () => { let root: string; beforeEach(() => { root = makeTmpPath("svc-disco"); }); afterEach(() => cleanupTmpPath(root)); it("finds top-level dirs with a manifest and uses dir name as identity", () => { fs.mkdirSync(path.join(root, "payments-module"), { recursive: true }); fs.writeFileSync(path.join(root, "payments-module", "pom.xml"), ""); fs.mkdirSync(path.join(root, "portal-ui"), { recursive: true }); fs.writeFileSync(path.join(root, "portal-ui", "package.json"), '{"name":"cp"}'); const svcs = discoverServices(root); const ids = svcs.map((s) => s.id).sort(); expect(ids).toEqual(["payments-module", "portal-ui"]); const java = svcs.find((s) => s.id === "payments-module")!; expect(java.language).toBe("java"); expect(java.root).toBe("payments-module"); expect(java.kind).toBe("service"); }); it("treats .gitmodules submodule paths as authoritative service roots", () => { fs.writeFileSync(path.join(root, ".gitmodules"), '[submodule "intake"]\n\tpath = intake\n\turl = https://x/intake\n'); fs.mkdirSync(path.join(root, "intake"), { recursive: true }); fs.writeFileSync(path.join(root, "intake", "pom.xml"), ""); const svcs = discoverServices(root); expect(svcs.map((s) => s.id)).toContain("intake"); expect(svcs.find((s) => s.id === "intake")!.identity_source).toBe("submodule"); }); it("does NOT treat nested maven modules as separate services", () => { fs.mkdirSync(path.join(root, "payments-module", "payments-core"), { recursive: true }); fs.writeFileSync(path.join(root, "payments-module", "pom.xml"), ""); fs.writeFileSync(path.join(root, "payments-module", "payments-core", "pom.xml"), ""); const svcs = discoverServices(root); expect(svcs.map((s) => s.id)).toEqual(["payments-module"]); }); it("classifies a shared lib by groupId and shared/ location", () => { fs.mkdirSync(path.join(root, "shared", "common-model"), { recursive: true }); fs.writeFileSync( path.join(root, "shared", "common-model", "pom.xml"), "com.example.app.sharedcommon-model", ); const svcs = discoverServices(root); expect(svcs.find((s) => s.id === "common-model")!.kind).toBe("library"); }); it("discovers shared/ aggregator sub-modules as individual libraries, not one 'shared' service", () => { // shared/ has a pom.xml with packaging=pom + — a Maven aggregator. // discoverServices must NOT register "shared" as a service; instead it must // discover each child module as its own library. fs.mkdirSync(path.join(root, "shared"), { recursive: true }); fs.writeFileSync( path.join(root, "shared", "pom.xml"), "com.x.sharedshared" + "pomcommon-modelcommon-messaging", ); for (const m of ["common-model", "common-messaging"]) { fs.mkdirSync(path.join(root, "shared", m), { recursive: true }); fs.writeFileSync( path.join(root, "shared", m, "pom.xml"), `com.x.shared${m}`, ); } const svcs = discoverServices(root); const ids = svcs.map((s) => s.id).sort(); expect(ids).toContain("common-model"); expect(ids).toContain("common-messaging"); expect(ids).not.toContain("shared"); // aggregator itself not a service expect(svcs.find((s) => s.id === "common-model")!.kind).toBe("library"); }); it("discovers a service whose pom is in a build subdir (feed-processor/project/pom.xml)", () => { const root2 = makeTmpPath("nested-pom"); try { fs.mkdirSync(path.join(root2, "feed-processor", "project", "src"), { recursive: true }); fs.writeFileSync(path.join(root2, "feed-processor", "project", "pom.xml"), "feed-processor"); // feed-processor/ itself has NO pom const svcs = discoverServices(root2); const feed = svcs.find((s) => s.id === "feed-processor"); expect(feed).toBeTruthy(); expect(feed!.root).toBe("feed-processor"); // service rooted at the parent (so its subtree is scanned) expect(feed!.language).toBe("java"); // a normal service with its OWN pom is unaffected: fs.mkdirSync(path.join(root2, "billing"), { recursive: true }); fs.writeFileSync(path.join(root2, "billing", "pom.xml"), "billing"); expect(discoverServices(root2).find((s) => s.id === "billing")!.root).toBe("billing"); } finally { cleanupTmpPath(root2); } }); it("keeps a normal multi-module service (payments-module/payments-api) as ONE service", () => { // payments-module has packaging=pom + but is NOT a shared-lib container by name, // so it should be registered as a single service, NOT recursed into. fs.mkdirSync(path.join(root, "payments-module", "payments-api"), { recursive: true }); fs.writeFileSync( path.join(root, "payments-module", "pom.xml"), "payments-modulepom" + "payments-api", ); fs.writeFileSync( path.join(root, "payments-module", "payments-api", "pom.xml"), "payments-api", ); const svcs = discoverServices(root); expect(svcs.map((s) => s.id)).toEqual(["payments-module"]); // ONE service, not payments-api }); // ── Finding 1: library classifier must agree with all shared-lib container names ── it("F1-libs: child of libs/ aggregator with non-.shared groupId → kind library (PR #58 review)", () => { // libs/ is a shared-lib container by name. Its pom.xml is a Maven aggregator. // util-lib's groupId is com.x (NOT ending in .shared) and relRoot is libs/util-lib // (NOT starting with shared/) — the old classifier wrongly left it as "service". fs.mkdirSync(path.join(root, "libs"), { recursive: true }); fs.writeFileSync( path.join(root, "libs", "pom.xml"), "com.xlibs" + "pomutil-lib", ); fs.mkdirSync(path.join(root, "libs", "util-lib"), { recursive: true }); fs.writeFileSync( path.join(root, "libs", "util-lib", "pom.xml"), "com.xutil-lib", ); const svcs = discoverServices(root); const lib = svcs.find((s) => s.id === "util-lib"); expect(lib).toBeDefined(); expect(lib!.kind).toBe("library"); // must be library, not service expect(svcs.map((s) => s.id)).not.toContain("libs"); // aggregator itself not registered }); it("F1-libraries: child of libraries/ aggregator with non-.shared groupId → kind library", () => { fs.mkdirSync(path.join(root, "libraries"), { recursive: true }); fs.writeFileSync( path.join(root, "libraries", "pom.xml"), "com.examplelibraries" + "pomcommon-utils", ); fs.mkdirSync(path.join(root, "libraries", "common-utils"), { recursive: true }); fs.writeFileSync( path.join(root, "libraries", "common-utils", "pom.xml"), "com.examplecommon-utils", ); const svcs = discoverServices(root); const lib = svcs.find((s) => s.id === "common-utils"); expect(lib).toBeDefined(); expect(lib!.kind).toBe("library"); }); it("F1-star-shared: child of *-shared aggregator with non-.shared groupId → kind library", () => { fs.mkdirSync(path.join(root, "my-shared"), { recursive: true }); fs.writeFileSync( path.join(root, "my-shared", "pom.xml"), "com.acmemy-shared" + "pomacme-model", ); fs.mkdirSync(path.join(root, "my-shared", "acme-model"), { recursive: true }); fs.writeFileSync( path.join(root, "my-shared", "acme-model", "pom.xml"), "com.acmeacme-model", ); const svcs = discoverServices(root); const lib = svcs.find((s) => s.id === "acme-model"); expect(lib).toBeDefined(); expect(lib!.kind).toBe("library"); }); it("F1-no-manifest-libs: child of libs/ (no container pom) with non-.shared groupId → kind library", () => { // libs/ has NO manifest itself (pure directory). Its child util-lib has a pom. // The recursion path is the "isSharedLibContainer without manifest" branch. fs.mkdirSync(path.join(root, "libs", "util-lib"), { recursive: true }); fs.writeFileSync( path.join(root, "libs", "util-lib", "pom.xml"), "com.xutil-lib", ); const svcs = discoverServices(root); const lib = svcs.find((s) => s.id === "util-lib"); expect(lib).toBeDefined(); expect(lib!.kind).toBe("library"); }); }); it("reads the project artifactId, skipping the block", () => { const pom = ` spring-boot-starter-parent com.example.app task-orchestrator 1.0.0 `; expect(parseMavenArtifactId(pom)).toBe("task-orchestrator"); }); it("derives ${x-service.url} and /api/{x}/ aliases for a service", () => { const aliases = deriveServiceAliases("payments-module"); expect(aliases).toContain("payments-service.url"); expect(aliases).toContain("payments"); }); // ── B7b: frontend-kind classification ──────────────────────────────────── describe("discoverServices frontend-kind (B7b)", () => { let root: string; beforeEach(() => { root = makeTmpPath("svc-frontend"); }); afterEach(() => cleanupTmpPath(root)); it("classifies a Vue app as frontend", () => { fs.mkdirSync(path.join(root, "ui"), { recursive: true }); fs.writeFileSync( path.join(root, "ui", "package.json"), JSON.stringify({ name: "ui", dependencies: { vue: "^3.0.0" } }), ); const svcs = discoverServices(root); const ui = svcs.find((s) => s.id === "ui"); expect(ui).toBeDefined(); expect(ui!.kind).toBe("frontend"); }); it("classifies a React app as frontend", () => { fs.mkdirSync(path.join(root, "web"), { recursive: true }); fs.writeFileSync( path.join(root, "web", "package.json"), JSON.stringify({ name: "web", dependencies: { react: "^18.0.0", "react-dom": "^18.0.0" } }), ); const svcs = discoverServices(root); const web = svcs.find((s) => s.id === "web"); expect(web).toBeDefined(); expect(web!.kind).toBe("frontend"); }); it("leaves an Express backend as service (not frontend)", () => { fs.mkdirSync(path.join(root, "api"), { recursive: true }); fs.writeFileSync( path.join(root, "api", "package.json"), JSON.stringify({ name: "api", dependencies: { express: "^4.0.0" } }), ); const svcs = discoverServices(root); const api = svcs.find((s) => s.id === "api"); expect(api).toBeDefined(); expect(api!.kind).toBe("service"); }); it("frontend does NOT override library kind (library wins)", () => { // A Maven lib that also happens to list vue in package.json at shared/ fs.mkdirSync(path.join(root, "shared", "common-model"), { recursive: true }); fs.writeFileSync( path.join(root, "shared", "common-model", "pom.xml"), "com.example.app.sharedcommon-model", ); // pom.xml wins the manifest detection; no package.json here so library takes precedence const svcs = discoverServices(root); const lib = svcs.find((s) => s.id === "common-model"); expect(lib).toBeDefined(); expect(lib!.kind).toBe("library"); }); it("classifies a Next.js app as frontend (devDependencies)", () => { fs.mkdirSync(path.join(root, "nextapp"), { recursive: true }); fs.writeFileSync( path.join(root, "nextapp", "package.json"), JSON.stringify({ name: "nextapp", devDependencies: { next: "^14.0.0" } }), ); const svcs = discoverServices(root); const nextapp = svcs.find((s) => s.id === "nextapp"); expect(nextapp).toBeDefined(); expect(nextapp!.kind).toBe("frontend"); }); });