@frak-labs/core-sdk 0.2.0 → 0.2.1-beta.06c52c98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +1 -2
  2. package/cdn/bundle.js +55 -3
  3. package/dist/actions.cjs +1 -1
  4. package/dist/actions.d.cts +2 -2
  5. package/dist/actions.d.ts +2 -2
  6. package/dist/actions.js +1 -1
  7. package/dist/bundle.cjs +1 -1
  8. package/dist/bundle.d.cts +4 -4
  9. package/dist/bundle.d.ts +4 -4
  10. package/dist/bundle.js +1 -1
  11. package/dist/{computeLegacyProductId-Raks6FXg.d.cts → computeLegacyProductId-BP-ciVsp.d.cts} +73 -88
  12. package/dist/{computeLegacyProductId-BkyJ4rEY.d.ts → computeLegacyProductId-DiJd7RNo.d.ts} +73 -88
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.d.cts +3 -3
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +1 -1
  17. package/dist/{openSso-BCJGchIb.d.cts → openSso-B8v3Vtnh.d.ts} +157 -52
  18. package/dist/{openSso-DG-_9CED.d.ts → openSso-n_B4LSuW.d.cts} +157 -52
  19. package/dist/setupClient-Dr_UYfTD.cjs +13 -0
  20. package/dist/setupClient-TuhDjVJx.js +13 -0
  21. package/dist/siweAuthenticate-0UPcUqI1.js +1 -0
  22. package/dist/{siweAuthenticate-Btem4QHs.d.ts → siweAuthenticate-CDCsp8EJ.d.ts} +35 -36
  23. package/dist/siweAuthenticate-CfQibjZR.cjs +1 -0
  24. package/dist/{siweAuthenticate-BH7Dn7nZ.d.cts → siweAuthenticate-yITE-iKh.d.cts} +35 -36
  25. package/dist/trackEvent-5j5kkOCj.js +1 -0
  26. package/dist/trackEvent-B2uom25e.cjs +1 -0
  27. package/package.json +8 -8
  28. package/src/actions/displayEmbeddedWallet.ts +6 -2
  29. package/src/actions/displayModal.ts +6 -2
  30. package/src/actions/ensureIdentity.ts +2 -2
  31. package/src/actions/referral/processReferral.test.ts +109 -125
  32. package/src/actions/referral/processReferral.ts +134 -180
  33. package/src/actions/referral/referralInteraction.test.ts +3 -5
  34. package/src/actions/referral/referralInteraction.ts +2 -7
  35. package/src/actions/trackPurchaseStatus.test.ts +32 -20
  36. package/src/actions/trackPurchaseStatus.ts +3 -5
  37. package/src/actions/wrapper/modalBuilder.test.ts +4 -2
  38. package/src/actions/wrapper/modalBuilder.ts +6 -8
  39. package/src/clients/createIFrameFrakClient.ts +146 -25
  40. package/src/clients/transports/iframeLifecycleManager.test.ts +0 -80
  41. package/src/clients/transports/iframeLifecycleManager.ts +0 -44
  42. package/src/index.ts +8 -3
  43. package/src/types/config.ts +10 -3
  44. package/src/types/context.ts +48 -6
  45. package/src/types/index.ts +8 -2
  46. package/src/types/lifecycle/client.ts +22 -27
  47. package/src/types/lifecycle/iframe.ts +0 -8
  48. package/src/types/resolvedConfig.ts +104 -0
  49. package/src/types/rpc/interaction.ts +9 -0
  50. package/src/types/rpc.ts +7 -5
  51. package/src/types/tracking.ts +5 -34
  52. package/src/utils/FrakContext.test.ts +270 -186
  53. package/src/utils/FrakContext.ts +78 -56
  54. package/src/utils/backendUrl.test.ts +2 -2
  55. package/src/utils/backendUrl.ts +1 -1
  56. package/src/utils/index.ts +1 -5
  57. package/src/utils/sdkConfigStore.test.ts +405 -0
  58. package/src/utils/sdkConfigStore.ts +277 -0
  59. package/src/utils/sso.ts +3 -7
  60. package/dist/setupClient-CQrMDGyZ.js +0 -13
  61. package/dist/setupClient-Ccv3XxwL.cjs +0 -13
  62. package/dist/siweAuthenticate-BJHbtty4.js +0 -1
  63. package/dist/siweAuthenticate-Cwj3HP0m.cjs +0 -1
  64. package/dist/trackEvent-M2RLTQ2p.js +0 -1
  65. package/dist/trackEvent-T_R9ER2S.cjs +0 -1
  66. package/src/utils/merchantId.test.ts +0 -653
  67. package/src/utils/merchantId.ts +0 -143
@@ -1,20 +1,39 @@
1
- import { type Address, bytesToHex, hexToBytes } from "viem";
2
- import type { FrakContext } from "../types";
1
+ import { type Address, bytesToHex, hexToBytes, isAddress } from "viem";
2
+ import type { FrakContext, FrakContextV1, FrakContextV2 } from "../types";
3
+ import { isV2Context } from "../types";
3
4
  import { base64urlDecode, base64urlEncode } from "./compression/b64";
5
+ import { compressJsonToB64 } from "./compression/compress";
6
+ import { decompressJsonFromB64 } from "./compression/decompress";
4
7
 
5
8
  /**
6
- * The context key
9
+ * URL parameter key for the Frak referral context
7
10
  */
8
11
  const contextKey = "fCtx";
9
12
 
10
13
  /**
11
- * Compress the current Frak context
12
- * @param context - The context to be compressed
13
- * @returns A compressed string containing the Frak context
14
+ * Compress a Frak context into a URL-safe string.
15
+ *
16
+ * - V2 contexts are serialized as compressed JSON (base64url).
17
+ * - V1 contexts encode the wallet address as raw bytes (base64url).
18
+ *
19
+ * @param context - The context to compress (V1 or V2)
20
+ * @returns A compressed base64url string, or undefined on failure
14
21
  */
15
- function compress(context?: Partial<FrakContext>): string | undefined {
16
- if (!context?.r) return;
22
+ function compress(context?: FrakContextV1 | FrakContextV2): string | undefined {
23
+ if (!context) return;
17
24
  try {
25
+ if (isV2Context(context)) {
26
+ // Runtime validation: all V2 fields must be present and truthy
27
+ if (!context.c || !context.m || !context.t) return undefined;
28
+ return compressJsonToB64({
29
+ v: 2,
30
+ c: context.c,
31
+ m: context.m,
32
+ t: context.t,
33
+ });
34
+ }
35
+
36
+ // V1 legacy: compress wallet address as raw bytes
18
37
  const bytes = hexToBytes(context.r);
19
38
  return base64urlEncode(bytes);
20
39
  } catch (e) {
@@ -24,15 +43,31 @@ function compress(context?: Partial<FrakContext>): string | undefined {
24
43
  }
25
44
 
26
45
  /**
27
- * Decompress the given Frak context
28
- * @param context - The raw context to be decompressed into a `FrakContext`
29
- * @returns The decompressed Frak context, or undefined if it fails
46
+ * Decompress a base64url string back into a Frak context.
47
+ *
48
+ * Attempts V2 JSON decompression first, then falls back to V1 raw bytes.
49
+ *
50
+ * @param context - The compressed context string
51
+ * @returns The decompressed FrakContext, or undefined on failure
30
52
  */
31
53
  function decompress(context?: string): FrakContext | undefined {
32
54
  if (!context || context.length === 0) return;
33
55
  try {
56
+ // Try V2 JSON first — V2 payloads are longer than V1's 20-byte address
57
+ const json = decompressJsonFromB64<FrakContextV2>(context);
58
+ if (json && typeof json === "object" && json.v === 2) {
59
+ if (json.c && json.m && json.t) {
60
+ return { v: 2, c: json.c, m: json.m, t: json.t };
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ // Fall back to V1: raw 20-byte address
34
66
  const bytes = base64urlDecode(context);
35
- return { r: bytesToHex(bytes, { size: 20 }) as Address };
67
+ const hex = bytesToHex(bytes, { size: 20 }) as Address;
68
+ if (isAddress(hex)) {
69
+ return { r: hex };
70
+ }
36
71
  } catch (e) {
37
72
  console.error("Error decompressing Frak context", { e, context });
38
73
  }
@@ -40,113 +75,100 @@ function decompress(context?: string): FrakContext | undefined {
40
75
  }
41
76
 
42
77
  /**
43
- * Parse the current URL into a Frak Context
78
+ * Parse a URL to extract the Frak referral context from the `fCtx` query parameter.
79
+ *
44
80
  * @param args
45
- * @param args.url - The url to parse
46
- * @returns The parsed Frak context
81
+ * @param args.url - The URL to parse
82
+ * @returns The parsed FrakContext, or null if absent
47
83
  */
48
- function parse({ url }: { url: string }) {
84
+ function parse({ url }: { url: string }): FrakContext | null | undefined {
49
85
  if (!url) return null;
50
86
 
51
- // Check if the url contain the frak context key
52
87
  const urlObj = new URL(url);
53
88
  const frakContext = urlObj.searchParams.get(contextKey);
54
89
  if (!frakContext) return null;
55
90
 
56
- // Decompress and return it
57
91
  return decompress(frakContext);
58
92
  }
59
93
 
60
94
  /**
61
- * Populate the current url with the given Frak context
95
+ * Add or replace the `fCtx` query parameter in a URL with the given context.
96
+ *
62
97
  * @param args
63
- * @param args.url - The url to update
64
- * @param args.context - The context to update
65
- * @returns The new url with the Frak context
98
+ * @param args.url - The URL to update
99
+ * @param args.context - The context to embed (V1 or V2)
100
+ * @returns The updated URL string, or null on failure
66
101
  */
67
102
  function update({
68
103
  url,
69
104
  context,
70
105
  }: {
71
106
  url?: string;
72
- context: Partial<FrakContext>;
73
- }) {
107
+ context: FrakContextV1 | FrakContextV2;
108
+ }): string | null {
74
109
  if (!url) return null;
75
110
 
76
- // Parse the current context
77
- const currentContext = parse({ url });
78
-
79
- // Merge the current context with the new context
80
- const mergedContext = currentContext
81
- ? { ...currentContext, ...context }
82
- : context;
83
-
84
- // If we don't have a referrer, early exit
85
- if (!mergedContext.r) return null;
86
-
87
- // Compress it
88
- const compressedContext = compress(mergedContext);
111
+ const compressedContext = compress(context);
89
112
  if (!compressedContext) return null;
90
113
 
91
- // Build the new url and return it
92
114
  const urlObj = new URL(url);
93
115
  urlObj.searchParams.set(contextKey, compressedContext);
94
116
  return urlObj.toString();
95
117
  }
96
118
 
97
119
  /**
98
- * Remove Frak context from current url
99
- * @param url - The url to update
100
- * @returns The new url without the Frak context
120
+ * Remove the `fCtx` query parameter from a URL.
121
+ *
122
+ * @param url - The URL to strip the context from
123
+ * @returns The cleaned URL string
101
124
  */
102
- function remove(url: string) {
125
+ function remove(url: string): string {
103
126
  const urlObj = new URL(url);
104
127
  urlObj.searchParams.delete(contextKey);
105
128
  return urlObj.toString();
106
129
  }
107
130
 
108
131
  /**
109
- * Replace the current url with the given Frak context
132
+ * Replace the current browser URL with an updated Frak context.
133
+ *
134
+ * - If `context` is non-null, embeds it via {@link update}.
135
+ * - If `context` is null, strips the context via {@link remove}.
136
+ *
110
137
  * @param args
111
- * @param args.url - The url to update
112
- * @param args.context - The context to update
138
+ * @param args.url - Base URL (defaults to `window.location.href`)
139
+ * @param args.context - Context to set, or null to remove
113
140
  */
114
141
  function replaceUrl({
115
142
  url: baseUrl,
116
143
  context,
117
144
  }: {
118
145
  url?: string;
119
- context: Partial<FrakContext> | null;
146
+ context: FrakContextV1 | FrakContextV2 | null;
120
147
  }) {
121
- // If no window here early exit
122
148
  if (!window.location?.href || typeof window === "undefined") {
123
149
  console.error("No window found, can't update context");
124
150
  return;
125
151
  }
126
152
 
127
- // If no url, try to use the current one
128
153
  const url = baseUrl ?? window.location.href;
129
154
 
130
- // Get our new url with the frak context
131
155
  let newUrl: string | null;
132
156
  if (context !== null) {
133
- newUrl = update({
134
- url,
135
- context,
136
- });
157
+ newUrl = update({ url, context });
137
158
  } else {
138
159
  newUrl = remove(url);
139
160
  }
140
161
 
141
- // If no new url, early exit
142
162
  if (!newUrl) return;
143
163
 
144
- // Update the url
145
164
  window.history.replaceState(null, "", newUrl.toString());
146
165
  }
147
166
 
148
167
  /**
149
- * Export our frak context
168
+ * Manager for Frak referral context in URLs.
169
+ *
170
+ * Handles compression, decompression, URL parsing, and browser history updates
171
+ * for both V1 (wallet address) and V2 (anonymous clientId) referral contexts.
150
172
  */
151
173
  export const FrakContextManager = {
152
174
  compress,
@@ -15,13 +15,13 @@ describe("getBackendUrl", () => {
15
15
  describe("with explicit walletUrl", () => {
16
16
  test("should return localhost backend for localhost:3000", () => {
17
17
  expect(getBackendUrl("https://localhost:3000")).toBe(
18
- "http://localhost:3030"
18
+ "https://localhost:3030"
19
19
  );
20
20
  });
21
21
 
22
22
  test("should return localhost backend for localhost:3010", () => {
23
23
  expect(getBackendUrl("https://localhost:3010")).toBe(
24
- "http://localhost:3030"
24
+ "https://localhost:3030"
25
25
  );
26
26
  });
27
27
 
@@ -19,7 +19,7 @@ function isLocalDevelopment(walletUrl: string): boolean {
19
19
  */
20
20
  function deriveBackendUrl(walletUrl: string): string {
21
21
  if (isLocalDevelopment(walletUrl)) {
22
- return "http://localhost:3030";
22
+ return "https://localhost:3030";
23
23
  }
24
24
  // Dev environment
25
25
  if (
@@ -22,11 +22,7 @@ export {
22
22
  createIframe,
23
23
  findIframeInOpener,
24
24
  } from "./iframeHelper";
25
- export {
26
- clearMerchantIdCache,
27
- fetchMerchantId,
28
- resolveMerchantId,
29
- } from "./merchantId";
25
+ export { sdkConfigStore } from "./sdkConfigStore";
30
26
  export {
31
27
  type AppSpecificSsoMetadata,
32
28
  type CompressedSsoData,
@@ -0,0 +1,405 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { sdkConfigStore } from "./sdkConfigStore";
3
+
4
+ vi.mock("./backendUrl", () => ({
5
+ getBackendUrl: vi.fn((walletUrl?: string) => {
6
+ if (walletUrl?.includes("localhost")) {
7
+ return "http://localhost:3030";
8
+ }
9
+ return "https://backend.frak.id";
10
+ }),
11
+ }));
12
+
13
+ describe("sdkConfigStore", () => {
14
+ beforeEach(() => {
15
+ sdkConfigStore.clearCache();
16
+ window.sessionStorage.clear();
17
+ window.localStorage.clear();
18
+ window.__frakSdkConfig = undefined;
19
+ vi.clearAllMocks();
20
+ vi.spyOn(console, "warn").mockImplementation(() => {});
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ describe("sdkConfigStore.resolve", () => {
28
+ it("should fetch from backend when not cached", async () => {
29
+ const mockResponse = {
30
+ merchantId: "merchant-123",
31
+ name: "Test",
32
+ domain: "shop.example.com",
33
+ allowedDomains: [],
34
+ };
35
+
36
+ global.fetch = vi.fn().mockResolvedValueOnce({
37
+ ok: true,
38
+ json: async () => mockResponse,
39
+ });
40
+
41
+ const result = await sdkConfigStore.resolve("shop.example.com");
42
+
43
+ expect(result).toEqual(mockResponse);
44
+ expect(global.fetch).toHaveBeenCalledWith(
45
+ "https://backend.frak.id/user/merchant/resolve?domain=shop.example.com"
46
+ );
47
+ });
48
+
49
+ it("should return cached response on subsequent calls", async () => {
50
+ const mockResponse = {
51
+ merchantId: "merchant-456",
52
+ name: "Test",
53
+ domain: "shop.example.com",
54
+ allowedDomains: [],
55
+ };
56
+
57
+ global.fetch = vi.fn().mockResolvedValueOnce({
58
+ ok: true,
59
+ json: async () => mockResponse,
60
+ });
61
+
62
+ const result1 = await sdkConfigStore.resolve("shop.example.com");
63
+ expect(result1).toEqual(mockResponse);
64
+
65
+ const result2 = await sdkConfigStore.resolve("shop.example.com");
66
+ expect(result2).toEqual(mockResponse);
67
+
68
+ expect(global.fetch).toHaveBeenCalledTimes(1);
69
+ });
70
+
71
+ it("should deduplicate concurrent requests", async () => {
72
+ const mockResponse = {
73
+ merchantId: "merchant-789",
74
+ name: "Test",
75
+ domain: "shop.example.com",
76
+ allowedDomains: [],
77
+ };
78
+
79
+ global.fetch = vi.fn().mockResolvedValueOnce({
80
+ ok: true,
81
+ json: async () => mockResponse,
82
+ });
83
+
84
+ const [result1, result2, result3] = await Promise.all([
85
+ sdkConfigStore.resolve("shop.example.com"),
86
+ sdkConfigStore.resolve("shop.example.com"),
87
+ sdkConfigStore.resolve("shop.example.com"),
88
+ ]);
89
+
90
+ expect(result1).toEqual(mockResponse);
91
+ expect(result2).toEqual(mockResponse);
92
+ expect(result3).toEqual(mockResponse);
93
+ expect(global.fetch).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ it("should use window.location.hostname when domain not provided", async () => {
97
+ const mockResponse = {
98
+ merchantId: "merchant-default",
99
+ name: "Test",
100
+ domain: "example.com",
101
+ allowedDomains: [],
102
+ };
103
+
104
+ global.fetch = vi.fn().mockResolvedValueOnce({
105
+ ok: true,
106
+ json: async () => mockResponse,
107
+ });
108
+
109
+ Object.defineProperty(window, "location", {
110
+ value: { hostname: "example.com" },
111
+ writable: true,
112
+ });
113
+
114
+ const result = await sdkConfigStore.resolve();
115
+
116
+ expect(result).toEqual(mockResponse);
117
+ expect(global.fetch).toHaveBeenCalledWith(
118
+ "https://backend.frak.id/user/merchant/resolve?domain=example.com"
119
+ );
120
+ });
121
+
122
+ it("should return undefined when domain is empty", async () => {
123
+ global.fetch = vi.fn();
124
+
125
+ const result = await sdkConfigStore.resolve("");
126
+
127
+ expect(result).toBeUndefined();
128
+ expect(global.fetch).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("should handle fetch errors gracefully", async () => {
132
+ global.fetch = vi
133
+ .fn()
134
+ .mockRejectedValueOnce(new Error("Network error"));
135
+
136
+ const result = await sdkConfigStore.resolve("shop.example.com");
137
+
138
+ expect(result).toBeUndefined();
139
+ expect(console.warn).toHaveBeenCalledWith(
140
+ "[Frak SDK] Failed to fetch merchant config:",
141
+ expect.any(Error)
142
+ );
143
+ });
144
+
145
+ it("should handle non-ok response (404, 500)", async () => {
146
+ global.fetch = vi.fn().mockResolvedValueOnce({
147
+ ok: false,
148
+ status: 404,
149
+ });
150
+
151
+ const result = await sdkConfigStore.resolve("nonexistent.com");
152
+
153
+ expect(result).toBeUndefined();
154
+ expect(console.warn).toHaveBeenCalledWith(
155
+ "[Frak SDK] Merchant lookup failed for domain nonexistent.com: 404"
156
+ );
157
+ });
158
+
159
+ it("should use custom walletUrl to derive backend URL", async () => {
160
+ const mockResponse = {
161
+ merchantId: "merchant-local",
162
+ name: "Test",
163
+ domain: "shop.example.com",
164
+ allowedDomains: [],
165
+ };
166
+
167
+ global.fetch = vi.fn().mockResolvedValueOnce({
168
+ ok: true,
169
+ json: async () => mockResponse,
170
+ });
171
+
172
+ const result = await sdkConfigStore.resolve(
173
+ "shop.example.com",
174
+ "http://localhost:3000"
175
+ );
176
+
177
+ expect(result).toEqual(mockResponse);
178
+ expect(global.fetch).toHaveBeenCalledWith(
179
+ "http://localhost:3030/user/merchant/resolve?domain=shop.example.com"
180
+ );
181
+ });
182
+
183
+ it("should encode domain in URL query parameter", async () => {
184
+ const mockResponse = {
185
+ merchantId: "merchant-encoded",
186
+ name: "Test",
187
+ domain: "shop.example.com",
188
+ allowedDomains: [],
189
+ };
190
+
191
+ global.fetch = vi.fn().mockResolvedValueOnce({
192
+ ok: true,
193
+ json: async () => mockResponse,
194
+ });
195
+
196
+ const domainWithSpecialChars = "shop.example.com?test=1&foo=bar";
197
+ await sdkConfigStore.resolve(domainWithSpecialChars);
198
+
199
+ expect(global.fetch).toHaveBeenCalledWith(
200
+ "https://backend.frak.id/user/merchant/resolve?domain=shop.example.com%3Ftest%3D1%26foo%3Dbar"
201
+ );
202
+ });
203
+
204
+ it("should write merchantId to sessionStorage as 'frak-merchant-id'", async () => {
205
+ const mockResponse = {
206
+ merchantId: "merchant-persisted",
207
+ name: "Test",
208
+ domain: "shop.example.com",
209
+ allowedDomains: [],
210
+ };
211
+
212
+ global.fetch = vi.fn().mockResolvedValueOnce({
213
+ ok: true,
214
+ json: async () => mockResponse,
215
+ });
216
+
217
+ await sdkConfigStore.resolve("shop.example.com");
218
+
219
+ expect(window.sessionStorage.getItem("frak-merchant-id")).toBe(
220
+ "merchant-persisted"
221
+ );
222
+ });
223
+
224
+ it("should pass lang parameter to backend when provided", async () => {
225
+ const mockResponse = {
226
+ merchantId: "merchant-lang",
227
+ name: "Test",
228
+ domain: "shop.example.com",
229
+ allowedDomains: [],
230
+ };
231
+
232
+ global.fetch = vi.fn().mockResolvedValueOnce({
233
+ ok: true,
234
+ json: async () => mockResponse,
235
+ });
236
+
237
+ await sdkConfigStore.resolve("shop.example.com", undefined, "fr");
238
+
239
+ expect(global.fetch).toHaveBeenCalledWith(
240
+ "https://backend.frak.id/user/merchant/resolve?domain=shop.example.com&lang=fr"
241
+ );
242
+ });
243
+ });
244
+
245
+ describe("sdkConfigStore.getMerchantId", () => {
246
+ it("should return merchantId from resolved config", () => {
247
+ window.__frakSdkConfig = {
248
+ isResolved: true,
249
+ merchantId: "config-merchant-123",
250
+ };
251
+
252
+ const result = sdkConfigStore.getMerchantId();
253
+
254
+ expect(result).toBe("config-merchant-123");
255
+ });
256
+
257
+ it("should fall back to sessionStorage", () => {
258
+ window.__frakSdkConfig = {
259
+ isResolved: false,
260
+ merchantId: "",
261
+ };
262
+ window.sessionStorage.setItem(
263
+ "frak-merchant-id",
264
+ "session-merchant-456"
265
+ );
266
+
267
+ const result = sdkConfigStore.getMerchantId();
268
+
269
+ expect(result).toBe("session-merchant-456");
270
+ });
271
+
272
+ it("should return undefined when nothing cached", () => {
273
+ window.__frakSdkConfig = {
274
+ isResolved: false,
275
+ merchantId: "",
276
+ };
277
+
278
+ const result = sdkConfigStore.getMerchantId();
279
+
280
+ expect(result).toBeUndefined();
281
+ });
282
+ });
283
+
284
+ describe("sdkConfigStore.resolveMerchantId", () => {
285
+ it("should return merchantId from store without fetch", async () => {
286
+ window.__frakSdkConfig = {
287
+ isResolved: true,
288
+ merchantId: "store-merchant-123",
289
+ };
290
+ global.fetch = vi.fn();
291
+
292
+ const result = await sdkConfigStore.resolveMerchantId();
293
+
294
+ expect(result).toBe("store-merchant-123");
295
+ expect(global.fetch).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it("should return merchantId from sessionStorage without fetch", async () => {
299
+ window.__frakSdkConfig = {
300
+ isResolved: false,
301
+ merchantId: "",
302
+ };
303
+ window.sessionStorage.setItem(
304
+ "frak-merchant-id",
305
+ "session-merchant-789"
306
+ );
307
+ global.fetch = vi.fn();
308
+
309
+ const result = await sdkConfigStore.resolveMerchantId();
310
+
311
+ expect(result).toBe("session-merchant-789");
312
+ expect(global.fetch).not.toHaveBeenCalled();
313
+ });
314
+
315
+ it("should fetch from backend as last resort", async () => {
316
+ const mockResponse = {
317
+ merchantId: "fetched-merchant-456",
318
+ name: "Test",
319
+ domain: "shop.example.com",
320
+ allowedDomains: [],
321
+ };
322
+
323
+ global.fetch = vi.fn().mockResolvedValueOnce({
324
+ ok: true,
325
+ json: async () => mockResponse,
326
+ });
327
+
328
+ Object.defineProperty(window, "location", {
329
+ value: { hostname: "shop.example.com" },
330
+ writable: true,
331
+ });
332
+
333
+ const result = await sdkConfigStore.resolveMerchantId();
334
+
335
+ expect(result).toBe("fetched-merchant-456");
336
+ expect(global.fetch).toHaveBeenCalled();
337
+ });
338
+
339
+ it("should return undefined when fetch fails", async () => {
340
+ global.fetch = vi
341
+ .fn()
342
+ .mockRejectedValueOnce(new Error("Network error"));
343
+
344
+ Object.defineProperty(window, "location", {
345
+ value: { hostname: "shop.example.com" },
346
+ writable: true,
347
+ });
348
+
349
+ const result = await sdkConfigStore.resolveMerchantId();
350
+
351
+ expect(result).toBeUndefined();
352
+ });
353
+ });
354
+
355
+ describe("sdkConfigStore.clearCache", () => {
356
+ it("should clear all caches and allow re-fetching", async () => {
357
+ const mockResponse = {
358
+ merchantId: "merchant-clear-test",
359
+ name: "Test",
360
+ domain: "shop.example.com",
361
+ allowedDomains: [],
362
+ };
363
+
364
+ global.fetch = vi.fn().mockResolvedValue({
365
+ ok: true,
366
+ json: async () => mockResponse,
367
+ });
368
+
369
+ const result1 = await sdkConfigStore.resolve("shop.example.com");
370
+ expect(result1).toEqual(mockResponse);
371
+ expect(global.fetch).toHaveBeenCalledTimes(1);
372
+
373
+ sdkConfigStore.clearCache();
374
+
375
+ const result2 = await sdkConfigStore.resolve("shop.example.com");
376
+ expect(result2).toEqual(mockResponse);
377
+ expect(global.fetch).toHaveBeenCalledTimes(2);
378
+ });
379
+
380
+ it("should clear sessionStorage frak-merchant-id", async () => {
381
+ const mockResponse = {
382
+ merchantId: "merchant-session-clear",
383
+ name: "Test",
384
+ domain: "shop.example.com",
385
+ allowedDomains: [],
386
+ };
387
+
388
+ global.fetch = vi.fn().mockResolvedValueOnce({
389
+ ok: true,
390
+ json: async () => mockResponse,
391
+ });
392
+
393
+ await sdkConfigStore.resolve("shop.example.com");
394
+ expect(window.sessionStorage.getItem("frak-merchant-id")).toBe(
395
+ "merchant-session-clear"
396
+ );
397
+
398
+ sdkConfigStore.clearCache();
399
+
400
+ expect(
401
+ window.sessionStorage.getItem("frak-merchant-id")
402
+ ).toBeNull();
403
+ });
404
+ });
405
+ });