Files
OpenFrontIO/tests/ResolveCosmetics.test.ts
Evan e0f73598d6 Add subscriptions: store tab, account panel, manage/cancel (#3918)
## Summary

- Add a **Subscriptions** tab to the Store. Each tier renders as a
`<cosmetic-button>` with description, daily Pu/Caps amounts, and a
Stripe checkout button driven by the existing `createCheckoutSession`
flow.
- Show the player's active subscription in the **Account modal** via a
new `<subscription-panel>` Lit component (status badge, period-end /
cancel-at-period-end, daily currency breakdown).
- **Manage** button opens the Stripe billing portal in a new tab (`POST
/subscriptions/@me/portal`).
- **Cancel** button (hidden once `cancelAtPeriodEnd === true`) calls
`POST /subscriptions/@me/cancel` after a `confirm()` prompt, then
invalidates the userMe cache and refetches.
- Block re-purchase: clicking Subscribe when the user already has a
`subscription:*` flare alerts "Already subscribed" before opening
checkout (upgrade/downgrade flows are out of scope for now).
- Schema additions:
- `CosmeticsSchema.subscriptions: Record<string, SubscriptionSchema>`
(optional) in `src/core/CosmeticSchemas.ts`.
- `UserMeResponse.player.subscription: { tier, status, currentPeriodEnd,
cancelAtPeriodEnd } | null` in `src/core/ApiSchemas.ts`.
- Translations: new `store.*` and `account_modal.sub_*` keys in
`resources/lang/en.json` (English only — Crowdin handles the rest).
- 
<img width="942" height="313" alt="Screenshot 2026-05-14 at 1 13 05 PM"
src="https://github.com/user-attachments/assets/3d28df13-9e03-49f0-bee8-a25f9ad0c420"
/>
<img width="545" height="439" alt="Screenshot 2026-05-14 at 1 13 32 PM"
src="https://github.com/user-attachments/assets/b413b275-d6f2-40dc-9230-d68cd11fb07a"
/>

## Discord

evanpelle
2026-05-14 13:47:16 -07:00

331 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolveCosmetics } from "../src/client/Cosmetics";
import { UserMeResponse } from "../src/core/ApiSchemas";
import { Cosmetics } from "../src/core/CosmeticSchemas";
const product = { productId: "prod_1", priceId: "price_1", price: "$4.99" };
function makeCosmetics(overrides: Partial<Cosmetics> = {}): Cosmetics {
return {
patterns: {},
flags: {},
colorPalettes: {},
...overrides,
} as Cosmetics;
}
function makeUserMe(flares: string[] = []): UserMeResponse {
return {
user: {},
player: {
publicId: "test",
adfree: false,
flares,
achievements: { singleplayerMap: [] },
subscription: null,
},
} as UserMeResponse;
}
describe("resolveCosmetics", () => {
test("returns empty array for null cosmetics", () => {
expect(resolveCosmetics(null, false, null)).toEqual([]);
});
test("always includes default pattern as first item, owned", () => {
const result = resolveCosmetics(makeCosmetics(), false, null);
expect(result[0]).toEqual({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
});
describe("patterns", () => {
const pattern = {
type: "pattern" as const,
name: "stripes",
pattern: "AAAAAA",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: undefined,
rarity: "common",
colorPalettes: [
{ name: "red", isArchived: false },
{ name: "blue", isArchived: false },
],
};
const colorPalettes = {
red: { name: "red", primaryColor: "#ff0000", secondaryColor: "#000000" },
blue: {
name: "blue",
primaryColor: "#0000ff",
secondaryColor: "#ffffff",
},
};
test("expands pattern × colorPalettes + null palette", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
// default + red + blue + null-palette
const patternItems = result.filter((r) =>
r.key.startsWith("pattern:stripes"),
);
expect(patternItems).toHaveLength(3);
expect(patternItems.map((r) => r.key)).toEqual([
"pattern:stripes:red",
"pattern:stripes:blue",
"pattern:stripes",
]);
});
test("resolves color palette from cosmetics.colorPalettes", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.colorPalette).toEqual(colorPalettes.red);
});
test("null palette entry has null colorPalette", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, false, null);
const nullPaletteItem = result.find((r) => r.key === "pattern:stripes");
expect(nullPaletteItem?.colorPalette).toBeNull();
});
test("pattern with no colorPalettes produces single null-palette entry", () => {
const noPalettePattern = { ...pattern, colorPalettes: undefined };
const cosmetics = makeCosmetics({
patterns: { stripes: noPalettePattern as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const patternItems = result.filter((r) =>
r.key.startsWith("pattern:stripes"),
);
expect(patternItems).toHaveLength(1);
expect(patternItems[0].key).toBe("pattern:stripes");
});
test("purchasable when user has no flares and product exists", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("purchasable");
});
test("owned when user has specific flare", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:stripes:red"]),
null,
);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("owned");
});
test("owned when user has wildcard flare", () => {
const cosmetics = makeCosmetics({
patterns: { stripes: pattern as any },
colorPalettes,
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:*"]),
null,
);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("owned");
});
test("blocked when affiliate code mismatch", () => {
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
const cosmetics = makeCosmetics({
patterns: { stripes: affiliatePattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("blocked");
});
test("purchasable when affiliate code matches", () => {
const affiliatePattern = { ...pattern, affiliateCode: "partner1" };
const cosmetics = makeCosmetics({
patterns: { stripes: affiliatePattern as any },
colorPalettes,
});
const result = resolveCosmetics(cosmetics, makeUserMe(), "partner1");
const redItem = result.find((r) => r.key === "pattern:stripes:red");
expect(redItem?.relationship).toBe("purchasable");
});
test("archived palette is blocked unless owned", () => {
const archivedPattern = {
...pattern,
colorPalettes: [{ name: "old", isArchived: true }],
};
const cosmetics = makeCosmetics({
patterns: { stripes: archivedPattern as any },
colorPalettes: {
old: {
name: "old",
primaryColor: "#111",
secondaryColor: "#222",
},
},
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
expect(oldItem?.relationship).toBe("blocked");
});
test("archived palette is owned when user has specific flare", () => {
const archivedPattern = {
...pattern,
colorPalettes: [{ name: "old", isArchived: true }],
};
const cosmetics = makeCosmetics({
patterns: { stripes: archivedPattern as any },
colorPalettes: {
old: {
name: "old",
primaryColor: "#111",
secondaryColor: "#222",
},
},
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["pattern:stripes:old"]),
null,
);
const oldItem = result.find((r) => r.key === "pattern:stripes:old");
expect(oldItem?.relationship).toBe("owned");
});
});
describe("flags", () => {
const flag = {
type: "flag" as const,
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
product,
priceSoft: undefined,
priceHard: undefined,
rarity: "rare",
};
test("includes flags with correct key", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem).toBeDefined();
expect(flagItem?.cosmetic).toEqual(flag);
expect(flagItem?.colorPalette).toBeNull();
});
test("purchasable when not logged in and product exists", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, false, null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("purchasable");
});
test("owned with wildcard flare", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(["flag:*"]), null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("owned");
});
test("owned with specific flare", () => {
const cosmetics = makeCosmetics({
flags: { cool_flag: flag as any },
});
const result = resolveCosmetics(
cosmetics,
makeUserMe(["flag:cool_flag"]),
null,
);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("owned");
});
test("blocked with no product", () => {
const freeFlag = { ...flag, product: null };
const cosmetics = makeCosmetics({
flags: { cool_flag: freeFlag as any },
});
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
const flagItem = result.find((r) => r.key === "flag:cool_flag");
expect(flagItem?.relationship).toBe("blocked");
});
});
describe("mixed cosmetics", () => {
test("returns all types in order: default, patterns, flags", () => {
const cosmetics = makeCosmetics({
patterns: {
stripes: {
type: "pattern" as const,
name: "stripes",
pattern: "AAAAAA",
affiliateCode: null,
product,
priceSoft: null,
priceHard: null,
rarity: "common",
} as any,
},
flags: {
heart: {
type: "flag" as const,
name: "heart",
url: "/flags/heart.svg",
affiliateCode: null,
product,
priceSoft: null,
priceHard: null,
rarity: "common",
} as any,
},
});
const result = resolveCosmetics(cosmetics, false, null);
const keys = result.map((r) => r.key);
expect(keys[0]).toBe("pattern:default");
expect(keys).toContain("pattern:stripes");
expect(keys).toContain("flag:heart");
// patterns come before flags
const patternIdx = keys.indexOf("pattern:stripes");
const flagIdx = keys.indexOf("flag:heart");
expect(patternIdx).toBeLessThan(flagIdx);
});
});
});