import type { PluginManifest } from "@suaveplan/api-types/manifest"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { LifecycleError } from "../error-codes/error-codes.js"; import { compareSemver, requiresReinstall } from "./reinstall-criteria.js"; function manifest(overrides: Partial = {}): PluginManifest { return { name: "billing", version: "0.1.0", apiHost: "1.x", isolation: "trusted", trustLevel: "first-party", ...overrides, }; } describe("reinstall-criteria", () => { describe("compareSemver", () => { it("orders patch versions", { timeout: 5000 }, () => { expect(compareSemver("1.0.0", "1.0.1")).toBe(-1); expect(compareSemver("1.0.1", "1.0.0")).toBe(1); expect(compareSemver("1.0.0", "1.0.0")).toBe(0); }); it("orders minor versions", { timeout: 5000 }, () => { expect(compareSemver("1.1.0", "1.2.0")).toBe(-1); expect(compareSemver("1.2.0", "1.1.0")).toBe(1); }); it("orders major versions", { timeout: 5000 }, () => { expect(compareSemver("1.0.0", "2.0.0")).toBe(-1); }); it("ignores prerelease and build metadata", { timeout: 5000 }, () => { expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBe(0); expect(compareSemver("1.0.0+build.1", "1.0.0")).toBe(0); }); it("handles missing segments as zeros", { timeout: 5000 }, () => { expect(compareSemver("1", "1.0.0")).toBe(0); expect(compareSemver("1.0", "1.0.1")).toBe(-1); // Reverse: b shorter than a — exercises the `bp[i] ?? 0` branch. expect(compareSemver("1.0.0", "1")).toBe(0); expect(compareSemver("1.0.1", "1.0")).toBe(1); }); it("treats non-numeric segments as zero", { timeout: 5000 }, () => { expect(compareSemver("a.b.c", "0.0.0")).toBe(0); }); }); describe("requiresReinstall", () => { it("identical manifest returns false", { timeout: 5000 }, () => { const m = manifest({ scopes: [{ name: "x" }] }); expect(requiresReinstall(m, m)).toBe(false); }); it("schemaVersion bump returns true", { timeout: 5000 }, () => { const prev = manifest({ database: { required: true, schemaVersion: 1 }, }); const next = manifest({ database: { required: true, schemaVersion: 2 }, }); expect(requiresReinstall(prev, next)).toBe(true); }); it("schemaVersion unchanged returns false", { timeout: 5000 }, () => { const prev = manifest({ database: { required: true, schemaVersion: 1 }, }); const next = manifest({ database: { required: true, schemaVersion: 1 }, }); expect(requiresReinstall(prev, next)).toBe(false); }); it("new scope returns true", { timeout: 5000 }, () => { const prev = manifest({ scopes: [{ name: "read" }] }); const next = manifest({ scopes: [{ name: "read" }, { name: "write" }], }); expect(requiresReinstall(prev, next)).toBe(true); }); it("new publish topic returns true", { timeout: 5000 }, () => { const prev = manifest({ events: { publishes: ["a"] } }); const next = manifest({ events: { publishes: ["a", "b"] } }); expect(requiresReinstall(prev, next)).toBe(true); }); it("new subscribe topic returns true", { timeout: 5000 }, () => { const prev = manifest({ events: { subscribes: ["a"] } }); const next = manifest({ events: { subscribes: ["a", "b"] } }); expect(requiresReinstall(prev, next)).toBe(true); }); it("new cron job returns true", { timeout: 5000 }, () => { const prev = manifest({ jobs: { cron: [{ name: "x", expression: "* * * * *" }] }, }); const next = manifest({ jobs: { cron: [ { name: "x", expression: "* * * * *" }, { name: "y", expression: "* * * * *" }, ], }, }); expect(requiresReinstall(prev, next)).toBe(true); }); it("new queued job returns true", { timeout: 5000 }, () => { const prev = manifest({ jobs: { queued: [{ name: "a" }] }, }); const next = manifest({ jobs: { queued: [{ name: "a" }, { name: "b" }] }, }); expect(requiresReinstall(prev, next)).toBe(true); }); it("new secret returns true", { timeout: 5000 }, () => { const prev = manifest({ secrets: [{ name: "STRIPE", required: true }], }); const next = manifest({ secrets: [ { name: "STRIPE", required: true }, { name: "PAYPAL", required: false }, ], }); expect(requiresReinstall(prev, next)).toBe(true); }); it("dependsOn change returns true", { timeout: 5000 }, () => { const prev = manifest({ dependsOn: { core: "1.x" } }); const next = manifest({ dependsOn: { core: "2.x" } }); expect(requiresReinstall(prev, next)).toBe(true); }); it("dependsOn additions return true", { timeout: 5000 }, () => { const prev = manifest({ dependsOn: { core: "1.x" } }); const next = manifest({ dependsOn: { core: "1.x", auth: "1.x" }, }); expect(requiresReinstall(prev, next)).toBe(true); }); it("version downgrade throws", { timeout: 5000 }, () => { const prev = manifest({ version: "1.0.0" }); const next = manifest({ version: "0.9.0" }); try { requiresReinstall(prev, next); expect.fail("expected throw"); } catch (err) { expect(err).toBeInstanceOf(LifecycleError); const e = err as LifecycleError; expect(e.code).toBe( "LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE" ); expect(e.context).toEqual({ prev: "1.0.0", next: "0.9.0" }); } }); it("no schemaVersion in either manifest treats as zero", { timeout: 5000, }, () => { expect(requiresReinstall(manifest(), manifest())).toBe(false); }); it("manifest with empty events block returns false", { timeout: 5000, }, () => { const prev = manifest({ events: { publishes: [], subscribes: [] }, }); const next = manifest({ events: { publishes: [], subscribes: [] }, }); expect(requiresReinstall(prev, next)).toBe(false); }); it("removing an item is NOT a reinstall trigger", { timeout: 5000, }, () => { const prev = manifest({ scopes: [{ name: "read" }, { name: "write" }], }); const next = manifest({ scopes: [{ name: "read" }] }); expect(requiresReinstall(prev, next)).toBe(false); }); }); });