Files
suaveplan/packages/api-stack/api/api-lifecycle/src/reinstall-criteria/reinstall-criteria.test.ts
2026-05-27 23:29:53 +01:00

195 lines
7.4 KiB
TypeScript

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> = {}): 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);
});
});
});