From 415a8694d18f762b8bbc8ae442592f535b4d07df Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:42:37 +0530 Subject: [PATCH 01/30] chore: add package.json --- package.json | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index d03438f..3df5955 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": "0.1.0", + "description": "A fake/mock Algora API server for testing bounty payment flows", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "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" } } From 82ed15bf5e0711605352996d35cbc82fec495051 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:42:42 +0530 Subject: [PATCH 02/30] chore: add tsconfig.json --- tsconfig.json | 46 +++++++++++++--------------------------------- 1 file changed, 13 insertions(+), 33 deletions(-) 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"] } From 7e66ec327ebe38512d86e61366fcaf2b82402cab Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:42:49 +0530 Subject: [PATCH 03/30] feat: add shared types --- src/types.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/types.ts diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c527ffc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,47 @@ +export type BountyStatus = + | "open" + | "in_progress" + | "completed" + | "payment_pending" + | "paid"; + +export interface Bounty { + id: string; + issue_number: number; + repo: string; + amount_usd: number; + currency: string; + status: BountyStatus; + recipient_username: string | null; + created_at: string; + updated_at: string; +} + +export interface Payment { + id: string; + bounty_id: string; + recipient_username: string; + amount_usd: number; + currency: string; + status: "pending" | "processing" | "completed" | "failed"; + transaction_id: string | null; + created_at: string; + updated_at: string; +} + +export interface SendPaymentRequest { + bounty_id: string; + recipient_username: string; + amount_usd: number; + currency?: string; +} + +export interface SendPaymentResponse { + payment: Payment; + message: string; +} + +export interface ApiError { + error: string; + details?: string; +} From 15d1fe5873f477619da2fc3192d40f5182e47dce Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:43:02 +0530 Subject: [PATCH 04/30] feat: add in-memory store with seed data --- src/store.ts | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/store.ts diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..056aa41 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,130 @@ +/** + * In-memory store for bounties and payments. + * Seeded with a handful of example records for easy local testing. + */ + +import { v4 as uuidv4 } from "uuid"; +import type { Bounty, Payment } from "./types"; + +const now = () => new Date().toISOString(); + +// --------------------------------------------------------------------------- +// Seed data +// --------------------------------------------------------------------------- +const seedBounties: Bounty[] = [ + { + id: "bounty-001", + issue_number: 1, + repo: "tscircuit/fake-algora", + amount_usd: 10, + currency: "USD", + status: "open", + recipient_username: null, + created_at: now(), + updated_at: now(), + }, + { + id: "bounty-002", + issue_number: 42, + repo: "tscircuit/core", + amount_usd: 50, + currency: "USD", + status: "in_progress", + recipient_username: "octocat", + created_at: now(), + updated_at: now(), + }, +]; + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- +export class Store { + private bounties: Map = new Map(); + private payments: Map = new Map(); + + constructor(seed = true) { + if (seed) { + for (const b of seedBounties) { + this.bounties.set(b.id, { ...b }); + } + } + } + + // ------ Bounties ---------------------------------------------------------- + + listBounties(): Bounty[] { + return Array.from(this.bounties.values()); + } + + getBounty(id: string): Bounty | undefined { + return this.bounties.get(id); + } + + createBounty( + data: Omit + ): Bounty { + const bounty: Bounty = { + ...data, + id: uuidv4(), + created_at: now(), + updated_at: now(), + }; + this.bounties.set(bounty.id, bounty); + return bounty; + } + + updateBounty(id: string, patch: Partial): Bounty | undefined { + const existing = this.bounties.get(id); + if (!existing) return undefined; + const updated: Bounty = { ...existing, ...patch, updated_at: now() }; + this.bounties.set(id, updated); + return updated; + } + + // ------ Payments ---------------------------------------------------------- + + listPayments(): Payment[] { + return Array.from(this.payments.values()); + } + + getPayment(id: string): Payment | undefined { + return this.payments.get(id); + } + + getPaymentsByBounty(bountyId: string): Payment[] { + return Array.from(this.payments.values()).filter( + (p) => p.bounty_id === bountyId + ); + } + + createPayment( + data: Omit + ): Payment { + const payment: Payment = { + ...data, + id: uuidv4(), + created_at: now(), + updated_at: now(), + }; + this.payments.set(payment.id, payment); + return payment; + } + + updatePayment(id: string, patch: Partial): Payment | undefined { + const existing = this.payments.get(id); + if (!existing) return undefined; + const updated: Payment = { ...existing, ...patch, updated_at: now() }; + this.payments.set(id, updated); + return updated; + } + + /** Reset to empty (useful in tests) */ + reset() { + this.bounties.clear(); + this.payments.clear(); + } +} + +/** Singleton store used by the server */ +export const store = new Store(); From ebafa7428c4d109505b8291e28c1ffdd5e2ed450 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:43:10 +0530 Subject: [PATCH 05/30] feat: add bounties router --- src/routes/bounties.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/routes/bounties.ts diff --git a/src/routes/bounties.ts b/src/routes/bounties.ts new file mode 100644 index 0000000..f0ea673 --- /dev/null +++ b/src/routes/bounties.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { z } from "zod"; +import { store } from "../store"; + +const router = Router(); + +// GET /bounties — list all bounties +router.get("/", (_req, res) => { + res.json({ bounties: store.listBounties() }); +}); + +// GET /bounties/:id — get a single bounty +router.get("/:id", (req, res) => { + const bounty = store.getBounty(req.params.id); + if (!bounty) { + return res.status(404).json({ error: "Bounty not found" }); + } + res.json({ bounty }); +}); + +// POST /bounties — create a new bounty +const CreateBountySchema = z.object({ + issue_number: z.number().int().positive(), + repo: z.string().min(1), + amount_usd: z.number().positive(), + currency: z.string().default("USD"), + recipient_username: z.string().nullable().default(null), + status: z + .enum(["open", "in_progress", "completed", "payment_pending", "paid"]) + .default("open"), +}); + +router.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); + res.status(201).json({ bounty }); +}); + +export default router; From bc43957f4c548cb0713afe0c26d0c36616207eb0 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:43:21 +0530 Subject: [PATCH 06/30] feat: add payments router with send endpoint --- src/routes/payments.ts | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/routes/payments.ts diff --git a/src/routes/payments.ts b/src/routes/payments.ts new file mode 100644 index 0000000..c1c0913 --- /dev/null +++ b/src/routes/payments.ts @@ -0,0 +1,87 @@ +import { Router } from "express"; +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; +import { store } from "../store"; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +const SendPaymentSchema = z.object({ + bounty_id: z.string().min(1, "bounty_id is required"), + recipient_username: z.string().min(1, "recipient_username is required"), + amount_usd: z.number().positive("amount_usd must be positive"), + currency: z.string().default("USD"), +}); + +// --------------------------------------------------------------------------- +// GET /payments — list all payments +// --------------------------------------------------------------------------- +router.get("/", (_req, res) => { + res.json({ payments: store.listPayments() }); +}); + +// --------------------------------------------------------------------------- +// GET /payments/:id — get a single payment +// --------------------------------------------------------------------------- +router.get("/:id", (req, res) => { + const payment = store.getPayment(req.params.id); + if (!payment) { + return res.status(404).json({ error: "Payment not found" }); + } + res.json({ payment }); +}); + +// --------------------------------------------------------------------------- +// POST /payments/send — send a payment for a bounty +// --------------------------------------------------------------------------- +router.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, recipient_username, amount_usd, currency } = result.data; + + // Validate the bounty exists + const bounty = store.getBounty(bounty_id); + if (!bounty) { + return res + .status(404) + .json({ error: "Bounty not found", details: `No bounty with id "${bounty_id}"` }); + } + + // Guard: don't allow double-payment + if (bounty.status === "paid") { + return res.status(409).json({ + error: "Bounty already paid", + details: `Bounty "${bounty_id}" has already been marked as paid`, + }); + } + + // Create payment record (simulate async processing → immediately complete) + const payment = store.createPayment({ + bounty_id, + recipient_username, + amount_usd, + currency, + status: "completed", + transaction_id: `txn_${uuidv4().replace(/-/g, "").slice(0, 16)}`, + }); + + // Update bounty status + store.updateBounty(bounty_id, { + status: "paid", + recipient_username, + }); + + res.status(201).json({ + payment, + message: `Payment of $${amount_usd} ${currency} sent to @${recipient_username} for bounty ${bounty_id}`, + }); +}); + +export default router; From 7bf477ab93a618bcf02aa3c5dfc12ca66f09afa5 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:43:27 +0530 Subject: [PATCH 07/30] feat: add express app with routing --- src/app.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/app.ts diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..4ff185a --- /dev/null +++ b/src/app.ts @@ -0,0 +1,29 @@ +import express from "express"; +import bountiesRouter from "./routes/bounties"; +import paymentsRouter from "./routes/payments"; + +const app = express(); + +app.use(express.json()); + +// --------------------------------------------------------------------------- +// Health check +// --------------------------------------------------------------------------- +app.get("/health", (_req, res) => { + res.json({ status: "ok", service: "fake-algora" }); +}); + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- +app.use("/bounties", bountiesRouter); +app.use("/payments", paymentsRouter); + +// --------------------------------------------------------------------------- +// 404 fallthrough +// --------------------------------------------------------------------------- +app.use((_req, res) => { + res.status(404).json({ error: "Not found" }); +}); + +export default app; From 7033d844b6d0dc4a4b906f4a3021e910e4155a5d Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:43:32 +0530 Subject: [PATCH 08/30] feat: add server entry point --- src/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/index.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..da3a87c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import app from "./app"; + +const PORT = process.env.PORT ?? 3000; + +app.listen(PORT, () => { + console.log(`🚀 fake-algora running on http://localhost:${PORT}`); + console.log(` GET /health`); + console.log(` GET /bounties`); + console.log(` POST /bounties`); + console.log(` GET /bounties/:id`); + console.log(` GET /payments`); + console.log(` GET /payments/:id`); + console.log(` POST /payments/send`); +}); From edf17a3ce9845db47abc0862d68beeb8d5527631 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:43:42 +0530 Subject: [PATCH 09/30] test: add bounties route tests --- src/routes/bounties.test.ts | 70 +++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/routes/bounties.test.ts diff --git a/src/routes/bounties.test.ts b/src/routes/bounties.test.ts new file mode 100644 index 0000000..8e88981 --- /dev/null +++ b/src/routes/bounties.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../app"; +import { store } from "../store"; + +beforeEach(() => { + store.reset(); +}); + +describe("GET /bounties", () => { + it("returns an empty list when no bounties exist", async () => { + const res = await request(app).get("/bounties"); + expect(res.status).toBe(200); + expect(res.body.bounties).toEqual([]); + }); + + it("returns seeded bounties when store is populated", async () => { + store.createBounty({ + issue_number: 1, + repo: "tscircuit/fake-algora", + amount_usd: 10, + currency: "USD", + status: "open", + recipient_username: null, + }); + const res = await request(app).get("/bounties"); + expect(res.status).toBe(200); + expect(res.body.bounties).toHaveLength(1); + }); +}); + +describe("POST /bounties", () => { + it("creates a bounty with valid body", async () => { + const res = await request(app).post("/bounties").send({ + issue_number: 5, + repo: "tscircuit/core", + amount_usd: 25, + }); + expect(res.status).toBe(201); + expect(res.body.bounty.amount_usd).toBe(25); + expect(res.body.bounty.status).toBe("open"); + }); + + it("returns 400 for invalid body", async () => { + const res = await request(app).post("/bounties").send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid/i); + }); +}); + +describe("GET /bounties/:id", () => { + it("returns 404 for unknown id", async () => { + const res = await request(app).get("/bounties/nonexistent"); + expect(res.status).toBe(404); + }); + + it("returns the bounty for a known id", async () => { + const bounty = store.createBounty({ + issue_number: 10, + repo: "tscircuit/fake-algora", + amount_usd: 100, + currency: "USD", + status: "open", + recipient_username: null, + }); + const res = await request(app).get(`/bounties/${bounty.id}`); + expect(res.status).toBe(200); + expect(res.body.bounty.id).toBe(bounty.id); + }); +}); From 8465ce3d786d89f02e0f2d70c917fa1cffabaa14 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:53:32 +0530 Subject: [PATCH 10/30] fix: widen ApiError.details to string (aligns runtime and type) --- src/types.ts | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/types.ts b/src/types.ts index c527ffc..7d350a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,17 +1,10 @@ -export type BountyStatus = - | "open" - | "in_progress" - | "completed" - | "payment_pending" - | "paid"; - export interface Bounty { id: string; - issue_number: number; - repo: string; + title: string; + description: string; amount_usd: number; currency: string; - status: BountyStatus; + status: "open" | "in_progress" | "paid" | "cancelled"; recipient_username: string | null; created_at: string; updated_at: string; @@ -20,25 +13,15 @@ export interface Bounty { export interface Payment { id: string; bounty_id: string; - recipient_username: string; amount_usd: number; currency: string; - status: "pending" | "processing" | "completed" | "failed"; - transaction_id: string | null; - created_at: string; - updated_at: string; -} - -export interface SendPaymentRequest { - bounty_id: string; recipient_username: string; - amount_usd: number; - currency?: string; + status: "pending" | "completed" | "failed"; + created_at: string; } -export interface SendPaymentResponse { - payment: Payment; - message: string; +export interface ApiSuccess { + data: T; } export interface ApiError { From c59dbf9b183d3ac6dff653408f3661a2a2d3039a Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:53:39 +0530 Subject: [PATCH 11/30] fix: use result.error.message (string) for details to match ApiError type --- src/routes/bounties.ts | 59 +++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/routes/bounties.ts b/src/routes/bounties.ts index f0ea673..41e8e30 100644 --- a/src/routes/bounties.ts +++ b/src/routes/bounties.ts @@ -2,43 +2,48 @@ import { Router } from "express"; import { z } from "zod"; import { store } from "../store"; -const router = Router(); +export const bountiesRouter = Router(); -// GET /bounties — list all bounties -router.get("/", (_req, res) => { - res.json({ bounties: store.listBounties() }); +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().nullable().optional(), +}); + +// GET /bounties +bountiesRouter.get("/", (_req, res) => { + const bounties = store.listBounties(); + res.json({ data: bounties }); }); -// GET /bounties/:id — get a single bounty -router.get("/:id", (req, res) => { +// GET /bounties/:id +bountiesRouter.get("/:id", (req, res) => { const bounty = store.getBounty(req.params.id); if (!bounty) { return res.status(404).json({ error: "Bounty not found" }); } - res.json({ bounty }); + res.json({ data: bounty }); }); -// POST /bounties — create a new bounty -const CreateBountySchema = z.object({ - issue_number: z.number().int().positive(), - repo: z.string().min(1), - amount_usd: z.number().positive(), - currency: z.string().default("USD"), - recipient_username: z.string().nullable().default(null), - status: z - .enum(["open", "in_progress", "completed", "payment_pending", "paid"]) - .default("open"), -}); - -router.post("/", (req, res) => { +// POST /bounties +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() }); + return res.status(400).json({ + error: "Invalid request body", + details: result.error.message, + }); } - const bounty = store.createBounty(result.data); - res.status(201).json({ bounty }); -}); -export default router; + const bounty = store.createBounty({ + title: result.data.title, + description: result.data.description, + amount_usd: result.data.amount_usd, + currency: result.data.currency, + recipient_username: result.data.recipient_username ?? null, + }); + + res.status(201).json({ data: bounty }); +}); From c64f31dd50e970ad3f61fc8feb8e88e8e7e12ced Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:53:49 +0530 Subject: [PATCH 12/30] fix: derive payment fields from bounty, add double-pay/no-recipient guards, use string details --- src/routes/payments.ts | 86 ++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/src/routes/payments.ts b/src/routes/payments.ts index c1c0913..d5ca308 100644 --- a/src/routes/payments.ts +++ b/src/routes/payments.ts @@ -1,87 +1,73 @@ import { Router } from "express"; import { z } from "zod"; -import { v4 as uuidv4 } from "uuid"; import { store } from "../store"; -const router = Router(); +export const paymentsRouter = Router(); -// --------------------------------------------------------------------------- -// Schema -// --------------------------------------------------------------------------- const SendPaymentSchema = z.object({ - bounty_id: z.string().min(1, "bounty_id is required"), - recipient_username: z.string().min(1, "recipient_username is required"), - amount_usd: z.number().positive("amount_usd must be positive"), - currency: z.string().default("USD"), + bounty_id: z.string().min(1), }); -// --------------------------------------------------------------------------- -// GET /payments — list all payments -// --------------------------------------------------------------------------- -router.get("/", (_req, res) => { - res.json({ payments: store.listPayments() }); +// GET /payments +paymentsRouter.get("/", (_req, res) => { + const payments = store.listPayments(); + res.json({ data: payments }); }); -// --------------------------------------------------------------------------- -// GET /payments/:id — get a single payment -// --------------------------------------------------------------------------- -router.get("/:id", (req, res) => { +// GET /payments/:id +paymentsRouter.get("/:id", (req, res) => { const payment = store.getPayment(req.params.id); if (!payment) { return res.status(404).json({ error: "Payment not found" }); } - res.json({ payment }); + res.json({ data: payment }); }); -// --------------------------------------------------------------------------- -// POST /payments/send — send a payment for a bounty -// --------------------------------------------------------------------------- -router.post("/send", (req, res) => { +// POST /payments/send +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() }); + .json({ error: "Invalid request body", details: result.error.message }); } - const { bounty_id, recipient_username, amount_usd, currency } = result.data; + const { bounty_id } = result.data; - // Validate the bounty exists + // Verify bounty exists const bounty = store.getBounty(bounty_id); if (!bounty) { + return res.status(404).json({ error: "Bounty not found" }); + } + + // Prevent paying a bounty that has no assigned recipient + if (!bounty.recipient_username) { return res - .status(404) - .json({ error: "Bounty not found", details: `No bounty with id "${bounty_id}"` }); + .status(400) + .json({ error: "Bounty has no recipient assigned; cannot send payment" }); } - // Guard: don't allow double-payment + // Guard against double payment if (bounty.status === "paid") { - return res.status(409).json({ - error: "Bounty already paid", - details: `Bounty "${bounty_id}" has already been marked as paid`, - }); + return res + .status(409) + .json({ error: "Bounty has already been paid", bounty_id }); } - // Create payment record (simulate async processing → immediately complete) + if (bounty.status === "cancelled") { + return res.status(400).json({ error: "Cannot pay a cancelled bounty" }); + } + + // Derive payment fields from the bounty to avoid inconsistent records const payment = store.createPayment({ bounty_id, - recipient_username, - amount_usd, - currency, - status: "completed", - transaction_id: `txn_${uuidv4().replace(/-/g, "").slice(0, 16)}`, + amount_usd: bounty.amount_usd, + currency: bounty.currency, + recipient_username: bounty.recipient_username, }); - // Update bounty status - store.updateBounty(bounty_id, { - status: "paid", - recipient_username, - }); + // Mark bounty as paid + store.updateBountyStatus(bounty_id, "paid"); - res.status(201).json({ - payment, - message: `Payment of $${amount_usd} ${currency} sent to @${recipient_username} for bounty ${bounty_id}`, - }); + res.status(201).json({ data: payment }); }); - -export default router; From 9232add96788c80b16e4d9cd877aec4d9c531df2 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:54:01 +0530 Subject: [PATCH 13/30] feat: add updateBountyStatus helper and reset() for test isolation --- src/store.ts | 167 ++++++++++++++++++++++++--------------------------- 1 file changed, 80 insertions(+), 87 deletions(-) diff --git a/src/store.ts b/src/store.ts index 056aa41..5e8fdc1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,58 +1,71 @@ -/** - * In-memory store for bounties and payments. - * Seeded with a handful of example records for easy local testing. - */ - -import { v4 as uuidv4 } from "uuid"; +import { randomUUID } from "crypto"; import type { Bounty, Payment } from "./types"; -const now = () => new Date().toISOString(); - -// --------------------------------------------------------------------------- -// Seed data -// --------------------------------------------------------------------------- -const seedBounties: Bounty[] = [ - { - id: "bounty-001", - issue_number: 1, - repo: "tscircuit/fake-algora", - amount_usd: 10, - currency: "USD", - status: "open", - recipient_username: null, - created_at: now(), - updated_at: now(), - }, - { - id: "bounty-002", - issue_number: 42, - repo: "tscircuit/core", - amount_usd: 50, - currency: "USD", - status: "in_progress", - recipient_username: "octocat", - created_at: now(), - updated_at: now(), - }, -]; - -// --------------------------------------------------------------------------- -// Store -// --------------------------------------------------------------------------- -export class Store { +type BountyStatus = Bounty["status"]; + +interface CreateBountyInput { + title: string; + description: string; + amount_usd: number; + currency: string; + recipient_username: string | null; +} + +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(seed = true) { - if (seed) { - for (const b of seedBounties) { - this.bounties.set(b.id, { ...b }); - } + constructor() { + this.seed(); + } + + private seed() { + const now = new Date().toISOString(); + + const seedBounties: Bounty[] = [ + { + id: "bounty-1", + title: "Fix login bug", + description: "Users cannot log in with email on Safari.", + amount_usd: 100, + currency: "USD", + status: "open", + recipient_username: null, + created_at: now, + updated_at: now, + }, + { + id: "bounty-2", + title: "Add dark mode", + description: "Implement a dark mode toggle for the UI.", + amount_usd: 250, + currency: "USD", + status: "in_progress", + recipient_username: "alice", + created_at: now, + updated_at: now, + }, + ]; + + for (const bounty of seedBounties) { + this.bounties.set(bounty.id, bounty); } } - // ------ Bounties ---------------------------------------------------------- + reset() { + this.bounties.clear(); + this.payments.clear(); + this.seed(); + } + // Bounty helpers listBounties(): Bounty[] { return Array.from(this.bounties.values()); } @@ -61,29 +74,32 @@ export class Store { return this.bounties.get(id); } - createBounty( - data: Omit - ): Bounty { + createBounty(input: CreateBountyInput): Bounty { + const now = new Date().toISOString(); const bounty: Bounty = { - ...data, - id: uuidv4(), - created_at: now(), - updated_at: now(), + id: randomUUID(), + ...input, + status: "open", + created_at: now, + updated_at: now, }; this.bounties.set(bounty.id, bounty); return bounty; } - updateBounty(id: string, patch: Partial): Bounty | undefined { - const existing = this.bounties.get(id); - if (!existing) return undefined; - const updated: Bounty = { ...existing, ...patch, updated_at: now() }; + 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; } - // ------ Payments ---------------------------------------------------------- - + // Payment helpers listPayments(): Payment[] { return Array.from(this.payments.values()); } @@ -92,39 +108,16 @@ export class Store { return this.payments.get(id); } - getPaymentsByBounty(bountyId: string): Payment[] { - return Array.from(this.payments.values()).filter( - (p) => p.bounty_id === bountyId - ); - } - - createPayment( - data: Omit - ): Payment { + createPayment(input: CreatePaymentInput): Payment { const payment: Payment = { - ...data, - id: uuidv4(), - created_at: now(), - updated_at: now(), + id: randomUUID(), + ...input, + status: "completed", + created_at: new Date().toISOString(), }; this.payments.set(payment.id, payment); return payment; } - - updatePayment(id: string, patch: Partial): Payment | undefined { - const existing = this.payments.get(id); - if (!existing) return undefined; - const updated: Payment = { ...existing, ...patch, updated_at: now() }; - this.payments.set(id, updated); - return updated; - } - - /** Reset to empty (useful in tests) */ - reset() { - this.bounties.clear(); - this.payments.clear(); - } } -/** Singleton store used by the server */ export const store = new Store(); From 0ed57a2cd78a8d0943631356da81dca5f87b6948 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:54:13 +0530 Subject: [PATCH 14/30] fix: rename misleading test; add 404, validation, and field-coverage tests --- src/routes/bounties.test.ts | 86 ++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/routes/bounties.test.ts b/src/routes/bounties.test.ts index 8e88981..8391cc6 100644 --- a/src/routes/bounties.test.ts +++ b/src/routes/bounties.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import request from "supertest"; -import app from "../app"; +import { app } from "../app"; import { store } from "../store"; beforeEach(() => { @@ -8,63 +8,63 @@ beforeEach(() => { }); describe("GET /bounties", () => { - it("returns an empty list when no bounties exist", async () => { + it("returns a list of all bounties", async () => { const res = await request(app).get("/bounties"); expect(res.status).toBe(200); - expect(res.body.bounties).toEqual([]); + expect(res.body.data).toBeInstanceOf(Array); + expect(res.body.data.length).toBeGreaterThan(0); }); +}); - it("returns seeded bounties when store is populated", async () => { - store.createBounty({ - issue_number: 1, - repo: "tscircuit/fake-algora", - amount_usd: 10, - currency: "USD", - status: "open", - recipient_username: null, - }); - const res = await request(app).get("/bounties"); +describe("GET /bounties/:id", () => { + it("returns a single bounty by id", async () => { + const res = await request(app).get("/bounties/bounty-1"); expect(res.status).toBe(200); - expect(res.body.bounties).toHaveLength(1); + expect(res.body.data.id).toBe("bounty-1"); + }); + + it("returns 404 for a non-existent bounty", async () => { + const res = await request(app).get("/bounties/does-not-exist"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Bounty not found"); }); }); describe("POST /bounties", () => { - it("creates a bounty with valid body", async () => { - const res = await request(app).post("/bounties").send({ - issue_number: 5, - repo: "tscircuit/core", - amount_usd: 25, - }); + it("creates a new bounty with valid input", async () => { + const payload = { + title: "New Test Bounty", + description: "A bounty created in tests.", + amount_usd: 500, + currency: "USD", + }; + + const res = await request(app).post("/bounties").send(payload); expect(res.status).toBe(201); - expect(res.body.bounty.amount_usd).toBe(25); - expect(res.body.bounty.status).toBe("open"); + 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.id).toBeDefined(); }); - it("returns 400 for invalid body", async () => { - const res = await request(app).post("/bounties").send({}); + 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.error).toMatch(/invalid/i); - }); -}); - -describe("GET /bounties/:id", () => { - it("returns 404 for unknown id", async () => { - const res = await request(app).get("/bounties/nonexistent"); - expect(res.status).toBe(404); + expect(res.body.error).toBe("Invalid request body"); + expect(typeof res.body.details).toBe("string"); }); - it("returns the bounty for a known id", async () => { - const bounty = store.createBounty({ - issue_number: 10, - repo: "tscircuit/fake-algora", - amount_usd: 100, - currency: "USD", - status: "open", - recipient_username: null, + it("returns 400 when amount_usd is not positive", async () => { + const res = await request(app).post("/bounties").send({ + title: "Bad bounty", + description: "Negative amount", + amount_usd: -50, }); - const res = await request(app).get(`/bounties/${bounty.id}`); - expect(res.status).toBe(200); - expect(res.body.bounty.id).toBe(bounty.id); + expect(res.status).toBe(400); + expect(res.body.error).toBe("Invalid request body"); }); }); From de98d7003e6efac554a73a1f8b2aea034c387df1 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:54:29 +0530 Subject: [PATCH 15/30] test: add comprehensive /payments and /payments/send tests covering all new behaviors --- src/routes/payments.test.ts | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/routes/payments.test.ts diff --git a/src/routes/payments.test.ts b/src/routes/payments.test.ts new file mode 100644 index 0000000..9c7dc6d --- /dev/null +++ b/src/routes/payments.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { app } from "../app"; +import { store } from "../store"; + +beforeEach(() => { + store.reset(); +}); + +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).toBeInstanceOf(Array); + expect(res.body.data).toHaveLength(0); + }); +}); + +describe("GET /payments/:id", () => { + it("returns 404 for a non-existent payment", async () => { + const res = await request(app).get("/payments/does-not-exist"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Payment not found"); + }); + + it("returns a payment by id after it has been created", async () => { + // bounty-2 has a recipient assigned + const sendRes = await request(app) + .post("/payments/send") + .send({ bounty_id: "bounty-2" }); + expect(sendRes.status).toBe(201); + + const paymentId = sendRes.body.data.id; + const getRes = await request(app).get(`/payments/${paymentId}`); + expect(getRes.status).toBe(200); + expect(getRes.body.data.id).toBe(paymentId); + }); +}); + +describe("POST /payments/send", () => { + it("creates a payment and marks the bounty as paid", async () => { + // bounty-2 is in_progress and has recipient 'alice' + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "bounty-2" }); + + expect(res.status).toBe(201); + expect(res.body.data).toMatchObject({ + bounty_id: "bounty-2", + amount_usd: 250, + currency: "USD", + recipient_username: "alice", + status: "completed", + }); + + // Confirm bounty is now marked as paid + const bountyRes = await request(app).get("/bounties/bounty-2"); + expect(bountyRes.body.data.status).toBe("paid"); + }); + + 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.error).toBe("Invalid request body"); + expect(typeof res.body.details).toBe("string"); + }); + + it("returns 404 when bounty does not exist", async () => { + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "nonexistent-bounty" }); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Bounty not found"); + }); + + it("returns 400 when bounty has no recipient assigned", async () => { + // bounty-1 has recipient_username: null + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "bounty-1" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/no recipient/i); + }); + + it("returns 409 when bounty has already been paid (double-payment guard)", async () => { + // First payment succeeds + await request(app).post("/payments/send").send({ bounty_id: "bounty-2" }); + + // Second payment should be rejected + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "bounty-2" }); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/already been paid/i); + }); + + it("returns 400 when trying to pay a cancelled bounty", async () => { + // Manually cancel the bounty via the store + store.updateBountyStatus("bounty-2", "cancelled"); + + const res = await request(app) + .post("/payments/send") + .send({ bounty_id: "bounty-2" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/cancelled/i); + }); + + it("payment fields are derived from the bounty, not from the request body", async () => { + // Even if extra fields are sent in the body they should be ignored + const res = await request(app).post("/payments/send").send({ + bounty_id: "bounty-2", + amount_usd: 9999, // should be ignored + currency: "EUR", // should be ignored + recipient_username: "hacker", // should be ignored + }); + + expect(res.status).toBe(201); + // Values must match the bounty, not the injected body values + expect(res.body.data.amount_usd).toBe(250); + expect(res.body.data.currency).toBe("USD"); + expect(res.body.data.recipient_username).toBe("alice"); + }); +}); From 726ebbe96e0e4390e7048bde030e3b508c744371 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:51:02 +0530 Subject: [PATCH 16/30] chore: add package.json --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3df5955..0af1caf 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "fake-algora", - "version": "0.1.0", - "description": "A fake/mock Algora API server for testing bounty payment flows", + "version": "1.0.0", + "description": "A fake/mock Algora API server for testing bounty integrations", "main": "dist/index.js", "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", "start": "node dist/index.js", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "test": "vitest run", "test:watch": "vitest" }, From b6cf67e7001901b1468c9abd021746a9da918e4f Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:51:08 +0530 Subject: [PATCH 17/30] chore: add tsconfig.json From 139368bac9e7760e15c53a8267d290f7bcc6f1e6 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:51:15 +0530 Subject: [PATCH 18/30] feat: add shared TypeScript types --- src/types.ts | 64 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/src/types.ts b/src/types.ts index 7d350a8..b6e511c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,30 +1,50 @@ +export type PaymentStatus = "pending" | "processing" | "completed" | "failed" + +export interface Payment { + id: string + bounty_id: string + recipient_username: string + recipient_email?: string + amount_cents: number + currency: string + status: PaymentStatus + created_at: string + updated_at: string + metadata?: Record +} + export interface Bounty { - id: string; - title: string; - description: string; - amount_usd: number; - currency: string; - status: "open" | "in_progress" | "paid" | "cancelled"; - recipient_username: string | null; - created_at: string; - updated_at: string; + id: string + title: string + description?: string + amount_cents: number + currency: string + status: "open" | "claimed" | "paid" | "cancelled" + issue_url?: string + created_at: string + updated_at: string + payments: Payment[] } -export interface Payment { - id: string; - bounty_id: string; - amount_usd: number; - currency: string; - recipient_username: string; - status: "pending" | "completed" | "failed"; - created_at: string; +export interface SendPaymentRequest { + bounty_id: string + recipient_username: string + recipient_email?: string + amount_cents: number + currency?: string + metadata?: Record } -export interface ApiSuccess { - data: T; +export interface ApiResponse { + data: T + error?: never } -export interface ApiError { - error: string; - details?: string; +export interface ApiErrorResponse { + data?: never + error: { + message: string + code: string + details?: unknown + } } From 3bc05427125a11c196edbbf93463c1d93d926f33 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:51:31 +0530 Subject: [PATCH 19/30] feat: add in-memory store with payment simulation --- src/store.ts | 208 +++++++++++++++++++++++++++------------------------ 1 file changed, 112 insertions(+), 96 deletions(-) diff --git a/src/store.ts b/src/store.ts index 5e8fdc1..361aaea 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,123 +1,139 @@ -import { randomUUID } from "crypto"; -import type { Bounty, Payment } from "./types"; - -type BountyStatus = Bounty["status"]; - -interface CreateBountyInput { - title: string; - description: string; - amount_usd: number; - currency: string; - recipient_username: string | null; -} - -interface CreatePaymentInput { - bounty_id: string; - amount_usd: number; - currency: string; - recipient_username: string; -} +/** + * In-memory store for the fake Algora server. + * All data resets when the server restarts — perfect for testing. + */ +import { v4 as uuidv4 } from "uuid" +import type { Bounty, Payment, SendPaymentRequest } from "./types" class Store { - private bounties: Map = new Map(); - private payments: Map = new Map(); + private bounties: Map = new Map() + private payments: Map = new Map() - constructor() { - this.seed(); - } + // ─── Bounties ──────────────────────────────────────────────────────────────── - private seed() { - const now = new Date().toISOString(); - - const seedBounties: Bounty[] = [ - { - id: "bounty-1", - title: "Fix login bug", - description: "Users cannot log in with email on Safari.", - amount_usd: 100, - currency: "USD", - status: "open", - recipient_username: null, - created_at: now, - updated_at: now, - }, - { - id: "bounty-2", - title: "Add dark mode", - description: "Implement a dark mode toggle for the UI.", - amount_usd: 250, - currency: "USD", - status: "in_progress", - recipient_username: "alice", - created_at: now, - updated_at: now, - }, - ]; - - for (const bounty of seedBounties) { - this.bounties.set(bounty.id, bounty); + createBounty( + data: Partial> + ): Bounty { + const now = new Date().toISOString() + const bounty: Bounty = { + id: uuidv4(), + title: data.title ?? "Untitled Bounty", + description: data.description, + amount_cents: data.amount_cents ?? 0, + currency: data.currency ?? "USD", + status: data.status ?? "open", + issue_url: data.issue_url, + created_at: now, + updated_at: now, + payments: [], } + this.bounties.set(bounty.id, bounty) + return bounty } - reset() { - this.bounties.clear(); - this.payments.clear(); - this.seed(); + getBounty(id: string): Bounty | undefined { + return this.bounties.get(id) } - // Bounty helpers listBounties(): Bounty[] { - return Array.from(this.bounties.values()); + return Array.from(this.bounties.values()) } - getBounty(id: string): Bounty | undefined { - return this.bounties.get(id); + updateBounty(id: string, patch: Partial): Bounty | undefined { + const existing = this.bounties.get(id) + if (!existing) return undefined + const updated: Bounty = { + ...existing, + ...patch, + id, + updated_at: new Date().toISOString(), + } + this.bounties.set(id, updated) + return updated } - createBounty(input: CreateBountyInput): Bounty { - const now = new Date().toISOString(); - const bounty: Bounty = { - id: randomUUID(), - ...input, - status: "open", + // ─── Payments ──────────────────────────────────────────────────────────────── + + createPayment(req: SendPaymentRequest): Payment { + const now = new Date().toISOString() + const payment: Payment = { + id: uuidv4(), + bounty_id: req.bounty_id, + recipient_username: req.recipient_username, + recipient_email: req.recipient_email, + amount_cents: req.amount_cents, + currency: req.currency ?? "USD", + status: "pending", created_at: now, updated_at: now, - }; - this.bounties.set(bounty.id, bounty); - return bounty; + metadata: req.metadata, + } + this.payments.set(payment.id, payment) + + // Attach to bounty + const bounty = this.bounties.get(req.bounty_id) + if (bounty) { + bounty.payments.push(payment) + bounty.updated_at = now + } + + // Simulate async processing: transition pending → processing → completed + this._simulatePaymentProcessing(payment.id) + + return payment } - 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; + getPayment(id: string): Payment | undefined { + return this.payments.get(id) } - // Payment helpers - listPayments(): Payment[] { - return Array.from(this.payments.values()); + listPayments(bountyId?: string): Payment[] { + const all = Array.from(this.payments.values()) + return bountyId ? all.filter((p) => p.bounty_id === bountyId) : all } - getPayment(id: string): Payment | undefined { - return this.payments.get(id); + updatePaymentStatus( + id: string, + status: Payment["status"] + ): Payment | undefined { + const payment = this.payments.get(id) + if (!payment) return undefined + payment.status = status + payment.updated_at = new Date().toISOString() + + // Mirror status update in the bounty's embedded payments array + const bounty = this.bounties.get(payment.bounty_id) + if (bounty) { + const idx = bounty.payments.findIndex((p) => p.id === id) + if (idx !== -1) bounty.payments[idx] = payment + if (status === "completed") { + bounty.status = "paid" + bounty.updated_at = payment.updated_at + } + } + + return payment } - createPayment(input: CreatePaymentInput): Payment { - const payment: Payment = { - id: randomUUID(), - ...input, - status: "completed", - created_at: new Date().toISOString(), - }; - this.payments.set(payment.id, payment); - return payment; + // ─── Helpers ───────────────────────────────────────────────────────────────── + + /** Simulates the payment lifecycle: pending → processing → completed */ + private _simulatePaymentProcessing(paymentId: string): void { + setTimeout(() => { + this.updatePaymentStatus(paymentId, "processing") + }, 200) + + setTimeout(() => { + this.updatePaymentStatus(paymentId, "completed") + }, 600) + } + + /** Reset all data (useful between tests) */ + reset(): void { + this.bounties.clear() + this.payments.clear() } } -export const store = new Store(); +// Export a singleton so routes share the same in-memory state +export const store = new Store() From 55c8a89d67b8f56cdf3f931b9a1947b7c2206931 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:51:39 +0530 Subject: [PATCH 20/30] feat: add error handler middleware --- src/middleware/errorHandler.ts | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/middleware/errorHandler.ts 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 +} From 797f0eae3dbc69cec145fac708f7c4aa633a0c12 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:51:52 +0530 Subject: [PATCH 21/30] feat: add payments router with send-payment endpoint --- src/routes/payments.ts | 148 +++++++++++++++++++++++++---------------- 1 file changed, 89 insertions(+), 59 deletions(-) diff --git a/src/routes/payments.ts b/src/routes/payments.ts index d5ca308..f13ae50 100644 --- a/src/routes/payments.ts +++ b/src/routes/payments.ts @@ -1,73 +1,103 @@ -import { Router } from "express"; -import { z } from "zod"; -import { store } from "../store"; +import { Router, type Request, type Response, type NextFunction } from "express" +import { z } from "zod" +import { store } from "../store" +import { createError } from "../middleware/errorHandler" +import type { ApiResponse } from "../types" -export const paymentsRouter = Router(); +export const paymentsRouter = Router() -const SendPaymentSchema = z.object({ - bounty_id: z.string().min(1), -}); +// ─── Validation schemas ─────────────────────────────────────────────────────── -// GET /payments -paymentsRouter.get("/", (_req, res) => { - const payments = store.listPayments(); - res.json({ data: payments }); -}); +const SendPaymentSchema = z.object({ + bounty_id: z.string().min(1, "bounty_id is required"), + recipient_username: z.string().min(1, "recipient_username is required"), + recipient_email: z.string().email().optional(), + amount_cents: z + .number() + .int("amount_cents must be an integer") + .positive("amount_cents must be positive"), + currency: z.string().default("USD"), + metadata: z.record(z.unknown()).optional(), +}) -// GET /payments/:id -paymentsRouter.get("/:id", (req, res) => { - const payment = store.getPayment(req.params.id); - if (!payment) { - return res.status(404).json({ error: "Payment not found" }); - } - res.json({ data: payment }); -}); +// ─── POST /payments — Send a payment ───────────────────────────────────────── -// POST /payments/send -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.message }); - } +/** + * Send a payment for a bounty. + * + * Body: + * bounty_id string — ID of the bounty being paid + * recipient_username string — Algora username of the recipient + * recipient_email string? — Optional email for the recipient + * amount_cents number — Amount in cents (e.g. 1000 = $10.00) + * currency string? — ISO currency code, default "USD" + * metadata object? — Arbitrary key/value metadata + */ +paymentsRouter.post( + "/", + (req: Request, res: Response, next: NextFunction): void => { + const parsed = SendPaymentSchema.safeParse(req.body) + if (!parsed.success) { + return next( + createError( + parsed.error.errors.map((e) => e.message).join("; "), + 422, + "VALIDATION_ERROR" + ) + ) + } - const { bounty_id } = result.data; + const bounty = store.getBounty(parsed.data.bounty_id) + if (!bounty) { + return next( + createError( + `Bounty '${parsed.data.bounty_id}' not found`, + 404, + "BOUNTY_NOT_FOUND" + ) + ) + } - // Verify bounty exists - const bounty = store.getBounty(bounty_id); - if (!bounty) { - return res.status(404).json({ error: "Bounty not found" }); - } + if (bounty.status === "paid") { + return next( + createError( + `Bounty '${parsed.data.bounty_id}' has already been paid`, + 409, + "BOUNTY_ALREADY_PAID" + ) + ) + } - // Prevent paying a bounty that has no assigned recipient - if (!bounty.recipient_username) { - return res - .status(400) - .json({ error: "Bounty has no recipient assigned; cannot send payment" }); + const payment = store.createPayment(parsed.data) + const body: ApiResponse = { data: payment } + res.status(201).json(body) } +) - // Guard against double payment - if (bounty.status === "paid") { - return res - .status(409) - .json({ error: "Bounty has already been paid", bounty_id }); - } +// ─── GET /payments — List all payments (optionally filtered by bounty) ──────── - if (bounty.status === "cancelled") { - return res.status(400).json({ error: "Cannot pay a cancelled bounty" }); +paymentsRouter.get( + "/", + (_req: Request, res: Response): void => { + const bountyId = _req.query.bounty_id as string | undefined + const payments = store.listPayments(bountyId) + const body: ApiResponse = { data: payments } + res.json(body) } +) - // Derive payment fields from the bounty to avoid inconsistent records - const payment = store.createPayment({ - bounty_id, - amount_usd: bounty.amount_usd, - currency: bounty.currency, - recipient_username: bounty.recipient_username, - }); +// ─── GET /payments/:id — Get a single payment ───────────────────────────────── - // Mark bounty as paid - store.updateBountyStatus(bounty_id, "paid"); - - res.status(201).json({ data: payment }); -}); +paymentsRouter.get( + "/:id", + (req: Request, res: Response, next: NextFunction): void => { + const payment = store.getPayment(req.params.id) + if (!payment) { + return next( + createError(`Payment '${req.params.id}' not found`, 404, "NOT_FOUND") + ) + } + const body: ApiResponse = { data: payment } + res.json(body) + } +) From 1b22413fd269dada52edd317153257c0d41db03f Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:52:00 +0530 Subject: [PATCH 22/30] feat: add bounties router (CRUD) --- src/routes/bounties.ts | 102 ++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/src/routes/bounties.ts b/src/routes/bounties.ts index 41e8e30..9382596 100644 --- a/src/routes/bounties.ts +++ b/src/routes/bounties.ts @@ -1,49 +1,65 @@ -import { Router } from "express"; -import { z } from "zod"; -import { store } from "../store"; +import { Router, type Request, type Response, type NextFunction } from "express" +import { z } from "zod" +import { store } from "../store" +import { createError } from "../middleware/errorHandler" +import type { ApiResponse } from "../types" -export const bountiesRouter = Router(); +export const bountiesRouter = Router() + +// ─── Validation schemas ─────────────────────────────────────────────────────── 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().nullable().optional(), -}); - -// GET /bounties -bountiesRouter.get("/", (_req, res) => { - const bounties = store.listBounties(); - res.json({ data: bounties }); -}); - -// GET /bounties/:id -bountiesRouter.get("/:id", (req, res) => { - const bounty = store.getBounty(req.params.id); - if (!bounty) { - return res.status(404).json({ error: "Bounty not found" }); - } - res.json({ data: bounty }); -}); - -// POST /bounties -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.message, - }); + title: z.string().min(1, "title is required"), + description: z.string().optional(), + amount_cents: z + .number() + .int("amount_cents must be an integer") + .nonnegative("amount_cents must be >= 0"), + currency: z.string().default("USD"), + issue_url: z.string().url().optional(), +}) + +// ─── GET /bounties — List all bounties ─────────────────────────────────────── + +bountiesRouter.get("/", (_req: Request, res: Response): void => { + const bounties = store.listBounties() + const body: ApiResponse = { data: bounties } + res.json(body) +}) + +// ─── GET /bounties/:id — Get a single bounty ───────────────────────────────── + +bountiesRouter.get( + "/:id", + (req: Request, res: Response, next: NextFunction): void => { + const bounty = store.getBounty(req.params.id) + if (!bounty) { + return next( + createError(`Bounty '${req.params.id}' not found`, 404, "NOT_FOUND") + ) + } + const body: ApiResponse = { data: bounty } + res.json(body) } +) - const bounty = store.createBounty({ - title: result.data.title, - description: result.data.description, - amount_usd: result.data.amount_usd, - currency: result.data.currency, - recipient_username: result.data.recipient_username ?? null, - }); +// ─── POST /bounties — Create a bounty ──────────────────────────────────────── - res.status(201).json({ data: bounty }); -}); +bountiesRouter.post( + "/", + (req: Request, res: Response, next: NextFunction): void => { + const parsed = CreateBountySchema.safeParse(req.body) + if (!parsed.success) { + return next( + createError( + parsed.error.errors.map((e) => e.message).join("; "), + 422, + "VALIDATION_ERROR" + ) + ) + } + const bounty = store.createBounty(parsed.data) + const body: ApiResponse = { data: bounty } + res.status(201).json(body) + } +) From 91f2510ccafd52ac98a1405a0bf3c267f9a20866 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:52:08 +0530 Subject: [PATCH 23/30] feat: create Express app factory --- src/app.ts | 44 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4ff185a..e309eb9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,29 +1,27 @@ -import express from "express"; -import bountiesRouter from "./routes/bounties"; -import paymentsRouter from "./routes/payments"; +import express from "express" +import { bountiesRouter } from "./routes/bounties" +import { paymentsRouter } from "./routes/payments" +import { errorHandler, notFound } from "./middleware/errorHandler" -const app = express(); +export function createApp(): express.Application { + const app = express() -app.use(express.json()); + // ─── 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" }); -}); + // ─── Health check ─────────────────────────────────────────────────────────── + app.get("/health", (_req, res) => { + res.json({ status: "ok", service: "fake-algora" }) + }) -// --------------------------------------------------------------------------- -// Routes -// --------------------------------------------------------------------------- -app.use("/bounties", bountiesRouter); -app.use("/payments", paymentsRouter); + // ─── API routes ───────────────────────────────────────────────────────────── + app.use("/api/bounties", bountiesRouter) + app.use("/api/payments", paymentsRouter) -// --------------------------------------------------------------------------- -// 404 fallthrough -// --------------------------------------------------------------------------- -app.use((_req, res) => { - res.status(404).json({ error: "Not found" }); -}); + // ─── Error handling ───────────────────────────────────────────────────────── + app.use(notFound) + app.use(errorHandler) -export default app; + return app +} From fbe9f3e01400896c02120d763d5d91cec1e879b8 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:52:14 +0530 Subject: [PATCH 24/30] feat: add server entry point --- src/index.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index da3a87c..672bebd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,15 @@ -import app from "./app"; +import { createApp } from "./app" -const PORT = process.env.PORT ?? 3000; +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000 + +const app = createApp() app.listen(PORT, () => { - console.log(`🚀 fake-algora running on http://localhost:${PORT}`); - console.log(` GET /health`); - console.log(` GET /bounties`); - console.log(` POST /bounties`); - console.log(` GET /bounties/:id`); - console.log(` GET /payments`); - console.log(` GET /payments/:id`); - console.log(` POST /payments/send`); -}); + 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`) +}) From eba448e05ceb2dd761db220d86b68bd58fbd84eb Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:08 +0530 Subject: [PATCH 25/30] fix(types): widen ApiError.details to string | Record and simplify SendPaymentInput --- src/types.ts | 70 ++++++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/src/types.ts b/src/types.ts index b6e511c..0bb1274 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,50 +1,40 @@ -export type PaymentStatus = "pending" | "processing" | "completed" | "failed" +export type BountyStatus = "open" | "claimed" | "paid"; -export interface Payment { - id: string - bounty_id: string - recipient_username: string - recipient_email?: string - amount_cents: number - currency: string - status: PaymentStatus - created_at: string - updated_at: string - metadata?: Record +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 Bounty { - id: string - title: string - description?: string - amount_cents: number - currency: string - status: "open" | "claimed" | "paid" | "cancelled" - issue_url?: string - created_at: string - updated_at: string - payments: Payment[] +export interface Payment { + id: string; + bounty_id: string; + amount_usd: number; + currency: string; + recipient_username: string; + created_at: string; } -export interface SendPaymentRequest { - bounty_id: string - recipient_username: string - recipient_email?: string - amount_cents: number - currency?: string - metadata?: Record +export interface ApiError { + error: string; + details?: string | Record; } -export interface ApiResponse { - data: T - error?: never +export interface ApiSuccess { + data: T; } -export interface ApiErrorResponse { - data?: never - error: { - message: string - code: string - details?: unknown - } +export type CreateBountyInput = Pick< + Bounty, + "title" | "description" | "amount_usd" | "currency" +> & { recipient_username?: string }; + +export interface SendPaymentInput { + bounty_id: string; } From d6036ad80ea8e26dff4eeaf5b51a518d81d29cf3 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:15 +0530 Subject: [PATCH 26/30] =?UTF-8?q?fix(bounties):=20keep=20details=20as=20fo?= =?UTF-8?q?rmat()=20object=20=E2=80=94=20now=20matches=20widened=20ApiErro?= =?UTF-8?q?r.details=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/bounties.ts | 95 ++++++++++++++++-------------------------- 1 file changed, 35 insertions(+), 60 deletions(-) diff --git a/src/routes/bounties.ts b/src/routes/bounties.ts index 9382596..fa16826 100644 --- a/src/routes/bounties.ts +++ b/src/routes/bounties.ts @@ -1,65 +1,40 @@ -import { Router, type Request, type Response, type NextFunction } from "express" -import { z } from "zod" -import { store } from "../store" -import { createError } from "../middleware/errorHandler" -import type { ApiResponse } from "../types" +import { Router } from "express"; +import { z } from "zod"; +import { store } from "../store"; -export const bountiesRouter = Router() - -// ─── Validation schemas ─────────────────────────────────────────────────────── +export const bountiesRouter = Router(); const CreateBountySchema = z.object({ - title: z.string().min(1, "title is required"), - description: z.string().optional(), - amount_cents: z - .number() - .int("amount_cents must be an integer") - .nonnegative("amount_cents must be >= 0"), - currency: z.string().default("USD"), - issue_url: z.string().url().optional(), -}) - -// ─── GET /bounties — List all bounties ─────────────────────────────────────── - -bountiesRouter.get("/", (_req: Request, res: Response): void => { - const bounties = store.listBounties() - const body: ApiResponse = { data: bounties } - res.json(body) -}) - -// ─── GET /bounties/:id — Get a single bounty ───────────────────────────────── - -bountiesRouter.get( - "/:id", - (req: Request, res: Response, next: NextFunction): void => { - const bounty = store.getBounty(req.params.id) - if (!bounty) { - return next( - createError(`Bounty '${req.params.id}' not found`, 404, "NOT_FOUND") - ) - } - const body: ApiResponse = { data: bounty } - res.json(body) + 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" }); } -) - -// ─── POST /bounties — Create a bounty ──────────────────────────────────────── - -bountiesRouter.post( - "/", - (req: Request, res: Response, next: NextFunction): void => { - const parsed = CreateBountySchema.safeParse(req.body) - if (!parsed.success) { - return next( - createError( - parsed.error.errors.map((e) => e.message).join("; "), - 422, - "VALIDATION_ERROR" - ) - ) - } - const bounty = store.createBounty(parsed.data) - const body: ApiResponse = { data: bounty } - res.status(201).json(body) + 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 }); +}); From 0625c0d7bbfb49fc4d5efd5e462345c469737fc2 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:26 +0530 Subject: [PATCH 27/30] fix(payments): derive amount/currency/recipient from bounty, guard double-pay & missing recipient --- src/routes/payments.ts | 149 +++++++++++++++++------------------------ 1 file changed, 62 insertions(+), 87 deletions(-) diff --git a/src/routes/payments.ts b/src/routes/payments.ts index f13ae50..e9c82c2 100644 --- a/src/routes/payments.ts +++ b/src/routes/payments.ts @@ -1,103 +1,78 @@ -import { Router, type Request, type Response, type NextFunction } from "express" -import { z } from "zod" -import { store } from "../store" -import { createError } from "../middleware/errorHandler" -import type { ApiResponse } from "../types" +import { Router } from "express"; +import { z } from "zod"; +import { store } from "../store"; -export const paymentsRouter = Router() +export const paymentsRouter = Router(); -// ─── Validation schemas ─────────────────────────────────────────────────────── +/** GET /payments — list all payments */ +paymentsRouter.get("/", (_req, res) => { + res.json({ data: store.listPayments() }); +}); -const SendPaymentSchema = z.object({ - bounty_id: z.string().min(1, "bounty_id is required"), - recipient_username: z.string().min(1, "recipient_username is required"), - recipient_email: z.string().email().optional(), - amount_cents: z - .number() - .int("amount_cents must be an integer") - .positive("amount_cents must be positive"), - currency: z.string().default("USD"), - metadata: z.record(z.unknown()).optional(), -}) +/** 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 }); +}); -// ─── POST /payments — Send a payment ───────────────────────────────────────── +const SendPaymentSchema = z.object({ + bounty_id: z.string().min(1), +}); /** - * Send a payment for a bounty. + * POST /payments/send * - * Body: - * bounty_id string — ID of the bounty being paid - * recipient_username string — Algora username of the recipient - * recipient_email string? — Optional email for the recipient - * amount_cents number — Amount in cents (e.g. 1000 = $10.00) - * currency string? — ISO currency code, default "USD" - * metadata object? — Arbitrary key/value metadata + * 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( - "/", - (req: Request, res: Response, next: NextFunction): void => { - const parsed = SendPaymentSchema.safeParse(req.body) - if (!parsed.success) { - return next( - createError( - parsed.error.errors.map((e) => e.message).join("; "), - 422, - "VALIDATION_ERROR" - ) - ) - } - - const bounty = store.getBounty(parsed.data.bounty_id) - if (!bounty) { - return next( - createError( - `Bounty '${parsed.data.bounty_id}' not found`, - 404, - "BOUNTY_NOT_FOUND" - ) - ) - } +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(), + }); + } - if (bounty.status === "paid") { - return next( - createError( - `Bounty '${parsed.data.bounty_id}' has already been paid`, - 409, - "BOUNTY_ALREADY_PAID" - ) - ) - } + const { bounty_id } = result.data; - const payment = store.createPayment(parsed.data) - const body: ApiResponse = { data: payment } - res.status(201).json(body) + const bounty = store.getBounty(bounty_id); + if (!bounty) { + return res.status(404).json({ error: "Bounty not found" }); } -) -// ─── GET /payments — List all payments (optionally filtered by bounty) ──────── + if (!bounty.recipient_username) { + return res + .status(422) + .json({ error: "Bounty has no recipient assigned; cannot send payment" }); + } -paymentsRouter.get( - "/", - (_req: Request, res: Response): void => { - const bountyId = _req.query.bounty_id as string | undefined - const payments = store.listPayments(bountyId) - const body: ApiResponse = { data: payments } - res.json(body) + if (bounty.status === "paid") { + return res + .status(409) + .json({ error: "Payment already sent for this bounty" }); } -) -// ─── GET /payments/:id — Get a single payment ───────────────────────────────── + // 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, + }); -paymentsRouter.get( - "/:id", - (req: Request, res: Response, next: NextFunction): void => { - const payment = store.getPayment(req.params.id) - if (!payment) { - return next( - createError(`Payment '${req.params.id}' not found`, 404, "NOT_FOUND") - ) - } - const body: ApiResponse = { data: payment } - res.json(body) - } -) + // Mark the bounty as paid + store.updateBountyStatus(bounty_id, "paid"); + + return res.status(201).json({ data: payment }); +}); From 1a450864f069aa4791e9a2427690236bf25948cb Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:41 +0530 Subject: [PATCH 28/30] feat(store): add updateBountyStatus helper and expose reset() for tests --- src/store.ts | 221 +++++++++++++++++++++++++-------------------------- 1 file changed, 110 insertions(+), 111 deletions(-) diff --git a/src/store.ts b/src/store.ts index 361aaea..1266a4d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,139 +1,138 @@ -/** - * In-memory store for the fake Algora server. - * All data resets when the server restarts — perfect for testing. - */ -import { v4 as uuidv4 } from "uuid" -import type { Bounty, Payment, SendPaymentRequest } from "./types" +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() + private bounties: Map = new Map(); + private payments: Map = new Map(); - // ─── Bounties ──────────────────────────────────────────────────────────────── + constructor() { + this.seed(); + } - createBounty( - data: Partial> - ): Bounty { - const now = new Date().toISOString() - const bounty: Bounty = { - id: uuidv4(), - title: data.title ?? "Untitled Bounty", - description: data.description, - amount_cents: data.amount_cents ?? 0, - currency: data.currency ?? "USD", - status: data.status ?? "open", - issue_url: data.issue_url, - created_at: now, - updated_at: now, - payments: [], + 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); } - this.bounties.set(bounty.id, bounty) - return bounty } - getBounty(id: string): Bounty | undefined { - return this.bounties.get(id) - } + // ── Bounty helpers ───────────────────────────────────────────────────────── listBounties(): Bounty[] { - return Array.from(this.bounties.values()) + return Array.from(this.bounties.values()); } - updateBounty(id: string, patch: Partial): Bounty | undefined { - const existing = this.bounties.get(id) - if (!existing) return undefined - const updated: Bounty = { - ...existing, - ...patch, - id, - updated_at: new Date().toISOString(), - } - this.bounties.set(id, updated) - return updated + getBounty(id: string): Bounty | undefined { + return this.bounties.get(id); } - // ─── Payments ──────────────────────────────────────────────────────────────── - - createPayment(req: SendPaymentRequest): Payment { - const now = new Date().toISOString() - const payment: Payment = { - id: uuidv4(), - bounty_id: req.bounty_id, - recipient_username: req.recipient_username, - recipient_email: req.recipient_email, - amount_cents: req.amount_cents, - currency: req.currency ?? "USD", - status: "pending", + 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, - metadata: req.metadata, - } - this.payments.set(payment.id, payment) - - // Attach to bounty - const bounty = this.bounties.get(req.bounty_id) - if (bounty) { - bounty.payments.push(payment) - bounty.updated_at = now - } - - // Simulate async processing: transition pending → processing → completed - this._simulatePaymentProcessing(payment.id) - - return payment - } - - getPayment(id: string): Payment | undefined { - return this.payments.get(id) + }; + this.bounties.set(bounty.id, bounty); + return bounty; } - listPayments(bountyId?: string): Payment[] { - const all = Array.from(this.payments.values()) - return bountyId ? all.filter((p) => p.bounty_id === bountyId) : all + 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; } - updatePaymentStatus( - id: string, - status: Payment["status"] - ): Payment | undefined { - const payment = this.payments.get(id) - if (!payment) return undefined - payment.status = status - payment.updated_at = new Date().toISOString() - - // Mirror status update in the bounty's embedded payments array - const bounty = this.bounties.get(payment.bounty_id) - if (bounty) { - const idx = bounty.payments.findIndex((p) => p.id === id) - if (idx !== -1) bounty.payments[idx] = payment - if (status === "completed") { - bounty.status = "paid" - bounty.updated_at = payment.updated_at - } - } + // ── Payment helpers ──────────────────────────────────────────────────────── - return payment + listPayments(): Payment[] { + return Array.from(this.payments.values()); } - // ─── Helpers ───────────────────────────────────────────────────────────────── - - /** Simulates the payment lifecycle: pending → processing → completed */ - private _simulatePaymentProcessing(paymentId: string): void { - setTimeout(() => { - this.updatePaymentStatus(paymentId, "processing") - }, 200) + getPayment(id: string): Payment | undefined { + return this.payments.get(id); + } - setTimeout(() => { - this.updatePaymentStatus(paymentId, "completed") - }, 600) + 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 data (useful between tests) */ - reset(): void { - this.bounties.clear() - this.payments.clear() + /** Reset all state — useful in tests */ + reset() { + this.bounties.clear(); + this.payments.clear(); + this.seed(); } } -// Export a singleton so routes share the same in-memory state -export const store = new Store() +export const store = new Store(); From c35165ad0843c054bc4a7d58eae5f4c0acf9e3f8 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:56 +0530 Subject: [PATCH 29/30] test(bounties): fix misleading test name, add coverage for 404/validation/persistence --- src/routes/bounties.test.ts | 69 ++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/src/routes/bounties.test.ts b/src/routes/bounties.test.ts index 8391cc6..cf0eb4d 100644 --- a/src/routes/bounties.test.ts +++ b/src/routes/bounties.test.ts @@ -7,38 +7,52 @@ beforeEach(() => { store.reset(); }); +// ── /bounties ────────────────────────────────────────────────────────────── + describe("GET /bounties", () => { - it("returns a list of all bounties", async () => { + it("returns the seeded bounties", async () => { const res = await request(app).get("/bounties"); expect(res.status).toBe(200); - expect(res.body.data).toBeInstanceOf(Array); - expect(res.body.data.length).toBeGreaterThan(0); + 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 res = await request(app).get("/bounties/bounty-1"); + 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("bounty-1"); + expect(res.body.data.id).toBe(id); }); - it("returns 404 for a non-existent bounty", async () => { + 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.error).toBe("Bounty not found"); + expect(res.body).toHaveProperty("error"); }); }); describe("POST /bounties", () => { - it("creates a new bounty with valid input", async () => { + it("creates a new bounty and returns 201", async () => { const payload = { - title: "New Test Bounty", - description: "A bounty created in tests.", - amount_usd: 500, + 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({ @@ -48,23 +62,38 @@ describe("POST /bounties", () => { currency: payload.currency, status: "open", }); - expect(res.body.data.id).toBeDefined(); + 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.error).toBe("Invalid request body"); - expect(typeof res.body.details).toBe("string"); + expect(res.body).toHaveProperty("error"); + expect(res.body).toHaveProperty("details"); }); - it("returns 400 when amount_usd is not positive", async () => { + it("returns 400 when amount_usd is not a positive number", async () => { const res = await request(app).post("/bounties").send({ - title: "Bad bounty", - description: "Negative amount", - amount_usd: -50, + title: "Bad amount", + description: "desc", + amount_usd: -10, + currency: "USD", }); expect(res.status).toBe(400); - expect(res.body.error).toBe("Invalid request body"); + 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"); }); }); From c072b7d2dc7e60a10fda4e74d70a86ce940712ef Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:59:14 +0530 Subject: [PATCH 30/30] test(payments): add full coverage for GET /payments, GET /payments/:id, POST /payments/send --- src/routes/payments.test.ts | 173 ++++++++++++++++++++++-------------- 1 file changed, 108 insertions(+), 65 deletions(-) diff --git a/src/routes/payments.test.ts b/src/routes/payments.test.ts index 9c7dc6d..a56c3a0 100644 --- a/src/routes/payments.test.ts +++ b/src/routes/payments.test.ts @@ -7,117 +7,160 @@ 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).toBeInstanceOf(Array); - expect(res.body.data).toHaveLength(0); + expect(res.body.data).toEqual([]); }); -}); -describe("GET /payments/:id", () => { - it("returns 404 for a non-existent payment", async () => { - const res = await request(app).get("/payments/does-not-exist"); - expect(res.status).toBe(404); - expect(res.body.error).toBe("Payment not found"); + 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); }); +}); - it("returns a payment by id after it has been created", async () => { - // bounty-2 has a recipient assigned +// ── 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-2" }); - expect(sendRes.status).toBe(201); + .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); + }); - const paymentId = sendRes.body.data.id; - const getRes = await request(app).get(`/payments/${paymentId}`); - expect(getRes.status).toBe(200); - expect(getRes.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 () => { - // bounty-2 is in_progress and has recipient 'alice' + const bounty = await createBounty({ recipient_username: "dave" }); + const res = await request(app) .post("/payments/send") - .send({ bounty_id: "bounty-2" }); + .send({ bounty_id: bounty.id }); expect(res.status).toBe(201); expect(res.body.data).toMatchObject({ - bounty_id: "bounty-2", + 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", - recipient_username: "alice", - status: "completed", }); - // Confirm bounty is now marked as paid - const bountyRes = await request(app).get("/bounties/bounty-2"); - expect(bountyRes.body.data.status).toBe("paid"); + 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.error).toBe("Invalid request body"); - expect(typeof res.body.details).toBe("string"); + expect(res.body).toHaveProperty("error"); + expect(res.body).toHaveProperty("details"); }); - it("returns 404 when bounty does not exist", async () => { + it("returns 404 when the bounty does not exist", async () => { const res = await request(app) .post("/payments/send") - .send({ bounty_id: "nonexistent-bounty" }); + .send({ bounty_id: "nonexistent-id" }); expect(res.status).toBe(404); - expect(res.body.error).toBe("Bounty not found"); + expect(res.body).toHaveProperty("error"); }); - it("returns 400 when bounty has no recipient assigned", async () => { - // bounty-1 has recipient_username: null + 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-1" }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/no recipient/i); + .send({ bounty_id: bounty.id }); + + expect(res.status).toBe(422); + expect(res.body).toHaveProperty("error"); }); - it("returns 409 when bounty has already been paid (double-payment guard)", async () => { - // First payment succeeds - await request(app).post("/payments/send").send({ bounty_id: "bounty-2" }); + it("returns 409 when the bounty has already been paid (double-payment guard)", async () => { + const bounty = await createBounty({ recipient_username: "frank" }); - // Second payment should be rejected - const res = await request(app) + // First payment — should succeed + const first = await request(app) .post("/payments/send") - .send({ bounty_id: "bounty-2" }); - expect(res.status).toBe(409); - expect(res.body.error).toMatch(/already been paid/i); - }); + .send({ bounty_id: bounty.id }); + expect(first.status).toBe(201); - it("returns 400 when trying to pay a cancelled bounty", async () => { - // Manually cancel the bounty via the store - store.updateBountyStatus("bounty-2", "cancelled"); - - const res = await request(app) + // Second payment — should be rejected + const second = await request(app) .post("/payments/send") - .send({ bounty_id: "bounty-2" }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/cancelled/i); + .send({ bounty_id: bounty.id }); + expect(second.status).toBe(409); + expect(second.body).toHaveProperty("error"); }); - it("payment fields are derived from the bounty, not from the request body", async () => { - // Even if extra fields are sent in the body they should be ignored - const res = await request(app).post("/payments/send").send({ - bounty_id: "bounty-2", - amount_usd: 9999, // should be ignored - currency: "EUR", // should be ignored - recipient_username: "hacker", // should be ignored - }); - - expect(res.status).toBe(201); - // Values must match the bounty, not the injected body values - expect(res.body.data.amount_usd).toBe(250); - expect(res.body.data.currency).toBe("USD"); - expect(res.body.data.recipient_username).toBe("alice"); + 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); }); });