@drip-apex/sdk 0.1.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,2823 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ function makeStorage(initial?: Record<string, string>) {
4
+ const map = new Map<string, string>(Object.entries(initial ?? {}));
5
+ return {
6
+ getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
7
+ key: (index: number) => Array.from(map.keys())[index] ?? null,
8
+ removeItem: (key: string) => { map.delete(key); },
9
+ setItem: (key: string, value: string) => { map.set(key, value); },
10
+ get length() { return map.size; },
11
+ };
12
+ }
13
+
14
+ describe("syncShopifyCartAttributes", () => {
15
+ const originalWindow = (globalThis as any).window;
16
+ const originalSessionStorage = (globalThis as any).sessionStorage;
17
+ const originalFetch = globalThis.fetch;
18
+
19
+ beforeEach(() => {
20
+ vi.resetModules();
21
+ (globalThis as any).window = { Shopify: {} };
22
+ (globalThis as any).sessionStorage = makeStorage();
23
+ globalThis.fetch = vi.fn(async () => new Response("ok", { status: 200 })) as any;
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ (globalThis as any).window = originalWindow;
29
+ (globalThis as any).sessionStorage = originalSessionStorage;
30
+ globalThis.fetch = originalFetch;
31
+ });
32
+
33
+ it("posts all Apex assignments plus primary attribution keys to the Shopify cart", async () => {
34
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
35
+
36
+ syncShopifyCartAttributes({
37
+ assignments: [
38
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
39
+ { experimentId: "exp_456", variationId: "var_456" },
40
+ ],
41
+ visitorId: "visitor_123",
42
+ sessionId: "session_123",
43
+ });
44
+ await Promise.resolve();
45
+
46
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
47
+ const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!;
48
+ expect(url).toBe("/cart/update.js");
49
+ expect(init).toMatchObject({
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ keepalive: true,
53
+ });
54
+ expect(JSON.parse(String(init?.body))).toEqual({
55
+ attributes: {
56
+ apex_assignments: JSON.stringify([
57
+ { e: "exp_123", v: "var_123", ep: 2 },
58
+ { e: "exp_456", v: "var_456" },
59
+ ]),
60
+ apex_assignment_authorization: "",
61
+ apex_experiment_id: "exp_123",
62
+ apex_variation_id: "var_123",
63
+ apex_assignment_epoch: "2",
64
+ apex_visitor_id: "visitor_123",
65
+ apex_session_id: "session_123",
66
+ },
67
+ });
68
+ await vi.waitFor(() =>
69
+ expect(JSON.parse(String(sessionStorage.getItem("drip_cart_attr")))).toEqual({
70
+ assignments: [
71
+ ["exp_123", "var_123", 2],
72
+ ["exp_456", "var_456", null],
73
+ ],
74
+ visitorId: "visitor_123",
75
+ sessionId: "session_123",
76
+ cartToken: null,
77
+ shippingAuthorizationRevision: null,
78
+ priceAuthorizationRevision: null,
79
+ }),
80
+ );
81
+ });
82
+
83
+ it("builds ordered Storefront API cart attribute entries with compact assignment keys", async () => {
84
+ const { buildCartAttributeEntries } = await import("./shopify-cart");
85
+
86
+ const entries = buildCartAttributeEntries({
87
+ assignments: [
88
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
89
+ { experimentId: "exp_456", variationId: "var_456" },
90
+ ],
91
+ visitorId: "visitor_123",
92
+ sessionId: "session_123",
93
+ });
94
+
95
+ expect(entries.map((entry) => entry.key)).toEqual([
96
+ "apex_assignments",
97
+ "apex_assignment_authorization",
98
+ "apex_experiment_id",
99
+ "apex_variation_id",
100
+ "apex_assignment_epoch",
101
+ "apex_visitor_id",
102
+ "apex_session_id",
103
+ ]);
104
+ expect(JSON.parse(entries[0]!.value)).toEqual([
105
+ { e: "exp_123", v: "var_123", ep: 2 },
106
+ { e: "exp_456", v: "var_456" },
107
+ ]);
108
+ expect(Object.fromEntries(entries.map((entry) => [entry.key, entry.value]))).toEqual({
109
+ apex_assignments: JSON.stringify([
110
+ { e: "exp_123", v: "var_123", ep: 2 },
111
+ { e: "exp_456", v: "var_456" },
112
+ ]),
113
+ apex_assignment_authorization: "",
114
+ apex_experiment_id: "exp_123",
115
+ apex_variation_id: "var_123",
116
+ apex_assignment_epoch: "2",
117
+ apex_visitor_id: "visitor_123",
118
+ apex_session_id: "session_123",
119
+ });
120
+ });
121
+
122
+ it("keeps ordinary cart attribution best-effort when assignment proof is unavailable", async () => {
123
+ globalThis.fetch = vi.fn()
124
+ .mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
125
+ .mockResolvedValueOnce(new Response("ok", { status: 200 }))
126
+ .mockRejectedValueOnce(new Error("assign network unavailable"))
127
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
128
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
129
+ const input = {
130
+ assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
131
+ visitorId: "visitor_123",
132
+ shopId: "shop_123",
133
+ endpoint: "https://events.example.com/events",
134
+ ingestSignature: "ingest-token",
135
+ };
136
+
137
+ await expect(syncShopifyCartAttributes({
138
+ ...input,
139
+ sessionId: "session_1",
140
+ })).resolves.toEqual({ status: "sent" });
141
+ await expect(syncShopifyCartAttributes({
142
+ ...input,
143
+ sessionId: "session_2",
144
+ })).resolves.toEqual({ status: "sent" });
145
+
146
+ expect(vi.mocked(globalThis.fetch).mock.calls.map(([url]) => url)).toEqual([
147
+ "https://events.example.com/v1/assign",
148
+ "/cart/update.js",
149
+ "https://events.example.com/v1/assign",
150
+ "/cart/update.js",
151
+ ]);
152
+ });
153
+
154
+ it("requests a server authorization before writing checkout attributes", async () => {
155
+ globalThis.fetch = vi
156
+ .fn()
157
+ .mockResolvedValueOnce(
158
+ new Response(
159
+ JSON.stringify({
160
+ assignments: [{
161
+ experiment_id: "exp_123",
162
+ variation_id: "var_123",
163
+ assignment_epoch: 2,
164
+ }],
165
+ commerce_authorization: "signed-envelope",
166
+ }),
167
+ { status: 200, headers: { "Content-Type": "application/json" } },
168
+ ),
169
+ )
170
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
171
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
172
+
173
+ syncShopifyCartAttributes({
174
+ assignments: [
175
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
176
+ ],
177
+ visitorId: "visitor_123",
178
+ sessionId: "session_123",
179
+ shopId: "shop_123",
180
+ endpoint: "https://events.example.com/events",
181
+ ingestSignature: "ingest-token",
182
+ url: "https://store.example.com/products/example",
183
+ environment: "staging",
184
+ shippingAuthorizationRevision: 7,
185
+ requiredAssignments: [
186
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
187
+ ],
188
+ exclusionGroupSelections: [{
189
+ groupId: "group_123",
190
+ experimentId: "exp_123",
191
+ epoch: 4,
192
+ }],
193
+ });
194
+
195
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
196
+ const [authorizationUrl, authorizationInit] = vi.mocked(globalThis.fetch).mock.calls[0]!;
197
+ expect(authorizationUrl).toBe("https://events.example.com/v1/assign");
198
+ expect(authorizationInit?.headers).toEqual({
199
+ "Content-Type": "application/json",
200
+ "X-Drip-Shop": "shop_123",
201
+ "X-Drip-Signature": "ingest-token",
202
+ });
203
+ expect(JSON.parse(String(authorizationInit?.body))).toEqual({
204
+ shop_id: "shop_123",
205
+ user_id: "visitor_123",
206
+ url: "https://store.example.com/products/example",
207
+ environment: "staging",
208
+ experiments: ["exp_123"],
209
+ exclusion_group_selections: [{
210
+ group_id: "group_123",
211
+ experiment_id: "exp_123",
212
+ epoch: 4,
213
+ }],
214
+ });
215
+ const cartBody = JSON.parse(
216
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
217
+ );
218
+ expect(cartBody.attributes.apex_assignment_authorization).toBe(
219
+ "signed-envelope",
220
+ );
221
+ await vi.waitFor(() =>
222
+ expect(JSON.parse(sessionStorage.getItem("drip_cart_attr")!)).toMatchObject({
223
+ shippingAuthorizationRevision: 7,
224
+ }),
225
+ );
226
+ });
227
+
228
+ it("clears stale shipping proof after a successful response reports no shipping assignment", async () => {
229
+ globalThis.fetch = vi.fn()
230
+ .mockResolvedValueOnce(Response.json({
231
+ assignments: [{
232
+ experiment_id: "exp_copy",
233
+ variation_id: "var_copy",
234
+ assignment_epoch: 4,
235
+ }],
236
+ }))
237
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
238
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
239
+
240
+ await expect(syncShopifyCartAttributes({
241
+ assignments: [{
242
+ experimentId: "exp_copy",
243
+ variationId: "var_copy",
244
+ assignmentEpoch: 4,
245
+ }],
246
+ visitorId: "visitor_123",
247
+ sessionId: "session_123",
248
+ shopId: "shop_123",
249
+ endpoint: "https://events.example.com/events",
250
+ ingestSignature: "ingest-token",
251
+ shippingAuthorizationRevision: 7,
252
+ })).resolves.toEqual({ status: "sent" });
253
+
254
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
255
+ const cartUpdate = vi.mocked(globalThis.fetch).mock.calls[1];
256
+ expect(cartUpdate?.[0]).toBe("/cart/update.js");
257
+ expect(JSON.parse(String(cartUpdate?.[1]?.body)).attributes).toMatchObject({
258
+ apex_assignments: JSON.stringify([{ e: "exp_copy", v: "var_copy", ep: 4 }]),
259
+ apex_assignment_authorization: "",
260
+ });
261
+ });
262
+
263
+ it("does not clear stale shipping proof when the assign transport fails", async () => {
264
+ globalThis.fetch = vi.fn()
265
+ .mockRejectedValueOnce(new Error("assign network unavailable")) as any;
266
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
267
+
268
+ await expect(syncShopifyCartAttributes({
269
+ assignments: [{
270
+ experimentId: "exp_copy",
271
+ variationId: "var_copy",
272
+ assignmentEpoch: 4,
273
+ }],
274
+ visitorId: "visitor_123",
275
+ sessionId: "session_123",
276
+ shopId: "shop_123",
277
+ endpoint: "https://events.example.com/events",
278
+ ingestSignature: "ingest-token",
279
+ shippingAuthorizationRevision: 7,
280
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
281
+
282
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
283
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
284
+ });
285
+
286
+ it("preserves stale shipping proof when local assignments are empty and assign transport fails", async () => {
287
+ globalThis.fetch = vi.fn()
288
+ .mockRejectedValueOnce(new Error("assign network unavailable")) as any;
289
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
290
+
291
+ await expect(syncShopifyCartAttributes({
292
+ assignments: [],
293
+ visitorId: "visitor_123",
294
+ sessionId: "session_123",
295
+ shopId: "shop_123",
296
+ endpoint: "https://events.example.com/events",
297
+ ingestSignature: "ingest-token",
298
+ shippingAuthorizationRevision: 7,
299
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
300
+
301
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
302
+ expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).toBe(
303
+ "https://events.example.com/v1/assign",
304
+ );
305
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) =>
306
+ url === "/cart/update.js"
307
+ )).toBe(false);
308
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
309
+ });
310
+
311
+ it("preserves stale shipping proof when the empty-local Worker response cannot be parsed", async () => {
312
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(
313
+ new Response("not-json", {
314
+ status: 200,
315
+ headers: { "Content-Type": "application/json" },
316
+ }),
317
+ ) as any;
318
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
319
+
320
+ await expect(syncShopifyCartAttributes({
321
+ assignments: [],
322
+ visitorId: "visitor_123",
323
+ sessionId: "session_123",
324
+ shopId: "shop_123",
325
+ endpoint: "https://events.example.com/events",
326
+ ingestSignature: "ingest-token",
327
+ shippingAuthorizationRevision: 7,
328
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
329
+
330
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
331
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) =>
332
+ url === "/cart/update.js"
333
+ )).toBe(false);
334
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
335
+ });
336
+
337
+ it("clears stale shipping proof for empty local assignments only after an empty Worker response", async () => {
338
+ globalThis.fetch = vi.fn()
339
+ .mockResolvedValueOnce(Response.json({ assignments: [] }))
340
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
341
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
342
+
343
+ await expect(syncShopifyCartAttributes({
344
+ assignments: [],
345
+ visitorId: "visitor_123",
346
+ sessionId: "session_123",
347
+ shopId: "shop_123",
348
+ endpoint: "https://events.example.com/events",
349
+ ingestSignature: "ingest-token",
350
+ shippingAuthorizationRevision: 7,
351
+ })).resolves.toEqual({ status: "sent" });
352
+
353
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
354
+ const assignBody = JSON.parse(
355
+ String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body),
356
+ );
357
+ expect(assignBody.experiments).toEqual([]);
358
+ const cartUpdate = vi.mocked(globalThis.fetch).mock.calls[1];
359
+ expect(cartUpdate?.[0]).toBe("/cart/update.js");
360
+ expect(JSON.parse(String(cartUpdate?.[1]?.body)).attributes).toMatchObject({
361
+ apex_assignments: "[]",
362
+ apex_assignment_authorization: "",
363
+ });
364
+ });
365
+
366
+ it("clears stale offer proof for an offers-only shop after a cart-proven empty Worker response", async () => {
367
+ globalThis.fetch = vi.fn()
368
+ .mockResolvedValueOnce(Response.json({ assignments: [] }))
369
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
370
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
371
+
372
+ await expect(syncShopifyCartAttributes({
373
+ assignments: [],
374
+ visitorId: "visitor_123",
375
+ sessionId: "session_123",
376
+ shopId: "shop_123",
377
+ endpoint: "https://events.example.com/events",
378
+ ingestSignature: "ingest-token",
379
+ cartToken: "cart-empty-offers?key=private-capability",
380
+ offersAuthorizationRevision: 4,
381
+ })).resolves.toEqual({ status: "sent" });
382
+
383
+ const cartUpdate = vi.mocked(globalThis.fetch).mock.calls[1];
384
+ expect(cartUpdate?.[0]).toBe("/cart/update.js");
385
+ expect(JSON.parse(String(cartUpdate?.[1]?.body)).attributes).toMatchObject({
386
+ apex_assignments: "[]",
387
+ apex_assignment_authorization: "",
388
+ });
389
+ });
390
+
391
+ it("preserves stale shipping proof when an empty-local Worker response still contains an assignment", async () => {
392
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
393
+ assignments: [{
394
+ experiment_id: "exp_shipping",
395
+ variation_id: "var_shipping",
396
+ assignment_epoch: 5,
397
+ }],
398
+ })) as any;
399
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
400
+
401
+ await expect(syncShopifyCartAttributes({
402
+ assignments: [],
403
+ visitorId: "visitor_123",
404
+ sessionId: "session_123",
405
+ shopId: "shop_123",
406
+ endpoint: "https://events.example.com/events",
407
+ ingestSignature: "ingest-token",
408
+ shippingAuthorizationRevision: 7,
409
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
410
+
411
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
412
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) =>
413
+ url === "/cart/update.js"
414
+ )).toBe(false);
415
+ });
416
+
417
+ it("preserves stale shipping proof for empty local assignments when authorization credentials are missing", async () => {
418
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
419
+
420
+ await expect(syncShopifyCartAttributes({
421
+ assignments: [],
422
+ visitorId: "visitor_123",
423
+ sessionId: "session_123",
424
+ shippingAuthorizationRevision: 7,
425
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
426
+
427
+ expect(globalThis.fetch).not.toHaveBeenCalled();
428
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
429
+ });
430
+
431
+ it("does not let a stale successful no-shipping response clear newer signed state", async () => {
432
+ let resolveOlderAuthorization!: (response: Response) => void;
433
+ let resolveNewerAuthorization!: (response: Response) => void;
434
+ globalThis.fetch = vi.fn((url) => {
435
+ if (url === "https://events.example.com/v1/assign") {
436
+ return new Promise<Response>((resolve) => {
437
+ if (!resolveOlderAuthorization) resolveOlderAuthorization = resolve;
438
+ else resolveNewerAuthorization = resolve;
439
+ });
440
+ }
441
+ return Promise.resolve(new Response("ok", { status: 200 }));
442
+ }) as any;
443
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
444
+
445
+ const older = syncShopifyCartAttributes({
446
+ assignments: [],
447
+ visitorId: "visitor_123",
448
+ sessionId: "session_older",
449
+ shopId: "shop_123",
450
+ endpoint: "https://events.example.com/events",
451
+ ingestSignature: "ingest-token",
452
+ shippingAuthorizationRevision: 7,
453
+ });
454
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
455
+ const newer = syncShopifyCartAttributes({
456
+ assignments: [{
457
+ experimentId: "exp_shipping",
458
+ variationId: "var_shipping",
459
+ assignmentEpoch: 5,
460
+ }],
461
+ requiredAssignments: [{
462
+ experimentId: "exp_shipping",
463
+ variationId: "var_shipping",
464
+ assignmentEpoch: 5,
465
+ }],
466
+ visitorId: "visitor_123",
467
+ sessionId: "session_newer",
468
+ shopId: "shop_123",
469
+ endpoint: "https://events.example.com/events",
470
+ ingestSignature: "ingest-token",
471
+ shippingAuthorizationRevision: 7,
472
+ });
473
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
474
+
475
+ resolveNewerAuthorization(Response.json({
476
+ assignments: [{
477
+ experiment_id: "exp_shipping",
478
+ variation_id: "var_shipping",
479
+ assignment_epoch: 5,
480
+ }],
481
+ commerce_authorization: "newer-proof",
482
+ }));
483
+ await expect(newer).resolves.toEqual({ status: "sent" });
484
+
485
+ resolveOlderAuthorization(Response.json({
486
+ assignments: [],
487
+ }));
488
+ await expect(older).resolves.toEqual({
489
+ status: "failed",
490
+ reason: "authorization_failed",
491
+ });
492
+
493
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
494
+ const cartUpdates = vi.mocked(globalThis.fetch).mock.calls.filter(([url]) =>
495
+ url === "/cart/update.js"
496
+ );
497
+ expect(cartUpdates).toHaveLength(1);
498
+ expect(JSON.parse(String(cartUpdates[0]?.[1]?.body)).attributes)
499
+ .toMatchObject({ apex_assignment_authorization: "newer-proof" });
500
+ });
501
+
502
+ it.each([
503
+ ["missing", {}],
504
+ ["empty", { commerce_authorization: "" }],
505
+ ])("fails closed when a charge-affecting authorization token is %s", async (
506
+ _case,
507
+ authorizationPayload,
508
+ ) => {
509
+ const assignmentProof = {
510
+ assignments: [{
511
+ experiment_id: "exp_123",
512
+ variation_id: "var_123",
513
+ assignment_epoch: 2,
514
+ }],
515
+ ...authorizationPayload,
516
+ };
517
+ globalThis.fetch = vi.fn()
518
+ .mockResolvedValueOnce(Response.json(assignmentProof))
519
+ .mockResolvedValueOnce(new Response("ok", { status: 200 }))
520
+ .mockResolvedValueOnce(Response.json(assignmentProof))
521
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
522
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
523
+ const input = {
524
+ assignments: [
525
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
526
+ ],
527
+ requiredAssignments: [
528
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
529
+ ],
530
+ visitorId: "visitor_123",
531
+ sessionId: "session_123",
532
+ shopId: "shop_123",
533
+ endpoint: "https://events.example.com/events",
534
+ ingestSignature: "ingest-token",
535
+ };
536
+
537
+ await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
538
+ status: "failed",
539
+ reason: "authorization_failed",
540
+ });
541
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
542
+
543
+ await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
544
+ status: "failed",
545
+ reason: "authorization_failed",
546
+ });
547
+ expect(globalThis.fetch).toHaveBeenCalledTimes(4);
548
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
549
+ });
550
+
551
+ it("rejects a shipping-only V2 envelope when a price assignment requires proof", async () => {
552
+ const shippingOnlyToken = JSON.stringify({
553
+ v: 2,
554
+ p: {
555
+ shipping: {
556
+ v: 1,
557
+ a: [{ e: "exp_shipping", v: "var_shipping", ep: 1 }],
558
+ },
559
+ },
560
+ });
561
+ const authorizationPayload = {
562
+ assignments: [{
563
+ experiment_id: "exp_price",
564
+ variation_id: "var_treatment",
565
+ assignment_epoch: 2,
566
+ }],
567
+ commerce_authorization: shippingOnlyToken,
568
+ };
569
+ globalThis.fetch = vi.fn()
570
+ .mockResolvedValueOnce(Response.json(authorizationPayload))
571
+ .mockResolvedValueOnce(new Response("ok", { status: 200 }))
572
+ .mockResolvedValueOnce(Response.json(authorizationPayload))
573
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
574
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
575
+ const input = {
576
+ assignments: [{
577
+ experimentId: "exp_price",
578
+ variationId: "var_treatment",
579
+ assignmentEpoch: 2,
580
+ }],
581
+ requiredAssignments: [{
582
+ experimentId: "exp_price",
583
+ variationId: "var_treatment",
584
+ assignmentEpoch: 2,
585
+ }],
586
+ visitorId: "visitor_123",
587
+ sessionId: "session_123",
588
+ shopId: "shop_123",
589
+ endpoint: "https://events.example.com/events",
590
+ ingestSignature: "ingest-token",
591
+ cartToken: "cart-token",
592
+ priceAuthorizationRevision: 3,
593
+ priceExperimentIds: ["exp_price"],
594
+ };
595
+
596
+ await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
597
+ status: "failed",
598
+ reason: "authorization_failed",
599
+ });
600
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
601
+ await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
602
+ status: "failed",
603
+ reason: "authorization_failed",
604
+ });
605
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
606
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) =>
607
+ url === "/cart/update.js"
608
+ )).toBe(false);
609
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
610
+ });
611
+
612
+ it("accepts a bare V1 price envelope covering the required assignment", async () => {
613
+ const token = JSON.stringify({
614
+ v: 1,
615
+ cartHash: "c".repeat(64),
616
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
617
+ });
618
+ globalThis.fetch = vi.fn()
619
+ .mockResolvedValueOnce(Response.json({
620
+ assignments: [{
621
+ experiment_id: "exp_price",
622
+ variation_id: "var_treatment",
623
+ assignment_epoch: 2,
624
+ }],
625
+ commerce_authorization: token,
626
+ }))
627
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
628
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
629
+
630
+ await expect(syncShopifyCartAttributes({
631
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
632
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
633
+ visitorId: "visitor_123",
634
+ sessionId: "session_123",
635
+ shopId: "shop_123",
636
+ endpoint: "https://events.example.com/events",
637
+ ingestSignature: "ingest-token",
638
+ cartToken: "cart-token",
639
+ priceAuthorizationRevision: 3,
640
+ priceExperimentIds: ["exp_price"],
641
+ })).resolves.toEqual({ status: "sent" });
642
+ });
643
+
644
+ it("accepts a V2 price plane covering the required assignment", async () => {
645
+ const token = JSON.stringify({
646
+ v: 2,
647
+ p: {
648
+ price: {
649
+ v: 1,
650
+ cartHash: "c".repeat(64),
651
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
652
+ },
653
+ },
654
+ });
655
+ globalThis.fetch = vi.fn()
656
+ .mockResolvedValueOnce(Response.json({
657
+ assignments: [{
658
+ experiment_id: "exp_price",
659
+ variation_id: "var_treatment",
660
+ assignment_epoch: 2,
661
+ }],
662
+ commerce_authorization: token,
663
+ }))
664
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
665
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
666
+
667
+ await expect(syncShopifyCartAttributes({
668
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
669
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
670
+ visitorId: "visitor_123",
671
+ sessionId: "session_123",
672
+ shopId: "shop_123",
673
+ endpoint: "https://events.example.com/events",
674
+ ingestSignature: "ingest-token",
675
+ cartToken: "cart-token",
676
+ priceAuthorizationRevision: 3,
677
+ priceExperimentIds: ["exp_price"],
678
+ })).resolves.toEqual({ status: "sent" });
679
+ });
680
+
681
+ it("lets a proven price plane proceed while a missing shipping plane fails closed", async () => {
682
+ const token = JSON.stringify({
683
+ v: 2,
684
+ p: {
685
+ price: {
686
+ v: 1,
687
+ cartHash: "c".repeat(64),
688
+ a: [{ e: "exp_price", v: "var_price", ep: 2 }],
689
+ },
690
+ },
691
+ });
692
+ globalThis.fetch = vi.fn()
693
+ .mockResolvedValueOnce(Response.json({
694
+ assignments: [
695
+ { experiment_id: "exp_price", variation_id: "var_price", assignment_epoch: 2 },
696
+ { experiment_id: "exp_shipping", variation_id: "var_shipping", assignment_epoch: 4 },
697
+ ],
698
+ commerce_authorization: token,
699
+ }))
700
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
701
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
702
+ const authorizePriceDisplayAssignments = vi.fn();
703
+ const authorizeShippingDisplayAssignments = vi.fn();
704
+
705
+ await expect(syncShopifyCartAttributes({
706
+ assignments: [
707
+ { experimentId: "exp_price", variationId: "var_price", assignmentEpoch: 2 },
708
+ { experimentId: "exp_shipping", variationId: "var_shipping", assignmentEpoch: 4 },
709
+ ],
710
+ requiredAssignments: [
711
+ { experimentId: "exp_price", variationId: "var_price", assignmentEpoch: 2 },
712
+ { experimentId: "exp_shipping", variationId: "var_shipping", assignmentEpoch: 4 },
713
+ ],
714
+ visitorId: "visitor_123",
715
+ sessionId: "session_123",
716
+ shopId: "shop_123",
717
+ endpoint: "https://events.example.com/events",
718
+ ingestSignature: "ingest-token",
719
+ cartToken: "cart-token",
720
+ shippingAuthorizationRevision: 7,
721
+ shippingExperimentIds: ["exp_shipping"],
722
+ priceAuthorizationRevision: 3,
723
+ priceExperimentIds: ["exp_price"],
724
+ authorizePriceDisplayAssignments,
725
+ authorizeShippingDisplayAssignments,
726
+ })).resolves.toEqual({ status: "sent" });
727
+
728
+ const cartBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
729
+ expect(JSON.parse(cartBody.attributes.apex_assignments)).toEqual([
730
+ { e: "exp_price", v: "var_price", ep: 2 },
731
+ ]);
732
+ expect(cartBody.attributes.apex_assignment_authorization).toBe(token);
733
+ expect(authorizePriceDisplayAssignments).toHaveBeenCalledWith([
734
+ { experimentId: "exp_price", variationId: "var_price", assignmentEpoch: 2 },
735
+ ]);
736
+ expect(authorizeShippingDisplayAssignments).toHaveBeenCalledWith([]);
737
+ });
738
+
739
+ it("stamps a signed offers plane and fails closed when its proof is absent", async () => {
740
+ const signed = JSON.stringify({
741
+ v: 2,
742
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_offer", ep: 3 }] } },
743
+ });
744
+ const assignment = { experimentId: "exp_offer", variationId: "var_offer", assignmentEpoch: 3 };
745
+ globalThis.fetch = vi.fn()
746
+ .mockResolvedValueOnce(Response.json({
747
+ assignments: [{ experiment_id: "exp_offer", variation_id: "var_offer", assignment_epoch: 3 }],
748
+ commerce_authorization: signed,
749
+ }))
750
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
751
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
752
+ await expect(syncShopifyCartAttributes({
753
+ assignments: [assignment],
754
+ requiredAssignments: [assignment],
755
+ visitorId: "visitor_123",
756
+ sessionId: "session_123",
757
+ shopId: "shop_123",
758
+ endpoint: "https://events.example.com/events",
759
+ ingestSignature: "ingest-token",
760
+ cartToken: "cart-offers?key=private-capability",
761
+ offersAuthorizationRevision: 5,
762
+ offersExperimentIds: ["exp_offer"],
763
+ })).resolves.toEqual({ status: "sent" });
764
+ expect(JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body)))
765
+ .toMatchObject({ cart_token: "cart-offers?key=private-capability" });
766
+ const cartBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
767
+ expect(cartBody.attributes.apex_assignment_authorization).toBe(signed);
768
+
769
+ vi.resetModules();
770
+ (globalThis as any).sessionStorage = makeStorage();
771
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
772
+ assignments: [{ experiment_id: "exp_offer", variation_id: "var_offer", assignment_epoch: 3 }],
773
+ commerce_authorization: JSON.stringify({ v: 2, p: {} }),
774
+ })) as any;
775
+ const fresh = await import("./shopify-cart");
776
+ await expect(fresh.syncShopifyCartAttributes({
777
+ assignments: [assignment],
778
+ requiredAssignments: [assignment],
779
+ visitorId: "visitor_123",
780
+ sessionId: "session_123",
781
+ shopId: "shop_123",
782
+ endpoint: "https://events.example.com/events",
783
+ ingestSignature: "ingest-token",
784
+ cartToken: "cart-offers?key=private-capability",
785
+ offersAuthorizationRevision: 5,
786
+ offersExperimentIds: ["exp_offer"],
787
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
788
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
789
+ });
790
+
791
+ it("adopts the pinned offer arm when no local assignment exists (reused cart, new identity)", async () => {
792
+ const token = JSON.stringify({
793
+ v: 2,
794
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }] } },
795
+ });
796
+ globalThis.fetch = vi.fn()
797
+ .mockResolvedValueOnce(Response.json({
798
+ assignments: [{
799
+ experiment_id: "exp_offer",
800
+ variation_id: "var_treatment",
801
+ assignment_epoch: 3,
802
+ }],
803
+ commerce_authorization: token,
804
+ }))
805
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
806
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
807
+ const adoptOffersAssignments = vi.fn(() => true);
808
+
809
+ // No local assignments at all — the stale-clear guard must NOT reject the
810
+ // nonempty pinned-arm response before adoption runs.
811
+ await expect(syncShopifyCartAttributes({
812
+ assignments: [],
813
+ visitorId: "visitor_new",
814
+ sessionId: "session_new",
815
+ shopId: "shop_123",
816
+ endpoint: "https://events.example.com/events",
817
+ ingestSignature: "ingest-token",
818
+ cartToken: "cart-offers?key=private-capability",
819
+ offersAuthorizationRevision: 5,
820
+ offersExperimentIds: ["exp_offer"],
821
+ adoptOffersAssignments,
822
+ canAdoptOffersAssignments: () => true,
823
+ })).resolves.toEqual({ status: "sent" });
824
+ expect(adoptOffersAssignments).toHaveBeenCalledWith([{
825
+ experimentId: "exp_offer",
826
+ variationId: "var_treatment",
827
+ assignmentEpoch: 3,
828
+ }]);
829
+ });
830
+
831
+ it("adopts the Worker's pinned offer arm before proof and cart attribution", async () => {
832
+ const token = JSON.stringify({
833
+ v: 2,
834
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }] } },
835
+ });
836
+ globalThis.fetch = vi.fn()
837
+ .mockResolvedValueOnce(Response.json({
838
+ assignments: [{
839
+ experiment_id: "exp_offer",
840
+ variation_id: "var_treatment",
841
+ assignment_epoch: 3,
842
+ }],
843
+ commerce_authorization: token,
844
+ })) as any;
845
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
846
+ let cartWriteStarted = false;
847
+ let adoptedBeforeCartWrite: boolean | null = null;
848
+ const fetchMock = vi.mocked(globalThis.fetch);
849
+ fetchMock.mockImplementation(async (url) => {
850
+ if (String(url) === "/cart/update.js") cartWriteStarted = true;
851
+ return new Response("ok", { status: 200 });
852
+ });
853
+ const adoptOffersAssignments = vi.fn(() => {
854
+ adoptedBeforeCartWrite = !cartWriteStarted;
855
+ return true;
856
+ });
857
+
858
+ await expect(syncShopifyCartAttributes({
859
+ assignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
860
+ requiredAssignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
861
+ visitorId: "visitor_123",
862
+ sessionId: "session_123",
863
+ shopId: "shop_123",
864
+ endpoint: "https://events.example.com/events",
865
+ ingestSignature: "ingest-token",
866
+ cartToken: "cart-offers?key=private-capability",
867
+ offersAuthorizationRevision: 5,
868
+ offersExperimentIds: ["exp_offer"],
869
+ adoptOffersAssignments,
870
+ canAdoptOffersAssignments: () => true,
871
+ })).resolves.toEqual({ status: "sent" });
872
+ expect(adoptOffersAssignments).toHaveBeenCalledWith([{
873
+ experimentId: "exp_offer",
874
+ variationId: "var_treatment",
875
+ assignmentEpoch: 3,
876
+ }]);
877
+ // Adoption applies only AFTER the stamped cart write went out.
878
+ expect(adoptedBeforeCartWrite).toBe(false);
879
+ const cartBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
880
+ expect(JSON.parse(cartBody.attributes.apex_assignments)).toEqual([
881
+ { e: "exp_offer", v: "var_treatment", ep: 3 },
882
+ ]);
883
+ });
884
+
885
+ it("does not adopt the pinned offer arm when the cart write fails", async () => {
886
+ const token = JSON.stringify({
887
+ v: 2,
888
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }] } },
889
+ });
890
+ globalThis.fetch = vi.fn()
891
+ .mockResolvedValueOnce(Response.json({
892
+ assignments: [{
893
+ experiment_id: "exp_offer",
894
+ variation_id: "var_treatment",
895
+ assignment_epoch: 3,
896
+ }],
897
+ commerce_authorization: token,
898
+ }))
899
+ .mockResolvedValueOnce(new Response("no", { status: 500 })) as any;
900
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
901
+ const adoptOffersAssignments = vi.fn(() => true);
902
+ const canAdoptOffersAssignments = vi.fn(() => true);
903
+
904
+ await expect(syncShopifyCartAttributes({
905
+ assignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
906
+ requiredAssignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
907
+ visitorId: "visitor_123",
908
+ sessionId: "session_123",
909
+ shopId: "shop_123",
910
+ endpoint: "https://events.example.com/events",
911
+ ingestSignature: "ingest-token",
912
+ cartToken: "cart-offers?key=private-capability",
913
+ offersAuthorizationRevision: 5,
914
+ offersExperimentIds: ["exp_offer"],
915
+ adoptOffersAssignments,
916
+ canAdoptOffersAssignments,
917
+ })).resolves.toEqual({ status: "failed", reason: "cart_update_500" });
918
+ expect(canAdoptOffersAssignments).toHaveBeenCalled();
919
+ expect(adoptOffersAssignments).not.toHaveBeenCalled();
920
+ });
921
+
922
+ it("authorizes offer display exposure only after the stamped cart write", async () => {
923
+ const token = JSON.stringify({
924
+ v: 2,
925
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }] } },
926
+ });
927
+ globalThis.fetch = vi.fn()
928
+ .mockResolvedValueOnce(Response.json({
929
+ assignments: [{
930
+ experiment_id: "exp_offer",
931
+ variation_id: "var_treatment",
932
+ assignment_epoch: 3,
933
+ }],
934
+ commerce_authorization: token,
935
+ }))
936
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
937
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
938
+ const authorizeOffersDisplayAssignments = vi.fn();
939
+
940
+ await expect(syncShopifyCartAttributes({
941
+ assignments: [{ experimentId: "exp_offer", variationId: "var_treatment", assignmentEpoch: 3 }],
942
+ requiredAssignments: [{ experimentId: "exp_offer", variationId: "var_treatment", assignmentEpoch: 3 }],
943
+ visitorId: "visitor_123",
944
+ sessionId: "session_123",
945
+ shopId: "shop_123",
946
+ endpoint: "https://events.example.com/events",
947
+ ingestSignature: "ingest-token",
948
+ cartToken: "cart-offers?key=private-capability",
949
+ offersAuthorizationRevision: 5,
950
+ offersExperimentIds: ["exp_offer"],
951
+ authorizeOffersDisplayAssignments,
952
+ })).resolves.toEqual({ status: "sent" });
953
+ expect(authorizeOffersDisplayAssignments).toHaveBeenCalledWith([{
954
+ experimentId: "exp_offer",
955
+ variationId: "var_treatment",
956
+ assignmentEpoch: 3,
957
+ }]);
958
+ });
959
+
960
+ it("withholds offer display authorization when the cart write fails", async () => {
961
+ const token = JSON.stringify({
962
+ v: 2,
963
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }] } },
964
+ });
965
+ globalThis.fetch = vi.fn()
966
+ .mockResolvedValueOnce(Response.json({
967
+ assignments: [{
968
+ experiment_id: "exp_offer",
969
+ variation_id: "var_treatment",
970
+ assignment_epoch: 3,
971
+ }],
972
+ commerce_authorization: token,
973
+ }))
974
+ .mockResolvedValueOnce(new Response("no", { status: 500 })) as any;
975
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
976
+ const authorizeOffersDisplayAssignments = vi.fn();
977
+
978
+ await expect(syncShopifyCartAttributes({
979
+ assignments: [{ experimentId: "exp_offer", variationId: "var_treatment", assignmentEpoch: 3 }],
980
+ requiredAssignments: [{ experimentId: "exp_offer", variationId: "var_treatment", assignmentEpoch: 3 }],
981
+ visitorId: "visitor_123",
982
+ sessionId: "session_123",
983
+ shopId: "shop_123",
984
+ endpoint: "https://events.example.com/events",
985
+ ingestSignature: "ingest-token",
986
+ cartToken: "cart-offers?key=private-capability",
987
+ offersAuthorizationRevision: 5,
988
+ offersExperimentIds: ["exp_offer"],
989
+ authorizeOffersDisplayAssignments,
990
+ })).resolves.toEqual({ status: "failed", reason: "cart_update_500" });
991
+ expect(authorizeOffersDisplayAssignments).not.toHaveBeenCalled();
992
+ });
993
+
994
+ it("delivers the stamped cart snapshot to onCartSnapshot", async () => {
995
+ const cartBody = { items: [{ properties: { _apex_gwp: "exp_offer" } }] };
996
+ globalThis.fetch = vi.fn().mockResolvedValue(
997
+ new Response(JSON.stringify(cartBody), { status: 200 }),
998
+ ) as any;
999
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1000
+ const onCartSnapshot = vi.fn();
1001
+
1002
+ await expect(syncShopifyCartAttributes({
1003
+ assignments: [{ experimentId: "exp_a", variationId: "var_a", assignmentEpoch: 1 }],
1004
+ visitorId: "visitor_123",
1005
+ sessionId: "session_123",
1006
+ onCartSnapshot,
1007
+ })).resolves.toEqual({ status: "sent" });
1008
+ await vi.waitFor(() => expect(onCartSnapshot).toHaveBeenCalledWith(cartBody));
1009
+ });
1010
+
1011
+ it("fails the sync without stamping records when a pre-validated adoption cannot apply", async () => {
1012
+ const token = JSON.stringify({
1013
+ v: 2,
1014
+ p: { offers: { v: 1, a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }] } },
1015
+ });
1016
+ globalThis.fetch = vi.fn()
1017
+ .mockResolvedValueOnce(Response.json({
1018
+ assignments: [{
1019
+ experiment_id: "exp_offer",
1020
+ variation_id: "var_treatment",
1021
+ assignment_epoch: 3,
1022
+ }],
1023
+ commerce_authorization: token,
1024
+ }))
1025
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1026
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1027
+
1028
+ await expect(syncShopifyCartAttributes({
1029
+ assignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
1030
+ requiredAssignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
1031
+ visitorId: "visitor_123",
1032
+ sessionId: "session_123",
1033
+ shopId: "shop_123",
1034
+ endpoint: "https://events.example.com/events",
1035
+ ingestSignature: "ingest-token",
1036
+ cartToken: "cart-offers?key=private-capability",
1037
+ offersAuthorizationRevision: 5,
1038
+ offersExperimentIds: ["exp_offer"],
1039
+ adoptOffersAssignments: () => false,
1040
+ canAdoptOffersAssignments: () => true,
1041
+ })).resolves.toEqual({ status: "failed", reason: "offers_adoption_failed" });
1042
+ });
1043
+
1044
+ it("requests configured offer experiments even without a local assignment", async () => {
1045
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
1046
+ assignments: [],
1047
+ commerce_authorization: JSON.stringify({ v: 2, p: { offers: { v: 1, a: [] } } }),
1048
+ })) as any;
1049
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1050
+
1051
+ await syncShopifyCartAttributes({
1052
+ assignments: [],
1053
+ visitorId: "visitor_123",
1054
+ sessionId: "session_123",
1055
+ shopId: "shop_123",
1056
+ endpoint: "https://events.example.com/events",
1057
+ ingestSignature: "ingest-token",
1058
+ cartToken: "cart-offers?key=private-capability",
1059
+ offersAuthorizationRevision: 5,
1060
+ offersExperimentIds: ["exp_offer"],
1061
+ adoptOffersAssignments: () => true,
1062
+ });
1063
+
1064
+ expect(JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body)))
1065
+ .toMatchObject({ experiments: ["exp_offer"] });
1066
+ });
1067
+
1068
+ it("fails offer authorization closed when the Worker's pinned arm is unparseable", async () => {
1069
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
1070
+ assignments: [{
1071
+ experiment_id: "exp_offer",
1072
+ variation_id: "",
1073
+ assignment_epoch: 3,
1074
+ }],
1075
+ commerce_authorization: JSON.stringify({ v: 2, p: { offers: { v: 1, a: [] } } }),
1076
+ })) as any;
1077
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1078
+ const adoptOffersAssignments = vi.fn(() => true);
1079
+ await expect(syncShopifyCartAttributes({
1080
+ assignments: [{ experimentId: "exp_offer", variationId: "var_control", assignmentEpoch: 3 }],
1081
+ visitorId: "visitor_123",
1082
+ sessionId: "session_123",
1083
+ shopId: "shop_123",
1084
+ endpoint: "https://events.example.com/events",
1085
+ ingestSignature: "ingest-token",
1086
+ cartToken: "cart-offers?key=private-capability",
1087
+ offersAuthorizationRevision: 5,
1088
+ offersExperimentIds: ["exp_offer"],
1089
+ adoptOffersAssignments,
1090
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
1091
+ expect(adoptOffersAssignments).not.toHaveBeenCalled();
1092
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
1093
+ });
1094
+
1095
+ it("keeps both commerce planes authorized when both signatures are present", async () => {
1096
+ const token = JSON.stringify({
1097
+ v: 2,
1098
+ p: {
1099
+ price: {
1100
+ v: 1,
1101
+ cartHash: "c".repeat(64),
1102
+ a: [{ e: "exp_price", v: "var_price", ep: 2 }],
1103
+ },
1104
+ shipping: {
1105
+ v: 1,
1106
+ a: [{ e: "exp_shipping", v: "var_shipping", ep: 4 }],
1107
+ },
1108
+ },
1109
+ });
1110
+ globalThis.fetch = vi.fn()
1111
+ .mockResolvedValueOnce(Response.json({
1112
+ assignments: [
1113
+ { experiment_id: "exp_price", variation_id: "var_price", assignment_epoch: 2 },
1114
+ { experiment_id: "exp_shipping", variation_id: "var_shipping", assignment_epoch: 4 },
1115
+ ],
1116
+ commerce_authorization: token,
1117
+ }))
1118
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1119
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1120
+ const authorizeShippingDisplayAssignments = vi.fn();
1121
+
1122
+ await expect(syncShopifyCartAttributes({
1123
+ assignments: [
1124
+ { experimentId: "exp_price", variationId: "var_price", assignmentEpoch: 2 },
1125
+ { experimentId: "exp_shipping", variationId: "var_shipping", assignmentEpoch: 4 },
1126
+ ],
1127
+ requiredAssignments: [
1128
+ { experimentId: "exp_price", variationId: "var_price", assignmentEpoch: 2 },
1129
+ { experimentId: "exp_shipping", variationId: "var_shipping", assignmentEpoch: 4 },
1130
+ ],
1131
+ visitorId: "visitor_123",
1132
+ sessionId: "session_123",
1133
+ shopId: "shop_123",
1134
+ endpoint: "https://events.example.com/events",
1135
+ ingestSignature: "ingest-token",
1136
+ cartToken: "cart-token",
1137
+ shippingAuthorizationRevision: 7,
1138
+ shippingExperimentIds: ["exp_shipping"],
1139
+ priceAuthorizationRevision: 3,
1140
+ priceExperimentIds: ["exp_price"],
1141
+ authorizeShippingDisplayAssignments,
1142
+ })).resolves.toEqual({ status: "sent" });
1143
+
1144
+ const cartBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
1145
+ expect(JSON.parse(cartBody.attributes.apex_assignments)).toEqual([
1146
+ { e: "exp_price", v: "var_price", ep: 2 },
1147
+ { e: "exp_shipping", v: "var_shipping", ep: 4 },
1148
+ ]);
1149
+ expect(authorizeShippingDisplayAssignments).toHaveBeenCalledWith([
1150
+ { experimentId: "exp_shipping", variationId: "var_shipping", assignmentEpoch: 4 },
1151
+ ]);
1152
+ });
1153
+
1154
+ it("adopts a pinned price assignment that differs from the local assignment", async () => {
1155
+ const token = JSON.stringify({
1156
+ v: 1,
1157
+ cartHash: "c".repeat(64),
1158
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
1159
+ });
1160
+ globalThis.fetch = vi.fn()
1161
+ .mockResolvedValueOnce(Response.json({
1162
+ assignments: [{
1163
+ experiment_id: "exp_price",
1164
+ variation_id: "var_treatment",
1165
+ assignment_epoch: 2,
1166
+ }],
1167
+ commerce_authorization: token,
1168
+ }))
1169
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1170
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1171
+ const adoptPriceAssignments = vi.fn(() => true);
1172
+
1173
+ await expect(syncShopifyCartAttributes({
1174
+ assignments: [{ experimentId: "exp_price", variationId: "var_control", assignmentEpoch: 2 }],
1175
+ visitorId: "visitor_123",
1176
+ sessionId: "session_123",
1177
+ shopId: "shop_123",
1178
+ endpoint: "https://events.example.com/events",
1179
+ ingestSignature: "ingest-token",
1180
+ cartToken: "cart-token",
1181
+ priceAuthorizationRevision: 3,
1182
+ priceExperimentIds: ["exp_price"],
1183
+ adoptPriceAssignments,
1184
+ })).resolves.toEqual({ status: "sent" });
1185
+ expect(adoptPriceAssignments).toHaveBeenCalledWith([{
1186
+ experimentId: "exp_price",
1187
+ variationId: "var_treatment",
1188
+ assignmentEpoch: 2,
1189
+ }]);
1190
+ const cartUpdateBody = JSON.parse(
1191
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1192
+ );
1193
+ expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([{
1194
+ e: "exp_price",
1195
+ v: "var_treatment",
1196
+ ep: 2,
1197
+ }]);
1198
+ expect(sessionStorage.getItem("drip_cart_attr")).not.toBeNull();
1199
+ });
1200
+
1201
+ it("adopts a signed null price pin and clears the local price claim", async () => {
1202
+ const token = JSON.stringify({
1203
+ v: 2,
1204
+ p: {
1205
+ price: {
1206
+ v: 1,
1207
+ r: 3,
1208
+ c: "b".repeat(64),
1209
+ x: "2026-07-20",
1210
+ cartHash: "c".repeat(64),
1211
+ a: [],
1212
+ s: "d".repeat(64),
1213
+ },
1214
+ },
1215
+ });
1216
+ globalThis.fetch = vi.fn()
1217
+ .mockResolvedValueOnce(Response.json({
1218
+ assignments: [],
1219
+ commerce_authorization: token,
1220
+ }))
1221
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1222
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1223
+ const adoptPriceAssignments = vi.fn(() => true);
1224
+
1225
+ await expect(syncShopifyCartAttributes({
1226
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1227
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1228
+ visitorId: "visitor_123",
1229
+ sessionId: "session_123",
1230
+ shopId: "shop_123",
1231
+ endpoint: "https://events.example.com/events",
1232
+ ingestSignature: "ingest-token",
1233
+ cartToken: "cart-token",
1234
+ priceAuthorizationRevision: 3,
1235
+ priceExperimentIds: ["exp_price"],
1236
+ adoptPriceAssignments,
1237
+ })).resolves.toEqual({ status: "sent" });
1238
+
1239
+ expect(adoptPriceAssignments).toHaveBeenCalledWith([]);
1240
+ const cartUpdateBody = JSON.parse(
1241
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1242
+ );
1243
+ expect(cartUpdateBody.attributes).toMatchObject({
1244
+ apex_assignments: "[]",
1245
+ apex_assignment_authorization: token,
1246
+ apex_experiment_id: "",
1247
+ apex_variation_id: "",
1248
+ apex_assignment_epoch: "",
1249
+ });
1250
+ });
1251
+
1252
+ it("adopts null pins only inside the V2 evaluated set and preserves browser-only assignments", async () => {
1253
+ const browserOnlyAssignment = {
1254
+ experimentId: "exp_browser_only",
1255
+ variationId: "var_browser_treatment",
1256
+ assignmentEpoch: 4,
1257
+ };
1258
+ const token = JSON.stringify({
1259
+ v: 2,
1260
+ e: ["exp_evaluated"],
1261
+ p: {
1262
+ price: {
1263
+ v: 1,
1264
+ r: 3,
1265
+ c: "b".repeat(64),
1266
+ x: "2026-07-20",
1267
+ cartHash: "c".repeat(64),
1268
+ a: [],
1269
+ s: "d".repeat(64),
1270
+ },
1271
+ },
1272
+ });
1273
+ globalThis.fetch = vi.fn()
1274
+ .mockResolvedValueOnce(Response.json({
1275
+ assignments: [],
1276
+ commerce_authorization: token,
1277
+ }))
1278
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1279
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1280
+ const adoptPriceAssignments = vi.fn(() => true);
1281
+ const authorizePriceDisplayAssignments = vi.fn();
1282
+
1283
+ await expect(syncShopifyCartAttributes({
1284
+ assignments: [
1285
+ { experimentId: "exp_evaluated", variationId: "var_local", assignmentEpoch: 2 },
1286
+ browserOnlyAssignment,
1287
+ ],
1288
+ requiredAssignments: [
1289
+ { experimentId: "exp_evaluated", variationId: "var_local", assignmentEpoch: 2 },
1290
+ ],
1291
+ visitorId: "visitor_123",
1292
+ sessionId: "session_123",
1293
+ shopId: "shop_123",
1294
+ endpoint: "https://events.example.com/events",
1295
+ ingestSignature: "ingest-token",
1296
+ cartToken: "cart-token",
1297
+ priceAuthorizationRevision: 3,
1298
+ priceExperimentIds: ["exp_evaluated", "exp_browser_only"],
1299
+ adoptPriceAssignments,
1300
+ authorizePriceDisplayAssignments,
1301
+ })).resolves.toEqual({ status: "sent" });
1302
+
1303
+ expect(adoptPriceAssignments).toHaveBeenCalledWith([browserOnlyAssignment]);
1304
+ expect(authorizePriceDisplayAssignments).toHaveBeenCalledWith([]);
1305
+ const cartUpdateBody = JSON.parse(
1306
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1307
+ );
1308
+ expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([{
1309
+ e: "exp_browser_only",
1310
+ v: "var_browser_treatment",
1311
+ ep: 4,
1312
+ }]);
1313
+ });
1314
+
1315
+ it("treats every configured price experiment as evaluated when an older V2 envelope omits the set", async () => {
1316
+ const token = JSON.stringify({
1317
+ v: 2,
1318
+ p: {
1319
+ price: {
1320
+ v: 1,
1321
+ r: 3,
1322
+ c: "b".repeat(64),
1323
+ x: "2026-07-20",
1324
+ cartHash: "c".repeat(64),
1325
+ a: [],
1326
+ s: "d".repeat(64),
1327
+ },
1328
+ },
1329
+ });
1330
+ globalThis.fetch = vi.fn()
1331
+ .mockResolvedValueOnce(Response.json({
1332
+ assignments: [],
1333
+ commerce_authorization: token,
1334
+ }))
1335
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1336
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1337
+ const adoptPriceAssignments = vi.fn(() => true);
1338
+
1339
+ await expect(syncShopifyCartAttributes({
1340
+ assignments: [
1341
+ { experimentId: "exp_first", variationId: "var_first", assignmentEpoch: 2 },
1342
+ { experimentId: "exp_second", variationId: "var_second", assignmentEpoch: 3 },
1343
+ ],
1344
+ visitorId: "visitor_compat",
1345
+ sessionId: "session_compat",
1346
+ shopId: "shop_123",
1347
+ endpoint: "https://events.example.com/events",
1348
+ ingestSignature: "ingest-token",
1349
+ cartToken: "cart-token",
1350
+ priceAuthorizationRevision: 3,
1351
+ priceExperimentIds: ["exp_first", "exp_second"],
1352
+ adoptPriceAssignments,
1353
+ })).resolves.toEqual({ status: "sent" });
1354
+
1355
+ expect(adoptPriceAssignments).toHaveBeenCalledWith([]);
1356
+ const cartUpdateBody = JSON.parse(
1357
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1358
+ );
1359
+ expect(cartUpdateBody.attributes.apex_assignments).toBe("[]");
1360
+ });
1361
+
1362
+ it("does not let a stale pinned assignment replace the newer adopted runtime state", async () => {
1363
+ let resolveOlderAuthorization!: (response: Response) => void;
1364
+ let resolveNewerAuthorization!: (response: Response) => void;
1365
+ const olderToken = JSON.stringify({
1366
+ v: 1,
1367
+ cartHash: "a".repeat(64),
1368
+ a: [{ e: "exp_price", v: "var_older", ep: 2 }],
1369
+ });
1370
+ const newerToken = JSON.stringify({
1371
+ v: 1,
1372
+ cartHash: "b".repeat(64),
1373
+ a: [{ e: "exp_price", v: "var_newer", ep: 2 }],
1374
+ });
1375
+ globalThis.fetch = vi.fn((url) => {
1376
+ if (url === "https://events.example.com/v1/assign") {
1377
+ return new Promise<Response>((resolve) => {
1378
+ if (!resolveOlderAuthorization) resolveOlderAuthorization = resolve;
1379
+ else resolveNewerAuthorization = resolve;
1380
+ });
1381
+ }
1382
+ return Promise.resolve(new Response("ok", { status: 200 }));
1383
+ }) as any;
1384
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1385
+ let runtimeAssignments = [{
1386
+ experimentId: "exp_price",
1387
+ variationId: "var_control",
1388
+ assignmentEpoch: 2,
1389
+ }];
1390
+ const adoptPriceAssignments = vi.fn((assignments: Array<{
1391
+ experimentId: string;
1392
+ variationId: string;
1393
+ assignmentEpoch?: number;
1394
+ }>) => {
1395
+ runtimeAssignments = assignments;
1396
+ return true;
1397
+ });
1398
+ const input = (sessionId: string) => ({
1399
+ assignments: runtimeAssignments,
1400
+ visitorId: "visitor_123",
1401
+ sessionId,
1402
+ shopId: "shop_123",
1403
+ endpoint: "https://events.example.com/events",
1404
+ ingestSignature: "ingest-token",
1405
+ cartToken: "cart-token",
1406
+ priceAuthorizationRevision: 3,
1407
+ priceExperimentIds: ["exp_price"],
1408
+ adoptPriceAssignments,
1409
+ });
1410
+
1411
+ const older = syncShopifyCartAttributes(input("session_older"));
1412
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
1413
+ const newer = syncShopifyCartAttributes(input("session_newer"));
1414
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
1415
+
1416
+ resolveNewerAuthorization(Response.json({
1417
+ assignments: [{
1418
+ experiment_id: "exp_price",
1419
+ variation_id: "var_newer",
1420
+ assignment_epoch: 2,
1421
+ }],
1422
+ commerce_authorization: newerToken,
1423
+ }));
1424
+ await expect(newer).resolves.toEqual({ status: "sent" });
1425
+ expect(runtimeAssignments).toEqual([{
1426
+ experimentId: "exp_price",
1427
+ variationId: "var_newer",
1428
+ assignmentEpoch: 2,
1429
+ }]);
1430
+
1431
+ resolveOlderAuthorization(Response.json({
1432
+ assignments: [{
1433
+ experiment_id: "exp_price",
1434
+ variation_id: "var_older",
1435
+ assignment_epoch: 2,
1436
+ }],
1437
+ commerce_authorization: olderToken,
1438
+ }));
1439
+ await expect(older).resolves.toEqual({
1440
+ status: "failed",
1441
+ reason: "authorization_failed",
1442
+ });
1443
+ expect(adoptPriceAssignments).toHaveBeenCalledOnce();
1444
+ expect(runtimeAssignments).toEqual([{
1445
+ experimentId: "exp_price",
1446
+ variationId: "var_newer",
1447
+ assignmentEpoch: 2,
1448
+ }]);
1449
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
1450
+ });
1451
+
1452
+ it("does not let a stale null pin clear the newer adopted runtime state", async () => {
1453
+ let resolveOlderAuthorization!: (response: Response) => void;
1454
+ let resolveNewerAuthorization!: (response: Response) => void;
1455
+ const nullPinToken = JSON.stringify({
1456
+ v: 2,
1457
+ p: {
1458
+ price: {
1459
+ v: 1,
1460
+ r: 3,
1461
+ c: "b".repeat(64),
1462
+ x: "2026-07-20",
1463
+ cartHash: "c".repeat(64),
1464
+ a: [],
1465
+ s: "d".repeat(64),
1466
+ },
1467
+ },
1468
+ });
1469
+ const newerToken = JSON.stringify({
1470
+ v: 1,
1471
+ cartHash: "e".repeat(64),
1472
+ a: [{ e: "exp_price", v: "var_newer", ep: 2 }],
1473
+ });
1474
+ globalThis.fetch = vi.fn((url) => {
1475
+ if (url === "https://events.example.com/v1/assign") {
1476
+ return new Promise<Response>((resolve) => {
1477
+ if (!resolveOlderAuthorization) resolveOlderAuthorization = resolve;
1478
+ else resolveNewerAuthorization = resolve;
1479
+ });
1480
+ }
1481
+ return Promise.resolve(new Response("ok", { status: 200 }));
1482
+ }) as any;
1483
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1484
+ let runtimeAssignments = [{
1485
+ experimentId: "exp_price",
1486
+ variationId: "var_control",
1487
+ assignmentEpoch: 2,
1488
+ }];
1489
+ const adoptPriceAssignments = vi.fn((assignments: Array<{
1490
+ experimentId: string;
1491
+ variationId: string;
1492
+ assignmentEpoch?: number;
1493
+ }>) => {
1494
+ runtimeAssignments = assignments;
1495
+ return true;
1496
+ });
1497
+ const input = (sessionId: string) => ({
1498
+ assignments: runtimeAssignments,
1499
+ requiredAssignments: runtimeAssignments,
1500
+ visitorId: "visitor_123",
1501
+ sessionId,
1502
+ shopId: "shop_123",
1503
+ endpoint: "https://events.example.com/events",
1504
+ ingestSignature: "ingest-token",
1505
+ cartToken: "cart-token",
1506
+ priceAuthorizationRevision: 3,
1507
+ priceExperimentIds: ["exp_price"],
1508
+ adoptPriceAssignments,
1509
+ });
1510
+
1511
+ const older = syncShopifyCartAttributes(input("session_older"));
1512
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
1513
+ const newer = syncShopifyCartAttributes(input("session_newer"));
1514
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
1515
+
1516
+ resolveNewerAuthorization(Response.json({
1517
+ assignments: [{
1518
+ experiment_id: "exp_price",
1519
+ variation_id: "var_newer",
1520
+ assignment_epoch: 2,
1521
+ }],
1522
+ commerce_authorization: newerToken,
1523
+ }));
1524
+ await expect(newer).resolves.toEqual({ status: "sent" });
1525
+
1526
+ resolveOlderAuthorization(Response.json({
1527
+ assignments: [],
1528
+ commerce_authorization: nullPinToken,
1529
+ }));
1530
+ await expect(older).resolves.toEqual({
1531
+ status: "failed",
1532
+ reason: "authorization_failed",
1533
+ });
1534
+ expect(adoptPriceAssignments).toHaveBeenCalledOnce();
1535
+ expect(runtimeAssignments).toEqual([{
1536
+ experimentId: "exp_price",
1537
+ variationId: "var_newer",
1538
+ assignmentEpoch: 2,
1539
+ }]);
1540
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
1541
+ });
1542
+
1543
+ it("fails closed for an unsigned empty V2 price envelope", async () => {
1544
+ const token = JSON.stringify({
1545
+ v: 2,
1546
+ p: {
1547
+ price: {
1548
+ v: 1,
1549
+ r: 3,
1550
+ c: "b".repeat(64),
1551
+ x: "2026-07-20",
1552
+ cartHash: "c".repeat(64),
1553
+ a: [],
1554
+ },
1555
+ },
1556
+ });
1557
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
1558
+ assignments: [],
1559
+ commerce_authorization: token,
1560
+ })) as any;
1561
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1562
+ const adoptPriceAssignments = vi.fn(() => true);
1563
+
1564
+ await expect(syncShopifyCartAttributes({
1565
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1566
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1567
+ visitorId: "visitor_123",
1568
+ sessionId: "session_123",
1569
+ shopId: "shop_123",
1570
+ endpoint: "https://events.example.com/events",
1571
+ ingestSignature: "ingest-token",
1572
+ cartToken: "cart-token",
1573
+ priceAuthorizationRevision: 3,
1574
+ priceExperimentIds: ["exp_price"],
1575
+ adoptPriceAssignments,
1576
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
1577
+
1578
+ expect(adoptPriceAssignments).not.toHaveBeenCalled();
1579
+ });
1580
+
1581
+ it("fails closed when an omitted price assignment has no signed price plane", async () => {
1582
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
1583
+ assignments: [],
1584
+ })) as any;
1585
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1586
+ const adoptPriceAssignments = vi.fn(() => true);
1587
+
1588
+ await expect(syncShopifyCartAttributes({
1589
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1590
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1591
+ visitorId: "visitor_123",
1592
+ sessionId: "session_123",
1593
+ shopId: "shop_123",
1594
+ endpoint: "https://events.example.com/events",
1595
+ ingestSignature: "ingest-token",
1596
+ cartToken: "cart-token",
1597
+ priceAuthorizationRevision: 3,
1598
+ priceExperimentIds: ["exp_price"],
1599
+ adoptPriceAssignments,
1600
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
1601
+
1602
+ expect(adoptPriceAssignments).not.toHaveBeenCalled();
1603
+ const cartUpdate = vi.mocked(globalThis.fetch).mock.calls.find(([url]) =>
1604
+ url === "/cart/update.js"
1605
+ );
1606
+ expect(cartUpdate).toBeUndefined();
1607
+ });
1608
+
1609
+ it("fails closed when a pinned price variation cannot be adopted locally", async () => {
1610
+ const token = JSON.stringify({
1611
+ v: 1,
1612
+ cartHash: "c".repeat(64),
1613
+ a: [{ e: "exp_price", v: "var_unknown", ep: 2 }],
1614
+ });
1615
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
1616
+ assignments: [{
1617
+ experiment_id: "exp_price",
1618
+ variation_id: "var_unknown",
1619
+ assignment_epoch: 2,
1620
+ }],
1621
+ commerce_authorization: token,
1622
+ })) as any;
1623
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1624
+ const adoptPriceAssignments = vi.fn(() => false);
1625
+
1626
+ await expect(syncShopifyCartAttributes({
1627
+ assignments: [{ experimentId: "exp_price", variationId: "var_control", assignmentEpoch: 2 }],
1628
+ visitorId: "visitor_123",
1629
+ sessionId: "session_123",
1630
+ shopId: "shop_123",
1631
+ endpoint: "https://events.example.com/events",
1632
+ ingestSignature: "ingest-token",
1633
+ cartToken: "cart-token",
1634
+ priceAuthorizationRevision: 3,
1635
+ priceExperimentIds: ["exp_price"],
1636
+ adoptPriceAssignments,
1637
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
1638
+ expect(adoptPriceAssignments).toHaveBeenCalledOnce();
1639
+ const cartUpdate = vi.mocked(globalThis.fetch).mock.calls.find(([url]) =>
1640
+ url === "/cart/update.js"
1641
+ );
1642
+ expect(cartUpdate).toBeUndefined();
1643
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
1644
+ });
1645
+
1646
+ it("does not overwrite a signed price pin for an envelope-less 200", async () => {
1647
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
1648
+ assignments: [{
1649
+ experiment_id: "exp_price",
1650
+ variation_id: "var_control",
1651
+ assignment_epoch: 2,
1652
+ }],
1653
+ })) as any;
1654
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1655
+
1656
+ await expect(syncShopifyCartAttributes({
1657
+ assignments: [{ experimentId: "exp_price", variationId: "var_control", assignmentEpoch: 2 }],
1658
+ visitorId: "visitor_123",
1659
+ sessionId: "session_123",
1660
+ shopId: "shop_123",
1661
+ endpoint: "https://events.example.com/events",
1662
+ ingestSignature: "ingest-token",
1663
+ cartToken: "cart-token",
1664
+ priceAuthorizationRevision: 3,
1665
+ priceExperimentIds: ["exp_price"],
1666
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
1667
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
1668
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) =>
1669
+ url === "/cart/update.js"
1670
+ )).toBe(false);
1671
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
1672
+ });
1673
+
1674
+ it("does not overwrite a signed price pin when authorization transport fails", async () => {
1675
+ globalThis.fetch = vi.fn().mockRejectedValueOnce(
1676
+ new Error("assign network unavailable"),
1677
+ ) as any;
1678
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1679
+
1680
+ await expect(syncShopifyCartAttributes({
1681
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
1682
+ visitorId: "visitor_123",
1683
+ sessionId: "session_123",
1684
+ shopId: "shop_123",
1685
+ endpoint: "https://events.example.com/events",
1686
+ ingestSignature: "ingest-token",
1687
+ cartToken: "cart-token",
1688
+ priceAuthorizationRevision: 3,
1689
+ priceExperimentIds: ["exp_price"],
1690
+ })).resolves.toEqual({ status: "failed", reason: "authorization_failed" });
1691
+
1692
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
1693
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) =>
1694
+ url === "/cart/update.js"
1695
+ )).toBe(false);
1696
+ });
1697
+
1698
+ it("rejects a stale charge-affecting epoch before writing checkout attributes", async () => {
1699
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(
1700
+ new Response(JSON.stringify({
1701
+ assignments: [{
1702
+ experiment_id: "exp_123",
1703
+ variation_id: "var_123",
1704
+ assignment_epoch: 3,
1705
+ }],
1706
+ commerce_authorization: "signed-envelope",
1707
+ }), { status: 200, headers: { "Content-Type": "application/json" } }),
1708
+ ) as any;
1709
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1710
+
1711
+ const result = await syncShopifyCartAttributes({
1712
+ assignments: [
1713
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
1714
+ ],
1715
+ requiredAssignments: [
1716
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
1717
+ ],
1718
+ visitorId: "visitor_123",
1719
+ sessionId: "session_123",
1720
+ shopId: "shop_123",
1721
+ endpoint: "https://events.example.com/events",
1722
+ ingestSignature: "ingest-token",
1723
+ });
1724
+
1725
+ expect(result).toEqual({ status: "failed", reason: "authorization_failed" });
1726
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
1727
+ const failedClosedBody = JSON.parse(
1728
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1729
+ );
1730
+ expect(failedClosedBody.attributes.apex_assignment_authorization).toBe("");
1731
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
1732
+ });
1733
+
1734
+ it("revalidates a persisted charge-affecting stamp in each SDK process", async () => {
1735
+ (globalThis as any).sessionStorage = makeStorage({
1736
+ drip_cart_attr: JSON.stringify({
1737
+ assignments: [["exp_123", "var_123", 2]],
1738
+ requiredAssignments: [["exp_123", "var_123", 2]],
1739
+ visitorId: "visitor_123",
1740
+ sessionId: "session_123",
1741
+ cartToken: null,
1742
+ shippingAuthorizationRevision: null,
1743
+ priceAuthorizationRevision: null,
1744
+ }),
1745
+ });
1746
+ globalThis.fetch = vi.fn()
1747
+ .mockResolvedValueOnce(new Response(JSON.stringify({
1748
+ assignments: [{
1749
+ experiment_id: "exp_123",
1750
+ variation_id: "var_123",
1751
+ assignment_epoch: 2,
1752
+ }],
1753
+ commerce_authorization: "signed-envelope",
1754
+ }), { status: 200, headers: { "Content-Type": "application/json" } }))
1755
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1756
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1757
+
1758
+ const result = await syncShopifyCartAttributes({
1759
+ assignments: [
1760
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
1761
+ ],
1762
+ requiredAssignments: [
1763
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
1764
+ ],
1765
+ visitorId: "visitor_123",
1766
+ sessionId: "session_123",
1767
+ shopId: "shop_123",
1768
+ endpoint: "https://events.example.com/events",
1769
+ ingestSignature: "ingest-token",
1770
+ });
1771
+
1772
+ expect(result).toEqual({ status: "sent" });
1773
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
1774
+ });
1775
+
1776
+ it("does not post outside Shopify storefronts", async () => {
1777
+ (globalThis as any).window = {};
1778
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1779
+
1780
+ syncShopifyCartAttributes({
1781
+ assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
1782
+ visitorId: "visitor_123",
1783
+ sessionId: "session_123",
1784
+ });
1785
+ await Promise.resolve();
1786
+
1787
+ expect(globalThis.fetch).not.toHaveBeenCalled();
1788
+ });
1789
+
1790
+ it("does not repeat a cart update already stamped this session", async () => {
1791
+ (globalThis as any).sessionStorage = makeStorage({
1792
+ drip_cart_attr: JSON.stringify({
1793
+ assignments: [
1794
+ ["exp_123", "var_123", null],
1795
+ ["exp_456", "var_456", null],
1796
+ ],
1797
+ visitorId: "visitor_123",
1798
+ sessionId: "session_123",
1799
+ cartToken: null,
1800
+ shippingAuthorizationRevision: null,
1801
+ priceAuthorizationRevision: null,
1802
+ }),
1803
+ });
1804
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1805
+
1806
+ syncShopifyCartAttributes({
1807
+ assignments: [
1808
+ { experimentId: "exp_456", variationId: "var_456" },
1809
+ { experimentId: "exp_123", variationId: "var_123" },
1810
+ ],
1811
+ visitorId: "visitor_123",
1812
+ sessionId: "session_123",
1813
+ });
1814
+ await Promise.resolve();
1815
+
1816
+ expect(globalThis.fetch).not.toHaveBeenCalled();
1817
+ });
1818
+
1819
+ it("re-requests price authorization after an SPA route change but caches the same normalized route", async () => {
1820
+ const token = JSON.stringify({
1821
+ v: 1,
1822
+ cartHash: "c".repeat(64),
1823
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
1824
+ });
1825
+ const authorizationResponse = () => Response.json({
1826
+ assignments: [{
1827
+ experiment_id: "exp_price",
1828
+ variation_id: "var_treatment",
1829
+ assignment_epoch: 2,
1830
+ }],
1831
+ commerce_authorization: token,
1832
+ });
1833
+ globalThis.fetch = vi.fn()
1834
+ .mockResolvedValueOnce(authorizationResponse())
1835
+ .mockResolvedValueOnce(new Response("ok", { status: 200 }))
1836
+ .mockResolvedValueOnce(authorizationResponse())
1837
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1838
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1839
+ const input = {
1840
+ assignments: [{
1841
+ experimentId: "exp_price",
1842
+ variationId: "var_treatment",
1843
+ assignmentEpoch: 2,
1844
+ }],
1845
+ visitorId: "visitor_123",
1846
+ sessionId: "session_123",
1847
+ shopId: "shop_123",
1848
+ endpoint: "https://events.example.com/events",
1849
+ ingestSignature: "ingest-token",
1850
+ cartToken: "cart-token",
1851
+ priceAuthorizationRevision: 3,
1852
+ priceExperimentIds: ["exp_price"],
1853
+ };
1854
+
1855
+ await expect(syncShopifyCartAttributes({
1856
+ ...input,
1857
+ url: " HTTPS://STORE.EXAMPLE.COM:443/products/first ",
1858
+ })).resolves.toEqual({ status: "sent" });
1859
+ await expect(syncShopifyCartAttributes({
1860
+ ...input,
1861
+ url: "https://store.example.com/products/first",
1862
+ })).resolves.toEqual({ status: "sent", reason: "already_stamped" });
1863
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
1864
+
1865
+ await expect(syncShopifyCartAttributes({
1866
+ ...input,
1867
+ url: "https://store.example.com/products/second",
1868
+ })).resolves.toEqual({ status: "sent" });
1869
+ expect(globalThis.fetch).toHaveBeenCalledTimes(4);
1870
+ const authorizationBodies = [0, 2].map((index) =>
1871
+ JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[index]?.[1]?.body))
1872
+ );
1873
+ expect(authorizationBodies.map((body) => body.url)).toEqual([
1874
+ " HTTPS://STORE.EXAMPLE.COM:443/products/first ",
1875
+ "https://store.example.com/products/second",
1876
+ ]);
1877
+ expect(JSON.parse(sessionStorage.getItem("drip_cart_attr") ?? "{}").url)
1878
+ .toBe("https://store.example.com/products/second");
1879
+ });
1880
+
1881
+ it("force re-stamps an assignment after the cart itself changes", async () => {
1882
+ (globalThis as any).sessionStorage = makeStorage({
1883
+ drip_cart_attr: JSON.stringify({
1884
+ assignments: [["exp_123", "var_123", null]],
1885
+ visitorId: "visitor_123",
1886
+ sessionId: "session_123",
1887
+ cartToken: null,
1888
+ shippingAuthorizationRevision: null,
1889
+ priceAuthorizationRevision: null,
1890
+ }),
1891
+ });
1892
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1893
+
1894
+ const result = await syncShopifyCartAttributes({
1895
+ assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
1896
+ visitorId: "visitor_123",
1897
+ sessionId: "session_123",
1898
+ }, { force: true });
1899
+
1900
+ expect(result).toEqual({ status: "sent" });
1901
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
1902
+ });
1903
+
1904
+ it("invalidates an in-flight token-null sync before a forced replacement-cart resync", async () => {
1905
+ let resolveCartA!: (response: Response) => void;
1906
+ let cartReads = 0;
1907
+ let authorizationRequests = 0;
1908
+ const assignment = {
1909
+ experimentId: "exp_price",
1910
+ variationId: "var_treatment",
1911
+ assignmentEpoch: 2,
1912
+ };
1913
+ const priceEnvelope = (cartHash: string) => JSON.stringify({
1914
+ v: 1,
1915
+ cartHash,
1916
+ a: [{ e: assignment.experimentId, v: assignment.variationId, ep: assignment.assignmentEpoch }],
1917
+ });
1918
+ globalThis.fetch = vi.fn((url) => {
1919
+ if (url === "/cart.js") {
1920
+ cartReads += 1;
1921
+ if (cartReads === 1) {
1922
+ return new Promise<Response>((resolve) => {
1923
+ resolveCartA = resolve;
1924
+ });
1925
+ }
1926
+ return Promise.resolve(Response.json({ token: "cart-b?key=b" }));
1927
+ }
1928
+ if (url === "https://events.example.com/v1/assign") {
1929
+ authorizationRequests += 1;
1930
+ return Promise.resolve(Response.json({
1931
+ assignments: [{
1932
+ experiment_id: assignment.experimentId,
1933
+ variation_id: assignment.variationId,
1934
+ assignment_epoch: assignment.assignmentEpoch,
1935
+ }],
1936
+ commerce_authorization: priceEnvelope(
1937
+ (authorizationRequests === 1 ? "a" : "b").repeat(64),
1938
+ ),
1939
+ }));
1940
+ }
1941
+ return Promise.resolve(new Response("ok", { status: 200 }));
1942
+ }) as any;
1943
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1944
+ const input = {
1945
+ assignments: [assignment],
1946
+ requiredAssignments: [assignment],
1947
+ visitorId: "visitor_123",
1948
+ sessionId: "session_123",
1949
+ shopId: "shop_123",
1950
+ endpoint: "https://events.example.com/events",
1951
+ ingestSignature: "ingest-token",
1952
+ priceAuthorizationRevision: 3,
1953
+ priceExperimentIds: [assignment.experimentId],
1954
+ };
1955
+
1956
+ const cartA = syncShopifyCartAttributes(input);
1957
+ await vi.waitFor(() => expect(cartReads).toBe(1));
1958
+ const cartB = syncShopifyCartAttributes(input, { force: true });
1959
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
1960
+
1961
+ resolveCartA(Response.json({ token: "cart-a?key=a" }));
1962
+ await expect(cartA).resolves.toEqual({
1963
+ status: "failed",
1964
+ reason: "authorization_failed",
1965
+ });
1966
+ await expect(cartB).resolves.toEqual({ status: "sent" });
1967
+
1968
+ const calls = vi.mocked(globalThis.fetch).mock.calls;
1969
+ const authorizationCartTokens = calls
1970
+ .filter(([url]) => url === "https://events.example.com/v1/assign")
1971
+ .map(([, init]) => JSON.parse(String(init?.body)).cart_token);
1972
+ expect(authorizationCartTokens).toEqual(["cart-a?key=a", "cart-b?key=b"]);
1973
+ const cartUpdates = calls.filter(([url]) => url === "/cart/update.js");
1974
+ expect(cartUpdates).toHaveLength(1);
1975
+ expect(JSON.parse(String(cartUpdates[0]?.[1]?.body)).attributes)
1976
+ .toMatchObject({ apex_assignment_authorization: priceEnvelope("b".repeat(64)) });
1977
+ });
1978
+
1979
+ it("leaves a replacement cart unstamped when its forced resync fails", async () => {
1980
+ let resolveCartA!: (response: Response) => void;
1981
+ let cartReads = 0;
1982
+ let authorizationRequests = 0;
1983
+ const assignment = {
1984
+ experimentId: "exp_price",
1985
+ variationId: "var_treatment",
1986
+ assignmentEpoch: 2,
1987
+ };
1988
+ const cartAEnvelope = JSON.stringify({
1989
+ v: 1,
1990
+ cartHash: "a".repeat(64),
1991
+ a: [{ e: assignment.experimentId, v: assignment.variationId, ep: assignment.assignmentEpoch }],
1992
+ });
1993
+ globalThis.fetch = vi.fn((url) => {
1994
+ if (url === "/cart.js") {
1995
+ cartReads += 1;
1996
+ if (cartReads === 1) {
1997
+ return new Promise<Response>((resolve) => {
1998
+ resolveCartA = resolve;
1999
+ });
2000
+ }
2001
+ return Promise.resolve(Response.json({ token: "cart-b?key=b" }));
2002
+ }
2003
+ if (url === "https://events.example.com/v1/assign") {
2004
+ authorizationRequests += 1;
2005
+ if (authorizationRequests === 2) {
2006
+ return Promise.resolve(new Response("unavailable", { status: 503 }));
2007
+ }
2008
+ return Promise.resolve(Response.json({
2009
+ assignments: [{
2010
+ experiment_id: assignment.experimentId,
2011
+ variation_id: assignment.variationId,
2012
+ assignment_epoch: assignment.assignmentEpoch,
2013
+ }],
2014
+ commerce_authorization: cartAEnvelope,
2015
+ }));
2016
+ }
2017
+ return Promise.resolve(new Response("ok", { status: 200 }));
2018
+ }) as any;
2019
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2020
+ const input = {
2021
+ assignments: [assignment],
2022
+ requiredAssignments: [assignment],
2023
+ visitorId: "visitor_123",
2024
+ sessionId: "session_123",
2025
+ shopId: "shop_123",
2026
+ endpoint: "https://events.example.com/events",
2027
+ ingestSignature: "ingest-token",
2028
+ priceAuthorizationRevision: 3,
2029
+ priceExperimentIds: [assignment.experimentId],
2030
+ };
2031
+
2032
+ const cartA = syncShopifyCartAttributes(input);
2033
+ await vi.waitFor(() => expect(cartReads).toBe(1));
2034
+ const cartB = syncShopifyCartAttributes(input, { force: true });
2035
+
2036
+ resolveCartA(Response.json({ token: "cart-a?key=a" }));
2037
+ await expect(cartA).resolves.toEqual({
2038
+ status: "failed",
2039
+ reason: "authorization_failed",
2040
+ });
2041
+ await expect(cartB).resolves.toEqual({
2042
+ status: "failed",
2043
+ reason: "authorization_failed",
2044
+ });
2045
+
2046
+ expect(vi.mocked(globalThis.fetch).mock.calls
2047
+ .filter(([url]) => url === "/cart/update.js")).toHaveLength(0);
2048
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
2049
+ });
2050
+
2051
+ it("keeps non-forced token-null overlaps coalesced", async () => {
2052
+ let resolveCart!: (response: Response) => void;
2053
+ const assignment = {
2054
+ experimentId: "exp_price",
2055
+ variationId: "var_treatment",
2056
+ assignmentEpoch: 2,
2057
+ };
2058
+ const envelope = JSON.stringify({
2059
+ v: 1,
2060
+ cartHash: "c".repeat(64),
2061
+ a: [{ e: assignment.experimentId, v: assignment.variationId, ep: assignment.assignmentEpoch }],
2062
+ });
2063
+ globalThis.fetch = vi.fn()
2064
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => {
2065
+ resolveCart = resolve;
2066
+ }))
2067
+ .mockResolvedValueOnce(Response.json({
2068
+ assignments: [{
2069
+ experiment_id: assignment.experimentId,
2070
+ variation_id: assignment.variationId,
2071
+ assignment_epoch: assignment.assignmentEpoch,
2072
+ }],
2073
+ commerce_authorization: envelope,
2074
+ }))
2075
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
2076
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2077
+ const input = {
2078
+ assignments: [assignment],
2079
+ requiredAssignments: [assignment],
2080
+ visitorId: "visitor_123",
2081
+ sessionId: "session_123",
2082
+ shopId: "shop_123",
2083
+ endpoint: "https://events.example.com/events",
2084
+ ingestSignature: "ingest-token",
2085
+ priceAuthorizationRevision: 3,
2086
+ priceExperimentIds: [assignment.experimentId],
2087
+ };
2088
+
2089
+ const first = syncShopifyCartAttributes(input);
2090
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2091
+ const overlap = syncShopifyCartAttributes(input);
2092
+ expect(overlap).toBe(first);
2093
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
2094
+
2095
+ resolveCart(Response.json({ token: "cart-c?key=c" }));
2096
+ await expect(overlap).resolves.toEqual({ status: "sent" });
2097
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
2098
+ expect(vi.mocked(globalThis.fetch).mock.calls
2099
+ .filter(([url]) => url === "/cart/update.js")).toHaveLength(1);
2100
+ });
2101
+
2102
+ it("retries a failed cart update and stamps only after a successful response", async () => {
2103
+ globalThis.fetch = vi.fn()
2104
+ .mockResolvedValueOnce(new Response("failed", { status: 500 }))
2105
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
2106
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2107
+ const input = {
2108
+ assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
2109
+ visitorId: "visitor_123",
2110
+ sessionId: "session_123",
2111
+ };
2112
+
2113
+ syncShopifyCartAttributes(input);
2114
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2115
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
2116
+ await Promise.resolve();
2117
+ await Promise.resolve();
2118
+ await Promise.resolve();
2119
+
2120
+ syncShopifyCartAttributes(input);
2121
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2122
+ await vi.waitFor(() => expect(sessionStorage.getItem("drip_cart_attr")).not.toBeNull());
2123
+ });
2124
+
2125
+ it("updates cart identity when assignments stay the same", async () => {
2126
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2127
+ const assignments = [{ experimentId: "exp_123", variationId: "var_123" }];
2128
+
2129
+ syncShopifyCartAttributes({ assignments, visitorId: "visitor_old", sessionId: "session_1" });
2130
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2131
+ syncShopifyCartAttributes({ assignments, visitorId: "visitor_new", sessionId: "session_2" });
2132
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2133
+
2134
+ const secondBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
2135
+ expect(secondBody.attributes).toMatchObject({
2136
+ apex_visitor_id: "visitor_new",
2137
+ apex_session_id: "session_2",
2138
+ });
2139
+ });
2140
+
2141
+ it("does not let an older overlapping sync overwrite the newest stamp", async () => {
2142
+ let resolveFirst!: (response: Response) => void;
2143
+ let resolveSecond!: (response: Response) => void;
2144
+ globalThis.fetch = vi.fn()
2145
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => {
2146
+ resolveFirst = resolve;
2147
+ }))
2148
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => {
2149
+ resolveSecond = resolve;
2150
+ })) as any;
2151
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2152
+ const firstInput = {
2153
+ assignments: [{ experimentId: "exp_old", variationId: "var_old" }],
2154
+ visitorId: "visitor_123",
2155
+ sessionId: "session_123",
2156
+ };
2157
+ const secondInput = {
2158
+ assignments: [{ experimentId: "exp_new", variationId: "var_new" }],
2159
+ visitorId: "visitor_123",
2160
+ sessionId: "session_123",
2161
+ };
2162
+
2163
+ const first = syncShopifyCartAttributes(firstInput);
2164
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2165
+ const second = syncShopifyCartAttributes(secondInput);
2166
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2167
+
2168
+ resolveSecond(new Response("ok", { status: 200 }));
2169
+ await expect(second).resolves.toEqual({ status: "sent" });
2170
+ const newestStamp = sessionStorage.getItem("drip_cart_attr");
2171
+ expect(newestStamp).toContain("exp_new");
2172
+
2173
+ resolveFirst(new Response("ok", { status: 200 }));
2174
+ await expect(first).resolves.toEqual({ status: "sent" });
2175
+ expect(sessionStorage.getItem("drip_cart_attr")).toBe(newestStamp);
2176
+
2177
+ vi.mocked(globalThis.fetch).mockClear();
2178
+ await expect(syncShopifyCartAttributes(secondInput)).resolves.toEqual({
2179
+ status: "sent",
2180
+ reason: "already_stamped",
2181
+ });
2182
+ expect(globalThis.fetch).not.toHaveBeenCalled();
2183
+ });
2184
+
2185
+ it("does not let stale authorization mutate the Shopify cart", async () => {
2186
+ let resolveOlderAuthorization!: (response: Response) => void;
2187
+ let resolveNewerAuthorization!: (response: Response) => void;
2188
+ globalThis.fetch = vi.fn((url) => {
2189
+ if (url === "https://events.example.com/v1/assign") {
2190
+ return new Promise<Response>((resolve) => {
2191
+ if (!resolveOlderAuthorization) resolveOlderAuthorization = resolve;
2192
+ else resolveNewerAuthorization = resolve;
2193
+ });
2194
+ }
2195
+ return Promise.resolve(new Response("ok", { status: 200 }));
2196
+ }) as any;
2197
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2198
+ const input = (
2199
+ experimentId: string,
2200
+ variationId: string,
2201
+ assignmentEpoch: number,
2202
+ ) => ({
2203
+ assignments: [{ experimentId, variationId, assignmentEpoch }],
2204
+ requiredAssignments: [{ experimentId, variationId, assignmentEpoch }],
2205
+ visitorId: "visitor_123",
2206
+ sessionId: "session_123",
2207
+ shopId: "shop_123",
2208
+ endpoint: "https://events.example.com/events",
2209
+ ingestSignature: "ingest-token",
2210
+ });
2211
+
2212
+ const older = syncShopifyCartAttributes(input("exp_old", "var_old", 1));
2213
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2214
+ const newer = syncShopifyCartAttributes(input("exp_new", "var_new", 2));
2215
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2216
+
2217
+ resolveNewerAuthorization(new Response(JSON.stringify({
2218
+ assignments: [{
2219
+ experiment_id: "exp_new",
2220
+ variation_id: "var_new",
2221
+ assignment_epoch: 2,
2222
+ }],
2223
+ commerce_authorization: "newer-proof",
2224
+ }), { status: 200, headers: { "Content-Type": "application/json" } }));
2225
+ await expect(newer).resolves.toEqual({ status: "sent" });
2226
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
2227
+
2228
+ resolveOlderAuthorization(new Response(JSON.stringify({
2229
+ assignments: [{
2230
+ experiment_id: "exp_old",
2231
+ variation_id: "var_old",
2232
+ assignment_epoch: 1,
2233
+ }],
2234
+ commerce_authorization: "older-proof",
2235
+ }), { status: 200, headers: { "Content-Type": "application/json" } }));
2236
+ await expect(older).resolves.toEqual({
2237
+ status: "failed",
2238
+ reason: "authorization_failed",
2239
+ });
2240
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
2241
+
2242
+ const cartUpdate = vi.mocked(globalThis.fetch).mock.calls[2];
2243
+ expect(cartUpdate?.[0]).toBe("/cart/update.js");
2244
+ expect(JSON.parse(String(cartUpdate?.[1]?.body)).attributes).toMatchObject({
2245
+ apex_experiment_id: "exp_new",
2246
+ apex_variation_id: "var_new",
2247
+ apex_assignment_epoch: "2",
2248
+ apex_assignment_authorization: "newer-proof",
2249
+ });
2250
+ });
2251
+
2252
+ it("lets price-plane teardown invalidate an in-flight cart authorization before it writes", async () => {
2253
+ let resolveAuthorization!: (response: Response) => void;
2254
+ globalThis.fetch = vi.fn((url) => {
2255
+ if (url === "/cart.js") {
2256
+ return Promise.resolve(Response.json({ token: "cart-runtime?key=runtime-secret" }));
2257
+ }
2258
+ if (url === "https://events.example.com/v1/assign") {
2259
+ return new Promise<Response>((resolve) => { resolveAuthorization = resolve; });
2260
+ }
2261
+ return Promise.resolve(new Response("ok", { status: 200 }));
2262
+ }) as any;
2263
+ const {
2264
+ invalidateShopifyCartAttributeSync,
2265
+ syncShopifyCartAttributes,
2266
+ } = await import("./shopify-cart");
2267
+ const sync = syncShopifyCartAttributes({
2268
+ assignments: [{ experimentId: "exp_price", variationId: "treatment", assignmentEpoch: 1 }],
2269
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "treatment", assignmentEpoch: 1 }],
2270
+ visitorId: "visitor_123",
2271
+ sessionId: "session_123",
2272
+ shopId: "shop_123",
2273
+ endpoint: "https://events.example.com/events",
2274
+ ingestSignature: "ingest-token",
2275
+ priceAuthorizationRevision: 1,
2276
+ priceExperimentIds: ["exp_price"],
2277
+ });
2278
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2279
+
2280
+ invalidateShopifyCartAttributeSync();
2281
+ resolveAuthorization(Response.json({
2282
+ assignments: [{
2283
+ experiment_id: "exp_price",
2284
+ variation_id: "treatment",
2285
+ assignment_epoch: 1,
2286
+ }],
2287
+ commerce_authorization: "stale-proof",
2288
+ }));
2289
+
2290
+ await expect(sync).resolves.toEqual({
2291
+ status: "failed",
2292
+ reason: "authorization_failed",
2293
+ });
2294
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
2295
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
2296
+ });
2297
+
2298
+ it("does not overwrite a newer stamp when its sync is already complete", async () => {
2299
+ const newestStamp = JSON.stringify({
2300
+ assignments: [["exp_new", "var_new", null]],
2301
+ visitorId: "visitor_123",
2302
+ sessionId: "session_123",
2303
+ cartToken: null,
2304
+ shippingAuthorizationRevision: null,
2305
+ priceAuthorizationRevision: null,
2306
+ });
2307
+ (globalThis as any).sessionStorage = makeStorage({
2308
+ drip_cart_attr: newestStamp,
2309
+ });
2310
+ let resolveOlder!: (response: Response) => void;
2311
+ globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
2312
+ resolveOlder = resolve;
2313
+ })) as any;
2314
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2315
+
2316
+ const older = syncShopifyCartAttributes({
2317
+ assignments: [{ experimentId: "exp_old", variationId: "var_old" }],
2318
+ visitorId: "visitor_123",
2319
+ sessionId: "session_123",
2320
+ });
2321
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2322
+ await expect(syncShopifyCartAttributes({
2323
+ assignments: [{ experimentId: "exp_new", variationId: "var_new" }],
2324
+ visitorId: "visitor_123",
2325
+ sessionId: "session_123",
2326
+ })).resolves.toEqual({ status: "sent", reason: "already_stamped" });
2327
+
2328
+ resolveOlder(new Response("ok", { status: 200 }));
2329
+ await expect(older).resolves.toEqual({ status: "sent" });
2330
+ expect(sessionStorage.getItem("drip_cart_attr")).toBe(newestStamp);
2331
+ });
2332
+
2333
+ it("retains the locale cart key in the price proof request", async () => {
2334
+ (globalThis as any).window = {
2335
+ location: { origin: "https://store.example.com" },
2336
+ Shopify: { routes: { root: "/fr/" } },
2337
+ };
2338
+ globalThis.fetch = vi.fn()
2339
+ .mockResolvedValueOnce(Response.json({ token: "cart-native?key=native-secret" }))
2340
+ .mockResolvedValueOnce(Response.json({
2341
+ assignments: [{
2342
+ experiment_id: "exp_123",
2343
+ variation_id: "var_123",
2344
+ assignment_epoch: 2,
2345
+ }],
2346
+ commerce_authorization: "signed-envelope",
2347
+ }))
2348
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
2349
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2350
+
2351
+ await expect(syncShopifyCartAttributes({
2352
+ assignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
2353
+ requiredAssignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
2354
+ visitorId: "visitor_123",
2355
+ sessionId: "session_123",
2356
+ shopId: "shop_123",
2357
+ endpoint: "https://events.example.com/events",
2358
+ ingestSignature: "ingest-token",
2359
+ priceAuthorizationRevision: 3,
2360
+ })).resolves.toEqual({ status: "sent" });
2361
+
2362
+ expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).toBe("/fr/cart.js");
2363
+ expect(JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body)))
2364
+ .toMatchObject({ cart_token: "cart-native?key=native-secret" });
2365
+ expect(JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[1]?.body)).attributes)
2366
+ .toMatchObject({ apex_assignment_authorization: "signed-envelope" });
2367
+ });
2368
+
2369
+ it("returns complete signed attributes for a headless cart", async () => {
2370
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
2371
+ commerce_authorization: "signed-envelope",
2372
+ })) as any;
2373
+ const { buildSignedCartAttributeEntries } = await import("./shopify-cart");
2374
+ const entries = await buildSignedCartAttributeEntries({
2375
+ assignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
2376
+ visitorId: "visitor_123",
2377
+ sessionId: "session_123",
2378
+ shopId: "shop_123",
2379
+ endpoint: "https://events.example.com/events",
2380
+ ingestSignature: "ingest-token",
2381
+ cartToken: "gid://shopify/Cart/headless?key=headless-secret",
2382
+ priceAuthorizationRevision: 3,
2383
+ });
2384
+ expect(Object.fromEntries(entries.map((entry) => [entry.key, entry.value])))
2385
+ .toMatchObject({ apex_assignment_authorization: "signed-envelope" });
2386
+ expect(JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body)))
2387
+ .toMatchObject({ cart_token: "gid://shopify/Cart/headless?key=headless-secret" });
2388
+ });
2389
+
2390
+ it("authorizes headless price display when signed attributes are returned", async () => {
2391
+ const token = JSON.stringify({
2392
+ v: 1,
2393
+ cartHash: "c".repeat(64),
2394
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
2395
+ });
2396
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(Response.json({
2397
+ assignments: [{
2398
+ experiment_id: "exp_price",
2399
+ variation_id: "var_treatment",
2400
+ assignment_epoch: 2,
2401
+ }],
2402
+ commerce_authorization: token,
2403
+ })) as any;
2404
+ const { buildSignedCartAttributeEntries } = await import("./shopify-cart");
2405
+ const authorizePriceDisplayAssignments = vi.fn();
2406
+
2407
+ const entries = await buildSignedCartAttributeEntries({
2408
+ assignments: [{
2409
+ experimentId: "exp_price",
2410
+ variationId: "var_treatment",
2411
+ assignmentEpoch: 2,
2412
+ }],
2413
+ visitorId: "visitor_123",
2414
+ sessionId: "session_123",
2415
+ shopId: "shop_123",
2416
+ endpoint: "https://events.example.com/events",
2417
+ ingestSignature: "ingest-token",
2418
+ cartToken: "gid://shopify/Cart/headless?key=headless-secret",
2419
+ priceAuthorizationRevision: 3,
2420
+ priceExperimentIds: ["exp_price"],
2421
+ authorizePriceDisplayAssignments,
2422
+ });
2423
+
2424
+ expect(entries).not.toEqual([]);
2425
+ expect(authorizePriceDisplayAssignments).toHaveBeenCalledOnce();
2426
+ expect(authorizePriceDisplayAssignments).toHaveBeenCalledWith([{
2427
+ experimentId: "exp_price",
2428
+ variationId: "var_treatment",
2429
+ assignmentEpoch: 2,
2430
+ }]);
2431
+ });
2432
+
2433
+ it("lets only the latest overlapping headless call adopt a resurrected price pin", async () => {
2434
+ const token = JSON.stringify({
2435
+ v: 1,
2436
+ cartHash: "c".repeat(64),
2437
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
2438
+ });
2439
+ let resolveOlder!: (response: Response) => void;
2440
+ let resolveNewer!: (response: Response) => void;
2441
+ globalThis.fetch = vi.fn()
2442
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveOlder = resolve; }))
2443
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveNewer = resolve; })) as any;
2444
+ const { buildSignedCartAttributeEntries } = await import("./shopify-cart");
2445
+ const adoptOlder = vi.fn(() => true);
2446
+ const adoptNewer = vi.fn(() => true);
2447
+ const input = {
2448
+ assignments: [],
2449
+ visitorId: "visitor_123",
2450
+ sessionId: "session_123",
2451
+ shopId: "shop_123",
2452
+ endpoint: "https://events.example.com/events",
2453
+ ingestSignature: "ingest-token",
2454
+ priceAuthorizationRevision: 3,
2455
+ priceExperimentIds: ["exp_price"],
2456
+ };
2457
+
2458
+ const older = buildSignedCartAttributeEntries({
2459
+ ...input,
2460
+ cartToken: "gid://shopify/Cart/older?key=older-secret",
2461
+ adoptPriceAssignments: adoptOlder,
2462
+ });
2463
+ const newer = buildSignedCartAttributeEntries({
2464
+ ...input,
2465
+ cartToken: "gid://shopify/Cart/newer?key=newer-secret",
2466
+ adoptPriceAssignments: adoptNewer,
2467
+ });
2468
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2469
+
2470
+ resolveNewer(Response.json({
2471
+ assignments: [{
2472
+ experiment_id: "exp_price",
2473
+ variation_id: "var_treatment",
2474
+ assignment_epoch: 2,
2475
+ commerce_price_only: true,
2476
+ }],
2477
+ commerce_authorization: token,
2478
+ }));
2479
+ await expect(newer).resolves.not.toEqual([]);
2480
+ expect(adoptNewer).toHaveBeenCalledWith([{
2481
+ experimentId: "exp_price",
2482
+ variationId: "var_treatment",
2483
+ assignmentEpoch: 2,
2484
+ commercePriceOnly: true,
2485
+ }]);
2486
+
2487
+ resolveOlder(Response.json({
2488
+ assignments: [{
2489
+ experiment_id: "exp_price",
2490
+ variation_id: "var_treatment",
2491
+ assignment_epoch: 2,
2492
+ commerce_price_only: true,
2493
+ }],
2494
+ commerce_authorization: token,
2495
+ }));
2496
+ await expect(older).resolves.toEqual([]);
2497
+ expect(adoptOlder).not.toHaveBeenCalled();
2498
+ });
2499
+
2500
+ it("returns no stale headless attributes when an older cart response matches local state", async () => {
2501
+ const olderToken = JSON.stringify({
2502
+ v: 1,
2503
+ cartHash: "a".repeat(64),
2504
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
2505
+ });
2506
+ const newerToken = JSON.stringify({
2507
+ v: 1,
2508
+ cartHash: "b".repeat(64),
2509
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
2510
+ });
2511
+ let resolveOlder!: (response: Response) => void;
2512
+ let resolveNewer!: (response: Response) => void;
2513
+ globalThis.fetch = vi.fn()
2514
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveOlder = resolve; }))
2515
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveNewer = resolve; })) as any;
2516
+ const {
2517
+ buildCartAttributeEntries,
2518
+ buildSignedCartAttributeEntries,
2519
+ } = await import("./shopify-cart");
2520
+ const writeAttributes = vi.fn();
2521
+ const input = {
2522
+ assignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
2523
+ requiredAssignments: [{ experimentId: "exp_price", variationId: "var_treatment", assignmentEpoch: 2 }],
2524
+ visitorId: "visitor_123",
2525
+ sessionId: "session_123",
2526
+ shopId: "shop_123",
2527
+ endpoint: "https://events.example.com/events",
2528
+ ingestSignature: "ingest-token",
2529
+ priceAuthorizationRevision: 3,
2530
+ priceExperimentIds: ["exp_price"],
2531
+ };
2532
+ const consume = async (entriesPromise: ReturnType<typeof buildSignedCartAttributeEntries>) => {
2533
+ const entries = await entriesPromise;
2534
+ if (entries.length > 0) writeAttributes(entries);
2535
+ return entries;
2536
+ };
2537
+
2538
+ const older = consume(buildSignedCartAttributeEntries({
2539
+ ...input,
2540
+ cartToken: "gid://shopify/Cart/older?key=older-secret",
2541
+ }));
2542
+ const newer = consume(buildSignedCartAttributeEntries({
2543
+ ...input,
2544
+ cartToken: "gid://shopify/Cart/newer?key=newer-secret",
2545
+ }));
2546
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2547
+
2548
+ resolveNewer(Response.json({
2549
+ assignments: [{
2550
+ experiment_id: "exp_price",
2551
+ variation_id: "var_treatment",
2552
+ assignment_epoch: 2,
2553
+ }],
2554
+ commerce_authorization: newerToken,
2555
+ }));
2556
+ await expect(newer).resolves.toEqual(buildCartAttributeEntries(input, newerToken));
2557
+ expect(writeAttributes).toHaveBeenCalledOnce();
2558
+
2559
+ resolveOlder(Response.json({
2560
+ assignments: [{
2561
+ experiment_id: "exp_price",
2562
+ variation_id: "var_treatment",
2563
+ assignment_epoch: 2,
2564
+ }],
2565
+ commerce_authorization: olderToken,
2566
+ }));
2567
+ await expect(older).resolves.toEqual([]);
2568
+ expect(writeAttributes).toHaveBeenCalledOnce();
2569
+ });
2570
+
2571
+ it("returns no stale headless attributes when overlapping carts both receive signed-empty proof", async () => {
2572
+ const signedEmptyToken = (cartHash: string, signature: string) => JSON.stringify({
2573
+ v: 2,
2574
+ p: {
2575
+ price: {
2576
+ v: 1,
2577
+ r: 3,
2578
+ c: "b".repeat(64),
2579
+ x: "2026-07-20",
2580
+ cartHash,
2581
+ a: [],
2582
+ s: signature,
2583
+ },
2584
+ },
2585
+ });
2586
+ const olderToken = signedEmptyToken("a".repeat(64), "c".repeat(64));
2587
+ const newerToken = signedEmptyToken("d".repeat(64), "e".repeat(64));
2588
+ let resolveOlder!: (response: Response) => void;
2589
+ let resolveNewer!: (response: Response) => void;
2590
+ globalThis.fetch = vi.fn()
2591
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveOlder = resolve; }))
2592
+ .mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveNewer = resolve; })) as any;
2593
+ const {
2594
+ buildCartAttributeEntries,
2595
+ buildSignedCartAttributeEntries,
2596
+ } = await import("./shopify-cart");
2597
+ const writeAttributes = vi.fn();
2598
+ const input = {
2599
+ assignments: [],
2600
+ visitorId: "visitor_123",
2601
+ sessionId: "session_123",
2602
+ shopId: "shop_123",
2603
+ endpoint: "https://events.example.com/events",
2604
+ ingestSignature: "ingest-token",
2605
+ priceAuthorizationRevision: 3,
2606
+ priceExperimentIds: ["exp_price"],
2607
+ };
2608
+ const consume = async (entriesPromise: ReturnType<typeof buildSignedCartAttributeEntries>) => {
2609
+ const entries = await entriesPromise;
2610
+ if (entries.length > 0) writeAttributes(entries);
2611
+ return entries;
2612
+ };
2613
+
2614
+ const older = consume(buildSignedCartAttributeEntries({
2615
+ ...input,
2616
+ cartToken: "gid://shopify/Cart/older?key=older-secret",
2617
+ }));
2618
+ const newer = consume(buildSignedCartAttributeEntries({
2619
+ ...input,
2620
+ cartToken: "gid://shopify/Cart/newer?key=newer-secret",
2621
+ }));
2622
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2623
+
2624
+ resolveNewer(Response.json({ assignments: [], commerce_authorization: newerToken }));
2625
+ await expect(newer).resolves.toEqual(buildCartAttributeEntries(input, newerToken));
2626
+ expect(writeAttributes).toHaveBeenCalledOnce();
2627
+
2628
+ resolveOlder(Response.json({ assignments: [], commerce_authorization: olderToken }));
2629
+ await expect(older).resolves.toEqual([]);
2630
+ expect(writeAttributes).toHaveBeenCalledOnce();
2631
+ });
2632
+
2633
+ it("fails closed with no signed attributes for a headless cart when authorization fails", async () => {
2634
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(
2635
+ new Response("unavailable", { status: 503 }),
2636
+ ) as any;
2637
+ const { buildSignedCartAttributeEntries } = await import("./shopify-cart");
2638
+
2639
+ await expect(buildSignedCartAttributeEntries({
2640
+ assignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
2641
+ requiredAssignments: [
2642
+ { experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 },
2643
+ ],
2644
+ visitorId: "visitor_123",
2645
+ sessionId: "session_123",
2646
+ shopId: "shop_123",
2647
+ endpoint: "https://events.example.com/events",
2648
+ ingestSignature: "ingest-token",
2649
+ cartToken: "gid://shopify/Cart/headless?key=headless-secret",
2650
+ priceAuthorizationRevision: 3,
2651
+ })).resolves.toEqual([]);
2652
+ });
2653
+
2654
+ it("preserves signed attribute clearing entries when assignments are empty", async () => {
2655
+ const { buildSignedCartAttributeEntries } = await import("./shopify-cart");
2656
+
2657
+ const entries = await buildSignedCartAttributeEntries({
2658
+ assignments: [],
2659
+ visitorId: "visitor_123",
2660
+ sessionId: "session_123",
2661
+ priceAuthorizationRevision: 3,
2662
+ });
2663
+
2664
+ expect(Object.fromEntries(entries.map((entry) => [entry.key, entry.value]))).toEqual({
2665
+ apex_assignments: "[]",
2666
+ apex_assignment_authorization: "",
2667
+ apex_experiment_id: "",
2668
+ apex_variation_id: "",
2669
+ apex_assignment_epoch: "",
2670
+ apex_visitor_id: "visitor_123",
2671
+ apex_session_id: "session_123",
2672
+ });
2673
+ expect(globalThis.fetch).not.toHaveBeenCalled();
2674
+ });
2675
+
2676
+ it("resurrects a pinned price assignment when the local assignment set is empty", async () => {
2677
+ const token = JSON.stringify({
2678
+ v: 1,
2679
+ cartHash: "c".repeat(64),
2680
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
2681
+ });
2682
+ globalThis.fetch = vi.fn()
2683
+ .mockResolvedValueOnce(Response.json({
2684
+ assignments: [{
2685
+ experiment_id: "exp_price",
2686
+ variation_id: "var_treatment",
2687
+ assignment_epoch: 2,
2688
+ }],
2689
+ commerce_authorization: token,
2690
+ }))
2691
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
2692
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2693
+ const adoptPriceAssignments = vi.fn(() => true);
2694
+
2695
+ await expect(syncShopifyCartAttributes({
2696
+ assignments: [],
2697
+ visitorId: "visitor_123",
2698
+ sessionId: "session_123",
2699
+ shopId: "shop_123",
2700
+ endpoint: "https://events.example.com/events",
2701
+ ingestSignature: "ingest-token",
2702
+ cartToken: "cart-token",
2703
+ priceAuthorizationRevision: 3,
2704
+ priceExperimentIds: ["exp_price"],
2705
+ adoptPriceAssignments,
2706
+ })).resolves.toEqual({ status: "sent" });
2707
+
2708
+ const assignRequest = vi.mocked(globalThis.fetch).mock.calls[0];
2709
+ expect(assignRequest?.[0]).toBe("https://events.example.com/v1/assign");
2710
+ expect(JSON.parse(String(assignRequest?.[1]?.body)).experiments).toEqual(["exp_price"]);
2711
+ expect(adoptPriceAssignments).toHaveBeenCalledWith([{
2712
+ experimentId: "exp_price",
2713
+ variationId: "var_treatment",
2714
+ assignmentEpoch: 2,
2715
+ }]);
2716
+ const cartUpdateBody = JSON.parse(
2717
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
2718
+ );
2719
+ expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([{
2720
+ e: "exp_price",
2721
+ v: "var_treatment",
2722
+ ep: 2,
2723
+ }]);
2724
+ expect(cartUpdateBody.attributes.apex_assignment_authorization).toBe(token);
2725
+ });
2726
+
2727
+ it("keeps unsigned cart attribute entries for non-commerce headless input", async () => {
2728
+ const { buildSignedCartAttributeEntries } = await import("./shopify-cart");
2729
+
2730
+ const entries = await buildSignedCartAttributeEntries({
2731
+ assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
2732
+ visitorId: "visitor_123",
2733
+ sessionId: "session_123",
2734
+ });
2735
+
2736
+ expect(Object.fromEntries(entries.map((entry) => [entry.key, entry.value])))
2737
+ .toMatchObject({
2738
+ apex_assignments: JSON.stringify([{ e: "exp_123", v: "var_123" }]),
2739
+ apex_assignment_authorization: "",
2740
+ apex_experiment_id: "exp_123",
2741
+ apex_variation_id: "var_123",
2742
+ });
2743
+ expect(globalThis.fetch).not.toHaveBeenCalled();
2744
+ });
2745
+
2746
+ it("round-trips a headless GID proof to the Worker while comparing the bare cart token", async () => {
2747
+ globalThis.fetch = vi.fn()
2748
+ .mockResolvedValueOnce(Response.json({
2749
+ assignments: [{
2750
+ experiment_id: "exp_123",
2751
+ variation_id: "var_123",
2752
+ assignment_epoch: 2,
2753
+ }],
2754
+ commerce_authorization: "signed-envelope",
2755
+ }))
2756
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
2757
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2758
+
2759
+ await expect(syncShopifyCartAttributes({
2760
+ assignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
2761
+ requiredAssignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
2762
+ visitorId: "visitor_123",
2763
+ sessionId: "session_123",
2764
+ shopId: "shop_123",
2765
+ endpoint: "https://events.example.com/events",
2766
+ ingestSignature: "ingest-token",
2767
+ cartToken: " gid://shopify/Cart/headless?key=headless-secret ",
2768
+ priceAuthorizationRevision: 3,
2769
+ })).resolves.toEqual({ status: "sent" });
2770
+
2771
+ const proofBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body));
2772
+ expect(proofBody.cart_token).toBe("gid://shopify/Cart/headless?key=headless-secret");
2773
+ const cartUpdateBody = JSON.parse(
2774
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
2775
+ );
2776
+ expect(cartUpdateBody.attributes.apex_assignment_authorization).toBe("signed-envelope");
2777
+ const persistedSignature = String(sessionStorage.getItem("drip_cart_attr"));
2778
+ expect(JSON.parse(persistedSignature).cartToken).toBe("headless");
2779
+ expect(persistedSignature).not.toContain("headless-secret");
2780
+ });
2781
+
2782
+ it("does not post without assignments", async () => {
2783
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2784
+
2785
+ syncShopifyCartAttributes({
2786
+ assignments: [],
2787
+ visitorId: "visitor_123",
2788
+ sessionId: "session_123",
2789
+ });
2790
+ await Promise.resolve();
2791
+
2792
+ expect(globalThis.fetch).not.toHaveBeenCalled();
2793
+ });
2794
+
2795
+ it("clears the commerce authorization locally when assignments are empty under a revision", async () => {
2796
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
2797
+
2798
+ await expect(syncShopifyCartAttributes({
2799
+ assignments: [],
2800
+ visitorId: "visitor_123",
2801
+ sessionId: "session_123",
2802
+ priceAuthorizationRevision: 3,
2803
+ })).resolves.toEqual({ status: "sent" });
2804
+
2805
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
2806
+ const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!;
2807
+ expect(url).toBe("/cart/update.js");
2808
+ expect(JSON.parse(String(init?.body))).toEqual({
2809
+ attributes: {
2810
+ apex_assignments: "[]",
2811
+ apex_assignment_authorization: "",
2812
+ apex_experiment_id: "",
2813
+ apex_variation_id: "",
2814
+ apex_assignment_epoch: "",
2815
+ apex_visitor_id: "visitor_123",
2816
+ apex_session_id: "session_123",
2817
+ },
2818
+ });
2819
+ expect(vi.mocked(globalThis.fetch).mock.calls.some(([requestUrl]) =>
2820
+ String(requestUrl).includes("/v1/assign")
2821
+ )).toBe(false);
2822
+ });
2823
+ });