Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/services/.server/subscriptionService.test.ts
T

137 lines
6.0 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
vi.mock("../../db.server", () => ({ db: {} }));
vi.mock("../../db/entities/entities.db.server", () => ({ findSimpleEntityByName: vi.fn() }));
vi.mock("../../db/stripe/checkoutSessions.db.server", () => ({ createCheckoutSessionStatus: vi.fn(), getCheckoutSessionStatus: vi.fn() }));
vi.mock("../../db/subscriptionProducts.db.server", () => ({ getAllSubscriptionFeatures: vi.fn() }));
vi.mock("@toolctrl/db/subscriptions.server", () => ({
createTenantSubscriptionUsageRecord: vi.fn(),
listTenantSubscriptionIds: vi.fn(),
updateTenantSubscriptionProduct: vi.fn(),
}));
vi.mock("../../db/tenantSubscriptions.db.server", () => ({ getTenantSubscription: vi.fn(), getTenantSubscriptions: vi.fn() }));
vi.mock("~/modules/emails/services/EmailService", () => ({ sendEmail: vi.fn() }));
vi.mock("../../helpers/ApiKeyHelper", () => ({ apiKeyCreditableStatusCodes: [], apiKeyIgnoreEndpoints: [] }));
vi.mock("../../stripe.server", () => ({
cancelStripeSubscription: vi.fn(),
createUsageRecord: vi.fn(),
getStripeSession: vi.fn(),
getStripeSubscription: vi.fn(),
}));
vi.mock("../../url.server", () => ({ getBaseURL: vi.fn() }));
vi.mock("./pricingService", () => ({ getAcquiredItemsFromCheckoutSession: vi.fn() }));
vi.mock("../../cache.server", () => ({ cachified: vi.fn(), clearCacheKey: vi.fn(), clearCacheKeyPrefix: vi.fn() }));
// Stable invalidate spy so tests can assert the cross-replica publish path.
const subscriptionCacheInvalidateMock = vi.fn().mockResolvedValue(undefined);
vi.mock("./subscriptionCache.server", () => ({
getSubscriptionCache: () => ({
invalidate: subscriptionCacheInvalidateMock,
get: vi.fn(),
set: vi.fn(),
subscribe: vi.fn(),
flushLocal: vi.fn(),
deleteLocal: vi.fn(),
}),
}));
vi.mock("~/utils/db/subscriptions/tenantSubscriptionProducts.db.server", () => ({
cancelTenantSubscriptionProduct: vi.fn(),
getTenantSubscriptionProductById: vi.fn(),
}));
vi.mock("~/modules/events/services/.server/EventsService", () => ({ default: { create: vi.fn() } }));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({ getUser: vi.fn() }));
vi.mock("~/modules/emails/utils/EmailTemplates", () => ({ default: {} }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() }));
import {
cancelTenantSubscription,
clearAllSubscriptionsCache,
clearTenantSubscriptionCache,
} from "./subscriptionService";
import { cancelStripeSubscription } from "../../stripe.server";
import { getTenantSubscriptionProductById } from "~/utils/db/subscriptions/tenantSubscriptionProducts.db.server";
import { clearCacheKey } from "../../cache.server";
import { listTenantSubscriptionIds } from "@toolctrl/db/subscriptions.server";
describe("cancelTenantSubscription", () => {
it("returns without logging or calling Stripe when the product is not subscribed", async () => {
vi.mocked(getTenantSubscriptionProductById).mockResolvedValueOnce({
id: "tenant-sub-product-id",
stripeSubscriptionId: null,
} as Awaited<ReturnType<typeof getTenantSubscriptionProductById>>);
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
await cancelTenantSubscription("tenant-sub-product-id", { tenantId: "tenant-id", userId: "user-id" });
expect(consoleLog).not.toHaveBeenCalled();
expect(cancelStripeSubscription).not.toHaveBeenCalled();
consoleLog.mockRestore();
});
});
describe("subscription cache invalidation", () => {
it("clearTenantSubscriptionCache only invalidates the affected tenant key", () => {
const clearCacheKeyMock = vi.mocked(clearCacheKey);
clearCacheKeyMock.mockClear();
clearTenantSubscriptionCache("tenant-a");
expect(clearCacheKeyMock).toHaveBeenCalledTimes(1);
expect(clearCacheKeyMock).toHaveBeenCalledWith("tenantSubscription:tenant-a");
});
it("clearTenantSubscriptionCache does not enumerate other tenants (no global thrash)", () => {
const clearCacheKeyMock = vi.mocked(clearCacheKey);
const listMock = vi.mocked(listTenantSubscriptionIds);
clearCacheKeyMock.mockClear();
listMock.mockClear();
clearTenantSubscriptionCache("tenant-x");
expect(listMock).not.toHaveBeenCalled();
});
it("clearTenantSubscriptionCache publishes a cross-replica invalidation for the tenant", () => {
subscriptionCacheInvalidateMock.mockClear();
clearTenantSubscriptionCache("tenant-b");
// Peer web replicas must drop their L1 too — not just this process.
expect(subscriptionCacheInvalidateMock).toHaveBeenCalledTimes(1);
expect(subscriptionCacheInvalidateMock).toHaveBeenCalledWith("tenant-b");
});
it("clearAllSubscriptionsCache broadcasts a wildcard cross-replica invalidation", async () => {
subscriptionCacheInvalidateMock.mockClear();
vi.mocked(listTenantSubscriptionIds).mockResolvedValueOnce(
[] as Awaited<ReturnType<typeof listTenantSubscriptionIds>>,
);
await clearAllSubscriptionsCache();
// No key argument == wildcard flush across every replica's L1.
expect(subscriptionCacheInvalidateMock).toHaveBeenCalledTimes(1);
expect(subscriptionCacheInvalidateMock).toHaveBeenCalledWith();
});
it("clearAllSubscriptionsCache invalidates every cached tenant subscription", async () => {
const clearCacheKeyMock = vi.mocked(clearCacheKey);
const listMock = vi.mocked(listTenantSubscriptionIds);
clearCacheKeyMock.mockClear();
listMock.mockReset();
listMock.mockResolvedValueOnce([
{ id: "sub-1", tenantId: "tenant-1" },
{ id: "sub-2", tenantId: "tenant-2" },
{ id: "sub-3", tenantId: "tenant-3" },
] as Awaited<ReturnType<typeof listTenantSubscriptionIds>>);
await clearAllSubscriptionsCache();
expect(clearCacheKeyMock).toHaveBeenCalledTimes(3);
expect(clearCacheKeyMock).toHaveBeenCalledWith("tenantSubscription:tenant-1");
expect(clearCacheKeyMock).toHaveBeenCalledWith("tenantSubscription:tenant-2");
expect(clearCacheKeyMock).toHaveBeenCalledWith("tenantSubscription:tenant-3");
});
});