diff --git a/package.json b/package.json index d03438f..0af1caf 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,28 @@ { - "name": "debug-api", - "module": "index.ts", - "type": "module", - "devDependencies": { - "@biomejs/biome": "^1.8.3", - "@types/bun": "latest", - "@types/react": "18.3.4", - "next": "^14.2.5", - "redaxios": "^0.5.1" - }, - "peerDependencies": { - "typescript": "^5.0.0" + "name": "fake-algora", + "version": "1.0.0", + "description": "A fake/mock Algora API server for testing bounty integrations", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "winterspec": "^0.0.86", - "zod": "^3.23.8", - "zustand": "^4.5.5", - "zustand-hoist": "^2.0.1" + "express": "^4.18.2", + "zod": "^3.22.4", + "uuid": "^9.0.0" }, - "scripts": { - "start": "bun run dev", - "dev": "winterspec dev", - "build": "winterspec bundle -o dist/bundle.js", - "next:dev": "next dev" + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.5", + "@types/uuid": "^9.0.7", + "ts-node-dev": "^2.0.0", + "typescript": "^5.3.3", + "vitest": "^1.2.2", + "supertest": "^6.3.4", + "@types/supertest": "^6.0.2" } } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e309eb9 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,27 @@ +import express from "express" +import { bountiesRouter } from "./routes/bounties" +import { paymentsRouter } from "./routes/payments" +import { errorHandler, notFound } from "./middleware/errorHandler" + +export function createApp(): express.Application { + const app = express() + + // ─── Global middleware ────────────────────────────────────────────────────── + app.use(express.json()) + app.use(express.urlencoded({ extended: true })) + + // ─── Health check ─────────────────────────────────────────────────────────── + app.get("/health", (_req, res) => { + res.json({ status: "ok", service: "fake-algora" }) + }) + + // ─── API routes ───────────────────────────────────────────────────────────── + app.use("/api/bounties", bountiesRouter) + app.use("/api/payments", paymentsRouter) + + // ─── Error handling ───────────────────────────────────────────────────────── + app.use(notFound) + app.use(errorHandler) + + return app +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..672bebd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,15 @@ +import { createApp } from "./app" + +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000 + +const app = createApp() + +app.listen(PORT, () => { + console.log(`🚀 fake-algora server running on http://localhost:${PORT}`) + console.log(` Health: GET /health`) + console.log(` Bounties: GET|POST /api/bounties`) + console.log(` GET /api/bounties/:id`) + console.log(` Payments: POST /api/payments`) + console.log(` GET /api/payments`) + console.log(` GET /api/payments/:id`) +}) diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts new file mode 100644 index 0000000..41f15ac --- /dev/null +++ b/src/middleware/errorHandler.ts @@ -0,0 +1,44 @@ +import type { Request, Response, NextFunction } from "express" +import type { ApiErrorResponse } from "../types" + +export interface AppError extends Error { + statusCode?: number + code?: string +} + +export function errorHandler( + err: AppError, + _req: Request, + res: Response, + _next: NextFunction +): void { + const statusCode = err.statusCode ?? 500 + const body: ApiErrorResponse = { + error: { + message: err.message ?? "Internal server error", + code: err.code ?? "INTERNAL_ERROR", + }, + } + res.status(statusCode).json(body) +} + +export function notFound(_req: Request, res: Response): void { + const body: ApiErrorResponse = { + error: { + message: "Route not found", + code: "NOT_FOUND", + }, + } + res.status(404).json(body) +} + +export function createError( + message: string, + statusCode: number, + code: string +): AppError { + const err: AppError = new Error(message) + err.statusCode = statusCode + err.code = code + return err +} diff --git a/src/routes/bounties.test.ts b/src/routes/bounties.test.ts new file mode 100644 index 0000000..cf0eb4d --- /dev/null +++ b/src/routes/bounties.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { app } from "../app"; +import { store } from "../store"; + +beforeEach(() => { + store.reset(); +}); + +// ── /bounties ────────────────────────────────────────────────────────────── + +describe("GET /bounties", () => { + it("returns the seeded bounties", async () => { + const res = await request(app).get("/bounties"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThanOrEqual(3); + }); + + it("includes expected fields on each bounty", async () => { + const res = await request(app).get("/bounties"); + const bounty = res.body.data[0]; + expect(bounty).toHaveProperty("id"); + expect(bounty).toHaveProperty("title"); + expect(bounty).toHaveProperty("amount_usd"); + expect(bounty).toHaveProperty("currency"); + expect(bounty).toHaveProperty("status"); + }); +}); + +describe("GET /bounties/:id", () => { + it("returns a single bounty by id", async () => { + const listRes = await request(app).get("/bounties"); + const { id } = listRes.body.data[0]; + + const res = await request(app).get(`/bounties/${id}`); + expect(res.status).toBe(200); + expect(res.body.data.id).toBe(id); + }); + + it("returns 404 for an unknown id", async () => { + const res = await request(app).get("/bounties/does-not-exist"); + expect(res.status).toBe(404); + expect(res.body).toHaveProperty("error"); + }); +}); + +describe("POST /bounties", () => { + it("creates a new bounty and returns 201", async () => { + const payload = { + title: "New bounty", + description: "A fresh bounty created in tests", + amount_usd: 200, + currency: "USD", + }; + const res = await request(app).post("/bounties").send(payload); + expect(res.status).toBe(201); + expect(res.body.data).toMatchObject({ + title: payload.title, + description: payload.description, + amount_usd: payload.amount_usd, + currency: payload.currency, + status: "open", + }); + expect(res.body.data).toHaveProperty("id"); + expect(res.body.data).toHaveProperty("created_at"); + }); + + it("returns 400 when required fields are missing", async () => { + const res = await request(app).post("/bounties").send({ title: "Only title" }); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error"); + expect(res.body).toHaveProperty("details"); + }); + + it("returns 400 when amount_usd is not a positive number", async () => { + const res = await request(app).post("/bounties").send({ + title: "Bad amount", + description: "desc", + amount_usd: -10, + currency: "USD", + }); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error"); + }); + + it("persists the new bounty so it appears in GET /bounties", async () => { + await request(app).post("/bounties").send({ + title: "Persisted bounty", + description: "Should show up in list", + amount_usd: 50, + currency: "USD", + }); + + const listRes = await request(app).get("/bounties"); + const titles = listRes.body.data.map((b: { title: string }) => b.title); + expect(titles).toContain("Persisted bounty"); + }); +}); diff --git a/src/routes/bounties.ts b/src/routes/bounties.ts new file mode 100644 index 0000000..fa16826 --- /dev/null +++ b/src/routes/bounties.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { z } from "zod"; +import { store } from "../store"; + +export const bountiesRouter = Router(); + +const CreateBountySchema = z.object({ + title: z.string().min(1), + description: z.string().min(1), + amount_usd: z.number().positive(), + currency: z.string().min(1).default("USD"), + recipient_username: z.string().optional(), +}); + +/** GET /bounties — list all bounties */ +bountiesRouter.get("/", (_req, res) => { + res.json({ data: store.listBounties() }); +}); + +/** GET /bounties/:id — get a single bounty */ +bountiesRouter.get("/:id", (req, res) => { + const bounty = store.getBounty(req.params.id); + if (!bounty) { + return res.status(404).json({ error: "Bounty not found" }); + } + return res.json({ data: bounty }); +}); + +/** POST /bounties — create a new bounty */ +bountiesRouter.post("/", (req, res) => { + const result = CreateBountySchema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ + error: "Invalid request body", + details: result.error.format(), + }); + } + const bounty = store.createBounty(result.data); + return res.status(201).json({ data: bounty }); +}); diff --git a/src/routes/payments.test.ts b/src/routes/payments.test.ts new file mode 100644 index 0000000..a56c3a0 --- /dev/null +++ b/src/routes/payments.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { app } from "../app"; +import { store } from "../store"; + +beforeEach(() => { + store.reset(); +}); + +// ── helpers ──────────────────────────────────────────────────────────────── + +/** Create a bounty via the API and return it. */ +async function createBounty(overrides: Partial<{ + title: string; + description: string; + amount_usd: number; + currency: string; + recipient_username: string; +}> = {}) { + const payload = { + title: "Test bounty", + description: "A bounty for testing payments", + amount_usd: 100, + currency: "USD", + ...overrides, + }; + const res = await request(app).post("/bounties").send(payload); + expect(res.status).toBe(201); + return res.body.data; +} + +// ── GET /payments ────────────────────────────────────────────────────────── + +describe("GET /payments", () => { + it("returns an empty list when no payments exist", async () => { + const res = await request(app).get("/payments"); + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it("lists payments after one has been sent", async () => { + const bounty = await createBounty({ recipient_username: "alice" }); + await request(app).post("/payments/send").send({ bounty_id: bounty.id }); + + const res = await request(app).get("/payments"); + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); +}); + +// ── GET /payments/:id ────────────────────────────────────────────────────── + +describe("GET /payments/:id", () => { + it("returns a payment by id", async () => { + const bounty = await createBounty({ recipient_username: "carol" }); + const sendRes = await request(app) + .post("/payments/send") + .send({ bounty_id: bounty.id }); + const paymentId: string = sendRes.body.data.id; + + const res = await request(app).get(`/payments/${paymentId}`); + expect(res.status).toBe(200); + expect(res.body.data.id).toBe(paymentId); + }); + + it("returns 404 for an unknown payment id", async () => { + const res = await request(app).get("/payments/does-not-exist"); + expect(res.status).toBe(404); + expect(res.body).toHaveProperty("error"); + }); +}); + +// ── POST /payments/send ──────────────────────────────────────────────────── + +describe("POST /payments/send", () => { + it("creates a payment and marks the bounty as paid", async () => { + const bounty = await createBounty({ recipient_username: "dave" }); + + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: bounty.id }); + + expect(res.status).toBe(201); + expect(res.body.data).toMatchObject({ + bounty_id: bounty.id, + amount_usd: bounty.amount_usd, + currency: bounty.currency, + recipient_username: "dave", + }); + expect(res.body.data).toHaveProperty("id"); + expect(res.body.data).toHaveProperty("created_at"); + + // The bounty should now be marked as paid + const bountyRes = await request(app).get(`/bounties/${bounty.id}`); + expect(bountyRes.body.data.status).toBe("paid"); + }); + + it("derives amount/currency from the bounty (not the request body)", async () => { + const bounty = await createBounty({ + recipient_username: "eve", + amount_usd: 250, + currency: "USD", + }); + + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: bounty.id }); + + expect(res.status).toBe(201); + expect(res.body.data.amount_usd).toBe(250); + expect(res.body.data.currency).toBe("USD"); + expect(res.body.data.recipient_username).toBe("eve"); + }); + + it("returns 400 when bounty_id is missing", async () => { + const res = await request(app).post("/payments/send").send({}); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error"); + expect(res.body).toHaveProperty("details"); + }); + + it("returns 404 when the bounty does not exist", async () => { + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "nonexistent-id" }); + expect(res.status).toBe(404); + expect(res.body).toHaveProperty("error"); + }); + + it("returns 422 when the bounty has no recipient assigned", async () => { + // Create a bounty WITHOUT a recipient_username + const bounty = await createBounty(); + + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: bounty.id }); + + expect(res.status).toBe(422); + expect(res.body).toHaveProperty("error"); + }); + + it("returns 409 when the bounty has already been paid (double-payment guard)", async () => { + const bounty = await createBounty({ recipient_username: "frank" }); + + // First payment — should succeed + const first = await request(app) + .post("/payments/send") + .send({ bounty_id: bounty.id }); + expect(first.status).toBe(201); + + // Second payment — should be rejected + const second = await request(app) + .post("/payments/send") + .send({ bounty_id: bounty.id }); + expect(second.status).toBe(409); + expect(second.body).toHaveProperty("error"); + }); + + it("also rejects payment for a seeded bounty that is already paid", async () => { + // seed data includes bounty-seed-3 with status "paid" + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "bounty-seed-3" }); + expect(res.status).toBe(409); + }); +}); diff --git a/src/routes/payments.ts b/src/routes/payments.ts new file mode 100644 index 0000000..e9c82c2 --- /dev/null +++ b/src/routes/payments.ts @@ -0,0 +1,78 @@ +import { Router } from "express"; +import { z } from "zod"; +import { store } from "../store"; + +export const paymentsRouter = Router(); + +/** GET /payments — list all payments */ +paymentsRouter.get("/", (_req, res) => { + res.json({ data: store.listPayments() }); +}); + +/** GET /payments/:id — get a single payment */ +paymentsRouter.get("/:id", (req, res) => { + const payment = store.getPayment(req.params.id); + if (!payment) { + return res.status(404).json({ error: "Payment not found" }); + } + return res.json({ data: payment }); +}); + +const SendPaymentSchema = z.object({ + bounty_id: z.string().min(1), +}); + +/** + * POST /payments/send + * + * Sends a payment for the given bounty. All payment fields + * (amount_usd, currency, recipient_username) are derived from the + * bounty record to prevent inconsistent data. + * + * Rules: + * - The bounty must exist. + * - The bounty must have a recipient_username assigned. + * - The bounty must not already be paid (idempotency guard). + * - On success the bounty status is updated to "paid". + */ +paymentsRouter.post("/send", (req, res) => { + const result = SendPaymentSchema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ + error: "Invalid request body", + details: result.error.format(), + }); + } + + const { bounty_id } = result.data; + + const bounty = store.getBounty(bounty_id); + if (!bounty) { + return res.status(404).json({ error: "Bounty not found" }); + } + + if (!bounty.recipient_username) { + return res + .status(422) + .json({ error: "Bounty has no recipient assigned; cannot send payment" }); + } + + if (bounty.status === "paid") { + return res + .status(409) + .json({ error: "Payment already sent for this bounty" }); + } + + // Derive all monetary / recipient fields directly from the bounty + const payment = store.createPayment({ + bounty_id, + amount_usd: bounty.amount_usd, + currency: bounty.currency, + recipient_username: bounty.recipient_username, + }); + + // Mark the bounty as paid + store.updateBountyStatus(bounty_id, "paid"); + + return res.status(201).json({ data: payment }); +}); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..1266a4d --- /dev/null +++ b/src/store.ts @@ -0,0 +1,138 @@ +import { randomUUID } from "crypto"; +import type { Bounty, BountyStatus, Payment } from "./types"; + +interface CreateBountyInput { + title: string; + description: string; + amount_usd: number; + currency: string; + recipient_username?: string; +} + +interface CreatePaymentInput { + bounty_id: string; + amount_usd: number; + currency: string; + recipient_username: string; +} + +class Store { + private bounties: Map = new Map(); + private payments: Map = new Map(); + + constructor() { + this.seed(); + } + + private seed() { + const now = new Date().toISOString(); + const seedBounties: Bounty[] = [ + { + id: "bounty-seed-1", + title: "Fix login bug", + description: "The login page throws a 500 on bad credentials.", + amount_usd: 50, + currency: "USD", + status: "open", + created_at: now, + updated_at: now, + }, + { + id: "bounty-seed-2", + title: "Add dark mode", + description: "Implement a system-level dark mode toggle.", + amount_usd: 100, + currency: "USD", + status: "claimed", + recipient_username: "alice", + created_at: now, + updated_at: now, + }, + { + id: "bounty-seed-3", + title: "Write API docs", + description: "Document all REST endpoints with OpenAPI.", + amount_usd: 75, + currency: "USD", + status: "paid", + recipient_username: "bob", + created_at: now, + updated_at: now, + }, + ]; + for (const b of seedBounties) { + this.bounties.set(b.id, b); + } + } + + // ── Bounty helpers ───────────────────────────────────────────────────────── + + listBounties(): Bounty[] { + return Array.from(this.bounties.values()); + } + + getBounty(id: string): Bounty | undefined { + return this.bounties.get(id); + } + + createBounty(input: CreateBountyInput): Bounty { + const now = new Date().toISOString(); + const bounty: Bounty = { + id: randomUUID(), + title: input.title, + description: input.description, + amount_usd: input.amount_usd, + currency: input.currency, + status: "open", + recipient_username: input.recipient_username, + created_at: now, + updated_at: now, + }; + this.bounties.set(bounty.id, bounty); + return bounty; + } + + updateBountyStatus(id: string, status: BountyStatus): Bounty | undefined { + const bounty = this.bounties.get(id); + if (!bounty) return undefined; + const updated: Bounty = { + ...bounty, + status, + updated_at: new Date().toISOString(), + }; + this.bounties.set(id, updated); + return updated; + } + + // ── Payment helpers ──────────────────────────────────────────────────────── + + listPayments(): Payment[] { + return Array.from(this.payments.values()); + } + + getPayment(id: string): Payment | undefined { + return this.payments.get(id); + } + + createPayment(input: CreatePaymentInput): Payment { + const payment: Payment = { + id: randomUUID(), + bounty_id: input.bounty_id, + amount_usd: input.amount_usd, + currency: input.currency, + recipient_username: input.recipient_username, + created_at: new Date().toISOString(), + }; + this.payments.set(payment.id, payment); + return payment; + } + + /** Reset all state — useful in tests */ + reset() { + this.bounties.clear(); + this.payments.clear(); + this.seed(); + } +} + +export const store = new Store(); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0bb1274 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,40 @@ +export type BountyStatus = "open" | "claimed" | "paid"; + +export interface Bounty { + id: string; + title: string; + description: string; + amount_usd: number; + currency: string; + status: BountyStatus; + recipient_username?: string; + created_at: string; + updated_at: string; +} + +export interface Payment { + id: string; + bounty_id: string; + amount_usd: number; + currency: string; + recipient_username: string; + created_at: string; +} + +export interface ApiError { + error: string; + details?: string | Record; +} + +export interface ApiSuccess { + data: T; +} + +export type CreateBountyInput = Pick< + Bounty, + "title" | "description" | "amount_usd" | "currency" +> & { recipient_username?: string }; + +export interface SendPaymentInput { + bounty_id: string; +} diff --git a/tsconfig.json b/tsconfig.json index cbcc783..bb5605d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,39 +1,19 @@ { "compilerOptions": { - // Enable latest features - "lib": [ - "ESNext", - "DOM" - ], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "preserve", - "allowJs": true, - "baseUrl": ".", - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - // Best practices + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, - "incremental": true, "esModuleInterop": true, - "resolveJsonModule": true + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx" - ], - "exclude": [ - "node_modules" - ] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] }