Skip to content
Closed
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
34 changes: 17 additions & 17 deletions apps/web/app/api/[[...slug]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
38 changes: 38 additions & 0 deletions apps/web/app/api/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
20 changes: 20 additions & 0 deletions apps/web/lib/api-notFound.test.ts
Original file line number Diff line number Diff line change
@@ -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." });
}
});
});
96 changes: 96 additions & 0 deletions apps/web/lib/coinpay.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 28 additions & 8 deletions apps/web/lib/coinpay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,45 @@ export async function createCoinpayPayment(args: {
}
}

/** Fetch current payment status from CoinPay. */
export async function getCoinpayPayment(id: string): Promise<CoinpayPayment | null> {
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<CoinpayFetchResult> {
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<CoinpayPayment | null> {
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 (
Expand Down
37 changes: 37 additions & 0 deletions apps/web/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 9 additions & 0 deletions apps/web/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down