Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dapps/pos-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ SENTRY_AUTH_TOKEN=""
EXPO_PUBLIC_API_URL=""
EXPO_PUBLIC_GATEWAY_URL=""
EXPO_PUBLIC_DEFAULT_MERCHANT_ID=""
EXPO_PUBLIC_DEFAULT_PARTNER_API_KEY=""
EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY=""
EXPO_PUBLIC_MERCHANT_API_URL=""
EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY=""
6 changes: 3 additions & 3 deletions dapps/pos-app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ SENTRY_AUTH_TOKEN="" # Sentry authentication token
EXPO_PUBLIC_API_URL="" # Payment API base URL
EXPO_PUBLIC_GATEWAY_URL="" # WalletConnect gateway URL
EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" # Default merchant ID (optional)
EXPO_PUBLIC_DEFAULT_PARTNER_API_KEY="" # Default partner API key (optional)
EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" # Default customer API key (optional)
EXPO_PUBLIC_MERCHANT_API_URL="" # Merchant Portal API base URL
EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY="" # Merchant Portal API key (for Activity screen)
```
Expand Down Expand Up @@ -678,10 +678,10 @@ const { data, isLoading, error } = usePaymentStatus(paymentId, {
import { secureStorage, SECURE_STORAGE_KEYS } from "@/utils/secure-storage";

// Store
await secureStorage.setItem(SECURE_STORAGE_KEYS.PARTNER_API_KEY, apiKey);
await secureStorage.setItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY, apiKey);

// Retrieve
const apiKey = await secureStorage.getItem(SECURE_STORAGE_KEYS.PARTNER_API_KEY);
const apiKey = await secureStorage.getItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY);
```

## Code Quality Guidelines
Expand Down
202 changes: 192 additions & 10 deletions dapps/pos-app/__tests__/hooks/use-url-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,34 @@ import { useUrlCredentials } from "@/hooks/use-url-credentials";
import { resetSettingsStore, resetLogsStore } from "../utils/store-helpers";
import { waitForAsync } from "../utils/test-helpers";

type MessageHandler = (event: { data: unknown }) => void;
const messageListeners: MessageHandler[] = [];

function setWindowLocation(search: string) {
const href = `http://localhost${search}`;
(global as any).window = {
...((global as any).window || {}),
location: { search, href },
addEventListener: (type: string, handler: MessageHandler) => {
if (type === "message") messageListeners.push(handler);
},
removeEventListener: (type: string, handler: MessageHandler) => {
if (type === "message") {
const idx = messageListeners.indexOf(handler);
if (idx !== -1) messageListeners.splice(idx, 1);
}
},
};
}

function dispatchPostMessage(data: unknown) {
for (const handler of [...messageListeners]) {
handler({ data });
}
}

function clearWindow() {
messageListeners.length = 0;
delete (global as any).window;
}

Expand All @@ -36,11 +55,11 @@ afterEach(() => {
});

describe("useUrlCredentials", () => {
it("applies both merchantId and partnerApiKey from base64-encoded URL params", async () => {
it("applies both merchantId and customerApiKey from base64-encoded URL params", async () => {
const merchantId = "test-merchant-123";
const apiKey = "test-api-key-456";
setWindowLocation(
`?merchantId=${toBase64(merchantId)}&partnerApiKey=${toBase64(apiKey)}`,
`?merchantId=${toBase64(merchantId)}&customerApiKey=${toBase64(apiKey)}`,
);

useSettingsStore.setState({ _hasHydrated: true });
Expand All @@ -50,11 +69,11 @@ describe("useUrlCredentials", () => {

const state = useSettingsStore.getState();
expect(state.merchantId).toBe(merchantId);
expect(state.isPartnerApiKeySet).toBe(true);
expect(state.isCustomerApiKeySet).toBe(true);
expect(router.replace).toHaveBeenCalledWith("/");
});

it("applies only merchantId when partnerApiKey is absent", async () => {
it("applies only merchantId when customerApiKey is absent", async () => {
const merchantId = "only-merchant";
setWindowLocation(`?merchantId=${toBase64(merchantId)}`);

Expand All @@ -65,12 +84,12 @@ describe("useUrlCredentials", () => {

const state = useSettingsStore.getState();
expect(state.merchantId).toBe(merchantId);
expect(state.isPartnerApiKeySet).toBe(false);
expect(state.isCustomerApiKeySet).toBe(false);
});

it("applies only partnerApiKey when merchantId is absent", async () => {
it("applies only customerApiKey when merchantId is absent", async () => {
const apiKey = "only-api-key";
setWindowLocation(`?partnerApiKey=${toBase64(apiKey)}`);
setWindowLocation(`?customerApiKey=${toBase64(apiKey)}`);

useSettingsStore.setState({ _hasHydrated: true, merchantId: null });

Expand All @@ -79,7 +98,7 @@ describe("useUrlCredentials", () => {

const state = useSettingsStore.getState();
expect(state.merchantId).toBeNull();
expect(state.isPartnerApiKeySet).toBe(true);
expect(state.isCustomerApiKeySet).toBe(true);
});

it("does nothing when no URL params are present", async () => {
Expand Down Expand Up @@ -132,7 +151,7 @@ describe("useUrlCredentials", () => {

it("logs actions when credentials are applied", async () => {
setWindowLocation(
`?merchantId=${toBase64("log-test")}&partnerApiKey=${toBase64("log-key")}`,
`?merchantId=${toBase64("log-test")}&customerApiKey=${toBase64("log-key")}`,
);

useSettingsStore.setState({ _hasHydrated: true });
Expand All @@ -144,6 +163,169 @@ describe("useUrlCredentials", () => {
const infoLogs = logs.filter((l) => l.level === "info");
expect(infoLogs).toHaveLength(2);
expect(infoLogs[0].message).toContain("Merchant ID set from URL");
expect(infoLogs[1].message).toContain("Partner API key set from URL");
expect(infoLogs[1].message).toContain("Customer API key set from URL");
});
});

describe("useUrlCredentials — postMessage", () => {
it("applies both merchantId and customerApiKey from postMessage", async () => {
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true });

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage({
type: "pos-credentials",
merchantId: "pm-merchant-123",
customerApiKey: "pm-key-456",
});
await waitForAsync();
});

const state = useSettingsStore.getState();
expect(state.merchantId).toBe("pm-merchant-123");
expect(state.isCustomerApiKeySet).toBe(true);
});

it("applies only merchantId from postMessage", async () => {
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true });

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage({
type: "pos-credentials",
merchantId: "pm-only-merchant",
});
await waitForAsync();
});

expect(useSettingsStore.getState().merchantId).toBe("pm-only-merchant");
expect(useSettingsStore.getState().isCustomerApiKeySet).toBe(false);
});

it("applies only customerApiKey from postMessage", async () => {
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true, merchantId: null });

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage({
type: "pos-credentials",
customerApiKey: "pm-only-key",
});
await waitForAsync();
});

expect(useSettingsStore.getState().merchantId).toBeNull();
expect(useSettingsStore.getState().isCustomerApiKeySet).toBe(true);
});

it("ignores messages with wrong type", async () => {
setWindowLocation("");
useSettingsStore.setState({
_hasHydrated: true,
merchantId: "existing",
});

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage({
type: "some-other-message",
merchantId: "should-not-apply",
});
await waitForAsync();
});

expect(useSettingsStore.getState().merchantId).toBe("existing");
});

it("ignores non-object messages", async () => {
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true });

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage("just a string");
dispatchPostMessage(null);
dispatchPostMessage(42);
await waitForAsync();
});

expect(useSettingsStore.getState().merchantId).toBeNull();
});

it("does nothing on native platforms", async () => {
(Platform as any).OS = "ios";
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true });

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage({
type: "pos-credentials",
merchantId: "should-not-apply",
});
await waitForAsync();
});

expect(useSettingsStore.getState().merchantId).toBeNull();
});

it("logs source as postMessage", async () => {
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true });

renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

await act(async () => {
dispatchPostMessage({
type: "pos-credentials",
merchantId: "log-pm-test",
customerApiKey: "log-pm-key",
});
await waitForAsync();
});

const logs = useLogsStore.getState().logs;
const infoLogs = logs.filter((l) => l.level === "info");
expect(infoLogs).toHaveLength(2);
expect(infoLogs[0].message).toContain("Merchant ID set from postMessage");
expect(infoLogs[1].message).toContain(
"Customer API key set from postMessage",
);
});

it("cleans up listener on unmount", async () => {
setWindowLocation("");
useSettingsStore.setState({ _hasHydrated: true });

const { unmount } = renderHook(() => useUrlCredentials());
await act(() => waitForAsync());

unmount();

await act(async () => {
dispatchPostMessage({
type: "pos-credentials",
merchantId: "after-unmount",
});
await waitForAsync();
});

expect(useSettingsStore.getState().merchantId).toBeNull();
});
});
12 changes: 6 additions & 6 deletions dapps/pos-app/__tests__/services/payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ describe("Payment Service", () => {
describe("getApiHeaders (via startPayment/getPaymentStatus)", () => {
it("should throw error when merchant ID is not configured", async () => {
// Set API key but not merchant ID
await SecureStore.setItemAsync("partner_api_key", "test-api-key");
await SecureStore.setItemAsync("customer_api_key", "test-api-key");
useSettingsStore.setState({
merchantId: null,
isPartnerApiKeySet: true,
isCustomerApiKeySet: true,
});

await expect(
Expand All @@ -57,10 +57,10 @@ describe("Payment Service", () => {
});

it("should throw error when merchant ID is empty string", async () => {
await SecureStore.setItemAsync("partner_api_key", "test-api-key");
await SecureStore.setItemAsync("customer_api_key", "test-api-key");
useSettingsStore.setState({
merchantId: " ", // whitespace only
isPartnerApiKeySet: true,
isCustomerApiKeySet: true,
});

await expect(
Expand All @@ -74,7 +74,7 @@ describe("Payment Service", () => {
it("should throw error when API key is not configured", async () => {
useSettingsStore.setState({
merchantId: "merchant-123",
isPartnerApiKeySet: false,
isCustomerApiKeySet: false,
});
// Don't set the API key in secure storage

Expand All @@ -83,7 +83,7 @@ describe("Payment Service", () => {
referenceId: "ref-123",
amount: { value: "1000", unit: "cents" },
}),
).rejects.toThrow("Partner API key is not configured");
).rejects.toThrow("Customer API key is not configured");
});

it("should include correct headers when merchant is configured", async () => {
Expand Down
Loading