diff --git a/server/src/init/services.ts b/server/src/init/services.ts index 880caf24c..0d54a1eaa 100644 --- a/server/src/init/services.ts +++ b/server/src/init/services.ts @@ -19,7 +19,6 @@ import { AuthService, MonitorService, QueueService, - ServiceRegistry, TeamService, RoleService, TeamMemberService, @@ -109,9 +108,5 @@ export const initServices = async () => { stripeService, }; - Object.values(services).forEach((service) => { - ServiceRegistry.register(service.SERVICE_NAME, service); - }); - return services; }; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 5863ebe72..dea05c770 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -22,7 +22,6 @@ export { default as StatusService } from "@/services/infrastructure/StatusServic export { default as JobQueue } from "@/services/infrastructure/JobQueue.js"; export { default as JobGenerator } from "@/services/infrastructure/JobGenerator.js"; -export { default as ServiceRegistry } from "@/services/system/ServiceRegistry.js"; export { default as SettingsService } from "@/services/system/SettingsService.js"; export { default as BillingService } from "@/services/system/BillingService.js"; export { default as StripeService } from "@/services/system/StripeService.js"; diff --git a/server/src/services/system/ServiceRegistry.ts b/server/src/services/system/ServiceRegistry.ts deleted file mode 100644 index 2b8883b56..000000000 --- a/server/src/services/system/ServiceRegistry.ts +++ /dev/null @@ -1,69 +0,0 @@ -const SERVICE_NAME = "ServiceRegistry"; - -export interface IServiceRegistry { - register: (name: string, service: any) => void; - get: (name: string) => any; - listServices: () => string[]; -} - -class ServiceRegistry implements IServiceRegistry { - public SERVICE_NAME: string; - private static _instance: ServiceRegistry; - - private services: { [key: string]: any }; - - private constructor() { - this.SERVICE_NAME = SERVICE_NAME; - this.services = {}; - } - - static get instance(): ServiceRegistry { - if (!this._instance) { - this._instance = new ServiceRegistry(); - } - return this._instance; - } - - get serviceName() { - return this.SERVICE_NAME; - } - - // Instance methods - register(name: string, service: any) { - this.services[name] = service; - } - - get(name: string) { - if (!this.services[name]) { - throw new Error(`Service ${name} is not registered`); - } - return this.services[name]; - } - - listServices() { - return Object.keys(this.services); - } - - static get(name: string) { - if (!ServiceRegistry.instance) { - throw new Error("ServiceRegistry not initialized"); - } - return ServiceRegistry.instance.get(name); - } - - static register(name: string, service: any) { - if (!ServiceRegistry.instance) { - throw new Error("ServiceRegistry not initialized"); - } - return ServiceRegistry.instance.register(name, service); - } - - static listServices() { - if (!ServiceRegistry.instance) { - throw new Error("ServiceRegistry not initialized"); - } - return ServiceRegistry.instance.listServices(); - } -} - -export default ServiceRegistry; diff --git a/server/tests/services/infrastructure/StatusService.test.js b/server/tests/services/infrastructure/StatusService.test.js index 6c5ab2fd8..9d286d072 100644 --- a/server/tests/services/infrastructure/StatusService.test.js +++ b/server/tests/services/infrastructure/StatusService.test.js @@ -257,6 +257,106 @@ describe("StatusService.updateMonitorStats", () => { expect(stats.avgResponseTime).toBeCloseTo((200 * 10 + 400) / 11); expect(stats.uptimePercentage).toBeCloseTo(7 / 11); expect(stats.lastResponseTime).toBe(400); - expect(stats.maxResponseTime).toBe(400); - }); + expect(stats.maxResponseTime).toBe(400); + }); +}); + +// Additional coverage: evaluateThresholds branches +describe("StatusService.evaluateThresholds", () => { + const service = new StatusService(); + + const buildCheck = (systemOverrides = {}) => ({ + system: { + cpu: { usage_percent: 0.2, temperature: [40, 45] }, + memory: { usage_percent: 0.3 }, + disk: [{ usage_percent: 0.4 }], + ...systemOverrides, + }, + }); + + it("returns early when monitor is not infrastructure or not up", async () => { + const { monitor } = buildMonitor({ type: "http", status: "up", thresholds: { cpu: 50 } }); + const check = buildCheck(); + const res1 = await service.evaluateThresholds(monitor, check); + expect(res1).toEqual({ hasBreach: false, notes: [], metrics: expect.any(Object) }); + + const { monitor: m2 } = buildMonitor({ type: "infrastructure", status: "down", thresholds: { cpu: 50 } }); + const res2 = await service.evaluateThresholds(m2, check); + expect(res2).toEqual({ hasBreach: false, notes: [], metrics: expect.any(Object) }); + }); + + it("returns early when thresholds or system missing", async () => { + const { monitor } = buildMonitor({ type: "infrastructure", status: "up" }); + const check = buildCheck(); + const res1 = await service.evaluateThresholds(monitor, check); + expect(res1.hasBreach).toBe(false); + + const { monitor: m2 } = buildMonitor({ type: "infrastructure", status: "up", thresholds: { cpu: 50 } }); + const res2 = await service.evaluateThresholds(m2, { system: undefined }); + expect(res2.hasBreach).toBe(false); + }); + + it("detects cpu/memory/disk/temperature breaches and fills notes", async () => { + const { monitor } = buildMonitor({ + type: "infrastructure", + status: "up", + thresholds: { cpu: 50, memory: 60, disk: 80, temperature: 70 }, + }); + const check = buildCheck({ + cpu: { usage_percent: 0.8, temperature: [60, 75] }, // 80% & 75C + memory: { usage_percent: 0.7 }, // 70% + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }], // max 95% + }); + const res = await service.evaluateThresholds(monitor, check); + expect(res.hasBreach).toBe(true); + expect(res.notes).toEqual( + expect.arrayContaining([ + "cpu 80% > 50%", + "memory 70% > 60%", + "disk 95.00% > 80%", + "temperature 75C > 70C", + ]) + ); + expect(res.metrics.cpu).toMatchObject({ value: 80, threshold: 50, breached: true }); + expect(res.metrics.memory).toMatchObject({ value: 70, threshold: 60, breached: true }); + expect(res.metrics.disk).toMatchObject({ value: 95, threshold: 80, breached: true }); + }); + + it("handles non-breach values and undefined fields safely", async () => { + const { monitor } = buildMonitor({ + type: "infrastructure", + status: "up", + thresholds: { cpu: 95, memory: 95, disk: 99, temperature: 100 }, + }); + const check = buildCheck({ + cpu: { usage_percent: undefined, temperature: [] }, + memory: {}, + disk: [{}, { usage_percent: 0.1 }], // max 10% + }); + const res = await service.evaluateThresholds(monitor, check); + expect(res.hasBreach).toBe(false); + expect(res.notes).toEqual([]); + expect(res.metrics.cpu.breached).toBe(false); + expect(res.metrics.memory.breached).toBe(false); + expect(res.metrics.disk.breached).toBe(false); + }); + + it("returns safe default when unexpected error occurs", async () => { + const { monitor } = buildMonitor({ + type: "infrastructure", + status: "up", + thresholds: { cpu: 50 }, + }); + // Create a system object that throws when accessed to exercise catch {} + const throwingSystem = new Proxy( + {}, + { + get() { + throw new Error("boom"); + }, + } + ); + const res = await service.evaluateThresholds(monitor, { system: throwingSystem }); + expect(res).toEqual({ hasBreach: false, notes: [], metrics: expect.any(Object) }); + }); }); diff --git a/server/tests/services/system/BillingService.test.js b/server/tests/services/system/BillingService.test.js new file mode 100644 index 000000000..f3c793d09 --- /dev/null +++ b/server/tests/services/system/BillingService.test.js @@ -0,0 +1,182 @@ +import BillingService from "@/services/system/BillingService.js"; +import ApiError from "@/utils/ApiError.js"; + +// Mock Org model +jest.mock("@/db/models/index.js", () => ({ + Org: { findById: jest.fn(), findOne: jest.fn() }, +})); +import { Org } from "@/db/models/index.js"; + +// Capture a shared mock Stripe client instance +jest.mock("stripe", () => { + const mockClient = { + prices: { list: jest.fn() }, + customers: { create: jest.fn() }, + checkout: { sessions: { create: jest.fn() } }, + billingPortal: { sessions: { create: jest.fn() } }, + subscriptions: { retrieve: jest.fn() }, + }; + const MockStripe = jest.fn(() => mockClient); + MockStripe.__mockClient = mockClient; + return { __esModule: true, default: MockStripe }; +}); +import Stripe from "stripe"; + +describe("BillingService", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("listPlans", () => { + it("lists plans in order and maps prices by lookup_key", async () => { + (Stripe.__mockClient).prices.list.mockResolvedValue({ + data: [ + { lookup_key: "free", unit_amount: 0 }, + { lookup_key: "pro", unit_amount: 1000 }, + { lookup_key: "business", unit_amount: 2500 }, + { lookup_key: "enterprise", unit_amount: 9999 }, + ], + }); + const svc = new BillingService(); + const plans = await svc.listPlans(); + expect(plans.map((p) => p.plan)).toEqual([ + "free", + "pro", + "business", + "enterprise", + ]); + expect(plans.find((p) => p.plan === "pro")?.price).toBe(1000); + expect(plans.find((p) => p.plan === "enterprise")?.price).toBe(9999); + }); + }); + + describe("subscribePlan", () => { + it("throws when planKey missing", async () => { + const svc = new BillingService(); + await expect(svc.subscribePlan("e@x.com", "org1", undefined)).rejects.toMatchObject({ + message: "planKey is required", + status: 400, + }); + }); + + it("throws when planKey invalid", async () => { + const svc = new BillingService(); + await expect(svc.subscribePlan("e@x.com", "org1", "bogus")) + .rejects.toMatchObject({ message: "Invalid planKey", status: 400 }); + }); + + it("throws when org not found", async () => { + Org.findById.mockResolvedValue(null); + const svc = new BillingService(); + await expect(svc.subscribePlan("e@x.com", "org1", "pro")) + .rejects.toMatchObject({ message: "Organization not found", status: 404 }); + }); + + it("creates customer and new subscription checkout when none exists", async () => { + const org = { _id: "org1", save: jest.fn(), billingCustomerId: null, subscriptionId: null }; + Org.findById.mockResolvedValue(org); + (Stripe.__mockClient).customers.create.mockResolvedValue({ id: "cus_123" }); + (Stripe.__mockClient).prices.list.mockResolvedValue({ data: [{ id: "price_123", lookup_key: "pro" }] }); + (Stripe.__mockClient).checkout.sessions.create.mockResolvedValue({ url: "https://checkout" }); + + const svc = new BillingService(); + const url = await svc.subscribePlan("e@x.com", "org1", "pro"); + expect(org.billingCustomerId).toBe("cus_123"); + expect(org.save).toHaveBeenCalled(); + expect(Stripe.__mockClient.checkout.sessions.create).toHaveBeenCalled(); + expect(url).toBe("https://checkout"); + }); + + it("updates existing subscription via billing portal when subscriptionId exists", async () => { + const org = { _id: "org1", save: jest.fn(), billingCustomerId: "cus_123", subscriptionId: "sub_1" }; + Org.findById.mockResolvedValue(org); + (Stripe.__mockClient).prices.list.mockResolvedValue({ data: [{ id: "price_123", lookup_key: "pro" }] }); + (Stripe.__mockClient).billingPortal.sessions.create.mockResolvedValue({ url: "https://portal" }); + + const svc = new BillingService(); + const url = await svc.subscribePlan("e@x.com", "org1", "pro"); + expect(Stripe.__mockClient.billingPortal.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ customer: "cus_123" }) + ); + expect(url).toBe("https://portal"); + }); + + it("throws when no price found for plan", async () => { + const org = { _id: "org1", save: jest.fn(), billingCustomerId: "cus_123", subscriptionId: null }; + Org.findById.mockResolvedValue(org); + (Stripe.__mockClient).prices.list.mockResolvedValue({ data: [] }); + const svc = new BillingService(); + await expect(svc.subscribePlan("e@x.com", "org1", "pro")) + .rejects.toMatchObject({ message: "Price not found for the selected plan", status: 404 }); + }); + }); + + describe("cancelSubscription", () => { + it("throws when org not found", async () => { + Org.findById.mockResolvedValue(null); + const svc = new BillingService(); + await expect(svc.cancelSubscription("org1")) + .rejects.toMatchObject({ message: "Organization not found", status: 404 }); + }); + + it("throws when no active subscription found", async () => { + Org.findById.mockResolvedValue({ _id: "org1", subscriptionId: null }); + const svc = new BillingService(); + await expect(svc.cancelSubscription("org1")) + .rejects.toMatchObject({ message: "No active subscription found", status: 400 }); + }); + + it("returns billing portal url on success", async () => { + Org.findById.mockResolvedValue({ _id: "org1", billingCustomerId: "cus_1", subscriptionId: "sub_1" }); + (Stripe.__mockClient).billingPortal.sessions.create.mockResolvedValue({ url: "https://cancel" }); + const svc = new BillingService(); + const url = await svc.cancelSubscription("org1"); + expect(url).toBe("https://cancel"); + }); + + it("throws when billing portal session lacks url", async () => { + Org.findById.mockResolvedValue({ _id: "org1", billingCustomerId: "cus_1", subscriptionId: "sub_1" }); + (Stripe.__mockClient).billingPortal.sessions.create.mockResolvedValue({ url: null }); + const svc = new BillingService(); + await expect(svc.cancelSubscription("org1")) + .rejects.toMatchObject({ message: "Failed to create billing portal session", status: 500 }); + }); + }); + + describe("confirmPlan", () => { + it("throws when org not found", async () => { + Org.findOne.mockResolvedValue(null); + const svc = new BillingService(); + await expect(svc.confirmPlan("org1", "pro")) + .rejects.toMatchObject({ message: "Organization not found", status: 404 }); + }); + + it("returns true only when expected is free if no subscriptionId", async () => { + Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: null }); + const svc = new BillingService(); + await expect(svc.confirmPlan("org1", "free")).resolves.toBe(true); + await expect(svc.confirmPlan("org1", "pro")).resolves.toBe(false); + }); + + it("returns true when active plan equals org.planKey", async () => { + Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: "sub1", planKey: "business" }); + (Stripe.__mockClient).subscriptions.retrieve.mockResolvedValue({ items: { data: [{ price: { lookup_key: "business" } }] } }); + const svc = new BillingService(); + await expect(svc.confirmPlan("org1", "pro")).resolves.toBe(true); + }); + + it("returns true when active plan equals expectedPlanKey", async () => { + Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: "sub1", planKey: "pro" }); + (Stripe.__mockClient).subscriptions.retrieve.mockResolvedValue({ items: { data: [{ price: { lookup_key: "enterprise" } }] } }); + const svc = new BillingService(); + await expect(svc.confirmPlan("org1", "enterprise")).resolves.toBe(true); + }); + + it("returns false when active plan differs from expected", async () => { + Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: "sub1", planKey: "pro" }); + (Stripe.__mockClient).subscriptions.retrieve.mockResolvedValue({ items: { data: [{ price: { lookup_key: "business" } }] } }); + const svc = new BillingService(); + await expect(svc.confirmPlan("org1", "enterprise")).resolves.toBe(false); + }); + }); +}); diff --git a/server/tests/services/system/EntitlementsService.test.js b/server/tests/services/system/EntitlementsService.test.js new file mode 100644 index 000000000..5cdf98b39 --- /dev/null +++ b/server/tests/services/system/EntitlementsService.test.js @@ -0,0 +1,49 @@ +import { EntitlementsFactory, SaaSEntitlementsProvider, SelfHostedEntitlementsProvider } from "@/services/system/EntitlementsService.js"; +import { Org } from "@/db/models/index.js"; + +jest.mock("@/db/models/index.js", () => ({ + Org: { findById: jest.fn() }, +})); + +describe("EntitlementsService", () => { + afterEach(() => jest.resetAllMocks()); + + test("SaaS provider maps plan keys to Plans", async () => { + const cases = [ + { planKey: "free", expected: "free" }, + { planKey: "pro", expected: "pro" }, + { planKey: "business", expected: "business" }, + { planKey: "enterprise", expected: "enterprise" }, + { planKey: undefined, expected: "free" }, + ]; + + for (const c of cases) { + Org.findById.mockReturnValueOnce({ lean: () => Promise.resolve({ planKey: c.planKey }) }); + const provider = new SaaSEntitlementsProvider(); + const ent = await provider.getForOrg("org1"); + expect(ent.plan).toBe(c.expected); + } + }); + + test("Self-hosted provider returns unlimited plan", async () => { + const provider = new SelfHostedEntitlementsProvider(); + const ent = await provider.getForOrg("org1"); + expect(ent.plan).toBe("unlimited"); + }); + + test("Factory returns correct provider based on DEPLOYMENT_MODE", () => { + jest.resetModules(); + jest.isolateModules(() => { + jest.doMock("@/config/index.js", () => ({ config: { DEPLOYMENT_MODE: "self_hosted" } })); + const mod = require("@/services/system/EntitlementsService.js"); + expect(mod.EntitlementsFactory.create()).toBeInstanceOf(mod.SelfHostedEntitlementsProvider); + }); + + jest.resetModules(); + jest.isolateModules(() => { + jest.doMock("@/config/index.js", () => ({ config: { DEPLOYMENT_MODE: "saas" } })); + const mod = require("@/services/system/EntitlementsService.js"); + expect(mod.EntitlementsFactory.create()).toBeInstanceOf(mod.SaaSEntitlementsProvider); + }); + }); +});