@carlonicora/nextjs-jsonapi 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/{BlockNoteEditor-4UAIVF3G.js → BlockNoteEditor-6G27S6P3.js} +19 -19
  2. package/dist/{BlockNoteEditor-4UAIVF3G.js.map → BlockNoteEditor-6G27S6P3.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-PFCHUFTD.mjs → BlockNoteEditor-CR5KH4AI.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-5MITJ7AD.js} +3 -3
  5. package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-5MITJ7AD.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-NJKFUNXP.mjs} +2 -2
  7. package/dist/billing/index.d.mts +27 -0
  8. package/dist/billing/index.d.ts +27 -0
  9. package/dist/billing/index.js +697 -450
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +282 -35
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-V6CGCARD.js → chunk-4ETVCRQD.js} +7 -7
  14. package/dist/{chunk-V6CGCARD.js.map → chunk-4ETVCRQD.js.map} +1 -1
  15. package/dist/{chunk-IMSIVIIC.mjs → chunk-HVNEWDRF.mjs} +2 -2
  16. package/dist/{chunk-XZJJ4SZW.mjs → chunk-JEGL7KHK.mjs} +1218 -1154
  17. package/dist/chunk-JEGL7KHK.mjs.map +1 -0
  18. package/dist/{chunk-6J2SQZ3G.js → chunk-L7DZSTFU.js} +2 -1
  19. package/dist/chunk-L7DZSTFU.js.map +1 -0
  20. package/dist/{chunk-46CQ3RRQ.js → chunk-OKGGYCO5.js} +935 -871
  21. package/dist/chunk-OKGGYCO5.js.map +1 -0
  22. package/dist/{chunk-M5QAHSCY.mjs → chunk-Q2BREXH3.mjs} +2 -1
  23. package/dist/chunk-Q2BREXH3.mjs.map +1 -0
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.d.mts +2 -1
  27. package/dist/components/index.d.ts +2 -1
  28. package/dist/components/index.js +4 -4
  29. package/dist/components/index.mjs +3 -3
  30. package/dist/contexts/index.d.mts +18 -1
  31. package/dist/contexts/index.d.ts +18 -1
  32. package/dist/contexts/index.js +8 -4
  33. package/dist/contexts/index.js.map +1 -1
  34. package/dist/contexts/index.mjs +7 -3
  35. package/dist/core/index.d.mts +2 -1
  36. package/dist/core/index.d.ts +2 -1
  37. package/dist/core/index.js +2 -2
  38. package/dist/core/index.mjs +1 -1
  39. package/dist/features/help/index.js +37 -37
  40. package/dist/features/help/index.mjs +3 -3
  41. package/dist/features/tokenusage/index.js +101 -101
  42. package/dist/features/tokenusage/index.mjs +3 -3
  43. package/dist/index.js +3 -3
  44. package/dist/index.mjs +2 -2
  45. package/dist/server/index.js +3 -3
  46. package/dist/server/index.mjs +1 -1
  47. package/package.json +1 -1
  48. package/src/components/navigations/Breadcrumb.tsx +21 -9
  49. package/src/components/navigations/Header.tsx +3 -1
  50. package/src/contexts/BreadcrumbRootContext.tsx +28 -0
  51. package/src/contexts/index.ts +1 -0
  52. package/src/features/billing/i18n-keys.ts +7 -0
  53. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
  54. package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
  55. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
  56. package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
  57. package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
  58. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
  59. package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
  60. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
  61. package/dist/chunk-46CQ3RRQ.js.map +0 -1
  62. package/dist/chunk-6J2SQZ3G.js.map +0 -1
  63. package/dist/chunk-M5QAHSCY.mjs.map +0 -1
  64. package/dist/chunk-XZJJ4SZW.mjs.map +0 -1
  65. /package/dist/{BlockNoteEditor-PFCHUFTD.mjs.map → BlockNoteEditor-CR5KH4AI.mjs.map} +0 -0
  66. /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-NJKFUNXP.mjs.map} +0 -0
  67. /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-HVNEWDRF.mjs.map} +0 -0
@@ -0,0 +1,182 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ModuleRegistry } from "../../../../../../core/registry/ModuleRegistry";
4
+ import { configureI18n } from "../../../../../../i18n";
5
+ import { StripePriceSeed } from "../../../data/stripe-price.transfer";
6
+ import PriceEditor from "../PriceEditor";
7
+
8
+ // Hoisted so the vi.mock factories below (which run before the module body)
9
+ // can close over them.
10
+ const mocks = vi.hoisted(() => ({
11
+ findMany: vi.fn(),
12
+ createPrice: vi.fn(),
13
+ updatePrice: vi.fn(),
14
+ showToast: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("../../../../../feature", () => ({
18
+ FeatureService: { findMany: mocks.findMany },
19
+ }));
20
+
21
+ vi.mock("../../../../contexts/PriceContext", () => ({
22
+ usePriceContext: () => ({
23
+ createPrice: mocks.createPrice,
24
+ updatePrice: mocks.updatePrice,
25
+ productId: "product-from-context",
26
+ }),
27
+ priceLabel: (price: { nickname?: string }) => price.nickname ?? "price",
28
+ }));
29
+
30
+ vi.mock("../../../../../../utils/toast", () => ({
31
+ showToast: mocks.showToast,
32
+ showError: vi.fn(),
33
+ dismissToast: vi.fn(),
34
+ showCustomToast: vi.fn(),
35
+ }));
36
+
37
+ const CORE = { id: "feature-core", name: "Core", isCore: true };
38
+ const AI = { id: "feature-ai", name: "AI Assistant", isCore: false };
39
+ const SSO = { id: "feature-sso", name: "SSO", isCore: false };
40
+
41
+ const seed: StripePriceSeed = {
42
+ unitAmount: 5900, // MINOR units — the form must render 59
43
+ currency: "usd",
44
+ interval: "year",
45
+ intervalCount: 1,
46
+ usageType: "licensed",
47
+ nickname: "Cloned Pro",
48
+ description: "A cloned plan",
49
+ features: ["Unlimited exports"],
50
+ token: 500,
51
+ isTrial: false,
52
+ platformFeatures: ["AI Assistant"],
53
+ };
54
+
55
+ /** The sheet is portaled, so everything is queried from document. */
56
+ const input = (name: string) => document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
57
+ const selectTriggers = () => Array.from(document.querySelectorAll('[data-slot="select-trigger"]'));
58
+ // Base UI puts the `id` on the hidden native input it renders for the form,
59
+ // not on the visible control, so read the checked state from there.
60
+ const checkbox = (featureId: string) => document.getElementById(`feature-${featureId}`) as HTMLInputElement | null;
61
+ const isChecked = (featureId: string) => checkbox(featureId)?.checked === true;
62
+
63
+ /** Rendered once the async FeatureService.findMany has landed. */
64
+ const waitForFeatures = async () => {
65
+ await waitFor(() => expect(checkbox(CORE.id)).not.toBeNull());
66
+ };
67
+
68
+ // `Modules.StripePrice` resolves through the registry at render time.
69
+ beforeAll(() => {
70
+ ModuleRegistry.register("StripePrice", {
71
+ name: "stripe-prices",
72
+ pageUrl: "/administration/prices",
73
+ } as any);
74
+ });
75
+
76
+ beforeEach(() => {
77
+ vi.clearAllMocks();
78
+ mocks.findMany.mockResolvedValue([CORE, AI, SSO]);
79
+ mocks.createPrice.mockImplementation(async (payload: { id: string }) => ({ id: payload.id }));
80
+ configureI18n({
81
+ useRouter: () => ({
82
+ push: vi.fn(),
83
+ replace: vi.fn(),
84
+ back: vi.fn(),
85
+ forward: vi.fn(),
86
+ refresh: vi.fn(),
87
+ prefetch: vi.fn(),
88
+ }),
89
+ useTranslations: () => (key: string) => key,
90
+ Link: ({ children }: any) => children,
91
+ usePathname: () => "/",
92
+ });
93
+ });
94
+
95
+ describe("PriceEditor — seed", () => {
96
+ it("renders a seeded editor in CREATE mode", async () => {
97
+ render(<PriceEditor forceShow productId="product-1" seed={seed} />);
98
+
99
+ expect(await screen.findByText("common.edit.create.title")).not.toBeNull();
100
+ expect(screen.getByTestId("modal-button-create").textContent).toContain("ui.buttons.confirm_create");
101
+ expect(screen.queryByText("common.edit.update.title")).toBeNull();
102
+ expect(screen.queryByText("billing.admin.prices.immutability.title")).toBeNull();
103
+ });
104
+
105
+ it("pre-fills the fields from the seed, converting the amount to MAJOR units", async () => {
106
+ render(<PriceEditor forceShow productId="product-1" seed={seed} />);
107
+ await screen.findByText("common.edit.create.title");
108
+
109
+ // 5900 minor units -> 59 major units.
110
+ expect(input("unitAmount")?.value).toBe("59");
111
+ expect(input("nickname")?.value).toBe("Cloned Pro");
112
+ // The token pipeline is string-based end to end.
113
+ expect(input("token")?.value).toBe("500");
114
+ expect(input("intervalCount")?.value).toBe("1");
115
+ expect(screen.getByDisplayValue("A cloned plan")).not.toBeNull();
116
+ expect(screen.getByDisplayValue("Unlimited exports")).not.toBeNull();
117
+
118
+ // Trigger order: currency, interval, usageType.
119
+ expect(selectTriggers()[0]?.textContent).toContain("USD ($)");
120
+ expect(selectTriggers()[1]?.textContent).toContain("billing.admin.prices.interval.year");
121
+ });
122
+
123
+ it("checks the seeded platform features once the late feature list lands", async () => {
124
+ render(<PriceEditor forceShow productId="product-1" seed={seed} />);
125
+ await waitForFeatures();
126
+
127
+ // The guarded re-seed: `allFeatures` resolves AFTER useForm has seeded, and
128
+ // EditorSheet only re-seeds when isEdit — without the catch-up effect both
129
+ // of these stay unchecked.
130
+ await waitFor(() => expect(isChecked(AI.id)).toBe(true));
131
+ expect(isChecked(CORE.id)).toBe(true);
132
+ expect(isChecked(SSO.id)).toBe(false);
133
+ });
134
+
135
+ it("drops a platform feature this environment does not know, warns once, and does not throw", async () => {
136
+ const foreignSeed: StripePriceSeed = { ...seed, platformFeatures: ["AI Assistant", "Telepathy"] };
137
+
138
+ render(<PriceEditor forceShow productId="product-1" seed={foreignSeed} />);
139
+ await waitForFeatures();
140
+
141
+ await waitFor(() => expect(mocks.showToast).toHaveBeenCalledTimes(1));
142
+ expect(mocks.showToast).toHaveBeenCalledWith("billing.admin.prices.import.warnings.unknownFeatures");
143
+
144
+ // The known name still resolves; nothing extra gets checked.
145
+ await waitFor(() => expect(isChecked(AI.id)).toBe(true));
146
+ expect(isChecked(CORE.id)).toBe(true);
147
+ expect(isChecked(SSO.id)).toBe(false);
148
+ });
149
+
150
+ it("submits a seeded editor as a brand-new price: fresh uuid, no Stripe identity", async () => {
151
+ render(<PriceEditor forceShow productId="product-1" seed={seed} />);
152
+ await waitForFeatures();
153
+ await waitFor(() => expect(isChecked(AI.id)).toBe(true));
154
+
155
+ fireEvent.click(screen.getByTestId("modal-button-create"));
156
+
157
+ await waitFor(() => expect(mocks.createPrice).toHaveBeenCalledTimes(1));
158
+ expect(mocks.updatePrice).not.toHaveBeenCalled();
159
+
160
+ const payload = mocks.createPrice.mock.calls[0][0];
161
+ expect(payload.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
162
+ expect(payload).not.toHaveProperty("stripePriceId");
163
+ expect(JSON.stringify(payload)).not.toContain("stripePriceId");
164
+ expect(JSON.stringify(payload)).not.toContain("price_");
165
+
166
+ // The rest of the seed survives the round trip, back in MINOR units.
167
+ expect(payload.productId).toBe("product-1");
168
+ expect(payload.unitAmount).toBe(5900);
169
+ expect(payload.currency).toBe("usd");
170
+ expect(payload.recurring).toEqual({ interval: "year", intervalCount: 1, usageType: "licensed" });
171
+ expect(payload.token).toBe(500);
172
+ expect(payload.featureIds).toEqual(expect.arrayContaining([AI.id, CORE.id]));
173
+ });
174
+
175
+ it("does not put the editor in edit mode", async () => {
176
+ render(<PriceEditor forceShow productId="product-1" seed={seed} />);
177
+ await screen.findByText("common.edit.create.title");
178
+
179
+ expect(screen.queryByText("billing.admin.prices.immutability.title")).toBeNull();
180
+ expect(input("unitAmount")?.disabled).toBe(false);
181
+ });
182
+ });
@@ -1,16 +1,23 @@
1
1
  "use client";
2
2
 
3
- import { DollarSignIcon } from "lucide-react";
3
+ import { DollarSignIcon, UploadIcon } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
- import { ReactNode, useEffect } from "react";
6
- import { ContentListTable } from "../../../../../components";
5
+ import { ChangeEvent, Fragment, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { ContentListTable, errorToast } from "../../../../../components";
7
7
  import { Modules } from "../../../../../core";
8
8
  import { DataListRetriever, useDataListRetriever } from "../../../../../hooks";
9
- import { EmptyState } from "../../../../../shadcnui";
9
+ import { Button, EmptyState } from "../../../../../shadcnui";
10
10
  import { usePriceContext } from "../../../contexts/PriceContext";
11
11
  import { StripePriceFields } from "../../data/stripe-price.fields";
12
12
  import { StripePriceInterface } from "../../data/stripe-price.interface";
13
13
  import { StripePriceService } from "../../data/stripe-price.service";
14
+ import {
15
+ buildStripePriceTransferDocument,
16
+ parseStripePriceTransferDocument,
17
+ priceToSeed,
18
+ StripePriceSeed,
19
+ stripePriceTransferFilename,
20
+ } from "../../data/stripe-price.transfer";
14
21
  import PriceEditor from "../forms/PriceEditor";
15
22
 
16
23
  type PricesListProps = {
@@ -27,6 +34,17 @@ type PricesListProps = {
27
34
  export function PricesList({ productId, fullWidth }: PricesListProps) {
28
35
  const t = useTranslations();
29
36
  const { priceVersion } = usePriceContext();
37
+ const importInputRef = useRef<HTMLInputElement>(null);
38
+
39
+ // ONE seeded editor serves both clone and import. It is mounted on demand
40
+ // (`seed &&`) rather than kept mounted and toggled, because PriceEditor builds
41
+ // its react-hook-form defaults at construction time — a permanently mounted
42
+ // editor would be constructed with no seed and ignore every later one.
43
+ const [seed, setSeed] = useState<StripePriceSeed | undefined>(undefined);
44
+ // Remount key. Cloning a second row while the first clone's editor has just
45
+ // closed would otherwise reuse the same component instance and keep the first
46
+ // seed's defaults.
47
+ const [seedNonce, setSeedNonce] = useState(0);
30
48
 
31
49
  const data: DataListRetriever<StripePriceInterface> = useDataListRetriever({
32
50
  retriever: (params) => StripePriceService.listPrices({ ...params, productId }),
@@ -38,29 +56,138 @@ export function PricesList({ productId, fullWidth }: PricesListProps) {
38
56
  if (priceVersion > 0) void data.refresh();
39
57
  }, [priceVersion]);
40
58
 
41
- const functions: ReactNode[] = [<PriceEditor key="create-price" productId={productId} />];
59
+ const openSeededEditor = useCallback((next: StripePriceSeed) => {
60
+ setSeed(next);
61
+ setSeedNonce((nonce) => nonce + 1);
62
+ }, []);
63
+
64
+ const onClone = useCallback(
65
+ (price: StripePriceInterface) => openSeededEditor(priceToSeed(price)),
66
+ [openSeededEditor],
67
+ );
68
+
69
+ /**
70
+ * Purely client-side: the document is built from the price already in memory,
71
+ * so there is no API round trip. Download mechanics copied verbatim from
72
+ * BackupCodesDialog.handleDownload (Blob -> createObjectURL -> anchor ->
73
+ * click -> revokeObjectURL).
74
+ */
75
+ const onExport = useCallback((price: StripePriceInterface) => {
76
+ const text = JSON.stringify(buildStripePriceTransferDocument(price), null, 2);
77
+ const blob = new Blob([text], { type: "application/json" });
78
+ const url = URL.createObjectURL(blob);
79
+ const a = document.createElement("a");
80
+ a.href = url;
81
+ a.download = stripePriceTransferFilename(price);
82
+ a.click();
83
+ URL.revokeObjectURL(url);
84
+ }, []);
85
+
86
+ // Memoised: ContentListTable is `memo`-wrapped and feeds this straight into
87
+ // the table generator, so a fresh object every render would rebuild every
88
+ // column on every render.
89
+ const tableContext = useMemo(() => ({ onClone, onExport }), [onClone, onExport]);
90
+
91
+ const onImportFileChange = useCallback(
92
+ async (event: ChangeEvent<HTMLInputElement>) => {
93
+ const input = event.target;
94
+ const file = input.files?.[0];
95
+ // Reset immediately (EditableAvatar.tsx:119-126 does the same) so picking
96
+ // the SAME file twice still fires `change`. Held in `input` rather than
97
+ // read off the event after the await, since the handler is async.
98
+ input.value = "";
99
+ if (!file) return;
100
+
101
+ try {
102
+ // NOT named `document`: that shadows the global the export path uses for
103
+ // `document.createElement`, and a later edit that moved code between the
104
+ // two handlers would break silently.
105
+ const transfer = parseStripePriceTransferDocument(await file.text());
106
+ openSeededEditor(transfer.price);
107
+ } catch (error) {
108
+ // The parser's own messages are hardcoded English (it is a pure,
109
+ // i18n-free module), so they are logged for debugging and the user sees
110
+ // the translated line instead.
111
+ console.error("[PricesList] price import failed", error);
112
+ errorToast({
113
+ title: t("billing.admin.prices.import.errors.title"),
114
+ error: t("billing.admin.prices.import.errors.invalid"),
115
+ });
116
+ }
117
+ },
118
+ [openSeededEditor, t],
119
+ );
120
+
121
+ const functions: ReactNode[] = [
122
+ <PriceEditor
123
+ key="create-price"
124
+ productId={productId}
125
+ // Present ONLY to suppress navigation: without an `onSuccess`,
126
+ // EditorSheet.wrappedOnSubmit falls through to `onNavigate` and pushes the
127
+ // brand-new price's page, yanking the admin off the product they were
128
+ // editing. Deliberately does NOT call `data.refresh()` — PriceContext's
129
+ // `createPrice` already bumps `priceVersion`, and the effect above
130
+ // refreshes on that, so refreshing here too would fire two identical list
131
+ // requests for one create.
132
+ onSuccess={() => {}}
133
+ />,
134
+ <Fragment key="import-price">
135
+ <Button variant="outline" size="sm" type="button" onClick={() => importInputRef.current?.click()}>
136
+ <UploadIcon />
137
+ {t("billing.admin.prices.actions.import")}
138
+ </Button>
139
+ <input
140
+ ref={importInputRef}
141
+ type="file"
142
+ accept="application/json"
143
+ className="hidden"
144
+ onChange={onImportFileChange}
145
+ />
146
+ </Fragment>,
147
+ ];
42
148
 
43
149
  return (
44
- <ContentListTable
45
- data={data}
46
- fields={[
47
- StripePriceFields.nickname,
48
- StripePriceFields.amount,
49
- StripePriceFields.interval,
50
- StripePriceFields.token,
51
- StripePriceFields.status,
52
- ]}
53
- tableGeneratorType={Modules.StripePrice}
54
- functions={functions}
55
- fullWidth={fullWidth}
56
- title={t("billing.admin.prices.title")}
57
- emptyState={
58
- <EmptyState
59
- icon={DollarSignIcon}
60
- title={t("billing.admin.prices.empty.title")}
61
- description={t("billing.admin.prices.empty.description")}
150
+ <>
151
+ <ContentListTable
152
+ data={data}
153
+ fields={[
154
+ StripePriceFields.nickname,
155
+ StripePriceFields.amount,
156
+ StripePriceFields.interval,
157
+ StripePriceFields.token,
158
+ StripePriceFields.status,
159
+ StripePriceFields.actions,
160
+ ]}
161
+ tableGeneratorType={Modules.StripePrice}
162
+ functions={functions}
163
+ context={tableContext}
164
+ fullWidth={fullWidth}
165
+ title={t("billing.admin.prices.title")}
166
+ emptyState={
167
+ <EmptyState
168
+ icon={DollarSignIcon}
169
+ title={t("billing.admin.prices.empty.title")}
170
+ description={t("billing.admin.prices.empty.description")}
171
+ />
172
+ }
173
+ />
174
+ {/* Passing `dialogOpen` makes EditorSheet render no trigger at all
175
+ (EditorSheet.tsx:265-267), so this is a headless, caller-driven sheet. */}
176
+ {seed && (
177
+ <PriceEditor
178
+ key={seedNonce}
179
+ productId={productId}
180
+ seed={seed}
181
+ dialogOpen
182
+ onDialogOpenChange={(open) => {
183
+ if (!open) setSeed(undefined);
184
+ }}
185
+ onSuccess={async () => {
186
+ setSeed(undefined);
187
+ await data.refresh();
188
+ }}
62
189
  />
63
- }
64
- />
190
+ )}
191
+ </>
65
192
  );
66
193
  }
@@ -0,0 +1,268 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ModuleRegistry } from "../../../../../../core/registry/ModuleRegistry";
4
+ import { configureI18n } from "../../../../../../i18n";
5
+ // Side-effect import: registers the "stripe-prices" table generator, which is
6
+ // what turns `StripePriceFields.actions` into a real column. Without it the
7
+ // registry has nothing to resolve and the table renders no columns at all.
8
+ import "../../../hooks/useStripePriceTableStructure";
9
+ import { PricesList } from "../PricesList";
10
+
11
+ // Hoisted so the vi.mock factories below (which run before the module body) can
12
+ // close over them. `retriever` is a mutable holder because the factory is
13
+ // evaluated at import time, long before beforeEach builds the fake retriever.
14
+ const mocks = vi.hoisted(() => ({
15
+ errorToast: vi.fn(),
16
+ refresh: vi.fn(),
17
+ retriever: { current: null as any },
18
+ }));
19
+
20
+ // Only the two members PricesList takes from the barrel. Mocking it (rather
21
+ // than spreading the real one) keeps every feature barrel out of this suite;
22
+ // ContentListTable itself is the REAL component, because the assertions are
23
+ // about the columns it renders.
24
+ vi.mock("../../../../../../components", async () => {
25
+ const actual = await vi.importActual<typeof import("../../../../../../components/tables/ContentListTable")>(
26
+ "../../../../../../components/tables/ContentListTable",
27
+ );
28
+ return { ContentListTable: actual.ContentListTable, errorToast: mocks.errorToast };
29
+ });
30
+
31
+ // `useTableGenerator` stays REAL (the actions column is what is under test);
32
+ // only the network-backed list retriever is replaced.
33
+ vi.mock("../../../../../../hooks", async () => {
34
+ const actual = await vi.importActual<any>("../../../../../../hooks");
35
+ return { ...actual, useDataListRetriever: () => mocks.retriever.current };
36
+ });
37
+
38
+ vi.mock("../../../../contexts/PriceContext", () => ({
39
+ usePriceContext: () => ({ priceVersion: 0 }),
40
+ }));
41
+
42
+ // The real editor mounts a whole EditorSheet + form; here it only has to report
43
+ // WHICH editor was rendered and with what seed.
44
+ vi.mock("../../forms/PriceEditor", () => ({
45
+ default: (props: any) =>
46
+ props.seed ? (
47
+ <div
48
+ data-testid="seeded-editor"
49
+ data-seed={JSON.stringify(props.seed)}
50
+ data-dialog-open={String(!!props.dialogOpen)}
51
+ />
52
+ ) : (
53
+ <div data-testid="create-editor" data-has-on-success={String(typeof props.onSuccess === "function")} />
54
+ ),
55
+ }));
56
+
57
+ const makePrice = (overrides: Record<string, any> = {}) => ({
58
+ id: "price-1",
59
+ // Stripe identity: must NEVER reach an exported document.
60
+ stripePriceId: "price_1NabcXYZ",
61
+ nickname: "Standard",
62
+ unitAmount: 2900,
63
+ currency: "usd",
64
+ priceType: "recurring",
65
+ recurring: { interval: "month", intervalCount: 1, usageType: "licensed" },
66
+ token: 100,
67
+ isTrial: false,
68
+ active: true,
69
+ description: "The standard plan",
70
+ features: ["Everything in Basic"],
71
+ priceFeatures: [{ id: "feature-ai", name: "AI Assistant" }],
72
+ ...overrides,
73
+ });
74
+
75
+ const prices = [makePrice(), makePrice({ id: "price-2", stripePriceId: "price_2DefGHI", nickname: "Pro" })];
76
+
77
+ const makeRetriever = (data: any[]) =>
78
+ ({
79
+ data,
80
+ isLoaded: true,
81
+ ready: true,
82
+ next: undefined,
83
+ previous: undefined,
84
+ pageInfo: { startItem: 1, endItem: data.length },
85
+ total: data.length,
86
+ refresh: mocks.refresh,
87
+ addAdditionalParameter: vi.fn(),
88
+ removeAdditionalParameter: vi.fn(),
89
+ setRefreshedElement: vi.fn(),
90
+ removeElement: vi.fn(),
91
+ search: vi.fn(),
92
+ setReady: vi.fn(),
93
+ isSearch: false,
94
+ }) as any;
95
+
96
+ /** The hidden import input is driven by a ref, so it is queried, not clicked. */
97
+ const importInput = () => document.querySelector<HTMLInputElement>('input[type="file"]')!;
98
+
99
+ const cloneButtons = () => screen.queryAllByLabelText("billing.admin.prices.actions.clone");
100
+ const exportButtons = () => screen.queryAllByLabelText("billing.admin.prices.actions.export");
101
+
102
+ const anchorClicks: { href: string; download: string }[] = [];
103
+
104
+ // `Modules.StripePrice` resolves through the registry at render time, and the
105
+ // nickname column builds a URL from it.
106
+ beforeAll(() => {
107
+ ModuleRegistry.register("StripePrice", {
108
+ name: "stripe-prices",
109
+ pageUrl: "/administration/prices",
110
+ } as any);
111
+ });
112
+
113
+ beforeEach(() => {
114
+ vi.clearAllMocks();
115
+ anchorClicks.length = 0;
116
+ mocks.retriever.current = makeRetriever(prices);
117
+
118
+ (URL as any).createObjectURL = vi.fn(() => "blob:price-export");
119
+ (URL as any).revokeObjectURL = vi.fn();
120
+ // jsdom does not navigate, so a real anchor click only logs "Not
121
+ // implemented". Spying records the href/download the download path produced.
122
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
123
+ anchorClicks.push({ href: this.href, download: this.download });
124
+ });
125
+
126
+ configureI18n({
127
+ useRouter: () => ({
128
+ push: vi.fn(),
129
+ replace: vi.fn(),
130
+ back: vi.fn(),
131
+ forward: vi.fn(),
132
+ refresh: vi.fn(),
133
+ prefetch: vi.fn(),
134
+ }),
135
+ useTranslations: () => (key: string) => key,
136
+ Link: ({ children }: any) => children,
137
+ usePathname: () => "/",
138
+ });
139
+ });
140
+
141
+ describe("PricesList — actions column", () => {
142
+ it("renders a clone and an export button for every row", () => {
143
+ render(<PricesList productId="product-1" />);
144
+
145
+ expect(cloneButtons()).toHaveLength(prices.length);
146
+ expect(exportButtons()).toHaveLength(prices.length);
147
+ });
148
+
149
+ it("opens a seeded editor when a row is cloned", async () => {
150
+ render(<PricesList productId="product-1" />);
151
+ expect(screen.queryByTestId("seeded-editor")).not.toBeInTheDocument();
152
+
153
+ fireEvent.click(cloneButtons()[1]);
154
+
155
+ const editor = await screen.findByTestId("seeded-editor");
156
+ const seed = JSON.parse(editor.getAttribute("data-seed")!);
157
+ expect(seed.nickname).toBe("Pro");
158
+ // MINOR units survive the clone, identity does not.
159
+ expect(seed.unitAmount).toBe(2900);
160
+ expect(seed).not.toHaveProperty("stripePriceId");
161
+ expect(editor.getAttribute("data-dialog-open")).toBe("true");
162
+ });
163
+ });
164
+
165
+ describe("PricesList — export", () => {
166
+ it("downloads a JSON document that carries no Stripe identity", async () => {
167
+ render(<PricesList productId="product-1" />);
168
+
169
+ fireEvent.click(exportButtons()[0]);
170
+
171
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
172
+ const blob = (URL.createObjectURL as any).mock.calls[0][0] as Blob;
173
+ expect(blob.type).toBe("application/json");
174
+
175
+ const text = await blob.text();
176
+ expect(text).not.toContain("stripePriceId");
177
+ expect(text).not.toContain("price_1NabcXYZ");
178
+ expect(JSON.parse(text)).toMatchObject({
179
+ kind: "stripe-price",
180
+ version: 1,
181
+ price: { unitAmount: 2900, currency: "usd", interval: "month", nickname: "Standard" },
182
+ });
183
+
184
+ expect(anchorClicks).toEqual([{ href: "blob:price-export", download: "price-standard.json" }]);
185
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:price-export");
186
+ });
187
+
188
+ it("exports without touching the API", () => {
189
+ render(<PricesList productId="product-1" />);
190
+
191
+ fireEvent.click(exportButtons()[0]);
192
+
193
+ expect(mocks.refresh).not.toHaveBeenCalled();
194
+ });
195
+ });
196
+
197
+ describe("PricesList — import", () => {
198
+ const upload = (contents: string, name = "price.json") => {
199
+ const file = new File([contents], name, { type: "application/json" });
200
+ fireEvent.change(importInput(), { target: { files: [file] } });
201
+ };
202
+
203
+ it("opens the seeded editor from a valid transfer document", async () => {
204
+ render(<PricesList productId="product-1" />);
205
+
206
+ upload(
207
+ JSON.stringify({
208
+ kind: "stripe-price",
209
+ version: 1,
210
+ price: { unitAmount: 9900, currency: "eur", interval: "year", nickname: "Imported" },
211
+ }),
212
+ );
213
+
214
+ const editor = await screen.findByTestId("seeded-editor");
215
+ expect(JSON.parse(editor.getAttribute("data-seed")!)).toMatchObject({
216
+ unitAmount: 9900,
217
+ currency: "eur",
218
+ interval: "year",
219
+ nickname: "Imported",
220
+ });
221
+ expect(mocks.errorToast).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it("resets the input so the same file can be picked twice", async () => {
225
+ render(<PricesList productId="product-1" />);
226
+
227
+ upload(
228
+ JSON.stringify({
229
+ kind: "stripe-price",
230
+ version: 1,
231
+ price: { unitAmount: 100, currency: "usd", interval: "month" },
232
+ }),
233
+ );
234
+
235
+ await screen.findByTestId("seeded-editor");
236
+ expect(importInput().value).toBe("");
237
+ });
238
+
239
+ it("toasts and opens nothing when the file is garbage", async () => {
240
+ render(<PricesList productId="product-1" />);
241
+
242
+ upload("this is not json at all");
243
+
244
+ await waitFor(() => expect(mocks.errorToast).toHaveBeenCalledTimes(1));
245
+ expect(mocks.errorToast.mock.calls[0][0].title).toBe("billing.admin.prices.import.errors.title");
246
+ // The parser's messages are hardcoded English, so the toast carries the
247
+ // translated line instead and the raw error only reaches the console.
248
+ expect(mocks.errorToast.mock.calls[0][0].error).toBe("billing.admin.prices.import.errors.invalid");
249
+ expect(screen.queryByTestId("seeded-editor")).not.toBeInTheDocument();
250
+ });
251
+
252
+ it("toasts and opens nothing when the JSON is not a price transfer document", async () => {
253
+ render(<PricesList productId="product-1" />);
254
+
255
+ upload(JSON.stringify({ kind: "something-else", version: 1, price: {} }));
256
+
257
+ await waitFor(() => expect(mocks.errorToast).toHaveBeenCalledTimes(1));
258
+ expect(screen.queryByTestId("seeded-editor")).not.toBeInTheDocument();
259
+ });
260
+ });
261
+
262
+ describe("PricesList — create", () => {
263
+ it("gives the create editor an onSuccess, which is what suppresses the navigation", () => {
264
+ render(<PricesList productId="product-1" />);
265
+
266
+ expect(screen.getByTestId("create-editor")).toHaveAttribute("data-has-on-success", "true");
267
+ });
268
+ });