From 37f40009cf7dffd0d156f85fe8d25cb8bb446499 Mon Sep 17 00:00:00 2001 From: web3nova Date: Mon, 27 Jul 2026 04:03:30 +0100 Subject: [PATCH] test: add unit tests for typed configuration validation layer - Export loadConfig from appConfig.ts for testability - Rewrite tests to validate the real module (not a schema copy) - Mock expo-constants and process.env to test all config sources - Cover: missing var throws, malformed URL throws, invalid chainId throws, valid config passes through unchanged - Verify no silent fallback to production defaults Closes #77 --- src/config/appConfig.ts | 2 +- tests/appConfig.test.ts | 167 +++++++++++++++++----------------------- 2 files changed, 73 insertions(+), 96 deletions(-) diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index 8bacce8..cd5b826 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -29,7 +29,7 @@ const ConfigSchema = z.object({ export type AppConfig = z.infer; -function loadConfig(): AppConfig { +export function loadConfig(): AppConfig { const rawConfig = { apiUrl: Constants.expoConfig?.extra?.apiUrl ?? process.env.EXPO_PUBLIC_API_URL, chainId: Constants.expoConfig?.extra?.chainId ?? process.env.EXPO_PUBLIC_CHAIN_ID, diff --git a/tests/appConfig.test.ts b/tests/appConfig.test.ts index 1ae9e05..ac79f0e 100644 --- a/tests/appConfig.test.ts +++ b/tests/appConfig.test.ts @@ -1,120 +1,97 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { z } from "zod"; - -/** - * appConfig unit tests - * - * We cannot import appConfig directly because it runs loadConfig() at module - * level, which requires expo-constants. Instead we replicate the schema and - * loadConfig logic here so we can test validation rules in isolation without - * needing the Expo runtime. - */ - -const AppEnvSchema = z.enum(["development", "preview", "production"]).default("development"); - -const ConfigSchema = z.object({ - apiUrl: z.string().url("EXPO_PUBLIC_API_URL must be a valid URL"), - chainId: z.coerce.number().finite("EXPO_PUBLIC_CHAIN_ID must be a finite number"), - appEnv: AppEnvSchema, - walletConnectProjectId: z.string().optional(), -}); +import { describe, expect, it, beforeEach } from "vitest"; +import Constants from "expo-constants"; +import { loadConfig } from "../src/config/appConfig"; -type AppConfig = z.infer; +describe("appConfig validation", () => { + beforeEach(() => { + delete process.env.EXPO_PUBLIC_API_URL; + delete process.env.EXPO_PUBLIC_CHAIN_ID; + delete process.env.EXPO_PUBLIC_APP_ENV; + + Constants.expoConfig = { + extra: {}, + }; + }); -function loadConfig(raw: Record): AppConfig { - const parsed = ConfigSchema.safeParse(raw); + describe("valid config", () => { + it("passes valid config through unchanged", () => { + Constants.expoConfig!.extra.apiUrl = "https://api.guildpass.xyz"; + Constants.expoConfig!.extra.chainId = "11155111"; - if (!parsed.success) { - const errorMessages = parsed.error.issues - .map((i) => `${i.path.join(".")}: ${i.message}`) - .join("\n"); - throw new Error(`Invalid application configuration:\n${errorMessages}`); - } + const config = loadConfig(); - return parsed.data; -} + expect(config.apiUrl).toBe("https://api.guildpass.xyz"); + expect(config.chainId).toBe(11155111); + expect(config.appEnv).toBe("development"); + }); -describe("appConfig validation", () => { - const validConfig = { - apiUrl: "http://localhost:3000", - chainId: "11155111", - appEnv: "development", - }; - - it("parses a valid configuration", () => { - const config = loadConfig(validConfig); - - expect(config.apiUrl).toBe("http://localhost:3000"); - expect(config.chainId).toBe(11155111); - expect(config.appEnv).toBe("development"); - }); + it("reads apiUrl from process.env when Constants.expoConfig.extra is empty", () => { + process.env.EXPO_PUBLIC_API_URL = "https://env.guildpass.xyz"; + Constants.expoConfig!.extra.chainId = "1"; - it("coerces chainId from string to number", () => { - const config = loadConfig({ ...validConfig, chainId: "8453" }); + const config = loadConfig(); - expect(config.chainId).toBe(8453); - expect(typeof config.chainId).toBe("number"); - }); + expect(config.apiUrl).toBe("https://env.guildpass.xyz"); + }); - it("defaults appEnv to development when omitted", () => { - const { appEnv, ...withoutEnv } = validConfig; - const config = loadConfig(withoutEnv); + it("coerces chainId from string to number", () => { + Constants.expoConfig!.extra.apiUrl = "https://api.guildpass.xyz"; + Constants.expoConfig!.extra.chainId = "8453"; - expect(config.appEnv).toBe("development"); - }); + const config = loadConfig(); - it("accepts all valid appEnv values", () => { - for (const env of ["development", "preview", "production"] as const) { - const config = loadConfig({ ...validConfig, appEnv: env }); - expect(config.appEnv).toBe(env); - } + expect(config.chainId).toBe(8453); + expect(typeof config.chainId).toBe("number"); + }); }); - it("rejects missing apiUrl", () => { - const { apiUrl, ...withoutUrl } = validConfig; + describe("missing variables", () => { + it("throws when apiUrl is missing from both Constants.expoConfig and process.env", () => { + Constants.expoConfig!.extra.chainId = "1"; - expect(() => loadConfig(withoutUrl)).toThrow("Invalid application configuration"); - }); + expect(() => loadConfig()).toThrow("Invalid application configuration"); + }); - it("rejects invalid apiUrl format", () => { - expect(() => loadConfig({ ...validConfig, apiUrl: "not-a-url" })).toThrow( - "EXPO_PUBLIC_API_URL must be a valid URL", - ); - }); - - it("rejects missing chainId", () => { - const { chainId, ...withoutChain } = validConfig; + it("throws when chainId is missing from both Constants.expoConfig and process.env", () => { + Constants.expoConfig!.extra.apiUrl = "https://api.guildpass.xyz"; - expect(() => loadConfig(withoutChain)).toThrow("Invalid application configuration"); + expect(() => loadConfig()).toThrow("Invalid application configuration"); + }); }); - it("rejects non-numeric chainId", () => { - expect(() => loadConfig({ ...validConfig, chainId: "abc" })).toThrow( - "Invalid application configuration", - ); - }); + describe("malformed values", () => { + it("throws when apiUrl is not a valid URL", () => { + Constants.expoConfig!.extra.apiUrl = "not-a-url"; + Constants.expoConfig!.extra.chainId = "1"; - it("rejects Infinity as chainId", () => { - expect(() => loadConfig({ ...validConfig, chainId: Infinity })).toThrow( - "EXPO_PUBLIC_CHAIN_ID must be a finite number", - ); - }); + expect(() => loadConfig()).toThrow("EXPO_PUBLIC_API_URL must be a valid URL"); + }); - it("rejects invalid appEnv values", () => { - expect(() => loadConfig({ ...validConfig, appEnv: "staging" })).toThrow( - "Invalid application configuration", - ); - }); + it("throws when chainId is non-numeric", () => { + Constants.expoConfig!.extra.apiUrl = "https://api.guildpass.xyz"; + Constants.expoConfig!.extra.chainId = "abc"; - it("accepts optional walletConnectProjectId", () => { - const config = loadConfig({ ...validConfig, walletConnectProjectId: "test-project-id" }); + expect(() => loadConfig()).toThrow("Invalid application configuration"); + }); - expect(config.walletConnectProjectId).toBe("test-project-id"); - }); + it("throws when chainId is Infinity", () => { + Constants.expoConfig!.extra.apiUrl = "https://api.guildpass.xyz"; + Constants.expoConfig!.extra.chainId = Infinity; - it("allows walletConnectProjectId to be omitted", () => { - const config = loadConfig(validConfig); + expect(() => loadConfig()).toThrow("EXPO_PUBLIC_CHAIN_ID must be a finite number"); + }); + }); - expect(config.walletConnectProjectId).toBeUndefined(); + describe("fallback safety", () => { + it("throws rather than silently defaulting to a production URL when apiUrl is missing", () => { + Constants.expoConfig!.extra.chainId = "1"; + + expect(() => loadConfig()).toThrow(); + try { + loadConfig(); + } catch (e) { + expect((e as Error).message).not.toContain("api.guildpass.xyz"); + } + }); }); });