diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index fae6ebc..14cf55d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -62,9 +62,11 @@ jobs: # Run unit tests only on PRs to keep CI fast - name: Test (Vitest) id: vitest - if: ${{ github.event_name == 'pull_request' }} env: CI: true + CI_API_TEST: ${{ secrets.OXR_APP_ID && '1' || '0' }} + OXR_APP_ID: ${{ secrets.OXR_APP_ID }} + OXR_BASE_URL: ${{ secrets.OXR_BASE_URL }} run: pnpm run test:ci - name: Vitest Coverage Report diff --git a/app/api/rates/latest/route.ts b/app/api/rates/latest/route.ts index 9b6c523..2dcca09 100644 --- a/app/api/rates/latest/route.ts +++ b/app/api/rates/latest/route.ts @@ -1,7 +1,19 @@ -// src/app/api/rates/latest/route.ts + import { NextRequest, NextResponse } from "next/server"; import { latestAudRates, DEFAULTS_CURRENCY } from "@/lib/server/oxr_convert"; +/** + * GET /api/rates/latest + * + * Returns latest AUD-based exchange rates, optionally filtered by targets. + * + * Query: + * - targets?: comma-separated currency codes (e.g. "USD,EUR") + * (defaults to USD, EUR, JPY, GBP, CNY when omitted) + * + * Response: + * { base: "AUD", timestamp: number, rates: Record } + */ export async function GET(req: NextRequest) { try { const { searchParams } = req.nextUrl; diff --git a/lib/server/__tests__/oxr.integration.test.ts b/lib/server/__tests__/oxr.integration.test.ts new file mode 100644 index 0000000..68e720f --- /dev/null +++ b/lib/server/__tests__/oxr.integration.test.ts @@ -0,0 +1,63 @@ +// tests/integration/oxr.integration.test.ts +// @vitest-environment node +import { describe, it, expect , vi } from "vitest"; + +// 1) Avoid Next runtime constraints in Node +vi.mock("server-only", () => ({})); + +// Only run when explicitly enabled +const run = !!process.env.CI_API_TEST; +const envOK = !!process.env.OXR_BASE_URL && !!process.env.OXR_APP_ID; + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +// Simple retry to mitigate transient 5xx/rate-limit +async function withRetry(fn: () => Promise, tries = 2) { + let lastErr: unknown; + for (let i = 0; i < tries; i++) { + try { + return await fn(); + } catch (e) { + lastErr = e; + if (i < tries - 1) await sleep(500); + } + } + throw lastErr; +} + +(run && envOK ? describe : describe.skip)("integration: oxr.getLatest()", () => { + it( + "hits real upstream and returns a USD-based table including AUD", + async () => { + // Import after env is available + const { getLatest } = await import("../oxr"); + + const data = await withRetry(() => getLatest(), 2); + + // Basic shape checks (keep assertions resilient) + expect(data).toBeTruthy(); + expect(data.base).toBe("USD"); + expect(typeof data.timestamp).toBe("number"); + expect(data.timestamp).toBeGreaterThan(0); + + expect(data.rates).toBeTypeOf("object"); + expect(data.rates).toHaveProperty("AUD"); + + const aud = data.rates["AUD"]; + expect(typeof aud).toBe("number"); + // Reasonable bound to avoid flaky tight assertions + expect(aud).toBeGreaterThan(0); + expect(aud).toBeLessThan(1000); + }, + 30_000 // generous timeout for network + ); +}); + +// Helpful message when skipped +if (run && !envOK) { + console.warn( + "[integration:oxr] Skipped because OXR_BASE_URL or OXR_APP_ID is missing." + ); +} diff --git a/lib/server/__tests__/oxr.test.ts b/lib/server/__tests__/oxr.test.ts new file mode 100644 index 0000000..9f0f61a --- /dev/null +++ b/lib/server/__tests__/oxr.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// mock server-only +vi.mock("server-only", () => ({})); + +describe("oxr", () => { + beforeEach(() => { + process.env.OXR_BASE_URL = "https://api.test"; + process.env.OXR_APP_ID = "test-key"; + + const mockFetch: typeof fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + timestamp: 1, + base: "USD", + rates: { AUD: 1.0 }, + }), + } as unknown as Response); // 这里返回值“长得像”Response + + globalThis.fetch = mockFetch; +}); + + it("calls correct URL and returns data", async () => { + const { getLatest } = await import("../oxr"); // dynamic import after setting env + const result = await getLatest(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.test/latest.json?app_id=test-key", + expect.objectContaining({ cache: "no-store" }) + ); + + expect(result).toEqual({ + timestamp: 1, + base: "USD", + rates: { AUD: 1.0 }, + }); + }); +}); diff --git a/lib/server/__tests__/oxr_convert.test.ts b/lib/server/__tests__/oxr_convert.test.ts new file mode 100644 index 0000000..159202a --- /dev/null +++ b/lib/server/__tests__/oxr_convert.test.ts @@ -0,0 +1,105 @@ +// src/lib/server/__tests__/oxr_convert.test.ts +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// 1) Avoid Next runtime constraints in Node +vi.mock("server-only", () => ({})); + +// 2) Mock downstream data source used by the module under test +const getLatestMock = vi.fn(); +vi.mock("../oxr", () => ({ + getLatest: (...args: unknown[]) => getLatestMock(...args), +})); + +// 3) Import the module under test +import { rebaseToAUD, latestAudRates, DEFAULTS_CURRENCY } from "../oxr_convert"; + +describe("rebaseToAUD", () => { + it("re-bases USD/EUR etc by AUD and keeps AUD as 1", () => { + const src = { USD: 0.65, EUR: 0.60, AUD: 1 }; + const out = rebaseToAUD(src); + + // 1 AUD -> USD = rates.USD / rates.AUD + expect(out.USD).toBeCloseTo(0.65 / 1, 10); + expect(out.EUR).toBeCloseTo(0.60 / 1, 10); + expect(out.AUD).toBe(1); + }); + + it("does not mutate the input object", () => { + const src = { USD: 0.7, AUD: 2 }; + const snapshot = { ...src }; + const out = rebaseToAUD(src); + + expect(src).toEqual(snapshot); + expect(out).not.toBe(src); + }); + + it("when AUD is missing, divisions result in NaN and AUD is set to 1 (documenting current behavior)", () => { + const src = { USD: 0.65, EUR: 0.60 }; // no AUD + const out = rebaseToAUD(src); + + expect(Number.isNaN(out.USD)).toBe(true); + expect(Number.isNaN(out.EUR)).toBe(true); + expect(out.AUD).toBe(1); + }); + + it("when AUD is 0, divisions result in Infinity and AUD remains 1 (documenting current behavior)", () => { + const src = { USD: 0.65, AUD: 0 }; + const out = rebaseToAUD(src); + + expect(out.USD).toBe(Infinity); + expect(out.AUD).toBe(1); + }); +}); + +describe("latestAudRates", () => { + beforeEach(() => { + vi.restoreAllMocks(); + getLatestMock.mockReset().mockResolvedValue({ + timestamp: 1234567890, + base: "USD", + rates: { + USD: 0.65, + EUR: 0.60, + JPY: 100, + GBP: 0.5, + CNY: 0.45, + AUD: 1, + }, + }); + }); + + it("returns default currencies when targets are not provided", async () => { + const res = await latestAudRates(); + + expect(res.base).toBe("AUD"); + expect(res.timestamp).toBe(1234567890); + + // Only currencies that exist in source data should appear + for (const c of DEFAULTS_CURRENCY) { + expect(Object.keys(res.rates)).toContain(c); + } + }); + + it("uses provided targets (case-insensitive, deduplicated, trimmed)", async () => { + const res = await latestAudRates(["usd", "USD", " jpy ", "", "GBP"]); + expect(Object.keys(res.rates).sort()).toEqual(["GBP", "JPY", "USD"].sort()); + }); + + it("ignores targets that are not present in source rates", async () => { + const res = await latestAudRates(["USD", "ABC", "EUR"]); + expect(Object.keys(res.rates).sort()).toEqual(["EUR", "USD"].sort()); + }); + + it("bubbles up errors from getLatest", async () => { + getLatestMock.mockRejectedValueOnce(new Error("boom")); + + await expect(latestAudRates()).rejects.toThrow(/boom/); + }); + + it("propagates timestamp from getLatest and keeps base as AUD", async () => { + const res = await latestAudRates(["USD"]); + expect(res.base).toBe("AUD"); + expect(res.timestamp).toBe(1234567890); + }); +}); diff --git a/lib/server/oxr.ts b/lib/server/oxr.ts index 9d65804..59dff24 100644 --- a/lib/server/oxr.ts +++ b/lib/server/oxr.ts @@ -1,39 +1,71 @@ // lib/server/oxr.ts import "server-only"; -const OXR_APP_ID = process.env.OXR_APP_ID; -const OXR_BASE_URL = process.env.OXR_BASE_URL; - -if (!OXR_APP_ID) { - console.warn("[oxr] OXR_APP_ID is not set. Calls will fail."); -} -if (!OXR_BASE_URL) { - console.warn("[oxr] OXR_BASE_URL is not set. Calls will fail."); +/** + * Retrieves and validates the OXR application id from the environment. + * + * @returns Trimmed `OXR_APP_ID` string. + * @throws If the variable is missing or blank. + */ +function getAppId() { + const v = process.env.OXR_APP_ID?.trim(); + if (!v) throw new Error("[oxr] OXR_APP_ID is not set"); + return v; } +/** + * Reads the OXR base URL, trimming whitespace and trailing slash. + * + * @returns Base URL without trailing slash. + * @throws If the variable is missing or blank. + */ +function getBaseUrl() { + const v = process.env.OXR_BASE_URL?.trim(); + if (!v) throw new Error("[oxr] OXR_BASE_URL is not set"); + return v.endsWith("/") ? v.slice(0, -1) : v; +} +/** + * Shape of Open Exchange Rates `/latest.json` payload. + */ export type RatesResponse = { - timestamp: number; // second(UTC) - base: string; // USD - rates: Record; // USD -> * - disclaimer?: string; - license?: string; + timestamp: number; + base: string; + rates: Record; + disclaimer?: string; + license?: string; }; -// universal fetch wrapper (no-cache + error handling) +/** + * Performs a fetch request against OXR, adding app id query param and handling errors. + * + * @param path - API path such as `/latest.json`. + * @param init - Optional request overrides. + * @returns Parsed JSON response typed as `T`. + * @throws When HTTP status is not ok; includes response text when available. + */ async function oxrFetch(path: string, init?: RequestInit): Promise { - const url = `${OXR_BASE_URL}${path}${path.includes("?") ? "&" : "?"}app_id=${OXR_APP_ID}`; - const res = await fetch(url, { cache: "no-store", ...init }); - if (!res.ok) { - let body = ""; - try { body = await res.text(); } catch { } - throw new Error(`OXR ${res.status}: ${body || res.statusText}`); + const base = getBaseUrl(); + const appId = getAppId(); + const url = `${base}${path}${path.includes("?") ? "&" : "?"}app_id=${appId}`; + const res = await fetch(url, { cache: "no-store", ...init }); + if (!res.ok) { + let body = ""; + try { + body = await res.text(); + } catch { + // ignore read errors } - return res.json() as Promise; + throw new Error(`OXR ${res.status}: ${body || res.statusText}`); + } + return res.json() as Promise; } - -// Get latest exchange rates +/** + * Fetches the latest exchange rates from Open Exchange Rates. + * + * @returns A promise resolving to the `RatesResponse` payload. + */ export async function getLatest(): Promise { return oxrFetch("/latest.json"); } @@ -41,4 +73,3 @@ export async function getLatest(): Promise { - diff --git a/lib/server/oxr_convert.ts b/lib/server/oxr_convert.ts index 3854341..b2ae899 100644 --- a/lib/server/oxr_convert.ts +++ b/lib/server/oxr_convert.ts @@ -2,28 +2,56 @@ import "server-only"; import { getLatest } from "./oxr"; -export type ConvertedItem = { code: string; rate: number | null; amount: number | null }; -export type ConvertResult = { base: "AUD"; timestamp: number; amount: number; targets: ConvertedItem[] }; +/** + * Represents a single target currency conversion result. + */ +export type ConvertedItem = { + code: string; + rate: number | null; + amount: number | null; +}; + +/** + * Shape of the conversion payload returned to clients. + */ +export type ConvertResult = { + base: "AUD"; + timestamp: number; + amount: number; + targets: ConvertedItem[]; +}; + +/** + * Default currency codes returned when no targets are provided. + */ export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"] as const; -// Calculate AUD -> target rate +/** + * Re-bases exchange rates from USD to AUD (1 AUD -> target). + * + * @param rates - USD-based rates from the OXR API. + * @returns Record mapping codes to AUD-based rates. + */ export function rebaseToAUD(rates: Record) { const out: Record = {}; const rAUD = rates.AUD; - for (const k in rates) out[k] = rates[k] / rAUD; // 1 AUD -> k + for (const k in rates) out[k] = rates[k] / rAUD; out.AUD = 1; return out; } - -// Get latest AUD exchange rates, optionally filtered by targets +/** + * Fetches latest rates and returns an AUD-based subset for the requested targets. + * + * @param targets - Optional array of currency codes; defaults to `DEFAULTS_CURRENCY`. + * @returns Object containing base, timestamp, and filtered rates map. + */ export async function latestAudRates(targets?: string[]) { const { timestamp, rates } = await getLatest(); const audRates = rebaseToAUD(rates); - const list = (targets && targets.length - ? Array.from(new Set(targets.map(t => t.toUpperCase().trim()).filter(Boolean))) - : [...DEFAULTS_CURRENCY] - ); + const list = targets && targets.length + ? Array.from(new Set(targets.map((t) => t.toUpperCase().trim()).filter(Boolean))) + : [...DEFAULTS_CURRENCY]; const filtered: Record = {}; for (const c of list) if (audRates[c] != null) filtered[c] = audRates[c]; return { base: "AUD" as const, timestamp, rates: filtered }; diff --git a/package.json b/package.json index ff2e96d..36789b5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,10 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest", - "test:ci": "vitest run --coverage --reporter=dot" + "test": "CI_API_TEST=1 vitest -c vitest.config.mts", + "test:ci": "vitest run -c vitest.config.mts --coverage --reporter=dot", + "test:unit": "vitest -c vitest.config.mts --project unit", + "test:integration": "CI_API_TEST=1 vitest run -c vitest.config.mts --project integration --reporter=dot" }, "dependencies": { "@radix-ui/react-label": "^2.1.7", @@ -35,6 +37,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^4.0.4", + "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "16.0.0", "happy-dom": "^20.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86464ce..2d20f06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.4 version: 4.0.4(vitest@4.0.4(@types/node@20.19.23)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)) + dotenv: + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9 version: 9.38.0(jiti@2.6.1) @@ -1534,6 +1537,10 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4269,6 +4276,8 @@ snapshots: '@babel/runtime': 7.28.4 csstype: 3.1.3 + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/setup.env.ts b/setup.env.ts new file mode 100644 index 0000000..0df3ba1 --- /dev/null +++ b/setup.env.ts @@ -0,0 +1,2 @@ +import dotenv from "dotenv"; +dotenv.config({ path: ".env.test" }); \ No newline at end of file diff --git a/vitest.config.mts b/vitest.config.mts index d64b6e2..eaf754f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,16 +1,42 @@ -import { defineConfig } from "vitest/config"; +// vitest.config.mts +import { defineConfig , configDefaults, } from "vitest/config"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + plugins: [tsconfigPaths()], test: { - passWithNoTests: true, - environment: "happy-dom", globals: true, - include: [ - "**/__tests__/**/*.{test,spec}.{ts,tsx,js,jsx}", - "src/**/*.{test,spec}.{ts,tsx,js,jsx}", - "tests/**/*.{test,spec}.{ts,tsx,js,jsx}", + coverage: { + reporter: ["text", "html", "json-summary"], + reportsDirectory: "coverage", + }, + + setupFiles: ["setup.env.ts"], + + projects: [ + // Unit tests + { + + extends: true, + test: { + name: "unit", + include: ["**/*.{test,spec}.{ts,tsx,js,jsx}"], + exclude: [...configDefaults.exclude,"**/*.integration.test.*"], + environment: "happy-dom", + + }, + }, + // Integration tests + { + extends: true, + test: { + name: "integration", + include: ["**/*.integration.test.ts"], + environment: "node", + testTimeout: 30_000, + setupFiles: ["setup.env.ts"], // Load env vars for integration tests + }, + }, ], }, - plugins: [tsconfigPaths()], });