@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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { __giftManagerInternals, getPendingGiftChoices, noteCartSnapshotForGifts, selectGiftOption, syncGiftWithPurchase } from "./commerce-gift";
|
|
4
|
+
import { onShopifyCartMutation } from "./shopify-cart-events";
|
|
5
|
+
|
|
6
|
+
const experimentId = "exp-gwp";
|
|
7
|
+
const settings = (variant: Record<string, unknown> = {
|
|
8
|
+
offer_type: "gwp_threshold",
|
|
9
|
+
threshold: "5000",
|
|
10
|
+
gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
11
|
+
}) => ({
|
|
12
|
+
authorization: {
|
|
13
|
+
version: 1 as const,
|
|
14
|
+
revision: 1,
|
|
15
|
+
checksum: "a".repeat(64),
|
|
16
|
+
experimentIds: [experimentId, "exp-gwp-2"],
|
|
17
|
+
},
|
|
18
|
+
experiments: [{ experiment_id: experimentId, currency: "USD", variants: { treatment: variant } }],
|
|
19
|
+
});
|
|
20
|
+
const assignments = [{ experimentId, variationId: "treatment" }];
|
|
21
|
+
const cart = (items: unknown[], currency = "USD", token?: string) => ({
|
|
22
|
+
currency,
|
|
23
|
+
items,
|
|
24
|
+
...(token ? { token } : {}),
|
|
25
|
+
});
|
|
26
|
+
const merchandise = { key: "merch", variant_id: 1, line_price: 5000, properties: {} };
|
|
27
|
+
const apexGift = { key: "gift-key", variant_id: 9, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
28
|
+
|
|
29
|
+
function response(body: unknown, ok = true): Response {
|
|
30
|
+
return { ok, json: async () => body } as Response;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
__giftManagerInternals.reset();
|
|
36
|
+
sessionStorage.clear();
|
|
37
|
+
localStorage.clear();
|
|
38
|
+
(window as Window & { Shopify?: unknown }).Shopify = { routes: { root: "/" } };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("gift-with-purchase manager", () => {
|
|
42
|
+
it("adds one marked gift when treatment merchandise reaches the threshold", async () => {
|
|
43
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
44
|
+
window.fetch = fetch;
|
|
45
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
46
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/add.js", expect.objectContaining({
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: JSON.stringify({ items: [{ id: 9, quantity: 1, properties: { _apex_gwp: experimentId } }] }),
|
|
49
|
+
}));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("removes only the Apex-managed gift below threshold", async () => {
|
|
53
|
+
const shopperGift = { key: "shopper-gift", variant_id: 9, line_price: 0, properties: {} };
|
|
54
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 4999 }, apexGift, shopperGift]))).mockResolvedValue(response({}));
|
|
55
|
+
window.fetch = fetch;
|
|
56
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
57
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
58
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
59
|
+
}));
|
|
60
|
+
expect(fetch).not.toHaveBeenCalledWith("/cart/change.js", expect.objectContaining({ body: expect.stringContaining("shopper-gift") }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not remove or double-add a shopper-owned gift", async () => {
|
|
64
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise, { variant_id: 9, line_price: 0, properties: {} }])));
|
|
65
|
+
window.fetch = fetch;
|
|
66
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
67
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("treats disappearance after insertion as a session decline", async () => {
|
|
71
|
+
const fetch = vi.fn()
|
|
72
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
73
|
+
.mockResolvedValueOnce(response({}))
|
|
74
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
75
|
+
.mockResolvedValueOnce(response(cart([merchandise])));
|
|
76
|
+
window.fetch = fetch;
|
|
77
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
78
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
79
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
80
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
81
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBe("1");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("never inserts in control and removes a stale managed gift", async () => {
|
|
85
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
86
|
+
window.fetch = fetch;
|
|
87
|
+
await syncGiftWithPurchase(settings({ offer_type: "gwp_threshold", control: true }), assignments);
|
|
88
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({ body: JSON.stringify({ id: "gift-key", quantity: 0 }) }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("fails closed when cart and offer currencies differ", async () => {
|
|
92
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise], "EUR")));
|
|
93
|
+
window.fetch = fetch;
|
|
94
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
95
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("removes a managed gift when the cart currency changes", async () => {
|
|
99
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift], "EUR"))).mockResolvedValue(response({}));
|
|
100
|
+
window.fetch = fetch;
|
|
101
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
102
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
103
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
104
|
+
}));
|
|
105
|
+
// A currency switch is not a shopper decline.
|
|
106
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("seeds insertion records from a cart snapshot and enables teardown", async () => {
|
|
110
|
+
const { noteCartSnapshotForGifts } = await import("./commerce-gift");
|
|
111
|
+
// Fresh device: no local records, but the stamp-write response shows a
|
|
112
|
+
// marked line riding on the synced cart.
|
|
113
|
+
expect(noteCartSnapshotForGifts(cart([merchandise, apexGift]))).toBe(true);
|
|
114
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeTruthy();
|
|
115
|
+
// Teardown (no offers settings) can now reconcile.
|
|
116
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
117
|
+
window.fetch = fetch;
|
|
118
|
+
await syncGiftWithPurchase(undefined, []);
|
|
119
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
120
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
121
|
+
}));
|
|
122
|
+
expect(noteCartSnapshotForGifts(cart([merchandise]))).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("removes a managed gift after its offer leaves the projection", async () => {
|
|
126
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
127
|
+
window.fetch = fetch;
|
|
128
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "1");
|
|
129
|
+
await syncGiftWithPurchase(undefined, []);
|
|
130
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
131
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
132
|
+
}));
|
|
133
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("removes a managed gift whose assignment is gone", async () => {
|
|
137
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
138
|
+
window.fetch = fetch;
|
|
139
|
+
await syncGiftWithPurchase(settings(), []);
|
|
140
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
141
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
142
|
+
}));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("respects manual removal after a fresh page load", async () => {
|
|
146
|
+
const fetch = vi.fn()
|
|
147
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
148
|
+
.mockResolvedValueOnce(response({}))
|
|
149
|
+
.mockResolvedValueOnce(response(cart([merchandise])));
|
|
150
|
+
window.fetch = fetch;
|
|
151
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
152
|
+
// Simulate a navigation: in-memory state is gone, sessionStorage survives.
|
|
153
|
+
__giftManagerInternals.reset();
|
|
154
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
155
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
156
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBe("1");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("normalizes an incremented managed gift line back to one unit", async () => {
|
|
160
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, { ...apexGift, quantity: 2 }]))).mockResolvedValue(response({}));
|
|
161
|
+
window.fetch = fetch;
|
|
162
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
163
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
164
|
+
body: JSON.stringify({ id: "gift-key", quantity: 1 }),
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("queues one trailing reconciliation while a run is in flight", async () => {
|
|
169
|
+
let release!: (value: Response) => void;
|
|
170
|
+
const firstRead = new Promise<Response>((resolve) => { release = resolve; });
|
|
171
|
+
const shopperGift = { variant_id: 9, line_price: 0, properties: {} };
|
|
172
|
+
const fetch = vi.fn()
|
|
173
|
+
.mockReturnValueOnce(firstRead)
|
|
174
|
+
.mockResolvedValue(response(cart([merchandise, shopperGift])));
|
|
175
|
+
window.fetch = fetch;
|
|
176
|
+
const active = syncGiftWithPurchase(settings(), assignments);
|
|
177
|
+
const coalesced = syncGiftWithPurchase(settings(), assignments);
|
|
178
|
+
release(response(cart([merchandise, shopperGift])));
|
|
179
|
+
await active;
|
|
180
|
+
await coalesced;
|
|
181
|
+
// Two cart reads: the in-flight run plus the queued trailing run that
|
|
182
|
+
// re-observes the cart mutated underneath the first one.
|
|
183
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("uses locale-prefixed cart routes for reads and mutations", async () => {
|
|
187
|
+
(window as Window & { Shopify?: unknown }).Shopify = { routes: { root: "/fr/" } };
|
|
188
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
189
|
+
window.fetch = fetch;
|
|
190
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
191
|
+
expect(fetch).toHaveBeenNthCalledWith(1, "/fr/cart.js", expect.anything());
|
|
192
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/fr/cart/add.js", expect.anything());
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("uses the pre-discount line_price basis the offer Function compares", async () => {
|
|
196
|
+
// Line-level discount: final is below threshold, pre-discount is not.
|
|
197
|
+
const discounted = { key: "merch", variant_id: 1, line_price: 5000, final_line_price: 4000, properties: {} };
|
|
198
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([discounted]))).mockResolvedValue(response({}));
|
|
199
|
+
window.fetch = fetch;
|
|
200
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
201
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/add.js", expect.anything());
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("replaces a marked line whose gift variant changed on relaunch", async () => {
|
|
205
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
206
|
+
window.fetch = fetch;
|
|
207
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "1");
|
|
208
|
+
await syncGiftWithPurchase(settings({
|
|
209
|
+
offer_type: "gwp_threshold",
|
|
210
|
+
threshold: "5000",
|
|
211
|
+
gift_variant_gid: "gid://shopify/ProductVariant/10",
|
|
212
|
+
}), assignments);
|
|
213
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
214
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
215
|
+
}));
|
|
216
|
+
expect(fetch).toHaveBeenNthCalledWith(3, "/cart/add.js", expect.objectContaining({
|
|
217
|
+
body: JSON.stringify({ items: [{ id: 10, quantity: 1, properties: { _apex_gwp: experimentId } }] }),
|
|
218
|
+
}));
|
|
219
|
+
// The old line's removal is not a shopper decline of the new gift.
|
|
220
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("removes a marked line whose experiment relaunched as a non-GWP offer", async () => {
|
|
224
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
225
|
+
window.fetch = fetch;
|
|
226
|
+
await syncGiftWithPurchase(settings({ offer_type: "discount_depth", percent_off: 10 }), assignments);
|
|
227
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
228
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
229
|
+
}));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("retains the insertion record when orphan removal fails, then retries", async () => {
|
|
233
|
+
const fetch = vi.fn()
|
|
234
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift])))
|
|
235
|
+
.mockResolvedValueOnce(response({}, false))
|
|
236
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift])))
|
|
237
|
+
.mockResolvedValue(response({}));
|
|
238
|
+
window.fetch = fetch;
|
|
239
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "1");
|
|
240
|
+
await syncGiftWithPurchase(undefined, []);
|
|
241
|
+
// Removal failed — the record must survive so teardown cleanup retries.
|
|
242
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeTruthy();
|
|
243
|
+
await syncGiftWithPurchase(undefined, []);
|
|
244
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/change.js")).toHaveLength(2);
|
|
245
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("cleans up a marked line in a new browser session after teardown", async () => {
|
|
249
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
250
|
+
window.fetch = fetch;
|
|
251
|
+
// New session: sessionStorage is empty, but the durable insertion record
|
|
252
|
+
// survives alongside the persistent Shopify cart.
|
|
253
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "1");
|
|
254
|
+
sessionStorage.clear();
|
|
255
|
+
await syncGiftWithPurchase(undefined, []);
|
|
256
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
257
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
258
|
+
}));
|
|
259
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("treats a managed line discovered in a fresh session as inserted", async () => {
|
|
263
|
+
const fetch = vi.fn()
|
|
264
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift])))
|
|
265
|
+
.mockResolvedValueOnce(response(cart([merchandise])));
|
|
266
|
+
window.fetch = fetch;
|
|
267
|
+
// No insertion records at all — the marked line itself is the evidence.
|
|
268
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
269
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeTruthy();
|
|
270
|
+
// The shopper then removes it: that is a decline, not a re-add trigger.
|
|
271
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
272
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
273
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBe("1");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not treat a new cart as a manual decline of the previous cart's gift", async () => {
|
|
277
|
+
// Insertion evidence from the checked-out cart; the shopper starts fresh.
|
|
278
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "cart-old");
|
|
279
|
+
const fetch = vi.fn()
|
|
280
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "cart-new")))
|
|
281
|
+
.mockResolvedValue(response({}));
|
|
282
|
+
window.fetch = fetch;
|
|
283
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
284
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBeNull();
|
|
285
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/add.js", expect.anything());
|
|
286
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBe("cart-new");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("still records a decline when the line vanished from the same cart", async () => {
|
|
290
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "cart-same");
|
|
291
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise], "USD", "cart-same")));
|
|
292
|
+
window.fetch = fetch;
|
|
293
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
294
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBe("1");
|
|
295
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("shares one gift line across parallel GWP tests with the same variant", async () => {
|
|
299
|
+
const twoExperiments = {
|
|
300
|
+
authorization: settings().authorization,
|
|
301
|
+
experiments: [
|
|
302
|
+
settings().experiments[0],
|
|
303
|
+
{ ...settings().experiments[0], experiment_id: "exp-gwp-2" },
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
const bothAssignments = [
|
|
307
|
+
...assignments,
|
|
308
|
+
{ experimentId: "exp-gwp-2", variationId: "treatment" },
|
|
309
|
+
];
|
|
310
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
311
|
+
window.fetch = fetch;
|
|
312
|
+
await syncGiftWithPurchase(twoExperiments, bothAssignments);
|
|
313
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("never inserts for an off-target charge-only pinned assignment", async () => {
|
|
317
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise])));
|
|
318
|
+
window.fetch = fetch;
|
|
319
|
+
await syncGiftWithPurchase(settings(), [
|
|
320
|
+
{ experimentId, variationId: "treatment", commercePriceOnly: true },
|
|
321
|
+
]);
|
|
322
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("clears an orphaned record whose line and experiment are both gone", async () => {
|
|
326
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "cart-old");
|
|
327
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise])));
|
|
328
|
+
window.fetch = fetch;
|
|
329
|
+
await syncGiftWithPurchase(undefined, []);
|
|
330
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toBeNull();
|
|
331
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
332
|
+
// With the record gone, teardown syncs stop reading the cart entirely.
|
|
333
|
+
await syncGiftWithPurchase(undefined, []);
|
|
334
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("discovers an orphaned gift via the tombstoned plane without any local records", async () => {
|
|
338
|
+
// Offer stopped; storage is empty (new device); the projection tombstone
|
|
339
|
+
// keeps an empty offers plane alive for exactly this discovery read.
|
|
340
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
341
|
+
window.fetch = fetch;
|
|
342
|
+
await syncGiftWithPurchase({ experiments: [] }, []);
|
|
343
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
344
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
345
|
+
}));
|
|
346
|
+
// Once per session: the second sync performs no additional cart read.
|
|
347
|
+
const calls = fetch.mock.calls.length;
|
|
348
|
+
await syncGiftWithPurchase({ experiments: [] }, []);
|
|
349
|
+
expect(fetch.mock.calls.length).toBe(calls);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("inserts for a still-qualifying experiment after removing a shared gift line", async () => {
|
|
353
|
+
const twoExperiments = {
|
|
354
|
+
authorization: settings().authorization,
|
|
355
|
+
experiments: [
|
|
356
|
+
{
|
|
357
|
+
experiment_id: experimentId,
|
|
358
|
+
currency: "USD",
|
|
359
|
+
variants: { treatment: { offer_type: "gwp_threshold", control: true } },
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
...settings().experiments[0],
|
|
363
|
+
experiment_id: "exp-gwp-2",
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
368
|
+
window.fetch = fetch;
|
|
369
|
+
await syncGiftWithPurchase(twoExperiments, [
|
|
370
|
+
...assignments,
|
|
371
|
+
{ experimentId: "exp-gwp-2", variationId: "treatment" },
|
|
372
|
+
]);
|
|
373
|
+
// Control removes its marked line; the qualifying sibling must not treat
|
|
374
|
+
// the just-removed variant as still present and must insert its own gift.
|
|
375
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
376
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
377
|
+
}));
|
|
378
|
+
expect(fetch).toHaveBeenNthCalledWith(3, "/cart/add.js", expect.objectContaining({
|
|
379
|
+
body: JSON.stringify({ items: [{ id: 9, quantity: 1, properties: { _apex_gwp: "exp-gwp-2" } }] }),
|
|
380
|
+
}));
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("normalizes a concurrently consolidated gift line from the add response", async () => {
|
|
384
|
+
const fetch = vi.fn()
|
|
385
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
386
|
+
// Another tab added first; Shopify consolidated to quantity 2.
|
|
387
|
+
.mockResolvedValueOnce(response({
|
|
388
|
+
items: [{ key: "gift-key", quantity: 2, variant_id: 9, properties: { _apex_gwp: experimentId } }],
|
|
389
|
+
}))
|
|
390
|
+
.mockResolvedValue(response({}));
|
|
391
|
+
window.fetch = fetch;
|
|
392
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
393
|
+
expect(fetch).toHaveBeenNthCalledWith(3, "/cart/change.js", expect.objectContaining({
|
|
394
|
+
body: JSON.stringify({ id: "gift-key", quantity: 1 }),
|
|
395
|
+
}));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("revalidates after inserting and removes a gift the cart no longer earns", async () => {
|
|
399
|
+
const fetch = vi.fn()
|
|
400
|
+
// Eligible at read time…
|
|
401
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
402
|
+
// …gift added…
|
|
403
|
+
.mockResolvedValueOnce(response({}))
|
|
404
|
+
// …but the subtotal dropped concurrently before the add landed: the
|
|
405
|
+
// fixpoint re-read sees the below-threshold cart with the managed line.
|
|
406
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 100 }, apexGift])))
|
|
407
|
+
.mockResolvedValue(response({}));
|
|
408
|
+
window.fetch = fetch;
|
|
409
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
410
|
+
expect(fetch).toHaveBeenNthCalledWith(4, "/cart/change.js", expect.objectContaining({
|
|
411
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
412
|
+
}));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("never inserts without a signed offers authorization covering the experiment", async () => {
|
|
416
|
+
const unauthorized = { experiments: settings().experiments };
|
|
417
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise])));
|
|
418
|
+
window.fetch = fetch;
|
|
419
|
+
await syncGiftWithPurchase(unauthorized, assignments);
|
|
420
|
+
// The Function could not discount the line, so no insertion happens.
|
|
421
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("removes and never inserts a gift whose arm is absent from the stamped proof", async () => {
|
|
425
|
+
// Shared price+offers experiment: price proof valid, offers proof refused.
|
|
426
|
+
// Config membership alone must not insert or retain the gift.
|
|
427
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise, apexGift]))).mockResolvedValue(response({}));
|
|
428
|
+
window.fetch = fetch;
|
|
429
|
+
await syncGiftWithPurchase(settings(), assignments, { isArmProven: () => false });
|
|
430
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
431
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
432
|
+
}));
|
|
433
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("inserts normally when the stamped proof covers the arm", async () => {
|
|
437
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
438
|
+
window.fetch = fetch;
|
|
439
|
+
await syncGiftWithPurchase(settings(), assignments, {
|
|
440
|
+
isArmProven: (assignment) => assignment.experimentId === experimentId,
|
|
441
|
+
});
|
|
442
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/add.js", expect.anything());
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("fail-closed cleanup still removes a below-threshold gift but never inserts", async () => {
|
|
446
|
+
// Below threshold with a managed line: removal must run.
|
|
447
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 100 }, apexGift]))).mockResolvedValue(response({}));
|
|
448
|
+
window.fetch = fetch;
|
|
449
|
+
await syncGiftWithPurchase(settings(), assignments, { suppressInsertion: true });
|
|
450
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
451
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
452
|
+
}));
|
|
453
|
+
__giftManagerInternals.reset();
|
|
454
|
+
sessionStorage.clear();
|
|
455
|
+
localStorage.clear();
|
|
456
|
+
// Eligible cart without the line: insertion is still suppressed.
|
|
457
|
+
const fetch2 = vi.fn().mockResolvedValue(response(cart([merchandise])));
|
|
458
|
+
window.fetch = fetch2;
|
|
459
|
+
await syncGiftWithPurchase(settings(), assignments, {
|
|
460
|
+
suppressInsertion: true,
|
|
461
|
+
isArmProven: () => true,
|
|
462
|
+
});
|
|
463
|
+
expect(fetch2.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("keeps one current-variant line and removes duplicates and stale variants", async () => {
|
|
467
|
+
const staleGift = { key: "stale-key", variant_id: 7, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
468
|
+
const duplicateGift = { key: "dup-key", variant_id: 9, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
469
|
+
const fetch = vi.fn()
|
|
470
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift, staleGift, duplicateGift])))
|
|
471
|
+
.mockResolvedValue(response({}));
|
|
472
|
+
window.fetch = fetch;
|
|
473
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
474
|
+
const removals = fetch.mock.calls
|
|
475
|
+
.filter(([url]) => url === "/cart/change.js")
|
|
476
|
+
.map(([, init]) => JSON.parse(String((init as RequestInit).body)));
|
|
477
|
+
expect(removals).toEqual(expect.arrayContaining([
|
|
478
|
+
{ id: "stale-key", quantity: 0 },
|
|
479
|
+
{ id: "dup-key", quantity: 0 },
|
|
480
|
+
]));
|
|
481
|
+
expect(removals.some((body) => body.id === "gift-key")).toBe(false);
|
|
482
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
483
|
+
// Our stale removals never record a shopper decline.
|
|
484
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBeNull();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("fail-closed pass removes a managed line lacking current-cart evidence", async () => {
|
|
488
|
+
// Cart identity changed (merge) and the stamp refresh failed: the record
|
|
489
|
+
// belongs to the old cart, so the line has no proof on this one.
|
|
490
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "cart-old");
|
|
491
|
+
const fetch = vi.fn()
|
|
492
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift], "USD", "cart-new")))
|
|
493
|
+
.mockResolvedValue(response({}));
|
|
494
|
+
window.fetch = fetch;
|
|
495
|
+
await syncGiftWithPurchase(settings(), assignments, { suppressInsertion: true });
|
|
496
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
497
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
498
|
+
}));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("fail-closed pass keeps a managed line proven on the current cart", async () => {
|
|
502
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, "cart-same");
|
|
503
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise, apexGift], "USD", "cart-same")));
|
|
504
|
+
window.fetch = fetch;
|
|
505
|
+
await syncGiftWithPurchase(settings(), assignments, { suppressInsertion: true });
|
|
506
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("dispatches apex:cart:changed after a mutating reconciliation", async () => {
|
|
510
|
+
const events: Event[] = [];
|
|
511
|
+
const listener = (event: Event) => events.push(event);
|
|
512
|
+
window.addEventListener("apex:cart:changed", listener);
|
|
513
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
514
|
+
window.fetch = fetch;
|
|
515
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
516
|
+
window.removeEventListener("apex:cart:changed", listener);
|
|
517
|
+
expect(events).toHaveLength(1);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("retries within the run when a duplicate-line removal fails beside the keeper", async () => {
|
|
521
|
+
const duplicateGift = { key: "dup-key", variant_id: 9, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
522
|
+
const fetch = vi.fn()
|
|
523
|
+
// Pass 1: read, then the duplicate removal fails.
|
|
524
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift, duplicateGift])))
|
|
525
|
+
.mockResolvedValueOnce(response({}, false))
|
|
526
|
+
// Pass 2 (forced by the failed removal): read again, removal succeeds.
|
|
527
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift, duplicateGift])))
|
|
528
|
+
.mockResolvedValue(response({}));
|
|
529
|
+
window.fetch = fetch;
|
|
530
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
531
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/change.js")).toHaveLength(2);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("keeps accumulated mutation state when a later mutation rejects", async () => {
|
|
535
|
+
const events: Event[] = [];
|
|
536
|
+
const listener = (event: Event) => events.push(event);
|
|
537
|
+
window.addEventListener("apex:cart:changed", listener);
|
|
538
|
+
const staleA = { key: "stale-a", variant_id: 7, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
539
|
+
const staleB = { key: "stale-b", variant_id: 8, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
540
|
+
const fetch = vi.fn()
|
|
541
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift, staleA, staleB])))
|
|
542
|
+
.mockResolvedValueOnce(response({}))
|
|
543
|
+
// Network-level rejection mid-pass must not void the first removal.
|
|
544
|
+
.mockRejectedValueOnce(new TypeError("network down"))
|
|
545
|
+
.mockResolvedValue(response({}));
|
|
546
|
+
window.fetch = fetch;
|
|
547
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
548
|
+
window.removeEventListener("apex:cart:changed", listener);
|
|
549
|
+
// The successful removal still counts: the run signals the storefront
|
|
550
|
+
// and retries (fixpoint) rather than aborting the pass.
|
|
551
|
+
expect(events).toHaveLength(1);
|
|
552
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/change.js").length).toBeGreaterThanOrEqual(2);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("a shared gift removal declines every experiment the line represented", async () => {
|
|
556
|
+
const twoExperiments = {
|
|
557
|
+
authorization: settings().authorization,
|
|
558
|
+
experiments: [
|
|
559
|
+
settings().experiments[0],
|
|
560
|
+
{ ...settings().experiments[0], experiment_id: "exp-gwp-2" },
|
|
561
|
+
],
|
|
562
|
+
};
|
|
563
|
+
const bothAssignments = [
|
|
564
|
+
...assignments,
|
|
565
|
+
{ experimentId: "exp-gwp-2", variationId: "treatment" },
|
|
566
|
+
];
|
|
567
|
+
const fetch = vi.fn()
|
|
568
|
+
// Run 1: insert one shared line (marked for the first experiment).
|
|
569
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
570
|
+
.mockResolvedValueOnce(response({}))
|
|
571
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift])))
|
|
572
|
+
// Run 2: the shopper removed the shared line.
|
|
573
|
+
.mockResolvedValue(response(cart([merchandise])));
|
|
574
|
+
window.fetch = fetch;
|
|
575
|
+
await syncGiftWithPurchase(twoExperiments, bothAssignments);
|
|
576
|
+
// The fixpoint re-read with the shared line still present is NOT a
|
|
577
|
+
// decline for the co-owner.
|
|
578
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBeNull();
|
|
579
|
+
expect(sessionStorage.getItem("drip_gwp_declined:exp-gwp-2")).toBeNull();
|
|
580
|
+
await syncGiftWithPurchase(twoExperiments, bothAssignments);
|
|
581
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBe("1");
|
|
582
|
+
expect(sessionStorage.getItem("drip_gwp_declined:exp-gwp-2")).toBe("1");
|
|
583
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("counts another experiment's gift toward the threshold (Function parity)", async () => {
|
|
587
|
+
const twoExperiments = {
|
|
588
|
+
authorization: {
|
|
589
|
+
version: 1 as const,
|
|
590
|
+
revision: 1,
|
|
591
|
+
checksum: "a".repeat(64),
|
|
592
|
+
experimentIds: [experimentId, "exp-gwp-2"],
|
|
593
|
+
},
|
|
594
|
+
experiments: [
|
|
595
|
+
settings().experiments[0],
|
|
596
|
+
{
|
|
597
|
+
experiment_id: "exp-gwp-2",
|
|
598
|
+
currency: "USD",
|
|
599
|
+
variants: {
|
|
600
|
+
treatment: {
|
|
601
|
+
offer_type: "gwp_threshold",
|
|
602
|
+
threshold: "100",
|
|
603
|
+
gift_variant_gid: "gid://shopify/ProductVariant/7",
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
};
|
|
609
|
+
const otherGift = { key: "other-gift", variant_id: 7, line_price: 2000, properties: { _apex_gwp: "exp-gwp-2" } };
|
|
610
|
+
const fetch = vi.fn()
|
|
611
|
+
// $40 merchandise + the sibling's $20 gift = $60 >= $50 threshold —
|
|
612
|
+
// the Function counts the sibling's gift line, so the SDK must too.
|
|
613
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 4000 }, otherGift])))
|
|
614
|
+
.mockResolvedValue(response({}));
|
|
615
|
+
window.fetch = fetch;
|
|
616
|
+
await syncGiftWithPurchase(twoExperiments, [
|
|
617
|
+
...assignments,
|
|
618
|
+
{ experimentId: "exp-gwp-2", variationId: "treatment" },
|
|
619
|
+
]);
|
|
620
|
+
expect(fetch.mock.calls.some(([url, init]) =>
|
|
621
|
+
url === "/cart/add.js" && String((init as RequestInit).body).includes('"id":9'))).toBe(true);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("clears a charge-only co-owner's record when the SDK removed the shared line", async () => {
|
|
625
|
+
const twoExperiments = {
|
|
626
|
+
authorization: settings().authorization,
|
|
627
|
+
experiments: [
|
|
628
|
+
{
|
|
629
|
+
experiment_id: experimentId,
|
|
630
|
+
currency: "USD",
|
|
631
|
+
variants: { control: { offer_type: "gwp_threshold", control: true } },
|
|
632
|
+
},
|
|
633
|
+
{ ...settings().experiments[0], experiment_id: "exp-gwp-2" },
|
|
634
|
+
],
|
|
635
|
+
};
|
|
636
|
+
// The co-owner rode the shared line earlier and holds a record.
|
|
637
|
+
localStorage.setItem("drip_gwp_inserted:exp-gwp-2", "*");
|
|
638
|
+
const fetch = vi.fn()
|
|
639
|
+
// Run 1: the owner entered control; its line is removed while the
|
|
640
|
+
// co-owner is an off-target charge-only pin.
|
|
641
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift])))
|
|
642
|
+
.mockResolvedValueOnce(response({}))
|
|
643
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
644
|
+
// Run 2: the co-owner is targeted again — it must insert, not decline.
|
|
645
|
+
.mockResolvedValue(response({}));
|
|
646
|
+
window.fetch = fetch;
|
|
647
|
+
await syncGiftWithPurchase(twoExperiments, [
|
|
648
|
+
{ experimentId, variationId: "control" },
|
|
649
|
+
{ experimentId: "exp-gwp-2", variationId: "treatment", commercePriceOnly: true },
|
|
650
|
+
]);
|
|
651
|
+
expect(localStorage.getItem("drip_gwp_inserted:exp-gwp-2")).toBeNull();
|
|
652
|
+
const fetch2 = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
653
|
+
window.fetch = fetch2;
|
|
654
|
+
await syncGiftWithPurchase(twoExperiments, [
|
|
655
|
+
{ experimentId, variationId: "control" },
|
|
656
|
+
{ experimentId: "exp-gwp-2", variationId: "treatment" },
|
|
657
|
+
]);
|
|
658
|
+
expect(sessionStorage.getItem("drip_gwp_declined:exp-gwp-2")).toBeNull();
|
|
659
|
+
expect(fetch2.mock.calls.some(([url, init]) =>
|
|
660
|
+
url === "/cart/add.js" && String((init as RequestInit).body).includes("exp-gwp-2"))).toBe(true);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("retries a failed below-threshold removal within the run", async () => {
|
|
664
|
+
const fetch = vi.fn()
|
|
665
|
+
// Pass 1: read below-threshold cart; removal fails transiently.
|
|
666
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 100 }, apexGift])))
|
|
667
|
+
.mockResolvedValueOnce(response({}, false))
|
|
668
|
+
// Pass 2 (forced): read again; removal succeeds.
|
|
669
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 100 }, apexGift])))
|
|
670
|
+
.mockResolvedValue(response({}));
|
|
671
|
+
window.fetch = fetch;
|
|
672
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
673
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/change.js")).toHaveLength(2);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("suppresses its add notification so the cart observer cannot recurse", async () => {
|
|
677
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
678
|
+
window.fetch = fetch;
|
|
679
|
+
const observed = vi.fn(() => { void syncGiftWithPurchase(settings(), assignments); });
|
|
680
|
+
const stop = onShopifyCartMutation(observed);
|
|
681
|
+
await syncGiftWithPurchase(settings(), assignments);
|
|
682
|
+
await Promise.resolve();
|
|
683
|
+
stop();
|
|
684
|
+
expect(observed).not.toHaveBeenCalled();
|
|
685
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("moves up and down tiers at exact boundaries without keeping two gifts", async () => {
|
|
689
|
+
const tiered = settings({
|
|
690
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
691
|
+
tiers: [
|
|
692
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9" },
|
|
693
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/10" },
|
|
694
|
+
],
|
|
695
|
+
});
|
|
696
|
+
const lower = { ...apexGift, variant_id: 9 };
|
|
697
|
+
const upper = { ...apexGift, key: "upper-key", variant_id: 10 };
|
|
698
|
+
const fetch = vi.fn()
|
|
699
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 10000 }, lower])))
|
|
700
|
+
.mockResolvedValue(response({}));
|
|
701
|
+
window.fetch = fetch;
|
|
702
|
+
await syncGiftWithPurchase(tiered, assignments);
|
|
703
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({ body: JSON.stringify({ id: "gift-key", quantity: 0 }) }));
|
|
704
|
+
expect(fetch).toHaveBeenNthCalledWith(3, "/cart/add.js", expect.objectContaining({ body: expect.stringContaining('"id":10') }));
|
|
705
|
+
|
|
706
|
+
__giftManagerInternals.reset();
|
|
707
|
+
const fetchDown = vi.fn()
|
|
708
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 5000 }, upper])))
|
|
709
|
+
.mockResolvedValue(response({}));
|
|
710
|
+
window.fetch = fetchDown;
|
|
711
|
+
await syncGiftWithPurchase(tiered, assignments);
|
|
712
|
+
expect(fetchDown).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({ body: JSON.stringify({ id: "upper-key", quantity: 0 }) }));
|
|
713
|
+
expect(fetchDown).toHaveBeenNthCalledWith(3, "/cart/add.js", expect.objectContaining({ body: expect.stringContaining('"id":9') }));
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("emits a proven choice and inserts the selected option through reconciliation", async () => {
|
|
717
|
+
const choice = settings({
|
|
718
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
719
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
720
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
721
|
+
});
|
|
722
|
+
const events: CustomEvent[] = [];
|
|
723
|
+
window.addEventListener("apex:gwp:choice", ((event: CustomEvent) => events.push(event)) as EventListener);
|
|
724
|
+
const fetch = vi.fn().mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValueOnce(response(cart([merchandise]))).mockResolvedValue(response({}));
|
|
725
|
+
window.fetch = fetch;
|
|
726
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
727
|
+
expect(events[0]?.detail).toEqual({ experimentId, threshold: "5000", options: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] });
|
|
728
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
729
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
730
|
+
expect(fetch.mock.calls.some(([url, init]) => url === "/cart/add.js" && String((init as RequestInit).body).includes('"id":10'))).toBe(true);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("lets a late-mounted chooser query and select the live pending choice", async () => {
|
|
734
|
+
const choice = settings({
|
|
735
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
736
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
737
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
738
|
+
});
|
|
739
|
+
const fetch = vi.fn()
|
|
740
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
741
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
742
|
+
.mockResolvedValue(response({}));
|
|
743
|
+
window.fetch = fetch;
|
|
744
|
+
|
|
745
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
746
|
+
const [prompt] = getPendingGiftChoices(experimentId);
|
|
747
|
+
expect(prompt).toEqual({
|
|
748
|
+
experimentId,
|
|
749
|
+
threshold: "5000",
|
|
750
|
+
options: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"],
|
|
751
|
+
});
|
|
752
|
+
await selectGiftOption(prompt!.experimentId, prompt!.options[1]!);
|
|
753
|
+
expect(getPendingGiftChoices(experimentId)).toEqual([]);
|
|
754
|
+
expect(fetch.mock.calls.some(([url, init]) =>
|
|
755
|
+
url === "/cart/add.js" && String((init as RequestInit).body).includes('"id":10'))).toBe(true);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("re-arms the chooser when the selected option's add fails", async () => {
|
|
759
|
+
const choice = settings({
|
|
760
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
761
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
762
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
763
|
+
});
|
|
764
|
+
const fetch = vi.fn(async (url: string) =>
|
|
765
|
+
url === "/cart/add.js" ? response({}, false) : response(cart([merchandise])));
|
|
766
|
+
window.fetch = fetch as unknown as typeof window.fetch;
|
|
767
|
+
const events: CustomEvent[] = [];
|
|
768
|
+
window.addEventListener("apex:gwp:choice", ((event: CustomEvent) => events.push(event)) as EventListener);
|
|
769
|
+
|
|
770
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
771
|
+
expect(events).toHaveLength(1);
|
|
772
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
773
|
+
expect(fetch.mock.calls.some(([url]) => url === "/cart/add.js")).toBe(true);
|
|
774
|
+
|
|
775
|
+
// The failed add must not dead-end the shopper: the prompt is live again,
|
|
776
|
+
// re-dispatched, and an alternative selection still reaches cart/add.
|
|
777
|
+
expect(events).toHaveLength(2);
|
|
778
|
+
expect(getPendingGiftChoices(experimentId)).toHaveLength(1);
|
|
779
|
+
const addCallsBefore = fetch.mock.calls.filter(([url]) => url === "/cart/add.js").length;
|
|
780
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/9");
|
|
781
|
+
expect(
|
|
782
|
+
fetch.mock.calls.filter(([url]) => url === "/cart/add.js").length,
|
|
783
|
+
).toBeGreaterThan(addCallsBefore);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("treats any unmarked current-tier option as satisfying a selected choice", async () => {
|
|
787
|
+
const choice = settings({
|
|
788
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
789
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
790
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
791
|
+
});
|
|
792
|
+
const shopperOption = { key: "shopper-option", variant_id: 9, line_price: 0, properties: {} };
|
|
793
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise, shopperOption])));
|
|
794
|
+
window.fetch = fetch;
|
|
795
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
796
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
797
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
798
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/change.js")).toHaveLength(0);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("removes the marked line when the shopper owns another current-tier option", async () => {
|
|
802
|
+
const choice = settings({
|
|
803
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
804
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
805
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
806
|
+
});
|
|
807
|
+
const chosenGift = { ...apexGift, variant_id: 10 };
|
|
808
|
+
const shopperOption = { key: "shopper-option", variant_id: 9, line_price: 900, properties: {} };
|
|
809
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
810
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/10", tierThreshold: "5000",
|
|
811
|
+
}));
|
|
812
|
+
const fetch = vi.fn()
|
|
813
|
+
.mockResolvedValueOnce(response(cart([merchandise, shopperOption, chosenGift], "USD", "same")))
|
|
814
|
+
.mockResolvedValueOnce(response({}))
|
|
815
|
+
.mockResolvedValue(response(cart([merchandise, shopperOption], "USD", "same")));
|
|
816
|
+
window.fetch = fetch;
|
|
817
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
818
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
819
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
820
|
+
}));
|
|
821
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("relinquishes its marked line when another experiment marks an allowed option", async () => {
|
|
825
|
+
const otherExperimentId = "exp-gwp-2";
|
|
826
|
+
const overlapping = {
|
|
827
|
+
authorization: settings().authorization,
|
|
828
|
+
experiments: [
|
|
829
|
+
settings({
|
|
830
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
831
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
832
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
833
|
+
}).experiments[0],
|
|
834
|
+
{
|
|
835
|
+
experiment_id: otherExperimentId,
|
|
836
|
+
currency: "USD",
|
|
837
|
+
variants: { treatment: {
|
|
838
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/10",
|
|
839
|
+
} },
|
|
840
|
+
},
|
|
841
|
+
],
|
|
842
|
+
};
|
|
843
|
+
const otherGift = { key: "other-gift", variant_id: 10, line_price: 0, properties: { _apex_gwp: otherExperimentId } };
|
|
844
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
845
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/9", tierThreshold: "5000",
|
|
846
|
+
}));
|
|
847
|
+
localStorage.setItem(`drip_gwp_inserted:${otherExperimentId}`, "same");
|
|
848
|
+
const fetch = vi.fn()
|
|
849
|
+
.mockResolvedValueOnce(response(cart([merchandise, apexGift, otherGift], "USD", "same")))
|
|
850
|
+
.mockResolvedValueOnce(response({}))
|
|
851
|
+
.mockResolvedValue(response(cart([merchandise, otherGift], "USD", "same")));
|
|
852
|
+
window.fetch = fetch;
|
|
853
|
+
await syncGiftWithPurchase(overlapping, [
|
|
854
|
+
...assignments,
|
|
855
|
+
{ experimentId: otherExperimentId, variationId: "treatment" },
|
|
856
|
+
], { isArmProven: () => true });
|
|
857
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
858
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
859
|
+
}));
|
|
860
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("removes a fixed-tier gift and prompts when entering an overlapping choice tier", async () => {
|
|
864
|
+
const tieredChoice = settings({
|
|
865
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
866
|
+
tiers: [
|
|
867
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9" },
|
|
868
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
869
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
870
|
+
],
|
|
871
|
+
});
|
|
872
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
873
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/9", tierThreshold: "5000",
|
|
874
|
+
}));
|
|
875
|
+
const events: CustomEvent[] = [];
|
|
876
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
877
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
878
|
+
const qualifyingMerchandise = { ...merchandise, line_price: 10000 };
|
|
879
|
+
const fetch = vi.fn()
|
|
880
|
+
.mockResolvedValueOnce(response(cart([qualifyingMerchandise, apexGift], "USD", "same")))
|
|
881
|
+
.mockResolvedValueOnce(response({}))
|
|
882
|
+
.mockResolvedValue(response(cart([qualifyingMerchandise], "USD", "same")));
|
|
883
|
+
window.fetch = fetch;
|
|
884
|
+
await syncGiftWithPurchase(tieredChoice, assignments, { isArmProven: () => true });
|
|
885
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
886
|
+
expect(fetch).toHaveBeenNthCalledWith(2, "/cart/change.js", expect.objectContaining({
|
|
887
|
+
body: JSON.stringify({ id: "gift-key", quantity: 0 }),
|
|
888
|
+
}));
|
|
889
|
+
expect(events[0]?.detail).toEqual({
|
|
890
|
+
experimentId,
|
|
891
|
+
threshold: "10000",
|
|
892
|
+
options: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"],
|
|
893
|
+
});
|
|
894
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it("recovers a choice from a marked line when local storage has no prior record", async () => {
|
|
898
|
+
const choice = settings({
|
|
899
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
900
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
901
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
902
|
+
});
|
|
903
|
+
const chosenGift = { ...apexGift, variant_id: 10 };
|
|
904
|
+
expect(noteCartSnapshotForGifts(cart([merchandise, chosenGift], "USD", "same"))).toBe(true);
|
|
905
|
+
__giftManagerInternals.reset();
|
|
906
|
+
const events: CustomEvent[] = [];
|
|
907
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
908
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
909
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise, chosenGift], "USD", "same")));
|
|
910
|
+
window.fetch = fetch;
|
|
911
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
912
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
913
|
+
expect(events).toHaveLength(0);
|
|
914
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
915
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toContain("ProductVariant/10");
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("restores a persisted choice after navigation without removing or re-prompting", async () => {
|
|
919
|
+
const choice = settings({
|
|
920
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
921
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
922
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
923
|
+
});
|
|
924
|
+
const chosenGift = { ...apexGift, variant_id: 10 };
|
|
925
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
926
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/10", tierThreshold: "5000",
|
|
927
|
+
}));
|
|
928
|
+
__giftManagerInternals.reset();
|
|
929
|
+
const events: CustomEvent[] = [];
|
|
930
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
931
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
932
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise, chosenGift], "USD", "same")));
|
|
933
|
+
window.fetch = fetch;
|
|
934
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
935
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
936
|
+
expect(events).toHaveLength(0);
|
|
937
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("binds a shared-GID selection to the prompted tier and inserts the gift", async () => {
|
|
941
|
+
const twoChoiceTiers = settings({
|
|
942
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
943
|
+
tiers: [
|
|
944
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
945
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
946
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
947
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
948
|
+
],
|
|
949
|
+
});
|
|
950
|
+
const highMerchandise = { ...merchandise, line_price: 10000 };
|
|
951
|
+
const fetch = vi.fn()
|
|
952
|
+
// Prompt fires for the higher tier.
|
|
953
|
+
.mockResolvedValueOnce(response(cart([highMerchandise], "USD", "same")))
|
|
954
|
+
// Selection sync: read, insert, fixpoint.
|
|
955
|
+
.mockResolvedValueOnce(response(cart([highMerchandise], "USD", "same")))
|
|
956
|
+
.mockResolvedValueOnce(response({}))
|
|
957
|
+
.mockResolvedValue(response(cart([highMerchandise, { ...apexGift, variant_id: 10 }], "USD", "same")));
|
|
958
|
+
window.fetch = fetch;
|
|
959
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
960
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
961
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
962
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toContain('"tierThreshold":"10000"');
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("binds a selection to the applicable tier after crossing up and back down", async () => {
|
|
966
|
+
const twoChoiceTiers = settings({
|
|
967
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
968
|
+
tiers: [
|
|
969
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
970
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
971
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
972
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
973
|
+
],
|
|
974
|
+
});
|
|
975
|
+
const highMerchandise = { ...merchandise, line_price: 10000 };
|
|
976
|
+
const fetch = vi.fn()
|
|
977
|
+
// High tier prompts, then the cart drops back to the low tier.
|
|
978
|
+
.mockResolvedValueOnce(response(cart([highMerchandise], "USD", "same")))
|
|
979
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
980
|
+
// Selection sync at the LOW tier: read, insert, fixpoint.
|
|
981
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
982
|
+
.mockResolvedValueOnce(response({}))
|
|
983
|
+
.mockResolvedValue(response(cart([merchandise, { ...apexGift, variant_id: 10 }], "USD", "same")));
|
|
984
|
+
window.fetch = fetch;
|
|
985
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
986
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
987
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
988
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(1);
|
|
989
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toContain('"tierThreshold":"5000"');
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("ignores a delayed selection after the choice tier stopped applying", async () => {
|
|
993
|
+
const choice = settings({
|
|
994
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
995
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
996
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
997
|
+
});
|
|
998
|
+
const fetch = vi.fn()
|
|
999
|
+
// Prompt fires, then the cart drops below the threshold.
|
|
1000
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1001
|
+
.mockResolvedValue(response(cart([{ ...merchandise, line_price: 4999 }], "USD", "same")));
|
|
1002
|
+
window.fetch = fetch;
|
|
1003
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1004
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1005
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
1006
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("ignores a delayed selection when the arm is no longer proven", async () => {
|
|
1010
|
+
const choice = settings({
|
|
1011
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1012
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1013
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
1014
|
+
});
|
|
1015
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise], "USD", "same")));
|
|
1016
|
+
window.fetch = fetch;
|
|
1017
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1018
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => false });
|
|
1019
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
1020
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("re-prompts when a tierless record meets a higher choice tier sharing the GID", async () => {
|
|
1024
|
+
const twoChoiceTiers = settings({
|
|
1025
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1026
|
+
tiers: [
|
|
1027
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1028
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1029
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1030
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1031
|
+
],
|
|
1032
|
+
});
|
|
1033
|
+
// Snapshot-seeded record: variant known, tier provenance absent.
|
|
1034
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
1035
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/10",
|
|
1036
|
+
}));
|
|
1037
|
+
const chosenGift = { ...apexGift, variant_id: 10 };
|
|
1038
|
+
const events: CustomEvent[] = [];
|
|
1039
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1040
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1041
|
+
const highMerchandise = { ...merchandise, line_price: 10000 };
|
|
1042
|
+
const fetch = vi.fn()
|
|
1043
|
+
.mockResolvedValueOnce(response(cart([highMerchandise, chosenGift], "USD", "same")))
|
|
1044
|
+
.mockResolvedValueOnce(response({}))
|
|
1045
|
+
.mockResolvedValue(response(cart([highMerchandise], "USD", "same")));
|
|
1046
|
+
window.fetch = fetch;
|
|
1047
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
1048
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1049
|
+
expect(events.some((event) => (event.detail as { threshold?: string }).threshold === "10000")).toBe(true);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it("re-prompts tier A after visiting tier B and returning without answering", async () => {
|
|
1053
|
+
const twoChoiceTiers = settings({
|
|
1054
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1055
|
+
tiers: [
|
|
1056
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1057
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1058
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1059
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1060
|
+
],
|
|
1061
|
+
});
|
|
1062
|
+
// Also covered below: traversal through a FIXED tier re-arms the chooser.
|
|
1063
|
+
const events: CustomEvent[] = [];
|
|
1064
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1065
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1066
|
+
const fetch = vi.fn()
|
|
1067
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1068
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 10000 }], "USD", "same")))
|
|
1069
|
+
.mockResolvedValue(response(cart([merchandise], "USD", "same")));
|
|
1070
|
+
window.fetch = fetch;
|
|
1071
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
1072
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
1073
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
1074
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1075
|
+
const thresholds = events.map((event) => (event.detail as { threshold?: string }).threshold);
|
|
1076
|
+
expect(thresholds).toEqual(["5000", "10000", "5000"]);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("re-prompts a choice tier after traversing a fixed tier without answering", async () => {
|
|
1080
|
+
const mixedTiers = settings({
|
|
1081
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1082
|
+
tiers: [
|
|
1083
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1084
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1085
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/11" },
|
|
1086
|
+
],
|
|
1087
|
+
});
|
|
1088
|
+
const events: CustomEvent[] = [];
|
|
1089
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1090
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1091
|
+
const fixedGift = { key: "fixed-key", variant_id: 11, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
1092
|
+
const fetch = vi.fn()
|
|
1093
|
+
// Choice tier prompts; fixed tier inserts its gift; back to the choice tier.
|
|
1094
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1095
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 10000 }], "USD", "same")))
|
|
1096
|
+
.mockResolvedValueOnce(response({}))
|
|
1097
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 10000 }, fixedGift], "USD", "same")))
|
|
1098
|
+
.mockResolvedValueOnce(response(cart([merchandise, fixedGift], "USD", "same")))
|
|
1099
|
+
.mockResolvedValueOnce(response({}))
|
|
1100
|
+
.mockResolvedValue(response(cart([merchandise], "USD", "same")));
|
|
1101
|
+
window.fetch = fetch;
|
|
1102
|
+
await syncGiftWithPurchase(mixedTiers, assignments, { isArmProven: () => true });
|
|
1103
|
+
await syncGiftWithPurchase(mixedTiers, assignments, { isArmProven: () => true });
|
|
1104
|
+
await syncGiftWithPurchase(mixedTiers, assignments, { isArmProven: () => true });
|
|
1105
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1106
|
+
const thresholds = events.map((event) => (event.detail as { threshold?: string }).threshold);
|
|
1107
|
+
expect(thresholds).toEqual(["5000", "5000"]);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("adopts another tab's durable choice instead of reverting it", async () => {
|
|
1111
|
+
const choice = settings({
|
|
1112
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1113
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1114
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
1115
|
+
});
|
|
1116
|
+
const nineGift = { key: "nine-key", variant_id: 9, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
1117
|
+
// This tab selected 9 and its record landed (pending settled)…
|
|
1118
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
1119
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/9", tierThreshold: "5000",
|
|
1120
|
+
}));
|
|
1121
|
+
let fetch = vi.fn().mockResolvedValue(response(cart([merchandise, nineGift], "USD", "same")));
|
|
1122
|
+
window.fetch = fetch;
|
|
1123
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1124
|
+
// …then ANOTHER tab chose 10: durable record + cart line now say 10.
|
|
1125
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
1126
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/10", tierThreshold: "5000",
|
|
1127
|
+
}));
|
|
1128
|
+
const tenGift = { key: "ten-key", variant_id: 10, line_price: 0, properties: { _apex_gwp: experimentId } };
|
|
1129
|
+
fetch = vi.fn().mockResolvedValue(response(cart([merchandise, tenGift], "USD", "same")));
|
|
1130
|
+
window.fetch = fetch;
|
|
1131
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1132
|
+
// The stale local selection must not remove the other tab's gift.
|
|
1133
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/change.js")).toHaveLength(0);
|
|
1134
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("re-arms the chooser when the selected gift fails to add", async () => {
|
|
1138
|
+
const choice = settings({
|
|
1139
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1140
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1141
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
1142
|
+
});
|
|
1143
|
+
const events: CustomEvent[] = [];
|
|
1144
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1145
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1146
|
+
const fetch = vi.fn()
|
|
1147
|
+
// Prompt fires; selection sync reads, then the add FAILS.
|
|
1148
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1149
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1150
|
+
.mockResolvedValueOnce(new Response("unavailable", { status: 422 }))
|
|
1151
|
+
.mockResolvedValue(response(cart([merchandise], "USD", "same")));
|
|
1152
|
+
window.fetch = fetch;
|
|
1153
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1154
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
1155
|
+
// The failed selection is dropped and the prompt re-fires on the next sync.
|
|
1156
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1157
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1158
|
+
expect(events.length).toBeGreaterThanOrEqual(2);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it("rejects a fixed tier whose gift GID is not a numeric variant GID", async () => {
|
|
1162
|
+
const malformed = settings({
|
|
1163
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "not-a-gid",
|
|
1164
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "not-a-gid" }],
|
|
1165
|
+
});
|
|
1166
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise])));
|
|
1167
|
+
window.fetch = fetch;
|
|
1168
|
+
await syncGiftWithPurchase(malformed, assignments, { isArmProven: () => true });
|
|
1169
|
+
// Parse-time rejection: no add attempt, no crash-aborted reconciliation.
|
|
1170
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it("re-dispatches live prompts on apex:gwp:choice:request", async () => {
|
|
1174
|
+
const choice = settings({
|
|
1175
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1176
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1177
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
1178
|
+
});
|
|
1179
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([merchandise], "USD", "same")));
|
|
1180
|
+
window.fetch = fetch;
|
|
1181
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1182
|
+
// A chooser mounted late asks for a replay.
|
|
1183
|
+
const events: CustomEvent[] = [];
|
|
1184
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1185
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1186
|
+
window.dispatchEvent(new CustomEvent("apex:gwp:choice:request"));
|
|
1187
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1188
|
+
expect(events).toHaveLength(1);
|
|
1189
|
+
expect((events[0].detail as { threshold?: string }).threshold).toBe("5000");
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("queues a replay request made while reconciliation is still proving the tier", async () => {
|
|
1193
|
+
const choice = settings({
|
|
1194
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1195
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1196
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
1197
|
+
});
|
|
1198
|
+
let resolveCart!: (value: Response) => void;
|
|
1199
|
+
window.fetch = vi.fn().mockImplementation(() => new Promise<Response>((resolve) => {
|
|
1200
|
+
resolveCart = resolve;
|
|
1201
|
+
}));
|
|
1202
|
+
const sync = syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1203
|
+
const events: CustomEvent[] = [];
|
|
1204
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1205
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1206
|
+
window.dispatchEvent(new CustomEvent("apex:gwp:choice:request"));
|
|
1207
|
+
resolveCart(response(cart([merchandise], "USD", "same")));
|
|
1208
|
+
await sync;
|
|
1209
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1210
|
+
expect(events).toHaveLength(2);
|
|
1211
|
+
expect(events.map((event) => event.detail.threshold)).toEqual(["5000", "5000"]);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it("keeps tier ownership when a cart snapshot re-seeds an existing record", () => {
|
|
1215
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({
|
|
1216
|
+
cart: "same", variantGid: "gid://shopify/ProductVariant/10", tierThreshold: "5000",
|
|
1217
|
+
}));
|
|
1218
|
+
noteCartSnapshotForGifts({
|
|
1219
|
+
token: "same",
|
|
1220
|
+
items: [{ variant_id: 10, properties: { _apex_gwp: experimentId } }],
|
|
1221
|
+
});
|
|
1222
|
+
expect(localStorage.getItem(`drip_gwp_inserted:${experimentId}`)).toContain('"tierThreshold":"5000"');
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it("re-prompts when an in-memory choice from a lower tier crosses into a higher choice tier", async () => {
|
|
1226
|
+
const twoChoiceTiers = settings({
|
|
1227
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1228
|
+
tiers: [
|
|
1229
|
+
{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1230
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1231
|
+
{ threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1232
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] },
|
|
1233
|
+
],
|
|
1234
|
+
});
|
|
1235
|
+
const chosenGift = { ...apexGift, variant_id: 10 };
|
|
1236
|
+
const events: CustomEvent[] = [];
|
|
1237
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1238
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1239
|
+
const fetch = vi.fn()
|
|
1240
|
+
// Lower tier prompts, shopper selects option 10 (read, add, fixpoint).
|
|
1241
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1242
|
+
.mockResolvedValueOnce(response(cart([merchandise], "USD", "same")))
|
|
1243
|
+
.mockResolvedValueOnce(response({}))
|
|
1244
|
+
.mockResolvedValueOnce(response(cart([merchandise, chosenGift], "USD", "same")))
|
|
1245
|
+
// Higher tier now applies: the lower-tier selection must not carry over.
|
|
1246
|
+
.mockResolvedValue(response(cart([{ ...merchandise, line_price: 10000 }, chosenGift], "USD", "same")));
|
|
1247
|
+
window.fetch = fetch;
|
|
1248
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
1249
|
+
await selectGiftOption(experimentId, "gid://shopify/ProductVariant/10");
|
|
1250
|
+
const promptsBeforeCrossing = events.filter(
|
|
1251
|
+
(event) => (event.detail as { threshold?: string }).threshold === "10000",
|
|
1252
|
+
).length;
|
|
1253
|
+
expect(promptsBeforeCrossing).toBe(0);
|
|
1254
|
+
await syncGiftWithPurchase(twoChoiceTiers, assignments, { isArmProven: () => true });
|
|
1255
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1256
|
+
const higherTierPrompts = events.filter(
|
|
1257
|
+
(event) => (event.detail as { threshold?: string }).threshold === "10000",
|
|
1258
|
+
);
|
|
1259
|
+
expect(higherTierPrompts.length).toBeGreaterThan(0);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it("re-arms the choice event after dropping below threshold and requalifying", async () => {
|
|
1263
|
+
const choice = settings({
|
|
1264
|
+
offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1265
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1266
|
+
gift_choice_variant_gids: ["gid://shopify/ProductVariant/9", "gid://shopify/ProductVariant/10"] }],
|
|
1267
|
+
});
|
|
1268
|
+
const events: CustomEvent[] = [];
|
|
1269
|
+
const listener = ((event: CustomEvent) => events.push(event)) as EventListener;
|
|
1270
|
+
window.addEventListener("apex:gwp:choice", listener);
|
|
1271
|
+
const fetch = vi.fn()
|
|
1272
|
+
.mockResolvedValueOnce(response(cart([merchandise])))
|
|
1273
|
+
.mockResolvedValueOnce(response(cart([{ ...merchandise, line_price: 4999 }])))
|
|
1274
|
+
.mockResolvedValueOnce(response(cart([merchandise])));
|
|
1275
|
+
window.fetch = fetch;
|
|
1276
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1277
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1278
|
+
await syncGiftWithPurchase(choice, assignments, { isArmProven: () => true });
|
|
1279
|
+
window.removeEventListener("apex:gwp:choice", listener);
|
|
1280
|
+
expect(events).toHaveLength(2);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("keeps a shopper decline across tier changes", async () => {
|
|
1284
|
+
const tiered = settings({ offer_type: "gwp_threshold", threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9",
|
|
1285
|
+
tiers: [{ threshold: "5000", gift_variant_gid: "gid://shopify/ProductVariant/9" }, { threshold: "10000", gift_variant_gid: "gid://shopify/ProductVariant/10" }] });
|
|
1286
|
+
localStorage.setItem(`drip_gwp_inserted:${experimentId}`, JSON.stringify({ cart: "same", variantGid: "gid://shopify/ProductVariant/9", tierThreshold: "5000" }));
|
|
1287
|
+
const fetch = vi.fn().mockResolvedValue(response(cart([{ ...merchandise, line_price: 10000 }], "USD", "same")));
|
|
1288
|
+
window.fetch = fetch;
|
|
1289
|
+
await syncGiftWithPurchase(tiered, assignments);
|
|
1290
|
+
expect(sessionStorage.getItem(`drip_gwp_declined:${experimentId}`)).toBe("1");
|
|
1291
|
+
expect(fetch.mock.calls.filter(([url]) => url === "/cart/add.js")).toHaveLength(0);
|
|
1292
|
+
});
|
|
1293
|
+
});
|