@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
text/typescript
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"
);
});
});
});