diff --git a/apps/web/app/api/[[...slug]]/route.ts b/apps/web/app/api/[[...slug]]/route.ts index 92bdbbb..3a3eb25 100644 --- a/apps/web/app/api/[[...slug]]/route.ts +++ b/apps/web/app/api/[[...slug]]/route.ts @@ -2,37 +2,37 @@ import { NextResponse } from "next/server"; export const runtime = "edge"; -export async function GET() { +function jsonNotFound() { return NextResponse.json( { error: "Not found. See /api-docs for API documentation." }, { status: 404 }, ); } +export async function GET() { + return jsonNotFound(); +} + export async function POST() { - return NextResponse.json( - { error: "Not found. See /api-docs for API documentation." }, - { status: 404 }, - ); + return jsonNotFound(); } export async function PUT() { - return NextResponse.json( - { error: "Not found. See /api-docs for API documentation." }, - { status: 404 }, - ); + return jsonNotFound(); } export async function PATCH() { - return NextResponse.json( - { error: "Not found. See /api-docs for API documentation." }, - { status: 404 }, - ); + return jsonNotFound(); } export async function DELETE() { - return NextResponse.json( - { error: "Not found. See /api-docs for API documentation." }, - { status: 404 }, - ); + return jsonNotFound(); +} + +export async function OPTIONS() { + return jsonNotFound(); +} + +export async function HEAD() { + return jsonNotFound(); } diff --git a/apps/web/app/api/route.ts b/apps/web/app/api/route.ts new file mode 100644 index 0000000..3a3eb25 --- /dev/null +++ b/apps/web/app/api/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +function jsonNotFound() { + return NextResponse.json( + { error: "Not found. See /api-docs for API documentation." }, + { status: 404 }, + ); +} + +export async function GET() { + return jsonNotFound(); +} + +export async function POST() { + return jsonNotFound(); +} + +export async function PUT() { + return jsonNotFound(); +} + +export async function PATCH() { + return jsonNotFound(); +} + +export async function DELETE() { + return jsonNotFound(); +} + +export async function OPTIONS() { + return jsonNotFound(); +} + +export async function HEAD() { + return jsonNotFound(); +} diff --git a/apps/web/lib/api-notFound.test.ts b/apps/web/lib/api-notFound.test.ts new file mode 100644 index 0000000..a470be7 --- /dev/null +++ b/apps/web/lib/api-notFound.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } from "../app/api/route"; + +describe("/api root route handler (JSON 404)", () => { + it("returns JSON 404 response for GET", async () => { + const res = await GET(); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body).toEqual({ error: "Not found. See /api-docs for API documentation." }); + }); + + it("returns JSON 404 response for POST, PUT, PATCH, DELETE, OPTIONS, HEAD", async () => { + for (const handler of [POST, PUT, PATCH, DELETE, OPTIONS, HEAD]) { + const res = await handler(); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body).toEqual({ error: "Not found. See /api-docs for API documentation." }); + } + }); +}); diff --git a/apps/web/lib/coinpay.test.ts b/apps/web/lib/coinpay.test.ts new file mode 100644 index 0000000..b6707d7 --- /dev/null +++ b/apps/web/lib/coinpay.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +vi.mock("./env", () => ({ + env: { + coinpayConfigured: true, + coinpay: { + baseUrl: "https://api.coinpay.test", + apiKey: "test_api_key", + businessId: "test_biz", + }, + }, +})); + +import { + createCoinpayPayment, + getCoinpayPayment, + getCoinpayPaymentDetailed, + isPaymentPaid, +} from "./coinpay"; + +describe("isPaymentPaid", () => { + it("returns true for confirmed, forwarded, forwarding, completed, or paid", () => { + expect(isPaymentPaid("confirmed")).toBe(true); + expect(isPaymentPaid("forwarded")).toBe(true); + expect(isPaymentPaid("forwarding")).toBe(true); + expect(isPaymentPaid("completed")).toBe(true); + expect(isPaymentPaid("paid")).toBe(true); + }); + + it("returns false for pending or undefined", () => { + expect(isPaymentPaid("pending")).toBe(false); + expect(isPaymentPaid("failed")).toBe(false); + expect(isPaymentPaid(undefined)).toBe(false); + }); +}); + +describe("getCoinpayPaymentDetailed error handling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns error result when fetch fails with non-2xx status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + status: 502, + } as Response); + + const res = await getCoinpayPaymentDetailed("pay_123"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toContain("HTTP 502"); + } + }); + + it("returns error result when fetch throws an exception", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("Network timeout")); + + const res = await getCoinpayPaymentDetailed("pay_123"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toBe("Network timeout"); + } + }); + + it("returns ok result with payment data when fetch succeeds", async () => { + const mockPayment = { + id: "pay_123", + amount: 10, + currency: "USD", + blockchain: "solana", + status: "confirmed", + }; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, payment: mockPayment }), + } as Response); + + const res = await getCoinpayPaymentDetailed("pay_123"); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.payment).toEqual(mockPayment); + } + }); + + it("getCoinpayPayment backward compatibility returns null on failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + const res = await getCoinpayPayment("pay_123"); + expect(res).toBeNull(); + }); +}); diff --git a/apps/web/lib/coinpay.ts b/apps/web/lib/coinpay.ts index c0bef16..cbe13e5 100644 --- a/apps/web/lib/coinpay.ts +++ b/apps/web/lib/coinpay.ts @@ -51,25 +51,45 @@ export async function createCoinpayPayment(args: { } } -/** Fetch current payment status from CoinPay. */ -export async function getCoinpayPayment(id: string): Promise { - if (!env.coinpayConfigured) return null; +export type CoinpayFetchResult = + | { ok: true; payment: CoinpayPayment } + | { ok: false; error: string }; + +/** Fetch current payment status from CoinPay with detailed error reporting. */ +export async function getCoinpayPaymentDetailed(id: string): Promise { + if (!env.coinpayConfigured) { + return { ok: false, error: "CoinPay payments are not configured on this server." }; + } try { const res = await fetch(`${env.coinpay.baseUrl}/payments/${id}`, { headers: { Authorization: `Bearer ${env.coinpay.apiKey}` }, }); if (!res.ok) { - console.error(`[coinpay] status fetch failed for ${id}: HTTP ${res.status}`); - return null; + const errText = `CoinPay status fetch failed for payment ${id}: HTTP ${res.status}`; + console.error(`[coinpay] ${errText}`); + return { ok: false, error: errText }; } const data = (await res.json()) as { payment?: CoinpayPayment } & CoinpayPayment; - return data.payment ?? (data.id ? data : null); + const payment = data.payment ?? (data.id ? data : null); + if (!payment) { + const payloadErr = `CoinPay payment ${id} returned invalid or empty payment payload.`; + console.error(`[coinpay] ${payloadErr}`); + return { ok: false, error: payloadErr }; + } + return { ok: true, payment }; } catch (err) { - console.error(`[coinpay] status fetch error for ${id}:`, (err as Error).message); - return null; + const msg = (err as Error).message; + console.error(`[coinpay] status fetch error for ${id}:`, msg); + return { ok: false, error: msg }; } } +/** Fetch current payment status from CoinPay (returns null on failure/unconfigured). */ +export async function getCoinpayPayment(id: string): Promise { + const result = await getCoinpayPaymentDetailed(id); + return result.ok ? result.payment : null; +} + /** CoinPay marks a payment done via these statuses. */ export function isPaymentPaid(status: string | undefined): boolean { return ( diff --git a/apps/web/lib/rate-limit.test.ts b/apps/web/lib/rate-limit.test.ts new file mode 100644 index 0000000..460c8fb --- /dev/null +++ b/apps/web/lib/rate-limit.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearRateLimit, rateLimit } from "./rate-limit"; + +describe("rateLimit in-memory fixed-window bucket logic", () => { + beforeEach(() => { + clearRateLimit(); + }); + + it("allows initial request within limit and calculates remaining count", () => { + const res = rateLimit("ip:127.0.0.1", 5, 60_000); + expect(res.ok).toBe(true); + expect(res.remaining).toBe(4); + expect(res.resetAt).toBeGreaterThan(Date.now()); + }); + + it("blocks requests when rate limit threshold is exceeded", () => { + const key = "ip:192.168.1.1"; + for (let i = 0; i < 3; i++) { + rateLimit(key, 3, 60_000); + } + const blocked = rateLimit(key, 3, 60_000); + expect(blocked.ok).toBe(false); + expect(blocked.remaining).toBe(0); + }); + + it("resets bucket after clearRateLimit is called", () => { + const key = "user:123"; + rateLimit(key, 1, 60_000); + const blocked = rateLimit(key, 1, 60_000); + expect(blocked.ok).toBe(false); + + clearRateLimit(key); + const res = rateLimit(key, 1, 60_000); + expect(res.ok).toBe(true); + expect(res.remaining).toBe(0); + }); +}); diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts index 3397bbf..93e1c7d 100644 --- a/apps/web/lib/rate-limit.ts +++ b/apps/web/lib/rate-limit.ts @@ -22,6 +22,15 @@ export function rateLimit( return { ok, remaining: Math.max(0, limit - b.count), resetAt: b.resetAt }; } +/** Reset rate limit buckets for a specific key or all keys. Useful for testing and admin resets. */ +export function clearRateLimit(key?: string): void { + if (key) { + buckets.delete(key); + } else { + buckets.clear(); + } +} + // Occasional cleanup to bound memory. if (typeof setInterval !== "undefined") { setInterval(() => {