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
28 changes: 28 additions & 0 deletions app/api/rates/history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// app/api/rates/history/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getHistoryByDateAUD, toByCurrency } from "@/lib/server/oxr_history";
import { DEFAULTS_CURRENCY } from "@/lib/server/oxr_convert";


export async function GET(req: NextRequest) {
try {
const q = req.nextUrl.searchParams;
const days = Math.max(1, Math.min(60, Number(q.get("days")) || 14));
const orient = (q.get("orient") || "byCurrency").toLowerCase();

const data = await getHistoryByDateAUD(days, DEFAULTS_CURRENCY);

if (orient === "bycurrency") {
return NextResponse.json({
base: data.base,
startDate: data.startDate,
endDate: data.endDate,
series: toByCurrency(data.points, DEFAULTS_CURRENCY),
}, { headers: { "Cache-Control": "public, max-age=300" } });
}
return NextResponse.json(data, { headers: { "Cache-Control": "public, max-age=300" } });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "history fetch failed";
return NextResponse.json({ error: message }, { status: 502 });
}
}
28 changes: 26 additions & 2 deletions lib/server/__tests__/oxr.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// tests/integration/oxr.integration.test.ts
// @vitest-environment node
import { describe, it, expect , vi } from "vitest";
import { describe, it, expect, vi } from "vitest";

// 1) Avoid Next runtime constraints in Node
vi.mock("server-only", () => ({}));
Expand All @@ -27,7 +27,7 @@ async function withRetry<T>(fn: () => Promise<T>, tries = 2) {
throw lastErr;
}

(run && envOK ? describe : describe.skip)("integration: oxr.getLatest()", () => {
(run && envOK ? describe : describe.skip)("integration: oxr endpoints", () => {
it(
"hits real upstream and returns a USD-based table including AUD",
async () => {
Expand All @@ -53,6 +53,30 @@ async function withRetry<T>(fn: () => Promise<T>, tries = 2) {
},
30_000 // generous timeout for network
);

it(
"fetches historical snapshot for the previous UTC day",
async () => {
const { getHistorical } = await import("../oxr");

const now = new Date();
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
todayUTC.setUTCDate(todayUTC.getUTCDate() - 1);
const dateISO = todayUTC.toISOString().slice(0, 10);

const data = await withRetry(() => getHistorical(dateISO), 2);

expect(data.base).toBe("USD");
expect(data.timestamp).toBeGreaterThan(0);
expect(data.rates).toHaveProperty("AUD");

const aud = data.rates["AUD"];
expect(typeof aud).toBe("number");
expect(aud).toBeGreaterThan(0);
expect(aud).toBeLessThan(1000);
},
30_000
);
});

// Helpful message when skipped
Expand Down
138 changes: 138 additions & 0 deletions lib/server/__tests__/oxr_history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("server-only", () => ({})); // strip Next.js server-only guard in tests

const rebaseToAUDMock = vi.fn();
const getHistoricalMock = vi.fn();
const buildPastDatesUTCMock = vi.fn();

vi.mock("../oxr_convert", () => ({
rebaseToAUD: (...args: unknown[]) => rebaseToAUDMock(...args),
DEFAULTS_CURRENCY: ["USD", "EUR", "JPY", "GBP", "CNY"] as const,
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mocked DEFAULTS_CURRENCY uses as const while the actual implementation was changed to remove it (line 27 in oxr_convert.ts). This inconsistency could mask issues in tests. Update the mock to match the actual implementation.

Suggested change
DEFAULTS_CURRENCY: ["USD", "EUR", "JPY", "GBP", "CNY"] as const,
DEFAULTS_CURRENCY: ["USD", "EUR", "JPY", "GBP", "CNY"],

Copilot uses AI. Check for mistakes.
}));

vi.mock("../oxr", () => ({
getHistorical: (...args: unknown[]) => getHistoricalMock(...args),
}));

vi.mock("../../time_utils", () => ({
buildPastDatesUTC: (...args: unknown[]) => buildPastDatesUTCMock(...args),
}));

vi.mock("../../time_utils.ts", () => ({
buildPastDatesUTC: (...args: unknown[]) => buildPastDatesUTCMock(...args),
}));
Comment on lines +23 to +25
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate mock for ../../time_utils.ts on lines 19-21 and 23-25. The second mock (with .ts extension) will override the first one. Remove the redundant mock or consolidate them.

Suggested change
vi.mock("../../time_utils.ts", () => ({
buildPastDatesUTC: (...args: unknown[]) => buildPastDatesUTCMock(...args),
}));

Copilot uses AI. Check for mistakes.

import type { HistoryByDate } from "../oxr_history";

const loadModule = async () => {
// ensure the module is loaded after mocks are registered
return import("../oxr_history");
};

describe("oxr_history", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});

describe("toAudRow", () => {
it("rebases rates and keeps only requested targets", async () => {
const { toAudRow } = await loadModule();
const usdRates = { USD: 0.7, EUR: 0.65, AUD: 1, GBP: 0.5 };
rebaseToAUDMock.mockReturnValue({
USD: 0.75,
EUR: 0.66,
GBP: 0.52,
AUD: 1,
});

const row = toAudRow(usdRates, ["USD", "GBP", "NZD"], "2024-01-01");

expect(rebaseToAUDMock).toHaveBeenCalledWith(usdRates);
expect(row).toEqual({
date: "2024-01-01",
USD: 0.75,
GBP: 0.52,
});
expect(row).not.toHaveProperty("NZD");
});
});

describe("getHistoryByDateAUD", () => {
it("collects historical rows in date order", async () => {
const { getHistoryByDateAUD } = await loadModule();
buildPastDatesUTCMock.mockReturnValue(["2024-01-01", "2024-01-02"]);
rebaseToAUDMock.mockImplementation((rates: Record<string, number>) => ({ ...rates }));
getHistoricalMock
.mockResolvedValueOnce({
rates: { AUD: 1, USD: 0.7, EUR: 0.65 },
base: "USD",
timestamp: 1,
})
.mockResolvedValueOnce({
rates: { AUD: 1, USD: 0.71, EUR: 0.66 },
base: "USD",
timestamp: 2,
});

// verifies date ordering and case-insensitive target handling
const result = await getHistoryByDateAUD(2, ["usd", "EUR"]);

expect(buildPastDatesUTCMock).toHaveBeenCalledWith(2);
expect(getHistoricalMock).toHaveBeenCalledTimes(2);
expect(getHistoricalMock).toHaveBeenNthCalledWith(1, "2024-01-01");
expect(getHistoricalMock).toHaveBeenNthCalledWith(2, "2024-01-02");

expect(result.base).toBe("AUD");
expect(result.startDate).toBe("2024-01-01");
expect(result.endDate).toBe("2024-01-02");
expect(result.points).toEqual([
{ date: "2024-01-01", USD: 0.7, EUR: 0.65 },
{ date: "2024-01-02", USD: 0.71, EUR: 0.66 },
]);
});

it("marks failed dates with error after exhausting retries", async () => {
const { getHistoryByDateAUD } = await loadModule();
buildPastDatesUTCMock.mockReturnValue(["2024-01-05"]);
getHistoricalMock.mockRejectedValue(new Error("boom"));

vi.useFakeTimers();
let result: Awaited<ReturnType<typeof getHistoryByDateAUD>>;
try {
const promise = getHistoryByDateAUD(1, ["USD"]);
await vi.runAllTimersAsync(); // fast-forward retry backoff
result = await promise;
} finally {
vi.useRealTimers();
}

expect(getHistoricalMock).toHaveBeenCalledTimes(3);
expect(result.points).toEqual([{ date: "2024-01-05", error: "fetch_failed" }]);
});
});

describe("toByCurrency", () => {
it("builds per-currency series and skips non-numeric values", async () => {
const { toByCurrency } = await loadModule();
const points: HistoryByDate["points"] = [
{ date: "2024-01-01", USD: 0.7, EUR: 0.65 },
{ date: "2024-01-02", USD: 0.71, EUR: 0.66 },
{ date: "2024-01-03", error: "fetch_failed" },
];

const series = toByCurrency(points, ["USD", "EUR"]);

expect(series.USD).toEqual([
{ date: "2024-01-01", value: 0.7 },
{ date: "2024-01-02", value: 0.71 },
]);
expect(series.EUR).toEqual([
{ date: "2024-01-01", value: 0.65 },
{ date: "2024-01-02", value: 0.66 },
]);
});
});
});
20 changes: 20 additions & 0 deletions lib/server/oxr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,24 @@ export async function getLatest(): Promise<RatesResponse> {



/** * Shape of Open Exchange Rates historical payload.
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment has an extra space after the opening /**. It should be /** followed by a single space.

Suggested change
/** * Shape of Open Exchange Rates historical payload.
/** Shape of Open Exchange Rates historical payload.

Copilot uses AI. Check for mistakes.
*/
export type HistoricalResponse = {
timestamp: number; // Unix timestamp
base: "USD";
rates: Record<string, number>;
disclaimer?: string;
license?: string;
};



/** * Fetches historical exchange rates for a specific date from Open Exchange Rates.
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment has an extra space after the opening /**. It should be /** followed by a single space.

Suggested change
/** * Fetches historical exchange rates for a specific date from Open Exchange Rates.
/**
* Fetches historical exchange rates for a specific date from Open Exchange Rates.

Copilot uses AI. Check for mistakes.
*
* @param dateISO - Date string in `YYYY-MM-DD` format (UTC).
* @returns A promise resolving to the `HistoricalResponse` payload.
*/
export async function getHistorical(dateISO: string): Promise<HistoricalResponse> {
// dateISO = YYYY-MM-DD(UTC)
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment contains Chinese/full-width parentheses '(' and ')' which should be replaced with standard ASCII parentheses '(' and ')'.

Suggested change
// dateISO = YYYY-MM-DDUTC
// dateISO = YYYY-MM-DD (UTC)

Copilot uses AI. Check for mistakes.
return oxrFetch<HistoricalResponse>(`/historical/${dateISO}.json`);
}
2 changes: 1 addition & 1 deletion lib/server/oxr_convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type ConvertResult = {
/**
* Default currency codes returned when no targets are provided.
*/
export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"] as const;
export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"];
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing as const makes this array mutable, which could lead to unintended modifications at runtime. If immutability is no longer required, consider adding a comment explaining why this change was necessary, or use Object.freeze() to prevent mutation while maintaining assignability.

Suggested change
export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"];
export const DEFAULTS_CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY"] as const;

Copilot uses AI. Check for mistakes.

/**
* Re-bases exchange rates from USD to AUD (1 AUD -> target).
Expand Down
128 changes: 128 additions & 0 deletions lib/server/oxr_history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import "server-only";
import { getHistorical, type HistoricalResponse } from "./oxr";
import { rebaseToAUD } from "./oxr_convert";
import { buildPastDatesUTC } from "../time_utils";
import { DEFAULTS_CURRENCY } from "./oxr_convert";

export type SeriesPoint = { date: string; value: number };
export type SeriesMap = Record<string, SeriesPoint[]>;
export type HistoryByDate = {
base: "AUD";
startDate: string;
endDate: string;
points: Array<{ date: string; [code: string]: number | string }>;
};

// Controls how many historical requests we fire in parallel.
const WORKER_CONCURRENCY = 4;
// How many times we retry a failing historical request before giving up.
const RETRY_ATTEMPTS = 3;
// Delay between retries to give the upstream API a moment to recover.
const RETRY_DELAY_MS = 100;

// Simple promise-based sleep helper used for retry backoff.
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

/**
* Fetches a historical snapshot with limited retries to cope with transient OXR issues.
*
* @returns Historical payload on success, or an object describing the last error.
*/
async function fetchHistoricalWithRetry(date: string): Promise<HistoricalResponse | { error: unknown }> {
let lastError: unknown;

for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
return await getHistorical(date);
} catch (err) {
lastError = err;
if (attempt === RETRY_ATTEMPTS) break;
await sleep(RETRY_DELAY_MS);
}
}

return { error: lastError };
}

/**
* Converts a USD-based rate map into a single AUD-based row for the requested targets.
*
* @param usdRates - Snapshot returned by OXR for a particular date.
* @param targets - Currency codes to include in the output row.
* @param date - ISO date string used as the row identifier.
*/
export function toAudRow(
usdRates: Record<string, number>,
targets: string[],
date: string
): { date: string; [code: string]: number | string } {
const audRates = rebaseToAUD(usdRates);
const row: Record<string, number | string> = { date };
for (const t of targets) if (audRates[t] != null) row[t] = audRates[t];
return row as { date: string; [code: string]: number | string };
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The type assertion is unnecessary since row is already typed as Record<string, number | string> which is compatible with the return type. The explicit cast can be removed for cleaner code.

Suggested change
return row as { date: string; [code: string]: number | string };
return row;

Copilot uses AI. Check for mistakes.
}



/**
* Builds a history table of AUD-based exchange rates for the given period.
*
* @param days - Number of UTC calendar days to fetch (counting back from today).
* @param targets - Target currencies to include in each row.
*/
export async function getHistoryByDateAUD(days: number, targets: string[]): Promise<HistoryByDate> {
const dates = buildPastDatesUTC(days);
const upperTargets = Array.from(new Set(targets.map(s => s.toUpperCase())));
const concurrency = Math.min(WORKER_CONCURRENCY, dates.length);
const points: (HistoryByDate["points"][number] | undefined)[] = new Array(dates.length);
let index = 0;

// Small worker pool that processes pending dates until exhausted.
const worker = async () => {
while (true) {
const current = index++;
if (current >= dates.length) break;
const date = dates[current];
const result = await fetchHistoricalWithRetry(date);
if (result && "rates" in result) {
points[current] = toAudRow(result.rates, upperTargets, date);
} else {
points[current] = { date, error: "fetch_failed" };
}
}
};

await Promise.all(Array.from({ length: concurrency }, () => worker()));

return {
base: "AUD",
startDate: dates[0],
endDate: dates[dates.length - 1],
points: points.filter(Boolean) as HistoryByDate["points"],
};
}

/**
* Transposes date-based history rows into per-currency time series.
*
* @param points - History rows produced by `getHistoryByDateAUD`.
* @param targets - Currency codes to extract series for; defaults to the standard set.
*/
export function toByCurrency(
points: HistoryByDate["points"],
targets: readonly string[] = DEFAULTS_CURRENCY
): SeriesMap {
const series: SeriesMap = {};
for (const t of targets) series[t] = [];

for (const row of points) {
const d = row.date; // 已是 string
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is in Chinese ('已是 string' means 'already is string'). Comments should be in English for consistency with the rest of the codebase.

Suggested change
const d = row.date; // 已是 string
const d = row.date; // already a string

Copilot uses AI. Check for mistakes.
for (const t of targets) {
const v = row[t];
if (typeof v === "number") series[t].push({ date: d, value: v });
}
}
return series;
}
18 changes: 18 additions & 0 deletions lib/time_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@


export function toISODateUTC(d: Date) {
return d.toISOString().slice(0, 10);
}


export function buildPastDatesUTC(days: number): string[] {
const now = new Date();
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const out: string[] = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(todayUTC);
d.setUTCDate(d.getUTCDate() - i);
out.push(toISODateUTC(d));
}
return out;
}
Loading