Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion app/api/rates/latest/route.ts
Original file line number Diff line number Diff line change
@@ -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<currency, rate> }
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = req.nextUrl;
Expand Down
63 changes: 63 additions & 0 deletions lib/server/__tests__/oxr.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>, 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."
);
}
39 changes: 39 additions & 0 deletions lib/server/__tests__/oxr.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
});
});
105 changes: 105 additions & 0 deletions lib/server/__tests__/oxr_convert.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
79 changes: 55 additions & 24 deletions lib/server/oxr.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,75 @@
// 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<string, number>; // USD -> *
disclaimer?: string;
license?: string;
timestamp: number;
base: string;
rates: Record<string, number>;
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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
throw new Error(`OXR ${res.status}: ${body || res.statusText}`);
}
return res.json() as Promise<T>;
}


// 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<RatesResponse> {
return oxrFetch<RatesResponse>("/latest.json");
}





Loading
Loading