diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md new file mode 100644 index 0000000..2dcef21 --- /dev/null +++ b/docs/KNOWN_ISSUES.md @@ -0,0 +1,49 @@ +# Known Issues + +## `@guildpass/sdk` cannot be resolved in `tests/api.test.ts` + +Vite cannot resolve the entry point for the `@guildpass/sdk` package, which +prevents `tests/api.test.ts` from running. + +This issue predates the API-layer refactor in issue #218. It was reproduced +against the original repository state after stashing all refactor changes, and +then reproduced again after restoring them. + +Resolving the SDK package entry point is outside the scope of refactor #218 and +does not block that work. It should be investigated separately. + +## Project typecheck fails on pre-existing JSX syntax errors + +Running `tsc --noEmit` fails with multiple JSX syntax errors in: + +- `app/access-check.tsx` +- `app/access-scanner.tsx` +- `app/deep-link-error.tsx` +- `app/pending-changes.tsx` +- `app/profile.tsx` + +These errors predate this PR. The contents of all five files were verified to +match `upstream/main` byte for byte, and none of the errors are related to the +files changed by this PR. + +This does not block the API-layer refactor, but the +`pnpm typecheck passes` checklist item cannot be marked honestly until the +base-branch errors are resolved separately. + +## GuildPass SDK does not support app session token injection + +The GuildPass SDK does not currently expose a mechanism to inject the app's +session token or refresh it. Current Guilds SDK requests are public and +anonymous, so this does not block the Guilds migration pilot. + +If an SDK endpoint requires app session authentication in the future, the SDK +or the `guildpassClient.ts` integration will need to be extended to accept and +refresh session credentials. + +## Refactor #218 - Progress + +- Guilds: migrated to the centralized service layer and verified. +- Membership: pending. +- Access: pending. +- Notifications: pending. +- Attestation: pending. diff --git a/package.json b/package.json index f5ba24a..09d7580 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,6 @@ "react-native-passkeys": "^0.4.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", - "react-native-get-random-values": "^1.11.0", - "react-native-passkeys": "^0.4.1", "react-native-webview": "13.6.4", "viem": "^2.55.2", "zod": "^3.23.8", diff --git a/src/features/guilds/useGuilds.ts b/src/features/guilds/useGuilds.ts index 3339b5c..7a25de8 100644 --- a/src/features/guilds/useGuilds.ts +++ b/src/features/guilds/useGuilds.ts @@ -2,6 +2,10 @@ import { onlineManager, useQuery, useQueryClient, type QueryClient } from "@tans import { guildPassClient } from "../../lib/guildpassClient"; import { appConfig } from "../../config/appConfig"; import { queryKeys } from "../../lib/queryKeys"; +import { + GuildNotFoundError, + guildsService, +} from "../../services/guilds/guildsService"; import { getCachedMembershipSummaries, type GuildPassStatus } from "../passes/passCache"; export type GuildListItem = { @@ -13,12 +17,7 @@ export type GuildListItem = { lastSyncedAt?: number; }; -export class GuildNotFoundError extends Error { - constructor(guildId: string) { - super(`Guild not found: ${guildId}`); - this.name = "GuildNotFoundError"; - } -} +export { GuildNotFoundError }; export const walletGuildsQueryKey = (walletAddress: string | null | undefined) => queryKeys.walletGuilds.byWallet(walletAddress ?? ""); @@ -107,14 +106,7 @@ export const useGuilds = () => { return cached as any; } - try { - return await guildPassClient.guilds.getGuild({ guildId }); - } catch (error) { - if (error instanceof Error && /not found/i.test(error.message)) { - throw new GuildNotFoundError(guildId); - } - throw error; - } + return guildsService.getGuild(guildId); }, enabled: !!guildId, networkMode: "offlineFirst", @@ -133,7 +125,7 @@ export const useGuilds = () => { return cached as any; } - return guildPassClient.guilds.getGuildConfig({ guildId }); + return guildsService.getGuildConfig(guildId); }, enabled: !!guildId, networkMode: "offlineFirst", @@ -152,7 +144,7 @@ export const useGuilds = () => { return cached as any; } - return guildPassClient.roles.getRoles({ guildId }); + return guildsService.getRoles(guildId); }, enabled: !!guildId, networkMode: "offlineFirst", diff --git a/src/features/session/session.adapter.ts b/src/features/session/session.adapter.ts index 1fce4b9..1a691eb 100644 --- a/src/features/session/session.adapter.ts +++ b/src/features/session/session.adapter.ts @@ -1,10 +1,13 @@ import { SessionAdapter } from "./session.types"; /** - * No-op adapter — wallet connected state is treated as authenticated. - * Replace with a real SIWE or backend adapter when backend support is ready. + * Lightweight adapter that keeps the app's current behavior while exposing the + * contract required by the centralized API client. */ export const noopSessionAdapter: SessionAdapter = { + async getAccessToken() { + return null; + }, async signIn(walletAddress) { return { token: `noop:${walletAddress}`, expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000 }; }, @@ -14,4 +17,10 @@ export const noopSessionAdapter: SessionAdapter = { async signOut(_token) { // nothing to do }, + async invalidateSession() { + // nothing to do + }, + isAuthenticated() { + return false; + }, }; diff --git a/src/features/session/session.types.ts b/src/features/session/session.types.ts index 624eb0f..234cd9c 100644 --- a/src/features/session/session.types.ts +++ b/src/features/session/session.types.ts @@ -16,7 +16,10 @@ export interface Session { /** Adapter interface — implement to add SIWE, WalletConnect auth, or backend sessions */ export interface SessionAdapter { + getAccessToken?(): Promise; signIn(walletAddress: string): Promise<{ token: string; expiresAt: number }>; refresh(token: string): Promise<{ token: string; expiresAt: number }>; signOut(token: string): Promise; + invalidateSession?(): Promise; + isAuthenticated?(): boolean; } diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts index 13c3bde..2745667 100644 --- a/src/lib/queryClient.ts +++ b/src/lib/queryClient.ts @@ -16,10 +16,13 @@ export const queryClient = new QueryClient({ }), defaultOptions: { queries: { - retry: 2, + retry: 1, staleTime: QUERY_STALE_TIME_MS, gcTime: QUERY_GC_TIME_MS, networkMode: "offlineFirst", }, + mutations: { + retry: false, + }, }, }); diff --git a/src/services/api/client.ts b/src/services/api/client.ts new file mode 100644 index 0000000..e4360ba --- /dev/null +++ b/src/services/api/client.ts @@ -0,0 +1,133 @@ +import { ApiError } from "./errors"; +import { applyAuthInterceptor, type AuthConfig } from "./interceptors/authInterceptor"; +import { defaultRetryConfig, retryWithBackoff, type RetryConfig } from "./retry"; +import { parseJsonResponse } from "./response"; + +export interface ApiClientConfig { + baseUrl: string; + timeoutMs?: number; + headers?: Record; + auth?: AuthConfig; + retryConfig?: RetryConfig; + feature?: string; +} + +export interface RequestOptions { + path: string; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + headers?: Record; + parseJson?: boolean; + feature?: string; + operation?: string; +} + +function buildUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; +} + +function buildHeaders(headers: Record | undefined, authHeader?: string): HeadersInit { + const merged: Record = { + "content-type": "application/json", + ...(headers ?? {}), + }; + + if (authHeader) { + merged.Authorization = authHeader; + } + + return merged; +} + +export function createApiClient(config: ApiClientConfig) { + const timeoutMs = config.timeoutMs ?? 10000; + const retryConfig = config.retryConfig ?? defaultRetryConfig; + + const request = async (options: RequestOptions): Promise => { + const url = buildUrl(config.baseUrl, options.path); + const feature = options.feature ?? config.feature; + const operation = options.operation; + + const requestFn = async (token: string | null) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const headers = buildHeaders(options.headers, token ?? undefined); + + const response = await fetch(url, { + method: options.method ?? "GET", + headers, + body: options.body != null ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorPayload = await parseJsonResponse(response); + const errorMessage = + typeof errorPayload.data === "object" && errorPayload.data != null && "message" in errorPayload.data + ? String((errorPayload.data as { message?: string }).message) + : response.statusText || "Request failed"; + + throw new ApiError({ + code: response.status === 401 ? "unauthorized" : response.status === 403 ? "forbidden" : response.status === 404 ? "not_found" : response.status >= 500 ? "server" : "unknown", + message: errorMessage, + userMessage: errorMessage, + status: response.status, + retryable: response.status === 429 || (response.status >= 500 && response.status < 600), + cause: errorPayload.data, + feature, + operation, + }); + } + + const parsed = await parseJsonResponse(response); + return parsed.data; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof ApiError) { + throw error; + } + + const normalizedError = error instanceof Error && error.name === "AbortError" + ? new ApiError({ + code: "timeout", + message: "Request timed out", + userMessage: "The request timed out. Please try again.", + status: undefined, + retryable: true, + cause: error, + feature, + operation, + }) + : new ApiError({ + code: "network", + message: error instanceof Error ? error.message : "Network request failed", + userMessage: "We could not complete the request. Please try again.", + retryable: true, + cause: error, + feature, + operation, + }); + + throw normalizedError; + } + }; + + return retryWithBackoff( + () => + applyAuthInterceptor( + requestFn, + { auth: config.auth }, + feature, + operation, + ), + retryConfig, + ); + }; + + return { request }; +} diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts new file mode 100644 index 0000000..16aa911 --- /dev/null +++ b/src/services/api/errors.ts @@ -0,0 +1,146 @@ +export type ApiErrorCode = + | "network" + | "timeout" + | "unauthorized" + | "forbidden" + | "not_found" + | "validation" + | "server" + | "unknown"; + +export interface ApiErrorShape { + code: ApiErrorCode; + message: string; + userMessage: string; + status?: number; + retryable: boolean; + cause?: unknown; + feature?: string; + operation?: string; +} + +export interface ApiErrorContext { + feature?: string; + operation?: string; +} + +export class ApiError extends Error implements ApiErrorShape { + readonly code: ApiErrorCode; + readonly userMessage: string; + readonly status?: number; + readonly retryable: boolean; + readonly cause?: unknown; + readonly feature?: string; + readonly operation?: string; + + constructor({ + code, + message, + userMessage, + status, + retryable, + cause, + feature, + operation, + }: ApiErrorShape) { + super(message); + this.name = "ApiError"; + this.code = code; + this.userMessage = userMessage; + this.status = status; + this.retryable = retryable; + this.cause = cause; + this.feature = feature; + this.operation = operation; + } +} + +function getErrorStatus(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + + const candidate = error as { + status?: unknown; + statusCode?: unknown; + response?: { status?: unknown }; + }; + const status = candidate.status ?? candidate.statusCode ?? candidate.response?.status; + + return typeof status === "number" ? status : undefined; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message?: unknown }).message); + } + + return typeof error === "string" ? error : "SDK request failed"; +} + +function codeFromStatus(status: number | undefined): ApiErrorCode | undefined { + if (status === 401) return "unauthorized"; + if (status === 403) return "forbidden"; + if (status === 404) return "not_found"; + if (status === 400 || status === 422) return "validation"; + if (status === 429 || (status !== undefined && status >= 500)) return "server"; + return undefined; +} + +function userMessageForCode(code: ApiErrorCode): string { + switch (code) { + case "timeout": + return "The request timed out. Please try again."; + case "unauthorized": + return "Your session has expired. Please sign in again."; + case "forbidden": + return "You don't have permission to perform this action."; + case "not_found": + return "The requested resource could not be found."; + case "validation": + return "Some request information was invalid."; + case "network": + case "server": + case "unknown": + return "We could not complete the request. Please try again."; + } +} + +export function normalizeSdkError( + error: unknown, + context: ApiErrorContext = {}, +): ApiError { + if (error instanceof ApiError) { + return error; + } + + const status = getErrorStatus(error); + const message = getErrorMessage(error); + const isTimeout = + (error instanceof Error && error.name === "AbortError") || + /timeout|timed out/i.test(message); + const isNetworkError = /network|fetch|connection|offline/i.test(message); + const code = + codeFromStatus(status) ?? + (isTimeout ? "timeout" : isNetworkError ? "network" : "unknown"); + const retryable = + isTimeout || + isNetworkError || + status === 429 || + (status !== undefined && status >= 500 && status < 600); + + return new ApiError({ + code, + message, + userMessage: userMessageForCode(code), + status, + retryable, + cause: error, + feature: context.feature, + operation: context.operation, + }); +} diff --git a/src/services/api/interceptors/authInterceptor.ts b/src/services/api/interceptors/authInterceptor.ts new file mode 100644 index 0000000..569239b --- /dev/null +++ b/src/services/api/interceptors/authInterceptor.ts @@ -0,0 +1,91 @@ +import { ApiError } from "../errors"; + +export interface AuthConfig { + getAccessToken: () => Promise; + refreshAccessToken: () => Promise; + invalidateSession: () => void | Promise; +} + +export interface AuthContext { + auth?: AuthConfig; +} + +export async function applyAuthInterceptor( + request: (token: string | null) => Promise, + context: AuthContext, + feature?: string, + operation?: string, +): Promise { + const auth = context.auth; + + if (!auth) { + return request(null); + } + + const token = await auth.getAccessToken(); + if (!token) { + return request(null); + } + + try { + return await request(token); + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) { + throw error; + } + + let refreshedToken: string | null; + try { + refreshedToken = await auth.refreshAccessToken(); + } catch (refreshError) { + await auth.invalidateSession(); + throw new ApiError({ + code: "unauthorized", + message: "Session refresh failed", + userMessage: "We could not refresh your session. Please sign in again.", + status: 401, + retryable: false, + cause: refreshError, + feature, + operation, + }); + } + + if (!refreshedToken) { + await auth.invalidateSession(); + throw new ApiError({ + code: "unauthorized", + message: "Session expired", + userMessage: "Your session has expired. Please sign in again.", + status: 401, + retryable: false, + cause: error, + feature, + operation, + }); + } + + try { + return await request(refreshedToken); + } catch (retryError) { + if ( + !(retryError instanceof ApiError) || + (retryError.status !== 401 && retryError.status !== 403) + ) { + throw retryError; + } + + await auth.invalidateSession(); + throw new ApiError({ + code: "unauthorized", + message: "Session refresh failed", + userMessage: "We could not refresh your session. Please sign in again.", + status: 401, + retryable: false, + cause: retryError, + feature, + operation, + }); + } + } +} diff --git a/src/services/api/response.ts b/src/services/api/response.ts new file mode 100644 index 0000000..feb079a --- /dev/null +++ b/src/services/api/response.ts @@ -0,0 +1,22 @@ +export interface ApiResponse { + data: T; + status: number; +} + +export async function parseJsonResponse(response: Response): Promise> { + const text = await response.text(); + let data: T | undefined; + + if (text) { + try { + data = JSON.parse(text) as T; + } catch { + data = text as T; + } + } + + return { + data: (data ?? ({} as T)), + status: response.status, + }; +} diff --git a/src/services/api/retry.ts b/src/services/api/retry.ts new file mode 100644 index 0000000..470adf6 --- /dev/null +++ b/src/services/api/retry.ts @@ -0,0 +1,58 @@ +import { ApiError } from "./errors"; + +export interface RetryConfig { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + backoffMultiplier?: number; +} + +export const defaultRetryConfig: RetryConfig = { + maxAttempts: 3, + initialDelayMs: 200, + maxDelayMs: 2000, + backoffMultiplier: 2, +}; + +export function shouldRetry(error: unknown): boolean { + if (error instanceof ApiError) { + return error.retryable; + } + + if (error instanceof Error && error.name === "AbortError") { + return false; + } + + if (error instanceof Error && /timeout|network|fetch/i.test(error.message)) { + return true; + } + + return false; +} + +export async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function retryWithBackoff( + operation: () => Promise, + config: RetryConfig, + attempt = 1, +): Promise { + try { + return await operation(); + } catch (error) { + const shouldRetryRequest = attempt < config.maxAttempts && shouldRetry(error); + + if (!shouldRetryRequest) { + throw error; + } + + const delay = Math.min( + config.initialDelayMs * Math.pow(config.backoffMultiplier ?? 2, attempt - 1), + config.maxDelayMs, + ); + await sleep(delay); + return retryWithBackoff(operation, config, attempt + 1); + } +} diff --git a/src/services/guilds/guildsService.ts b/src/services/guilds/guildsService.ts new file mode 100644 index 0000000..c12eaed --- /dev/null +++ b/src/services/guilds/guildsService.ts @@ -0,0 +1,78 @@ +import { guildPassClient } from "../../lib/guildpassClient"; +import { + ApiError, + normalizeSdkError, + type ApiErrorContext, +} from "../api/errors"; +import { defaultRetryConfig, retryWithBackoff } from "../api/retry"; + +const feature = "guilds"; + +export class GuildNotFoundError extends ApiError { + constructor(guildId: string, cause?: unknown) { + super({ + code: "not_found", + message: `Guild not found: ${guildId}`, + userMessage: "We couldn't find this guild.", + status: 404, + retryable: false, + cause, + feature, + operation: "getGuild", + }); + this.name = "GuildNotFoundError"; + } +} + +async function executeSdkOperation( + operation: () => Promise, + context: ApiErrorContext, +): Promise { + return retryWithBackoff( + async () => { + try { + return await operation(); + } catch (error) { + throw normalizeSdkError(error, context); + } + }, + defaultRetryConfig, + ); +} + +async function getGuild(guildId: string) { + try { + return await executeSdkOperation( + () => guildPassClient.guilds.getGuild({ guildId }), + { feature, operation: "getGuild" }, + ); + } catch (error) { + const isNotFound = + (error instanceof ApiError && error.code === "not_found") || + (error instanceof Error && /not found/i.test(error.message)); + if (isNotFound) { + throw new GuildNotFoundError(guildId, error); + } + throw error; + } +} + +function getGuildConfig(guildId: string) { + return executeSdkOperation( + () => guildPassClient.guilds.getGuildConfig({ guildId }), + { feature, operation: "getGuildConfig" }, + ); +} + +function getRoles(guildId: string) { + return executeSdkOperation( + () => guildPassClient.roles.getRoles({ guildId }), + { feature, operation: "getRoles" }, + ); +} + +export const guildsService = { + getGuild, + getGuildConfig, + getRoles, +}; diff --git a/tests/hooks/useGuilds.test.ts b/tests/hooks/useGuilds.test.ts index 2972356..9963cd3 100644 --- a/tests/hooks/useGuilds.test.ts +++ b/tests/hooks/useGuilds.test.ts @@ -1,87 +1,90 @@ -/** - * useGuilds hook – contract & behaviour tests - * - * What we verify - * -------------- - * 1. Query key shape – the exact cache keys the app uses; if a key changes, - * the screen loses its cache entry silently, so we pin it. - * 2. SDK method called – correct namespace + method name + argument shape. - * 3. Response mapping – hook surfaces the SDK payload unchanged to the screen. - * 4. enabled guard – queries do not fire when guildId is empty. - * 5. Error propagation – SDK rejections surface as hook error state. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSdkMock, resetSdkMock } from "../fixtures/sdk.mock"; import { - GUILD_DETAIL_FIXTURE, GUILD_CONFIG_FIXTURE, - ROLES_LIST_FIXTURE, + GUILD_DETAIL_FIXTURE, ROLES_EMPTY_FIXTURE, + ROLES_LIST_FIXTURE, WALLET_GUILDS_FIXTURE, WALLET_GUILDS_EMPTY_FIXTURE, } from "../fixtures/guild.fixtures"; -// --------------------------------------------------------------------------- -// Mock the SDK before importing the module under test -// --------------------------------------------------------------------------- +const serviceMocks = vi.hoisted(() => ({ + getGuild: vi.fn(), + getGuildConfig: vi.fn(), + getRoles: vi.fn(), +})); + +const reactQueryMocks = vi.hoisted(() => ({ + useQuery: vi.fn((options: unknown) => options), + getQueryData: vi.fn(), + isOnline: vi.fn(() => true), +})); vi.mock("@guildpass/sdk", async () => { // @ts-expect-error Vitest runs this async mock factory through Vite. const { mockSdkModule } = await import("../fixtures/sdk.mock"); return mockSdkModule(); }); + vi.mock("expo-constants", () => ({ default: { expoConfig: { extra: { apiUrl: "https://api.guildpass.test", chainId: 1 } } }, })); -// Import after mocks are registered +vi.mock("@tanstack/react-query", () => ({ + onlineManager: { + isOnline: reactQueryMocks.isOnline, + }, + useQuery: reactQueryMocks.useQuery, + useQueryClient: () => ({ + getQueryData: reactQueryMocks.getQueryData, + }), +})); + +vi.mock("../../src/services/guilds/guildsService", () => { + class GuildNotFoundError extends Error { + constructor(guildId: string) { + super(`Guild not found: ${guildId}`); + this.name = "GuildNotFoundError"; + } + } + + return { + GuildNotFoundError, + guildsService: serviceMocks, + }; +}); + +// Imports after mocks are registered. import { guildPassClient } from "../../src/lib/guildpassClient"; import { fetchGuildsByWalletAddress, + GuildNotFoundError, + useGuilds, walletGuildsQueryKey, } from "../../src/features/guilds/useGuilds"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Executes a query function directly (bypassing React hooks) so we can test - * the SDK call boundary without needing renderHook. - * - * We test the queryFn and queryKey in isolation because the hook factory - * pattern used in this codebase (returning useQuery from inside a function) - * means the hook itself cannot be called outside of a React render context. - * Testing at the queryFn / queryKey level is the correct contract boundary. - */ -function makeQueryClient() { - return new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); +interface CapturedQuery { + queryKey: readonly unknown[]; + queryFn: () => Promise; + enabled: boolean; + networkMode: string; } -// Import the error class after mocks are registered -import { GuildNotFoundError } from "../../src/features/guilds/useGuilds"; - -// --------------------------------------------------------------------------- -// GuildNotFoundError -// --------------------------------------------------------------------------- +function asQuery(value: unknown): CapturedQuery { + return value as CapturedQuery; +} -describe("GuildNotFoundError", () => { +describe("GuildNotFoundError public export", () => { it("extends Error and has the correct name and message", () => { const error = new GuildNotFoundError("guild_404"); + expect(error).toBeInstanceOf(Error); expect(error.name).toBe("GuildNotFoundError"); expect(error.message).toMatch(/guild_404/); }); }); -// --------------------------------------------------------------------------- -// getGuildsByWalletAddress -// --------------------------------------------------------------------------- - describe("useGuilds – getGuildsByWalletAddress", () => { let sdk: ReturnType; @@ -144,221 +147,151 @@ describe("useGuilds – getGuildsByWalletAddress", () => { }); }); -// --------------------------------------------------------------------------- -// getGuild -// --------------------------------------------------------------------------- - -describe("useGuilds – getGuild", () => { - let sdk: ReturnType; - +describe("useGuilds - getGuild", () => { beforeEach(() => { - sdk = createSdkMock(); - }); - - afterEach(() => { - resetSdkMock(); vi.clearAllMocks(); + serviceMocks.getGuild.mockResolvedValue(GUILD_DETAIL_FIXTURE); + serviceMocks.getGuildConfig.mockResolvedValue(GUILD_CONFIG_FIXTURE); + serviceMocks.getRoles.mockResolvedValue(ROLES_LIST_FIXTURE); }); - it("calls guildPassClient.guilds.getGuild with the correct argument shape", async () => { - const guildId = "guild_abc"; + it("calls guildsService.getGuild with the guild ID", async () => { + const query = asQuery(useGuilds().getGuild("guild_abc")); - await guildPassClient.guilds.getGuild({ guildId }); + await query.queryFn(); - expect(sdk.guilds.getGuild).toHaveBeenCalledTimes(1); - expect(sdk.guilds.getGuild).toHaveBeenCalledWith({ guildId }); + expect(serviceMocks.getGuild).toHaveBeenCalledTimes(1); + expect(serviceMocks.getGuild).toHaveBeenCalledWith("guild_abc"); }); - it("returns the full guild fixture without transforming any fields", async () => { - const result = await guildPassClient.guilds.getGuild({ guildId: "guild_abc" }); + it("returns the full guild fixture without transforming fields", async () => { + const query = asQuery(useGuilds().getGuild("guild_abc")); + + const result = await query.queryFn(); - // Every field the GuildDetail screen reads must be present and match expect(result).toStrictEqual(GUILD_DETAIL_FIXTURE); - expect(result.id).toBe(GUILD_DETAIL_FIXTURE.id); - expect(result.name).toBe(GUILD_DETAIL_FIXTURE.name); - expect(result.description).toBe(GUILD_DETAIL_FIXTURE.description); - expect(result.ownerAddress).toBe(GUILD_DETAIL_FIXTURE.ownerAddress); - expect(result.chainId).toBe(GUILD_DETAIL_FIXTURE.chainId); - expect(result.isActive).toBe(GUILD_DETAIL_FIXTURE.isActive); }); - it("surfaces SDK rejection as a rejected promise (error state for screen)", async () => { + it("surfaces service rejection as a rejected query", async () => { const networkError = new Error("Network request failed"); - sdk.guilds.getGuild.mockRejectedValueOnce(networkError); + serviceMocks.getGuild.mockRejectedValueOnce(networkError); + const query = asQuery(useGuilds().getGuild("guild_abc")); - await expect(guildPassClient.guilds.getGuild({ guildId: "guild_abc" })).rejects.toThrow( - "Network request failed", - ); + await expect(query.queryFn()).rejects.toBe(networkError); }); - it("documents the expected query key: ['guild', guildId]", () => { - // Pinning query keys prevents silent cache misses when keys are refactored. - // If this changes, stale-while-revalidate and invalidation logic breaks. - const guildId = "guild_abc"; - const expectedQueryKey = ["guild", guildId]; + it("uses the existing guild query key", () => { + const query = asQuery(useGuilds().getGuild("guild_abc")); - // We assert the shape here as documentation – the hook test file uses this - // same key shape; any change to the hook must also update this expectation. - expect(expectedQueryKey).toStrictEqual(["guild", "guild_abc"]); + expect(query.queryKey).toStrictEqual(["guild", "guild_abc"]); }); - it("surfaces GuildNotFoundError when the SDK responds with 'Guild not found'", async () => { - const guildId = "nonexistent"; - sdk.guilds.getGuild.mockRejectedValueOnce(new Error("Guild not found")); - - // Simulate the queryFn wrapping used by the hook - const queryFn = async () => { - try { - return await guildPassClient.guilds.getGuild({ guildId }); - } catch (error) { - if (error instanceof Error && /not found/i.test(error.message)) { - throw new GuildNotFoundError(guildId); - } - throw error; - } - }; - - await expect(queryFn()).rejects.toThrow(GuildNotFoundError); - }); + it("surfaces GuildNotFoundError from the service", async () => { + const notFoundError = new GuildNotFoundError("nonexistent"); + serviceMocks.getGuild.mockRejectedValueOnce(notFoundError); + const query = asQuery(useGuilds().getGuild("nonexistent")); - it("preserves generic SDK errors (e.g. network failures) unchanged", async () => { - const guildId = "guild_abc"; - const networkError = new Error("Network request failed"); - sdk.guilds.getGuild.mockRejectedValueOnce(networkError); - - const queryFn = async () => { - try { - return await guildPassClient.guilds.getGuild({ guildId }); - } catch (error) { - if (error instanceof Error && /not found/i.test(error.message)) { - throw new GuildNotFoundError(guildId); - } - throw error; - } - }; - - await expect(queryFn()).rejects.toThrow("Network request failed"); + await expect(query.queryFn()).rejects.toBeInstanceOf(GuildNotFoundError); }); - it("does not call the SDK when guildId is an empty string (enabled guard)", async () => { - // The hook uses `enabled: !!guildId`. Simulate the guard by checking that - // we would not invoke the SDK for an empty string. - const guildId = ""; - const shouldFetch = !!guildId; + it("preserves generic service errors unchanged", async () => { + const serviceError = new Error("Service unavailable"); + serviceMocks.getGuild.mockRejectedValueOnce(serviceError); + const query = asQuery(useGuilds().getGuild("guild_abc")); - expect(shouldFetch).toBe(false); - // The actual SDK call must not have been made - expect(sdk.guilds.getGuild).not.toHaveBeenCalled(); + await expect(query.queryFn()).rejects.toBe(serviceError); }); -}); -// --------------------------------------------------------------------------- -// getGuildConfig -// --------------------------------------------------------------------------- - -describe("useGuilds – getGuildConfig", () => { - let sdk: ReturnType; + it("disables the query when guildId is empty", () => { + const query = asQuery(useGuilds().getGuild("")); - beforeEach(() => { - sdk = createSdkMock(); + expect(query.enabled).toBe(false); + expect(serviceMocks.getGuild).not.toHaveBeenCalled(); }); +}); - afterEach(() => { - resetSdkMock(); +describe("useGuilds - getGuildConfig", () => { + beforeEach(() => { vi.clearAllMocks(); + serviceMocks.getGuildConfig.mockResolvedValue(GUILD_CONFIG_FIXTURE); }); - it("calls guildPassClient.guilds.getGuildConfig with the correct argument shape", async () => { - const guildId = "guild_abc"; + it("calls guildsService.getGuildConfig with the guild ID", async () => { + const query = asQuery(useGuilds().getGuildConfig("guild_abc")); - await guildPassClient.guilds.getGuildConfig({ guildId }); + await query.queryFn(); - expect(sdk.guilds.getGuildConfig).toHaveBeenCalledWith({ guildId }); + expect(serviceMocks.getGuildConfig).toHaveBeenCalledWith("guild_abc"); }); it("returns the full guild config fixture", async () => { - const result = await guildPassClient.guilds.getGuildConfig({ guildId: "guild_abc" }); + const query = asQuery(useGuilds().getGuildConfig("guild_abc")); - expect(result).toStrictEqual(GUILD_CONFIG_FIXTURE); - expect(result.guildId).toBe("guild_abc"); - expect(Array.isArray(result.requiredRoles)).toBe(true); - expect(result.accessPolicy).toMatch(/^(any|all)$/); + await expect(query.queryFn()).resolves.toStrictEqual(GUILD_CONFIG_FIXTURE); }); - it("documents the expected query key: ['guild-config', guildId]", () => { - const guildId = "guild_abc"; - const expectedQueryKey = ["guild-config", guildId]; - expect(expectedQueryKey).toStrictEqual(["guild-config", "guild_abc"]); + it("uses the existing guild config query key", () => { + const query = asQuery(useGuilds().getGuildConfig("guild_abc")); + + expect(query.queryKey).toStrictEqual(["guild-config", "guild_abc"]); }); }); -// --------------------------------------------------------------------------- -// getRoles -// --------------------------------------------------------------------------- - -describe("useGuilds – getRoles", () => { - let sdk: ReturnType; - +describe("useGuilds - getRoles", () => { beforeEach(() => { - sdk = createSdkMock(); - }); - - afterEach(() => { - resetSdkMock(); vi.clearAllMocks(); + serviceMocks.getRoles.mockResolvedValue(ROLES_LIST_FIXTURE); }); - it("calls guildPassClient.roles.getRoles with the correct argument shape", async () => { - const guildId = "guild_abc"; + it("calls guildsService.getRoles with the guild ID", async () => { + const query = asQuery(useGuilds().getRoles("guild_abc")); - await guildPassClient.roles.getRoles({ guildId }); + await query.queryFn(); - expect(sdk.roles.getRoles).toHaveBeenCalledWith({ guildId }); + expect(serviceMocks.getRoles).toHaveBeenCalledWith("guild_abc"); }); - it("returns an array of role objects matching the fixture shape", async () => { - const result = await guildPassClient.roles.getRoles({ guildId: "guild_abc" }); - - expect(result).toStrictEqual(ROLES_LIST_FIXTURE); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(3); + it("returns the full roles fixture", async () => { + const query = asQuery(useGuilds().getRoles("guild_abc")); - // Each role must expose the fields RoleBadge and GuildDetail screens consume - result.forEach((role: { id: string; name: string; guildId: string }) => { - expect(typeof role.id).toBe("string"); - expect(typeof role.name).toBe("string"); - expect(role.guildId).toBe("guild_abc"); - }); + await expect(query.queryFn()).resolves.toStrictEqual(ROLES_LIST_FIXTURE); }); - it("returns an empty array when the guild has no roles defined", async () => { - sdk.roles.getRoles.mockResolvedValueOnce(ROLES_EMPTY_FIXTURE); + it("returns an empty roles array unchanged", async () => { + serviceMocks.getRoles.mockResolvedValueOnce(ROLES_EMPTY_FIXTURE); + const query = asQuery(useGuilds().getRoles("guild_123")); + + await expect(query.queryFn()).resolves.toStrictEqual([]); + }); - const result = await guildPassClient.roles.getRoles({ guildId: "guild_123" }); + it("surfaces service rejection as a rejected query", async () => { + const serviceError = new Error("Guild not found"); + serviceMocks.getRoles.mockRejectedValueOnce(serviceError); + const query = asQuery(useGuilds().getRoles("non_existent")); - expect(result).toStrictEqual([]); - expect(result.length).toBe(0); + await expect(query.queryFn()).rejects.toBe(serviceError); }); - it("surfaces SDK rejection as a rejected promise", async () => { - sdk.roles.getRoles.mockRejectedValueOnce(new Error("Guild not found")); + it("uses the existing guild roles query key", () => { + const query = asQuery(useGuilds().getRoles("guild_abc")); - await expect(guildPassClient.roles.getRoles({ guildId: "non_existent" })).rejects.toThrow( - "Guild not found", - ); + expect(query.queryKey).toStrictEqual(["guild-roles", "guild_abc"]); }); - it("documents the expected query key: ['guild-roles', guildId]", () => { - const guildId = "guild_abc"; - const expectedQueryKey = ["guild-roles", guildId]; - expect(expectedQueryKey).toStrictEqual(["guild-roles", "guild_abc"]); + it("disables the query when guildId is empty", () => { + const query = asQuery(useGuilds().getRoles("")); + + expect(query.enabled).toBe(false); + expect(serviceMocks.getRoles).not.toHaveBeenCalled(); }); +}); - it("does not call SDK when guildId is empty (enabled guard)", () => { - const guildId = ""; - const shouldFetch = !!guildId; +describe("useGuilds public interface", () => { + it("preserves the get* and use* aliases", () => { + const guilds = useGuilds(); - expect(shouldFetch).toBe(false); - expect(sdk.roles.getRoles).not.toHaveBeenCalled(); + expect(guilds.getGuild).toBe(guilds.useGuild); + expect(guilds.getGuildConfig).toBe(guilds.useGuildConfig); + expect(guilds.getRoles).toBe(guilds.useRoles); }); }); diff --git a/tests/services/api/client.test.ts b/tests/services/api/client.test.ts new file mode 100644 index 0000000..08a1338 --- /dev/null +++ b/tests/services/api/client.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createApiClient } from "../../../src/services/api/client"; +import { ApiError } from "../../../src/services/api/errors"; + +describe("api client", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("retries transient failures with backoff", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("network")) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ + baseUrl: "https://example.com", + timeoutMs: 1000, + retryConfig: { maxAttempts: 2, initialDelayMs: 1, maxDelayMs: 5 }, + }); + + const result = await client.request<{ ok: boolean }>({ path: "/health" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).toEqual({ ok: true }); + }); + + it("refreshes once and retries after 401", async () => { + const refreshAccessToken = vi.fn().mockResolvedValue("fresh-token"); + const getAccessToken = vi + .fn() + .mockResolvedValueOnce("stale-token") + .mockResolvedValueOnce("fresh-token"); + const invalidateSession = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ + baseUrl: "https://example.com", + timeoutMs: 1000, + auth: { + getAccessToken, + refreshAccessToken, + invalidateSession, + }, + }); + + const result = await client.request<{ ok: boolean }>({ path: "/secure" }); + + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(invalidateSession).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true }); + }); + + it("invalidates the session and throws unauthorized when refresh also fails", async () => { + const refreshAccessToken = vi.fn().mockRejectedValue(new Error("refresh network failure")); + const invalidateSession = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ + baseUrl: "https://example.com", + timeoutMs: 1000, + auth: { + getAccessToken: vi.fn().mockResolvedValue("stale-token"), + refreshAccessToken, + invalidateSession, + }, + }); + + await expect(client.request({ path: "/secure" })).rejects.toMatchObject({ + code: "unauthorized", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(invalidateSession).toHaveBeenCalledTimes(1); + }); + + it("invalidates session and throws unauthorized when retry after refresh still gets 401", async () => { + const refreshAccessToken = vi.fn().mockResolvedValue("fresh-token"); + const invalidateSession = vi.fn(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "still unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ + baseUrl: "https://example.com", + timeoutMs: 1000, + auth: { + getAccessToken: vi.fn().mockResolvedValue("stale-token"), + refreshAccessToken, + invalidateSession, + }, + }); + + await expect(client.request({ path: "/secure" })).rejects.toMatchObject({ + code: "unauthorized", + status: 401, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(invalidateSession).toHaveBeenCalledTimes(1); + }); + + it("does NOT invalidate session when retry after refresh fails with a transient error (e.g. 500)", async () => { + const refreshAccessToken = vi.fn().mockResolvedValue("fresh-token"); + const invalidateSession = vi.fn(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "server unavailable" }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ + baseUrl: "https://example.com", + timeoutMs: 1000, + retryConfig: { maxAttempts: 1, initialDelayMs: 1, maxDelayMs: 1 }, + auth: { + getAccessToken: vi.fn().mockResolvedValue("stale-token"), + refreshAccessToken, + invalidateSession, + }, + }); + + await expect(client.request({ path: "/secure" })).rejects.toMatchObject({ + code: "server", + status: 500, + retryable: true, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(invalidateSession).not.toHaveBeenCalled(); + }); + + it("normalizes errors into ApiError", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "boom" }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ + baseUrl: "https://example.com", + timeoutMs: 1000, + retryConfig: { maxAttempts: 1, initialDelayMs: 1, maxDelayMs: 1 }, + }); + + await expect(client.request({ path: "/fail" })).rejects.toMatchObject({ + code: "server", + status: 500, + retryable: true, + }); + }); + + it("throws ApiError on timeout", async () => { + const fetchMock = vi.fn().mockImplementation( + () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error("timeout")), 30)), + ); + + vi.stubGlobal("fetch", fetchMock); + + const client = createApiClient({ baseUrl: "https://example.com", timeoutMs: 5 }); + + await expect(client.request({ path: "/slow" })).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/tests/services/api/retry.test.ts b/tests/services/api/retry.test.ts new file mode 100644 index 0000000..2d4447d --- /dev/null +++ b/tests/services/api/retry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../../src/services/api/errors"; +import { + retryWithBackoff, + shouldRetry, + type RetryConfig, +} from "../../../src/services/api/retry"; + +const retryConfig: RetryConfig = { + maxAttempts: 2, + initialDelayMs: 1, + maxDelayMs: 1, +}; + +function createApiError(retryable: boolean, message: string): ApiError { + return new ApiError({ + code: retryable ? "server" : "validation", + message, + userMessage: "Request failed.", + retryable, + }); +} + +describe("API retry policy", () => { + it("retries an ApiError when retryable is true", async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(createApiError(true, "Server error")) + .mockResolvedValueOnce("success"); + + await expect(retryWithBackoff(operation, retryConfig)).resolves.toBe("success"); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it("does not retry an ApiError when retryable is false even if its message matches the heuristic", async () => { + const error = createApiError(false, "Network timeout while fetching"); + const operation = vi.fn().mockRejectedValue(error); + + await expect(retryWithBackoff(operation, retryConfig)).rejects.toBe(error); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it("uses the message heuristic for non-ApiError errors", () => { + expect(shouldRetry(new Error("Network request failed"))).toBe(true); + expect(shouldRetry(new Error("Request timeout"))).toBe(true); + expect(shouldRetry(new Error("fetch failed"))).toBe(true); + expect(shouldRetry(new Error("Invalid input"))).toBe(false); + }); +}); diff --git a/tests/services/guilds/guildsService.test.ts b/tests/services/guilds/guildsService.test.ts new file mode 100644 index 0000000..ba84d5f --- /dev/null +++ b/tests/services/guilds/guildsService.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../../src/services/api/errors"; + +const sdkMocks = vi.hoisted(() => ({ + getGuild: vi.fn(), + getGuildConfig: vi.fn(), + getRoles: vi.fn(), +})); + +vi.mock("../../../src/lib/guildpassClient", () => ({ + guildPassClient: { + guilds: { + getGuild: sdkMocks.getGuild, + getGuildConfig: sdkMocks.getGuildConfig, + }, + roles: { + getRoles: sdkMocks.getRoles, + }, + }, +})); + +import { + GuildNotFoundError, + guildsService, +} from "../../../src/services/guilds/guildsService"; + +const guildFixture = { + id: "guild_abc", + name: "Guild ABC", +}; + +const guildConfigFixture = { + guildId: "guild_abc", + requiredRoles: ["role_1"], + accessPolicy: "any", +}; + +const rolesFixture = [ + { + id: "role_1", + name: "Member", + guildId: "guild_abc", + }, +]; + +describe("guildsService", () => { + beforeEach(() => { + vi.clearAllMocks(); + sdkMocks.getGuild.mockResolvedValue(guildFixture); + sdkMocks.getGuildConfig.mockResolvedValue(guildConfigFixture); + sdkMocks.getRoles.mockResolvedValue(rolesFixture); + }); + + it("returns getGuild, getGuildConfig, and getRoles SDK responses unchanged", async () => { + await expect(guildsService.getGuild("guild_abc")).resolves.toEqual(guildFixture); + await expect(guildsService.getGuildConfig("guild_abc")).resolves.toEqual( + guildConfigFixture, + ); + await expect(guildsService.getRoles("guild_abc")).resolves.toEqual(rolesFixture); + + expect(sdkMocks.getGuild).toHaveBeenCalledWith({ guildId: "guild_abc" }); + expect(sdkMocks.getGuildConfig).toHaveBeenCalledWith({ guildId: "guild_abc" }); + expect(sdkMocks.getRoles).toHaveBeenCalledWith({ guildId: "guild_abc" }); + }); + + it("normalizes a transient SDK error and retries before succeeding", async () => { + const serverError = Object.assign(new Error("Service unavailable"), { + status: 500, + }); + sdkMocks.getGuild.mockRejectedValueOnce(serverError); + + await expect(guildsService.getGuild("guild_abc")).resolves.toEqual(guildFixture); + expect(sdkMocks.getGuild).toHaveBeenCalledTimes(2); + }); + + it("throws GuildNotFoundError for an SDK error with status 404 and no not-found message", async () => { + const missingGuildError = Object.assign(new Error("Missing resource"), { + status: 404, + }); + sdkMocks.getGuild.mockRejectedValueOnce(missingGuildError); + + const result = guildsService.getGuild("guild_404"); + + await expect(result).rejects.toBeInstanceOf(GuildNotFoundError); + await expect(result).rejects.toMatchObject({ + code: "not_found", + status: 404, + retryable: false, + message: "Guild not found: guild_404", + userMessage: "We couldn't find this guild.", + feature: "guilds", + operation: "getGuild", + } satisfies Partial); + expect(sdkMocks.getGuild).toHaveBeenCalledTimes(1); + }); + + it("preserves regex-based GuildNotFoundError detection for SDK error messages", async () => { + sdkMocks.getGuild.mockRejectedValueOnce(new Error("Guild not found")); + + await expect(guildsService.getGuild("guild_missing")).rejects.toBeInstanceOf( + GuildNotFoundError, + ); + expect(sdkMocks.getGuild).toHaveBeenCalledTimes(1); + }); +});