@tribe-nest/forge 3.4.0 → 3.9.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.
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
+ import { WalletPassButtons } from "../WalletPassButtons";
8
+ import type { WalletPassStatus } from "../../../data/queries/useWalletPass";
9
+
10
+ /**
11
+ * Events 2.3 — the one behaviour this component exists for: **when wallet
12
+ * passes are unavailable, the buyer sees NOTHING.**
13
+ *
14
+ * That is not a nicety. No signing credentials are configured in production, so
15
+ * the unavailable path is the ONLY one that runs today; if it leaked a greyed
16
+ * button, a "coming soon" or an error, every ticket-holder on the platform
17
+ * would be told about a feature they will never get.
18
+ *
19
+ * Rendered through `react-dom/server`, which needs no DOM — so these run in the
20
+ * package's existing node vitest environment with no jsdom project. Effects
21
+ * never fire under `renderToStaticMarkup`, so the seeded query cache is the
22
+ * only data source and nothing reaches the network.
23
+ */
24
+
25
+ const PROFILE_ID = "profile-1";
26
+ const ACCOUNT_ID = "account-1";
27
+ const PASS_ID = "TN-1001";
28
+
29
+ /** Matches `walletPassKey` in `useWalletPass`. */
30
+ const key = (passId: string) => ["wallet-pass", passId, ACCOUNT_ID, PROFILE_ID];
31
+
32
+ const status = (overrides: Partial<WalletPassStatus> = {}): WalletPassStatus => ({
33
+ passId: PASS_ID,
34
+ available: true,
35
+ reason: null,
36
+ apple: { available: true, downloadUrl: `/public/events/passes/${PASS_ID}/wallet/apple?profileId=${PROFILE_ID}` },
37
+ google: { available: true, saveUrl: "https://pay.google.com/gp/v/save/a.b.c" },
38
+ ...overrides,
39
+ });
40
+
41
+ function render(
42
+ seed: Record<string, WalletPassStatus> | null,
43
+ props: { passId?: string; passIds?: string[] } = { passId: PASS_ID },
44
+ auth: { user?: { id: string } } = { user: { id: ACCOUNT_ID } },
45
+ ) {
46
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
47
+ for (const [passId, value] of Object.entries(seed ?? {})) {
48
+ queryClient.setQueryData(key(passId), value);
49
+ }
50
+
51
+ return renderToStaticMarkup(
52
+ <QueryClientProvider client={queryClient}>
53
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
54
+ <PublicAuthContext.Provider value={auth as never}>
55
+ <ForgeThemeProvider
56
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
57
+ >
58
+ <WalletPassButtons {...props} />
59
+ </ForgeThemeProvider>
60
+ </PublicAuthContext.Provider>
61
+ </ForgeClientProvider>
62
+ </QueryClientProvider>,
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Nothing at all — not an empty wrapper, not a hidden node, not a class name.
68
+ *
69
+ * `ForgeThemeProvider` emits its own `<style>` block of CSS variables, which is
70
+ * the harness rather than anything this component drew, so it is stripped
71
+ * first. What remains must be the empty string: an exact equality, not a
72
+ * "does not contain Wallet", because the point is that the component
73
+ * contributes NO node a curious buyer could find in the DOM.
74
+ */
75
+ const rendersNothing = (html: string) => {
76
+ expect(html.replace(/<style>.*?<\/style>/gs, "")).toBe("");
77
+ expect(html).not.toContain("wallet");
78
+ expect(html).not.toContain("Wallet");
79
+ };
80
+
81
+ describe("WalletPassButtons — invisible when unavailable", () => {
82
+ it("renders NOTHING in the state production is in today (no signing credentials)", () => {
83
+ // The literal response the API gives right now, for every pass.
84
+ rendersNothing(
85
+ render({
86
+ [PASS_ID]: status({
87
+ available: false,
88
+ reason: "not_configured",
89
+ apple: { available: false, downloadUrl: null },
90
+ google: { available: false, saveUrl: null },
91
+ }),
92
+ }),
93
+ );
94
+ });
95
+
96
+ it("renders NOTHING for a pass on signed rotating QR", () => {
97
+ rendersNothing(
98
+ render({
99
+ [PASS_ID]: status({
100
+ available: false,
101
+ reason: "rotating_qr",
102
+ apple: { available: false, downloadUrl: null },
103
+ google: { available: false, saveUrl: null },
104
+ }),
105
+ }),
106
+ );
107
+ });
108
+
109
+ it("renders NOTHING for a cancelled or refunded order", () => {
110
+ rendersNothing(
111
+ render({
112
+ [PASS_ID]: status({
113
+ available: false,
114
+ reason: "order_not_active",
115
+ apple: { available: false, downloadUrl: null },
116
+ google: { available: false, saveUrl: null },
117
+ }),
118
+ }),
119
+ );
120
+ });
121
+
122
+ it("renders NOTHING before the API has answered — no spinner, no skeleton", () => {
123
+ // A placeholder where a button will never appear is the same tell as the
124
+ // button itself, so there is deliberately no loading state anywhere.
125
+ rendersNothing(render(null));
126
+ });
127
+
128
+ it("renders NOTHING for an anonymous visitor", () => {
129
+ // The query is disabled without an account id, so it never even asks.
130
+ rendersNothing(render({ [PASS_ID]: status() }, { passId: PASS_ID }, {}));
131
+ });
132
+
133
+ it("renders NOTHING when given no pass ids at all", () => {
134
+ // What `myTicketPassIds` yields for an order whose payload carries no
135
+ // passes — an older API build, or an order with nothing admitted yet.
136
+ rendersNothing(render({ [PASS_ID]: status() }, { passIds: [] }));
137
+ });
138
+
139
+ it("renders NOTHING for an id that is not a pass id, rather than requesting a certain 404", () => {
140
+ rendersNothing(render({ [PASS_ID]: status() }, { passId: "0f9c8b7a-1111-2222-3333-444455556666" }));
141
+ });
142
+
143
+ it("renders NOTHING when the API says available but both providers came back empty", () => {
144
+ rendersNothing(
145
+ render({
146
+ [PASS_ID]: status({
147
+ apple: { available: false, downloadUrl: null },
148
+ google: { available: false, saveUrl: null },
149
+ }),
150
+ }),
151
+ );
152
+ });
153
+
154
+ it("hides an unavailable pass while still drawing an available sibling", () => {
155
+ // The per-pass gate, not an all-or-nothing one: a rotating-QR ticket in the
156
+ // same order must vanish without taking its neighbour with it.
157
+ const html = render(
158
+ {
159
+ "TN-1": status({ passId: "TN-1" }),
160
+ "TN-2": status({
161
+ passId: "TN-2",
162
+ available: false,
163
+ reason: "rotating_qr",
164
+ apple: { available: false, downloadUrl: null },
165
+ google: { available: false, saveUrl: null },
166
+ }),
167
+ },
168
+ { passIds: ["TN-1", "TN-2"] },
169
+ );
170
+
171
+ expect(html).toContain("TN-1");
172
+ expect(html).not.toContain("TN-2");
173
+ });
174
+ });
175
+
176
+ describe("WalletPassButtons — what appears once a provider is configured", () => {
177
+ it("draws both badges when both providers answer", () => {
178
+ const html = render({ [PASS_ID]: status() });
179
+
180
+ expect(html).toContain('data-testid="wallet-pass-buttons"');
181
+ expect(html).toContain("Add to Apple Wallet");
182
+ expect(html).toContain("Save to Google Wallet");
183
+ // The Google save link is the vendor's own URL, used verbatim.
184
+ expect(html).toContain("https://pay.google.com/gp/v/save/a.b.c");
185
+ expect(html).toContain('rel="noopener noreferrer"');
186
+ });
187
+
188
+ it("degrades to Apple alone when Google is not configured", () => {
189
+ // The two providers are approved independently, so this is a state that
190
+ // will really happen — not a theoretical one.
191
+ const html = render({ [PASS_ID]: status({ google: { available: false, saveUrl: null } }) });
192
+
193
+ expect(html).toContain("Add to Apple Wallet");
194
+ expect(html).not.toContain("Save to Google Wallet");
195
+ });
196
+
197
+ it("degrades to Google alone when Apple is not configured", () => {
198
+ const html = render({ [PASS_ID]: status({ apple: { available: false, downloadUrl: null } }) });
199
+
200
+ expect(html).toContain("Save to Google Wallet");
201
+ expect(html).not.toContain("Add to Apple Wallet");
202
+ });
203
+
204
+ it("fetches the .pkpass through the client rather than linking at the API", () => {
205
+ // A bearer-authenticated binary: an `<a href>` straight at the endpoint
206
+ // would carry no Authorization header and 401.
207
+ const html = render({ [PASS_ID]: status({ google: { available: false, saveUrl: null } }) });
208
+
209
+ expect(html).toContain("<button");
210
+ expect(html).not.toContain("wallet/apple");
211
+ });
212
+
213
+ it("draws every colour from the theme", () => {
214
+ const html = render({ [PASS_ID]: status() });
215
+
216
+ // The badge inverts the theme rather than hardcoding the vendors' black.
217
+ expect(html).toContain("background:#111111");
218
+ expect(html).toContain("color:#ffffff");
219
+ // The only fixed colours are inside the Google mark itself, which is a
220
+ // brandmark and not a styling choice.
221
+ expect(html).toContain("#4285F4");
222
+ });
223
+ });