UNPKG

@frak-labs/core-sdk

Version:

Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.

698 lines (567 loc) 26.9 kB
import { mockWindowHistory } from "@frak-labs/test-foundation"; import type { Address } from "viem"; import { afterEach, beforeEach, describe, expect, it, vi, } from "../../tests/vitest-fixtures"; import type { FrakContextV1, FrakContextV2 } from "../types"; import { FrakContextManager } from "./frakContext"; describe("FrakContextManager", () => { let consoleErrorSpy: any; beforeEach(() => { consoleErrorSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); }); afterEach(() => { consoleErrorSpy.mockRestore(); }); describe("V2 context", () => { const MERCHANT_ID = "550e8400-e29b-41d4-a716-446655440000"; const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; const v2Context: FrakContextV2 = { v: 2, c: CLIENT_ID, m: MERCHANT_ID, t: 1709654400, }; describe("compress", () => { it("should compress v2 context with all fields", () => { const result = FrakContextManager.compress(v2Context); expect(result).toBeDefined(); expect(typeof result).toBe("string"); expect(result?.length).toBeGreaterThan(0); expect(result).not.toMatch(/[+/=]/); }); it("should return undefined when v2 context has neither clientId nor wallet", () => { const partial = { v: 2 as const, m: "m", t: 123 }; const result = FrakContextManager.compress( partial as FrakContextV2 ); expect(result).toBeUndefined(); }); it("should compress v2 context with wallet only (no clientId)", () => { const v2WithWalletOnly: FrakContextV2 = { v: 2, m: MERCHANT_ID, t: 1709654400, w: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.compress(v2WithWalletOnly); expect(result).toBeDefined(); const decompressed = FrakContextManager.decompress(result); expect(decompressed).toEqual(v2WithWalletOnly); }); it("should compress v2 context with both clientId and wallet", () => { const v2Hybrid: FrakContextV2 = { v: 2, c: CLIENT_ID, m: MERCHANT_ID, t: 1709654400, w: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.compress(v2Hybrid); expect(result).toBeDefined(); const decompressed = FrakContextManager.decompress(result); expect(decompressed).toEqual(v2Hybrid); }); it("should return undefined when v2 context is missing merchantId", () => { const partial = { v: 2 as const, c: CLIENT_ID, t: 123 }; const result = FrakContextManager.compress( partial as FrakContextV2 ); expect(result).toBeUndefined(); }); it("should return undefined when v2 context is missing timestamp", () => { const partial = { v: 2 as const, c: CLIENT_ID, m: MERCHANT_ID }; const result = FrakContextManager.compress( partial as FrakContextV2 ); expect(result).toBeUndefined(); }); it("should reject v2 context with a malformed wallet address", () => { const partial = { v: 2 as const, m: MERCHANT_ID, t: 1709654400, w: "0xnot-a-valid-address" as Address, }; const result = FrakContextManager.compress( partial as FrakContextV2 ); // Invalid wallet → falls back to clientId requirement; absent here → undefined expect(result).toBeUndefined(); }); it("should drop a malformed wallet but keep a valid clientId", () => { const hybrid = { v: 2 as const, c: CLIENT_ID, m: MERCHANT_ID, t: 1709654400, w: "0xnot-a-valid-address" as Address, }; const compressed = FrakContextManager.compress( hybrid as FrakContextV2 ); const decompressed = FrakContextManager.decompress(compressed); expect(decompressed).toEqual({ v: 2, c: CLIENT_ID, m: MERCHANT_ID, t: 1709654400, }); }); }); describe("decompress", () => { it("should round-trip compress and decompress v2 context", () => { const compressed = FrakContextManager.compress(v2Context); const decompressed = FrakContextManager.decompress(compressed); expect(decompressed).toEqual(v2Context); }); it("should reject payloads whose header reserved bits are set", async () => { // Craft a valid V2 binary payload then flip a reserved bit // in the header — decompress must refuse to parse it (forward-compat guard). const { encodeFrakContextV2 } = await import( "./frakContextV2Codec" ); const { base64urlEncode } = await import( "../utils/compression/b64" ); const encoded = encodeFrakContextV2(v2Context); expect(encoded).toBeDefined(); const tampered = new Uint8Array(encoded as Uint8Array); tampered[0] |= 0x40; // set a reserved bit const payload = base64urlEncode(tampered); const result = FrakContextManager.decompress(payload); expect(result).toBeUndefined(); }); }); describe("parse", () => { it("should parse URL with v2 fCtx parameter", () => { const compressed = FrakContextManager.compress(v2Context); const url = `https://example.com?fCtx=${compressed}`; const result = FrakContextManager.parse({ url }); expect(result).toBeDefined(); expect(result).toHaveProperty("v", 2); const v2 = result as FrakContextV2; expect(v2.c).toBe(CLIENT_ID); expect(v2.m).toBe(MERCHANT_ID); expect(v2.t).toBe(1709654400); }); }); describe("update", () => { it("should add v2 fCtx to URL", () => { const url = "https://example.com"; const result = FrakContextManager.update({ url, context: v2Context, }); expect(result).toBeDefined(); expect(result).toContain("fCtx="); expect(result).toContain("https://example.com"); const parsed = FrakContextManager.parse({ url: result! }); expect(parsed).toEqual(v2Context); }); it("should preserve other URL parameters", () => { const url = "https://example.com?foo=bar&baz=qux"; const result = FrakContextManager.update({ url, context: v2Context, }); expect(result).toContain("foo=bar"); expect(result).toContain("baz=qux"); expect(result).toContain("fCtx="); }); describe("update with attribution", () => { const url = "https://example.com/product"; it("should apply default attribution params when attribution is omitted", () => { const result = FrakContextManager.update({ url, context: v2Context, }); expect(result).toBeDefined(); expect(result).toContain("fCtx="); const parsedUrl = new URL(result!); expect(parsedUrl.searchParams.get("utm_source")).toBe( "frak" ); }); it("should apply default attribution params when attribution is an empty object", () => { const result = FrakContextManager.update({ url, context: v2Context, attribution: {}, }); expect(result).toBeDefined(); const parsedUrl = new URL(result!); expect(parsedUrl.searchParams.get("utm_source")).toBe( "frak" ); }); it("should honor overrides over defaults", () => { const result = FrakContextManager.update({ url, context: v2Context, attribution: { utmSource: "newsletter", utmMedium: "email", utmCampaign: "spring-sale", utmContent: "hero-banner", utmTerm: "wallet", via: "partner", ref: "alice", }, }); const parsedUrl = new URL(result!); expect(parsedUrl.searchParams.get("utm_source")).toBe( "newsletter" ); expect(parsedUrl.searchParams.get("utm_medium")).toBe( "email" ); expect(parsedUrl.searchParams.get("utm_campaign")).toBe( "spring-sale" ); expect(parsedUrl.searchParams.get("utm_content")).toBe( "hero-banner" ); expect(parsedUrl.searchParams.get("utm_term")).toBe( "wallet" ); expect(parsedUrl.searchParams.get("via")).toBe("partner"); expect(parsedUrl.searchParams.get("ref")).toBe("alice"); }); it("should preserve merchant-provided UTMs on the base URL (gap-fill)", () => { const baseUrl = "https://example.com/product?utm_source=google&utm_campaign=merchant-spring"; const result = FrakContextManager.update({ url: baseUrl, context: v2Context, attribution: {}, }); const parsedUrl = new URL(result!); // Merchant-provided values preserved expect(parsedUrl.searchParams.get("utm_source")).toBe( "google" ); expect(parsedUrl.searchParams.get("utm_campaign")).toBe( "merchant-spring" ); }); it("should skip fields with empty-string overrides", () => { const result = FrakContextManager.update({ url, context: v2Context, attribution: { utmContent: "", utmTerm: "" }, }); const parsedUrl = new URL(result!); expect(parsedUrl.searchParams.has("utm_content")).toBe( false ); expect(parsedUrl.searchParams.has("utm_term")).toBe(false); expect(parsedUrl.searchParams.has("utm_medium")).toBe( false ); expect(parsedUrl.searchParams.has("utm_campaign")).toBe( false ); expect(parsedUrl.searchParams.has("via")).toBe(false); expect(parsedUrl.searchParams.has("ref")).toBe(false); }); it("should skip context-derived defaults for V1 (no merchantId/clientId)", () => { const v1Context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.update({ url, context: v1Context, attribution: {}, }); const parsedUrl = new URL(result!); // Static defaults still applied expect(parsedUrl.searchParams.get("utm_source")).toBe( "frak" ); }); }); }); }); describe("V1 backward compatibility", () => { describe("compress", () => { it("should compress context with referrer address", () => { const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.compress(context); expect(result).toBeDefined(); expect(typeof result).toBe("string"); expect(result?.length).toBeGreaterThan(0); expect(result).not.toMatch(/[+/=]/); }); it("should return undefined when context has no referrer", () => { const context = {} as FrakContextV1; const result = FrakContextManager.compress(context); expect(result).toBeUndefined(); }); it("should return undefined when context is undefined", () => { const result = FrakContextManager.compress(undefined); expect(result).toBeUndefined(); }); it("should handle compression errors gracefully", () => { const invalidContext = { r: "invalid-address" as Address, }; const result = FrakContextManager.compress(invalidContext); expect(consoleErrorSpy).toHaveBeenCalled(); expect(result).toBeUndefined(); }); }); describe("decompress", () => { it("should decompress valid v1 base64url context", () => { const originalContext: FrakContextV1 = { r: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as Address, }; const compressed = FrakContextManager.compress(originalContext); const result = FrakContextManager.decompress(compressed); expect(result).toBeDefined(); expect((result as FrakContextV1).r).toBe( "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" ); }); it("should return undefined for empty string", () => { const result = FrakContextManager.decompress(""); expect(result).toBeUndefined(); }); it("should return undefined for undefined input", () => { const result = FrakContextManager.decompress(undefined); expect(result).toBeUndefined(); }); it("should handle decompression errors gracefully", () => { const result = FrakContextManager.decompress( "invalid-base64url!@#" ); expect(consoleErrorSpy).toHaveBeenCalled(); expect(result).toBeUndefined(); }); it("should round-trip compress and decompress v1", () => { const original: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const compressed = FrakContextManager.compress(original); const decompressed = FrakContextManager.decompress(compressed); expect(decompressed).toEqual(original); }); }); describe("parse", () => { it("should parse URL with v1 fCtx parameter", () => { const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const compressed = FrakContextManager.compress(context); const url = `https://example.com?fCtx=${compressed}`; const result = FrakContextManager.parse({ url }); expect(result).toBeDefined(); expect((result as FrakContextV1).r).toBe( "0x1234567890123456789012345678901234567890" ); }); it("should return null for URL without fCtx parameter", () => { const url = "https://example.com?other=param"; const result = FrakContextManager.parse({ url }); expect(result).toBeNull(); }); it("should return null for empty URL", () => { const result = FrakContextManager.parse({ url: "" }); expect(result).toBeNull(); }); it("should parse URL with multiple parameters", () => { const context: FrakContextV1 = { r: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as Address, }; const compressed = FrakContextManager.compress(context); const url = `https://example.com?foo=bar&fCtx=${compressed}&baz=qux`; const result = FrakContextManager.parse({ url }); expect(result).toBeDefined(); expect((result as FrakContextV1).r).toBe( "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" ); }); it("should return undefined for malformed fCtx parameter", () => { const url = "https://example.com?fCtx=!!!invalid!!!"; const result = FrakContextManager.parse({ url }); expect(result).toBeUndefined(); }); }); describe("update", () => { it("should add v1 fCtx to URL without existing context", () => { const url = "https://example.com"; const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.update({ url, context }); expect(result).toBeDefined(); expect(result).toContain("fCtx="); expect(result).toContain("https://example.com"); }); it("should return null when URL is undefined", () => { const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.update({ url: undefined, context, }); expect(result).toBeNull(); }); it("should return null when context has no data", () => { const url = "https://example.com"; // Runtime robustness: invalid object shape should return null. const context = {} as any; const result = FrakContextManager.update({ url, context }); expect(result).toBeNull(); }); it("should preserve other URL parameters", () => { const url = "https://example.com?foo=bar&baz=qux"; const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.update({ url, context }); expect(result).toContain("foo=bar"); expect(result).toContain("baz=qux"); expect(result).toContain("fCtx="); }); it("should preserve URL hash", () => { const url = "https://example.com#section"; const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const result = FrakContextManager.update({ url, context }); expect(result).toContain("#section"); expect(result).toContain("fCtx="); }); }); }); describe("remove", () => { it("should remove fCtx parameter from URL", () => { const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const compressed = FrakContextManager.compress(context); const url = `https://example.com?fCtx=${compressed}`; const result = FrakContextManager.remove(url); expect(result).toBe("https://example.com/"); expect(result).not.toContain("fCtx"); }); it("should preserve other parameters when removing fCtx", () => { const context: FrakContextV1 = { r: "0x1234567890123456789012345678901234567890" as Address, }; const compressed = FrakContextManager.compress(context); const url = `https://example.com?foo=bar&fCtx=${compressed}&baz=qux`; const result = FrakContextManager.remove(url); expect(result).toContain("foo=bar"); expect(result).toContain("baz=qux"); expect(result).not.toContain("fCtx"); }); it("should handle URL without fCtx parameter", () => { const url = "https://example.com?foo=bar"; const result = FrakContextManager.remove(url); expect(result).toContain("foo=bar"); expect(result).not.toContain("fCtx"); }); it("should preserve URL hash", () => { const url = "https://example.com?fCtx=test#section"; const result = FrakContextManager.remove(url); expect(result).toContain("#section"); expect(result).not.toContain("fCtx"); }); }); describe("replaceUrl", () => { const mockAddress = "0x1234567890123456789012345678901234567890" as Address; beforeEach(() => { Object.defineProperty(window, "location", { writable: true, value: { href: "https://example.com/page", }, }); mockWindowHistory(vi); }); it("should update window.location with v1 context", () => { const url = "https://example.com/test"; const context: FrakContextV1 = { r: mockAddress }; FrakContextManager.replaceUrl({ url, context }); const historySpy = vi.mocked(window.history.replaceState); expect(historySpy).toHaveBeenCalledTimes(1); expect(historySpy).toHaveBeenCalledWith( null, "", expect.stringContaining("fCtx=") ); const calledUrl = historySpy.mock.calls[0]?.[2] as string; expect(calledUrl).toContain("https://example.com/test"); expect(calledUrl).toContain("fCtx="); }); it("should update window.location with v2 context", () => { const url = "https://example.com/test"; const context: FrakContextV2 = { v: 2, c: "550e8400-e29b-41d4-a716-446655440001", m: "550e8400-e29b-41d4-a716-446655440000", t: 1709654400, }; FrakContextManager.replaceUrl({ url, context }); const historySpy = vi.mocked(window.history.replaceState); expect(historySpy).toHaveBeenCalledTimes(1); const calledUrl = historySpy.mock.calls[0]?.[2] as string; expect(calledUrl).toContain("fCtx="); const parsed = FrakContextManager.parse({ url: calledUrl }); expect(parsed).toEqual(context); }); it("should use provided URL instead of window.location.href", () => { const customUrl = "https://custom.com/path"; const context: FrakContextV1 = { r: mockAddress }; FrakContextManager.replaceUrl({ url: customUrl, context }); const historySpy = vi.mocked(window.history.replaceState); const calledUrl = historySpy.mock.calls[0]?.[2] as string; expect(calledUrl).toContain("https://custom.com/path"); expect(calledUrl).not.toContain("https://example.com/page"); }); it("should remove fCtx when context is null", () => { const url = "https://example.com/test?fCtx=existing"; FrakContextManager.replaceUrl({ url, context: null }); const historySpy = vi.mocked(window.history.replaceState); expect(historySpy).toHaveBeenCalledTimes(1); const calledUrl = historySpy.mock.calls[0]?.[2] as string; expect(calledUrl).not.toContain("fCtx="); }); it("should not call replaceState when context has no data", () => { const url = "https://example.com/test"; const context = {} as FrakContextV1; FrakContextManager.replaceUrl({ url, context }); const historySpy = vi.mocked(window.history.replaceState); expect(historySpy).not.toHaveBeenCalled(); }); it("should handle missing window gracefully", () => { Object.defineProperty(window, "location", { writable: true, value: undefined, }); const url = "https://example.com/test"; const context: FrakContextV1 = { r: mockAddress }; expect(() => { FrakContextManager.replaceUrl({ url, context }); }).not.toThrow(); const historySpy = vi.mocked(window.history.replaceState); expect(historySpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( "No window found, can't update context" ); }); }); });