From 2e3599f7e2d6a90348242c833957b775a2142192 Mon Sep 17 00:00:00 2001 From: Andres Reibel Date: Mon, 11 May 2026 17:02:45 +0700 Subject: [PATCH 1/2] Add fake payment API --- bun.lockb | Bin 67071 -> 68519 bytes lib/db/db-client.ts | 68 ++++++++++++++++++++++++++++++++-- lib/db/schema.ts | 21 +++++++++++ routes/payments/complete.ts | 20 ++++++++++ routes/payments/get.ts | 16 ++++++++ routes/payments/list.ts | 22 +++++++++++ routes/payments/send.ts | 26 +++++++++++++ tests/routes/payments.test.ts | 59 +++++++++++++++++++++++++++++ 8 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 routes/payments/complete.ts create mode 100644 routes/payments/get.ts create mode 100644 routes/payments/list.ts create mode 100644 routes/payments/send.ts create mode 100644 tests/routes/payments.test.ts diff --git a/bun.lockb b/bun.lockb index 557d79cc3b88accaaa0f1f96a19cff1aa0a4aa86..0c63394b12417524c4e11be672b37bb2a08fea50 100755 GIT binary patch delta 3272 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 diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..459afa1 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,9 +1,13 @@ -import { createStore, type StoreApi } from "zustand/vanilla" +import { type HoistedStoreApi, hoist } from "zustand-hoist" +import { combine } from "zustand/middleware" import { immer } from "zustand/middleware/immer" -import { hoist, type HoistedStoreApi } from "zustand-hoist" +import { type StoreApi, createStore } from "zustand/vanilla" +import { type Payment, type Thing, databaseSchema } from "./schema.ts" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" -import { combine } from "zustand/middleware" +type CreatePaymentInput = Omit< + Payment, + "payment_id" | "status" | "created_at" | "updated_at" | "completed_at" +> export const createDatabase = () => { return hoist(createStore(initializer)) @@ -21,4 +25,60 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + sendPayment: (input: CreatePaymentInput) => { + let payment: Payment | undefined + + set((state) => { + if (input.idempotency_key) { + const existingPayment = state.payments.find( + (item) => item.idempotency_key === input.idempotency_key, + ) + + if (existingPayment) { + payment = existingPayment + return {} + } + } + + const now = new Date().toISOString() + payment = { + ...input, + payment_id: state.paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } + + return { + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + } + }) + + return payment! + }, + completePayment: (paymentId: string) => { + let payment: Payment | undefined + const now = new Date().toISOString() + + set((state) => { + const payments = state.payments.map((item) => { + if (item.payment_id !== paymentId) return item + + payment = { + ...item, + status: "completed", + completed_at: item.completed_at ?? now, + updated_at: now, + } + return payment + }) + + if (!payment) return {} + + return { payments } + }) + + return payment + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..f3fec0b 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,29 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentStatusSchema = z.enum(["pending", "completed", "failed"]) +export type PaymentStatus = z.infer + +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient: z.string(), + amount: z.number(), + currency: z.string(), + status: paymentStatusSchema, + bounty_id: z.string().optional(), + issue_number: z.number().optional(), + repository: z.string().optional(), + idempotency_key: z.string().optional(), + created_at: z.string(), + updated_at: z.string(), + completed_at: z.string().optional(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), + paymentIdCounter: z.number().default(0), things: z.array(thingSchema).default([]), + payments: z.array(paymentSchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts new file mode 100644 index 0000000..30796cb --- /dev/null +++ b/routes/payments/complete.ts @@ -0,0 +1,20 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const completePaymentBodySchema = z.object({ + payment_id: z.string().min(1), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: completePaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.optional(), + }), +})(async (req, ctx) => { + const { payment_id } = completePaymentBodySchema.parse(await req.json()) + const payment = ctx.db.completePayment(payment_id) + + return ctx.json({ payment }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts new file mode 100644 index 0000000..fcabadc --- /dev/null +++ b/routes/payments/get.ts @@ -0,0 +1,16 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payment: paymentSchema.optional(), + }), +})((req, ctx) => { + const url = new URL(req.url) + const paymentId = url.searchParams.get("payment_id") + const payment = ctx.db.payments.find((item) => item.payment_id === paymentId) + + return ctx.json({ payment }) +}) diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..ee5fa29 --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,22 @@ +import { paymentSchema, paymentStatusSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + payments: z.array(paymentSchema), + }), +})((req, ctx) => { + const url = new URL(req.url) + const recipient = url.searchParams.get("recipient") + const status = paymentStatusSchema.safeParse(url.searchParams.get("status")) + + const payments = ctx.db.payments.filter((payment) => { + if (recipient && payment.recipient !== recipient) return false + if (status.success && payment.status !== status.data) return false + return true + }) + + return ctx.json({ payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..aa79b6e --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,26 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const paymentBodySchema = z.object({ + recipient: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(1).default("USD"), + bounty_id: z.string().min(1).optional(), + issue_number: z.number().int().positive().optional(), + repository: z.string().min(1).optional(), + idempotency_key: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: paymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema, + }), +})(async (req, ctx) => { + const body = paymentBodySchema.parse(await req.json()) + const payment = ctx.db.sendPayment(body) + + return ctx.json({ payment }) +}) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts new file mode 100644 index 0000000..683a763 --- /dev/null +++ b/tests/routes/payments.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("send and complete a payment", async () => { + const { axios } = await getTestServer() + + const { data: sendData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + bounty_id: "bounty_123", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-payment", + }) + + expect(sendData.payment).toMatchObject({ + payment_id: "0", + recipient: "maintainer@example.com", + amount: 10, + currency: "USD", + status: "pending", + bounty_id: "bounty_123", + issue_number: 1, + repository: "tscircuit/fake-algora", + idempotency_key: "retry-safe-payment", + }) + + const { data: duplicateData } = await axios.post("/payments/send", { + recipient: "maintainer@example.com", + amount: 10, + idempotency_key: "retry-safe-payment", + }) + + expect(duplicateData.payment.payment_id).toBe("0") + + const { data: listData } = await axios.get("/payments/list", { + params: { + recipient: "maintainer@example.com", + status: "pending", + }, + }) + + expect(listData.payments).toHaveLength(1) + + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: "0", + }) + + expect(completeData.payment.status).toBe("completed") + expect(completeData.payment.completed_at).toBeString() + + const { data: getData } = await axios.get("/payments/get", { + params: { + payment_id: "0", + }, + }) + + expect(getData.payment.status).toBe("completed") +}) From 4183ea4f877e14d7478a1060cfac60d4cf5947f2 Mon Sep 17 00:00:00 2001 From: Andres Reibel Date: Mon, 11 May 2026 18:25:52 +0700 Subject: [PATCH 2/2] Complete fake payment lifecycle API --- README.md | 54 ++++++++++++ lib/db/db-client.ts | 162 +++++++++++++++++++++++++--------- lib/db/schema.ts | 13 ++- routes/payments/cancel.ts | 23 +++++ routes/payments/complete.ts | 4 +- routes/payments/fail.ts | 23 +++++ routes/payments/get.ts | 6 +- routes/payments/list.ts | 14 +-- routes/payments/send.ts | 10 ++- tests/routes/payments.test.ts | 63 +++++++++++++ 10 files changed, 316 insertions(+), 56 deletions(-) create mode 100644 routes/payments/cancel.ts create mode 100644 routes/payments/fail.ts diff --git a/README.md b/README.md index 824427a..5aa7b60 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,57 @@ This is a template project with best-practice modules: - Winterspec for defining the API - bun testing - Zustand store with zod definition for database state + +## Fake Payment API + +This project includes a fake payment API for exercising bounty payout flows +without moving real money. + +### Send a payment + +```http +POST /payments/send +``` + +```json +{ + "recipient": "maintainer@example.com", + "amount": 10, + "currency": "USD", + "owner": "tscircuit", + "repo": "fake-algora", + "repository": "tscircuit/fake-algora", + "issue_number": 1, + "bounty_id": "bounty_123", + "idempotency_key": "retry-safe-payment" +} +``` + +The response includes the fake `payment` and an `idempotent` boolean. Reusing +the same `idempotency_key` returns the original payment instead of creating a +duplicate. + +### Read payments + +```http +GET /payments/get?payment_id=0 +GET /payments/list?recipient=maintainer@example.com&status=pending +GET /payments/list?owner=tscircuit&repo=fake-algora&issue_number=1 +``` + +List filters support `recipient`, `status`, `owner`, `repo`, `repository`, +`bounty_id`, and `issue_number`. + +### Update payment status + +```http +POST /payments/complete +POST /payments/cancel +POST /payments/fail +``` + +```json +{ "payment_id": "0" } +``` + +Cancel and fail also accept `cancel_reason` and `failure_reason` respectively. diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index 459afa1..ae5a5a0 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -2,20 +2,43 @@ import { type HoistedStoreApi, hoist } from "zustand-hoist" import { combine } from "zustand/middleware" import { immer } from "zustand/middleware/immer" import { type StoreApi, createStore } from "zustand/vanilla" -import { type Payment, type Thing, databaseSchema } from "./schema.ts" +import { + type Payment, + type PaymentStatus, + type Thing, + databaseSchema, +} from "./schema.ts" type CreatePaymentInput = Omit< Payment, - "payment_id" | "status" | "created_at" | "updated_at" | "completed_at" + | "payment_id" + | "status" + | "created_at" + | "updated_at" + | "completed_at" + | "canceled_at" + | "failed_at" + | "cancel_reason" + | "failure_reason" > +type PaymentFilters = { + recipient?: string + status?: PaymentStatus + owner?: string + repo?: string + repository?: string + bounty_id?: string + issue_number?: number +} + export const createDatabase = () => { return hoist(createStore(initializer)) } export type DbClient = ReturnType -const initializer = combine(databaseSchema.parse({}), (set) => ({ +const initializer = combine(databaseSchema.parse({}), (set, get) => ({ addThing: (thing: Omit) => { set((state) => ({ things: [ @@ -26,58 +49,109 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ })) }, sendPayment: (input: CreatePaymentInput) => { - let payment: Payment | undefined + if (input.idempotency_key) { + const existingPayment = get().payments.find( + (item) => item.idempotency_key === input.idempotency_key, + ) - set((state) => { - if (input.idempotency_key) { - const existingPayment = state.payments.find( - (item) => item.idempotency_key === input.idempotency_key, - ) + if (existingPayment) return existingPayment + } - if (existingPayment) { - payment = existingPayment - return {} - } - } + const now = new Date().toISOString() + const payment: Payment = { + ...input, + payment_id: get().paymentIdCounter.toString(), + status: "pending", + created_at: now, + updated_at: now, + } - const now = new Date().toISOString() - payment = { - ...input, - payment_id: state.paymentIdCounter.toString(), - status: "pending", - created_at: now, - updated_at: now, - } + set((state) => ({ + payments: [...state.payments, payment], + paymentIdCounter: state.paymentIdCounter + 1, + })) - return { - payments: [...state.payments, payment], - paymentIdCounter: state.paymentIdCounter + 1, + return payment + }, + listPayments: (filters: PaymentFilters = {}) => { + return get().payments.filter((payment) => { + if (filters.recipient && payment.recipient !== filters.recipient) { + return false + } + if (filters.status && payment.status !== filters.status) { + return false + } + if (filters.owner && payment.owner !== filters.owner) { + return false + } + if (filters.repo && payment.repo !== filters.repo) { + return false + } + if (filters.repository && payment.repository !== filters.repository) { + return false + } + if (filters.bounty_id && payment.bounty_id !== filters.bounty_id) { + return false } + if ( + filters.issue_number !== undefined && + payment.issue_number !== filters.issue_number + ) { + return false + } + return true }) - - return payment! + }, + getPayment: (paymentId: string) => { + return get().payments.find((payment) => payment.payment_id === paymentId) }, completePayment: (paymentId: string) => { - let payment: Payment | undefined - const now = new Date().toISOString() - - set((state) => { - const payments = state.payments.map((item) => { - if (item.payment_id !== paymentId) return item + return get().updatePaymentStatus(paymentId, "completed") + }, + cancelPayment: (paymentId: string, cancelReason?: string) => { + return get().updatePaymentStatus(paymentId, "canceled", { + cancel_reason: cancelReason, + }) + }, + failPayment: (paymentId: string, failureReason?: string) => { + return get().updatePaymentStatus(paymentId, "failed", { + failure_reason: failureReason, + }) + }, + updatePaymentStatus: ( + paymentId: string, + status: Exclude, + options: { cancel_reason?: string; failure_reason?: string } = {}, + ) => { + const existingPayment = get().payments.find( + (payment) => payment.payment_id === paymentId, + ) - payment = { - ...item, - status: "completed", - completed_at: item.completed_at ?? now, - updated_at: now, - } - return payment - }) + if (!existingPayment) return undefined - if (!payment) return {} + const now = new Date().toISOString() + const payment: Payment = { + ...existingPayment, + status, + updated_at: now, + completed_at: status === "completed" ? now : existingPayment.completed_at, + canceled_at: status === "canceled" ? now : existingPayment.canceled_at, + failed_at: status === "failed" ? now : existingPayment.failed_at, + cancel_reason: + status === "canceled" + ? options.cancel_reason + : existingPayment.cancel_reason, + failure_reason: + status === "failed" + ? options.failure_reason + : existingPayment.failure_reason, + } - return { payments } - }) + set((state) => ({ + payments: state.payments.map((item) => + item.payment_id === paymentId ? payment : item, + ), + })) return payment }, diff --git a/lib/db/schema.ts b/lib/db/schema.ts index f3fec0b..69e0cf4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,7 +9,12 @@ export const thingSchema = z.object({ }) export type Thing = z.infer -export const paymentStatusSchema = z.enum(["pending", "completed", "failed"]) +export const paymentStatusSchema = z.enum([ + "pending", + "completed", + "canceled", + "failed", +]) export type PaymentStatus = z.infer export const paymentSchema = z.object({ @@ -18,13 +23,19 @@ export const paymentSchema = z.object({ amount: z.number(), currency: z.string(), status: paymentStatusSchema, + owner: z.string().optional(), + repo: z.string().optional(), bounty_id: z.string().optional(), issue_number: z.number().optional(), repository: z.string().optional(), idempotency_key: z.string().optional(), + cancel_reason: z.string().optional(), + failure_reason: z.string().optional(), created_at: z.string(), updated_at: z.string(), completed_at: z.string().optional(), + canceled_at: z.string().optional(), + failed_at: z.string().optional(), }) export type Payment = z.infer diff --git a/routes/payments/cancel.ts b/routes/payments/cancel.ts new file mode 100644 index 0000000..a9ad8af --- /dev/null +++ b/routes/payments/cancel.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const cancelPaymentBodySchema = z.object({ + payment_id: z.string().min(1), + cancel_reason: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: cancelPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id, cancel_reason } = cancelPaymentBodySchema.parse( + await req.json(), + ) + const payment = ctx.db.cancelPayment(payment_id, cancel_reason) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/complete.ts b/routes/payments/complete.ts index 30796cb..5261a82 100644 --- a/routes/payments/complete.ts +++ b/routes/payments/complete.ts @@ -10,11 +10,11 @@ export default withRouteSpec({ methods: ["POST"], jsonBody: completePaymentBodySchema, jsonResponse: z.object({ - payment: paymentSchema.optional(), + payment: paymentSchema.nullable(), }), })(async (req, ctx) => { const { payment_id } = completePaymentBodySchema.parse(await req.json()) const payment = ctx.db.completePayment(payment_id) - return ctx.json({ payment }) + return ctx.json({ payment: payment ?? null }) }) diff --git a/routes/payments/fail.ts b/routes/payments/fail.ts new file mode 100644 index 0000000..ec5f90e --- /dev/null +++ b/routes/payments/fail.ts @@ -0,0 +1,23 @@ +import { paymentSchema } from "lib/db/schema" +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const failPaymentBodySchema = z.object({ + payment_id: z.string().min(1), + failure_reason: z.string().min(1).optional(), +}) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: failPaymentBodySchema, + jsonResponse: z.object({ + payment: paymentSchema.nullable(), + }), +})(async (req, ctx) => { + const { payment_id, failure_reason } = failPaymentBodySchema.parse( + await req.json(), + ) + const payment = ctx.db.failPayment(payment_id, failure_reason) + + return ctx.json({ payment: payment ?? null }) +}) diff --git a/routes/payments/get.ts b/routes/payments/get.ts index fcabadc..27f0046 100644 --- a/routes/payments/get.ts +++ b/routes/payments/get.ts @@ -5,12 +5,12 @@ import { z } from "zod" export default withRouteSpec({ methods: ["GET"], jsonResponse: z.object({ - payment: paymentSchema.optional(), + payment: paymentSchema.nullable(), }), })((req, ctx) => { const url = new URL(req.url) const paymentId = url.searchParams.get("payment_id") - const payment = ctx.db.payments.find((item) => item.payment_id === paymentId) + const payment = paymentId ? ctx.db.getPayment(paymentId) : undefined - return ctx.json({ payment }) + return ctx.json({ payment: payment ?? null }) }) diff --git a/routes/payments/list.ts b/routes/payments/list.ts index ee5fa29..cc553ce 100644 --- a/routes/payments/list.ts +++ b/routes/payments/list.ts @@ -9,13 +9,17 @@ export default withRouteSpec({ }), })((req, ctx) => { const url = new URL(req.url) - const recipient = url.searchParams.get("recipient") + const issueNumber = url.searchParams.get("issue_number") const status = paymentStatusSchema.safeParse(url.searchParams.get("status")) - const payments = ctx.db.payments.filter((payment) => { - if (recipient && payment.recipient !== recipient) return false - if (status.success && payment.status !== status.data) return false - return true + const payments = ctx.db.listPayments({ + recipient: url.searchParams.get("recipient") ?? undefined, + status: status.success ? status.data : undefined, + owner: url.searchParams.get("owner") ?? undefined, + repo: url.searchParams.get("repo") ?? undefined, + repository: url.searchParams.get("repository") ?? undefined, + bounty_id: url.searchParams.get("bounty_id") ?? undefined, + issue_number: issueNumber ? Number(issueNumber) : undefined, }) return ctx.json({ payments }) diff --git a/routes/payments/send.ts b/routes/payments/send.ts index aa79b6e..7bbe4d7 100644 --- a/routes/payments/send.ts +++ b/routes/payments/send.ts @@ -6,6 +6,8 @@ const paymentBodySchema = z.object({ recipient: z.string().min(1), amount: z.number().positive(), currency: z.string().min(1).default("USD"), + owner: z.string().min(1).optional(), + repo: z.string().min(1).optional(), bounty_id: z.string().min(1).optional(), issue_number: z.number().int().positive().optional(), repository: z.string().min(1).optional(), @@ -17,10 +19,16 @@ export default withRouteSpec({ jsonBody: paymentBodySchema, jsonResponse: z.object({ payment: paymentSchema, + idempotent: z.boolean(), }), })(async (req, ctx) => { const body = paymentBodySchema.parse(await req.json()) + const existingPayment = body.idempotency_key + ? ctx.db.payments.find( + (payment) => payment.idempotency_key === body.idempotency_key, + ) + : undefined const payment = ctx.db.sendPayment(body) - return ctx.json({ payment }) + return ctx.json({ payment, idempotent: Boolean(existingPayment) }) }) diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts index 683a763..35f8f09 100644 --- a/tests/routes/payments.test.ts +++ b/tests/routes/payments.test.ts @@ -7,6 +7,8 @@ test("send and complete a payment", async () => { const { data: sendData } = await axios.post("/payments/send", { recipient: "maintainer@example.com", amount: 10, + owner: "tscircuit", + repo: "fake-algora", bounty_id: "bounty_123", issue_number: 1, repository: "tscircuit/fake-algora", @@ -19,11 +21,14 @@ test("send and complete a payment", async () => { amount: 10, currency: "USD", status: "pending", + owner: "tscircuit", + repo: "fake-algora", bounty_id: "bounty_123", issue_number: 1, repository: "tscircuit/fake-algora", idempotency_key: "retry-safe-payment", }) + expect(sendData.idempotent).toBe(false) const { data: duplicateData } = await axios.post("/payments/send", { recipient: "maintainer@example.com", @@ -32,11 +37,17 @@ test("send and complete a payment", async () => { }) expect(duplicateData.payment.payment_id).toBe("0") + expect(duplicateData.idempotent).toBe(true) const { data: listData } = await axios.get("/payments/list", { params: { recipient: "maintainer@example.com", status: "pending", + owner: "tscircuit", + repo: "fake-algora", + repository: "tscircuit/fake-algora", + bounty_id: "bounty_123", + issue_number: 1, }, }) @@ -57,3 +68,55 @@ test("send and complete a payment", async () => { expect(getData.payment.status).toBe("completed") }) + +test("cancel and fail update payment status", async () => { + const { axios } = await getTestServer() + + const { data: cancelSource } = await axios.post("/payments/send", { + recipient: "first@example.com", + amount: 20, + }) + + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: cancelSource.payment.payment_id, + cancel_reason: "duplicate bounty claim", + }) + + expect(cancelData.payment.status).toBe("canceled") + expect(cancelData.payment.cancel_reason).toBe("duplicate bounty claim") + expect(cancelData.payment.canceled_at).toBeString() + + const { data: failSource } = await axios.post("/payments/send", { + recipient: "second@example.com", + amount: 30, + }) + + const { data: failData } = await axios.post("/payments/fail", { + payment_id: failSource.payment.payment_id, + failure_reason: "recipient missing payout details", + }) + + expect(failData.payment.status).toBe("failed") + expect(failData.payment.failure_reason).toBe( + "recipient missing payout details", + ) + expect(failData.payment.failed_at).toBeString() +}) + +test("missing payments return null", async () => { + const { axios } = await getTestServer() + + const { data: getData } = await axios.get("/payments/get", { + params: { payment_id: "missing" }, + }) + const { data: completeData } = await axios.post("/payments/complete", { + payment_id: "missing", + }) + const { data: cancelData } = await axios.post("/payments/cancel", { + payment_id: "missing", + }) + + expect(getData.payment).toBeNull() + expect(completeData.payment).toBeNull() + expect(cancelData.payment).toBeNull() +})