Files
cfw-autumn/server/tests/integration/crud/plans/get-plan-advanced.test.ts
2026-04-27 15:59:27 +01:00

418 lines
15 KiB
TypeScript

import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiPlanV1,
type ApiProduct,
ApiVersion,
BillingInterval,
BillingMethod,
type CreatePlanParamsInput,
TierBehavior,
TierInfinite,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 });
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
const inf = TierInfinite as "inf";
// ═══════════════════════════════════════════════════════════════════════════════
// GET: TIERED PRICING WITHOUT INCLUDED
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get: tiered pricing without included — same tiers across all versions")}`, async () => {
const productId = "get_tiered_no_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Tiered No Included",
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
const expectedTiers = [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: inf, amount: 0.05 },
];
// V2.1: tiers unchanged (no included to add)
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers);
// V2.0: tiers unchanged (no included to subtract)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual(expectedTiers);
// V1.2: tiers unchanged
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toEqual(expectedTiers);
});
// ═══════════════════════════════════════════════════════════════════════════════
// GET: TIERED PRICING WITH INCLUDED — CROSS-VERSION TIER VALUES
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get: graduated tiered pricing with included — V2.1 includes, V2.0/V1.2 do not")}`, async () => {
const productId = "get_tiered_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
// Create via V2.1: tier `to` INCLUDES included.
// included=200, tiers=[{to:700}, {to:1200}, {to:inf}]
// Internally stored as [{to:500}, {to:1000}, {to:inf}]
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Tiered With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 200,
price: {
tiers: [
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1 GET: tiers INCLUDE included (200 added back)
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].included).toBe(200);
expect(v2_1.items[0].price!.tiers).toEqual([
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: inf, amount: 2 },
]);
// V2.0 GET: tiers do NOT include included
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].granted_balance).toBe(200);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: inf, amount: 2 },
]);
// V1.2 GET: tiers do NOT include included
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].included_usage).toBe(200);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: inf, amount: 2 },
]);
});
test.concurrent(`${chalk.yellowBright("get: volume tiered pricing with included — V2.1 includes, V2.0/V1.2 do not")}`, async () => {
const productId = "get_volume_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
// Create via V2.1: included=100, tiers=[{to:600}, {to:inf}]
// Internally stored as [{to:500}, {to:inf}]
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Volume With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 100,
price: {
tiers: [
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1 GET: tiers INCLUDE included
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].included).toBe(100);
expect(v2_1.items[0].price!.tiers).toEqual([
{ to: 600, amount: 10 },
{ to: inf, amount: 5 },
]);
// V2.0 GET: tiers do NOT include included
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].granted_balance).toBe(100);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: inf, amount: 5 },
]);
// V1.2 GET: tiers do NOT include included
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].included_usage).toBe(100);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: inf, amount: 5 },
]);
});
// ═══════════════════════════════════════════════════════════════════════════════
// GET: CREATE VIA V2.0, READ ACROSS VERSIONS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get: create tiered via V2.0 (no included offset) — V2.1 adds included=0")}`, async () => {
const productId = "get_v2_created_tiered";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// Create via V2.0: tiers do NOT include included (V2.0 has no offset behavior)
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "V2 Created Tiered",
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
const expectedTiers = [
{ to: 100, amount: 10 },
{ to: 1000, amount: 5 },
{ to: inf, amount: 2 },
];
// V2.1 GET: included defaults to 0, so tiers stay the same
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers);
// V2.0 GET: same tiers
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual(expectedTiers);
// V1.2 GET: same tiers
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toEqual(expectedTiers);
});
// ═══════════════════════════════════════════════════════════════════════════════
// GET: PLAN CONFIG (ignore_past_due) — CROSS-VERSION
//
// `config.ignore_past_due` is exposed on V2.1 (ApiPlanV1) only. V2.0 (ApiPlan)
// and V1.2 (ApiProduct) do not surface a `config` field in their response
// shape, but the underlying flag must persist on the DB row regardless of
// which version is used to read or write the plan. These tests mirror the
// V2.1-only assertions in `plan-config.test.ts` and add cross-version GET
// + update guarantees.
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get plan config: defaults to ignore_past_due=false on create — visible across versions")}`, async () => {
const productId = "get_plan_config_default";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config Default",
});
// V2.1 GET: config defaults to { ignore_past_due: false }
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.config).toBeDefined();
expect(v2_1.config.ignore_past_due).toBe(false);
// V2.0 / V1.2 GET: response succeeds; older shapes do not surface `config`
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.id).toBe(productId);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.id).toBe(productId);
});
test.concurrent(`${chalk.yellowBright("get plan config: V2.1 create with ignore_past_due=true persists across versions")}`, async () => {
const productId = "get_plan_config_v2_1_true";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config V2.1 True",
config: { ignore_past_due: true },
});
// V2.1 GET: ignore_past_due=true exposed on the response
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.config.ignore_past_due).toBe(true);
// V2.0 / V1.2 GET: response succeeds, config stays out of the schema
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.id).toBe(productId);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.id).toBe(productId);
// Re-read via V2.1 to confirm older-version reads did not clobber the flag
const v2_1Again = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1Again.config.ignore_past_due).toBe(true);
});
test.concurrent(`${chalk.yellowBright("get plan config: V2.0 create with ignore_past_due=true is visible via V2.1")}`, async () => {
const productId = "get_plan_config_v2_0_true";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// V2.0 also accepts `config` on the create payload (CreateProductV2Params).
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config V2.0 True",
config: { ignore_past_due: true },
});
// V2.1 surfaces the flag set via V2.0
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.config.ignore_past_due).toBe(true);
});
test.concurrent(`${chalk.yellowBright("get plan config: V2.1 update flips ignore_past_due — older readers still succeed")}`, async () => {
const productId = "get_plan_config_update_flip";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config Update Flip",
});
const before = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(before.config.ignore_past_due).toBe(false);
await autumnV2_1.products.update<ApiPlanV1, Partial<CreatePlanParamsInput>>(
productId,
{ config: { ignore_past_due: true } },
);
const after = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(after.config.ignore_past_due).toBe(true);
// V2.0 / V1.2 reads still succeed; they just don't expose `config`
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.id).toBe(productId);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.id).toBe(productId);
});
test.concurrent(`${chalk.yellowBright("get plan config: V2.1 update flips ignore_past_due true → false")}`, async () => {
const productId = "get_plan_config_update_off";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config Update Off",
config: { ignore_past_due: true },
});
const before = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(before.config.ignore_past_due).toBe(true);
await autumnV2_1.products.update<ApiPlanV1, Partial<CreatePlanParamsInput>>(
productId,
{ config: { ignore_past_due: false } },
);
const after = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(after.config.ignore_past_due).toBe(false);
});
test.concurrent(`${chalk.yellowBright("get plan config: partial update via V2.0 (no config) preserves ignore_past_due")}`, async () => {
// Regression guard — both update paths (versioning + non-versioning) deep-merge
// `config` so a PATCH from an older client that doesn't know about `config`
// must not clobber the existing flag.
const productId = "get_plan_config_partial_update";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config Partial Original",
config: { ignore_past_due: true },
});
// V2.0 update body has no `config` field — flag must survive.
await autumnV2.products.update<ApiPlan, { name: string }>(productId, {
name: "Get Plan Config Partial Renamed",
});
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.name).toBe("Get Plan Config Partial Renamed");
expect(v2_1.config.ignore_past_due).toBe(true);
});
test.concurrent(`${chalk.yellowBright("get plan config: omitting config in V2.1 update does not reset it")}`, async () => {
const productId = "get_plan_config_omit_update";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Plan Config Persist",
config: { ignore_past_due: true },
});
// Update something unrelated via V2.1 — config should survive.
await autumnV2_1.products.update<ApiPlanV1, Partial<CreatePlanParamsInput>>(
productId,
{ name: "Get Plan Config Persist Renamed" },
);
const after = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(after.name).toBe("Get Plan Config Persist Renamed");
expect(after.config.ignore_past_due).toBe(true);
});