← back to Charge And Explore

backend/test/tiers.test.ts

181 lines

// Tier → entitlement rules (billing, TK-10227). Proves the FREE/PLUS/PRO
// gating math, the degrade-to-free normalization, the webhook→tier mapping,
// and the StripeBilling hard rails (test-mode-only key, inert by default).
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
  TIERS, TIER_ORDER, PAID_TIERS,
  normalizeTier, isPaidTier, entitlementsFor, tierAtLeast,
  clampHistoryHours, clampHistoryDays, vehicleLimit, tierFromStripeObject,
} from "../src/core/tiers.ts";
import { StripeBilling } from "../src/providers/stripe-billing.ts";

describe("tier model", () => {
  it("defines exactly free/plus/pro in ascending order", () => {
    assert.deepEqual([...TIER_ORDER], ["free", "plus", "pro"]);
    assert.deepEqual([...PAID_TIERS], ["plus", "pro"]);
    assert.deepEqual(Object.keys(TIERS).sort(), ["free", "plus", "pro"]);
  });

  it("free = 7-day history + 1 vehicle, no parked analytics", () => {
    const e = entitlementsFor("free");
    assert.equal(e.historyDays, 7);
    assert.equal(e.maxVehicles, 1);
    assert.equal(e.parkedAnalytics, false);
    assert.equal(e.prioritySampling, false);
    assert.equal(e.priceCentsMonthly, 0);
  });

  it("plus = 90-day history + all vehicles ($4.99 placeholder)", () => {
    const e = entitlementsFor("plus");
    assert.equal(e.historyDays, 90);
    assert.equal(e.maxVehicles, null);
    assert.equal(e.parkedAnalytics, false);
    assert.equal(e.priceCentsMonthly, 499);
  });

  it("pro = full history + parked analytics + priority sampling ($9.99 placeholder)", () => {
    const e = entitlementsFor("pro");
    assert.equal(e.historyDays, null);
    assert.equal(e.maxVehicles, null);
    assert.equal(e.parkedAnalytics, true);
    assert.equal(e.prioritySampling, true);
    assert.equal(e.priceCentsMonthly, 999);
  });

  it("normalizeTier degrades anything unknown to free (never throws)", () => {
    assert.equal(normalizeTier("plus"), "plus");
    assert.equal(normalizeTier("pro"), "pro");
    assert.equal(normalizeTier("free"), "free");
    assert.equal(normalizeTier("enterprise"), "free");
    assert.equal(normalizeTier(undefined), "free");
    assert.equal(normalizeTier(null), "free");
    assert.equal(normalizeTier(42), "free");
  });

  it("isPaidTier accepts only plus/pro", () => {
    assert.equal(isPaidTier("plus"), true);
    assert.equal(isPaidTier("pro"), true);
    assert.equal(isPaidTier("free"), false);
    assert.equal(isPaidTier("PLUS"), false);
    assert.equal(isPaidTier(""), false);
  });

  it("tierAtLeast follows the free < plus < pro order", () => {
    assert.equal(tierAtLeast("pro", "plus"), true);
    assert.equal(tierAtLeast("plus", "plus"), true);
    assert.equal(tierAtLeast("free", "plus"), false);
    assert.equal(tierAtLeast("plus", "pro"), false);
  });
});

describe("history clamping", () => {
  it("free clamps to 7 days (168h) — smaller requests pass through", () => {
    assert.equal(clampHistoryHours("free", 720), 168);
    assert.equal(clampHistoryHours("free", 168), 168);
    assert.equal(clampHistoryHours("free", 24), 24);
    assert.equal(clampHistoryDays("free", 30), 7);
    assert.equal(clampHistoryDays("free", 3), 3);
  });

  it("plus clamps to 90 days (2160h)", () => {
    assert.equal(clampHistoryHours("plus", 2160), 2160);
    assert.equal(clampHistoryHours("plus", 3000), 2160);
    assert.equal(clampHistoryDays("plus", 90), 90);
    assert.equal(clampHistoryDays("plus", 365), 90);
  });

  it("pro is unlimited — every request passes through", () => {
    assert.equal(clampHistoryHours("pro", 999999), 999999);
    assert.equal(clampHistoryDays("pro", 3650), 3650);
  });

  it("vehicleLimit: free=1, plus/pro=all", () => {
    assert.equal(vehicleLimit("free"), 1);
    assert.equal(vehicleLimit("plus"), Infinity);
    assert.equal(vehicleLimit("pro"), Infinity);
  });
});

describe("tierFromStripeObject (webhook mapping, pure)", () => {
  it("active subscription with ce_tier metadata grants that tier", () => {
    assert.equal(tierFromStripeObject({ status: "active", metadata: { ce_tier: "pro" } }), "pro");
    assert.equal(tierFromStripeObject({ status: "trialing", metadata: { ce_tier: "plus" } }), "plus");
  });

  it("falls back to the price's metadata when the subscription has none", () => {
    const obj = { status: "active", items: { data: [{ price: { metadata: { ce_tier: "plus" } } }] } };
    assert.equal(tierFromStripeObject(obj), "plus");
  });

  it("non-active statuses revoke to free", () => {
    for (const status of ["past_due", "canceled", "unpaid", "incomplete_expired"]) {
      assert.equal(tierFromStripeObject({ status, metadata: { ce_tier: "pro" } }), "free");
    }
  });

  it("missing/garbage objects degrade to free", () => {
    assert.equal(tierFromStripeObject(null), "free");
    assert.equal(tierFromStripeObject(undefined), "free");
    assert.equal(tierFromStripeObject({}), "free");
    assert.equal(tierFromStripeObject({ status: "active", metadata: { ce_tier: "gold" } }), "free");
  });
});

describe("StripeBilling hard rails (no network)", () => {
  it("is INERT by default — BILLING_ENABLED unset means configured:false even with a key", () => {
    const b = new StripeBilling({ STRIPE_TEST_SECRET_KEY: "sk_test_abc" });
    assert.equal(b.enabled, false);
    assert.equal(b.configured, false);
  });

  it("BILLING_ENABLED=1 + sk_test_ key = configured", () => {
    const b = new StripeBilling({ BILLING_ENABLED: "1", STRIPE_TEST_SECRET_KEY: "sk_test_abc" });
    assert.equal(b.configured, true);
  });

  it("REFUSES a live-mode key in TEST mode — sk_live_ never configures test billing", () => {
    const b = new StripeBilling({ BILLING_ENABLED: "1", STRIPE_TEST_SECRET_KEY: "sk_live_abc" });
    assert.equal(b.live, false);
    assert.equal(b.keyOk, false);
    assert.equal(b.configured, false);
  });

  it("BILLING_LIVE=1 requires a real sk_live_ key — the flag alone (with a test key) stays unconfigured", () => {
    // Live mode reads STRIPE_LIVE_SECRET_KEY, so a test key in the test var is invisible here.
    const b = new StripeBilling({ BILLING_ENABLED: "1", BILLING_LIVE: "1", STRIPE_TEST_SECRET_KEY: "sk_test_abc" });
    assert.equal(b.live, true);
    assert.equal(b.configured, false);
  });

  it("BILLING_LIVE=1 + sk_live_ key = configured (the deliberate two-part live gate)", () => {
    const b = new StripeBilling({ BILLING_ENABLED: "1", BILLING_LIVE: "1", STRIPE_LIVE_SECRET_KEY: "sk_live_abc" });
    assert.equal(b.live, true);
    assert.equal(b.keyOk, true);
    assert.equal(b.configured, true);
  });

  it("live/test env vars never cross modes — an sk_test_ key can't leak into live", () => {
    const b = new StripeBilling({ BILLING_ENABLED: "1", BILLING_LIVE: "1", STRIPE_LIVE_SECRET_KEY: "sk_test_abc" });
    assert.equal(b.keyOk, false);
    assert.equal(b.configured, false);
  });

  it("no key / garbage key = unconfigured", () => {
    assert.equal(new StripeBilling({ BILLING_ENABLED: "1" }).configured, false);
    assert.equal(new StripeBilling({ BILLING_ENABLED: "1", STRIPE_TEST_SECRET_KEY: "pk_test_abc" }).configured, false);
  });

  it("webhook verification refuses to run without a webhook secret", async () => {
    const b = new StripeBilling({ BILLING_ENABLED: "1", STRIPE_TEST_SECRET_KEY: "sk_test_abc" });
    assert.equal(b.hasWebhookSecret, false);
    await assert.rejects(() => b.verifyWebhook("{}", "t=1,v1=x"), /STRIPE_TEST_WEBHOOK_SECRET not set/);
  });

  it("unconfigured billing throws before ever touching the Stripe SDK", async () => {
    const b = new StripeBilling({});
    await assert.rejects(() => b.ensurePriceId("plus"), /billing not configured/);
    await assert.rejects(() => b.createCheckoutSession({ tier: "pro", userSub: "s", email: "e@x.com", baseUrl: "https://x" }), /billing not configured/);
  });
});