Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
65 changes: 62 additions & 3 deletions lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { createStore, type StoreApi } from "zustand/vanilla"
import { type HoistedStoreApi, hoist } from "zustand-hoist"
import { immer } from "zustand/middleware/immer"
import { hoist, type HoistedStoreApi } from "zustand-hoist"
import { type StoreApi, createStore } from "zustand/vanilla"

import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts"
import { combine } from "zustand/middleware"
import {
type DatabaseSchema,
type Payment,
type PaymentStatus,
type Thing,
databaseSchema,
} from "./schema.ts"

export const createDatabase = () => {
return hoist(createStore(initializer))
Expand All @@ -21,4 +27,57 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({
idCounter: state.idCounter + 1,
}))
},
sendPayment: (
payment: Omit<
Payment,
"payment_id" | "status" | "created_at" | "updated_at"
>,
) => {
let savedPayment: Payment | undefined
set((state) => {
if (payment.idempotency_key) {
const existingPayment = state.payments.find(
(existing) => existing.idempotency_key === payment.idempotency_key,
)
if (existingPayment) {
savedPayment = existingPayment
return state
}
}

const now = new Date().toISOString()
savedPayment = {
...payment,
payment_id: state.paymentIdCounter.toString(),
status: "pending",
created_at: now,
updated_at: now,
}
return {
...state,
payments: [...state.payments, savedPayment],
paymentIdCounter: state.paymentIdCounter + 1,
}
})
return savedPayment!
},
updatePaymentStatus: (
payment_id: string,
status: Extract<PaymentStatus, "completed" | "cancelled" | "failed">,
) => {
let updatedPayment: Payment | undefined
set((state) => {
const payments = state.payments.map((payment) => {
if (payment.payment_id !== payment_id) return payment
updatedPayment = {
...payment,
status,
updated_at: new Date().toISOString(),
}
return updatedPayment
})
return { ...state, payments }
})
return updatedPayment
},
}))
25 changes: 25 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,33 @@ export const thingSchema = z.object({
})
export type Thing = z.infer<typeof thingSchema>

export const paymentStatusSchema = z.enum([
"pending",
"completed",
"cancelled",
"failed",
])
export type PaymentStatus = z.infer<typeof paymentStatusSchema>

export const paymentSchema = z.object({
payment_id: z.string(),
recipient: z.string(),
amount: z.number().positive(),
currency: z.string().default("USD"),
status: paymentStatusSchema.default("pending"),
bounty_id: z.string().optional(),
issue_number: z.number().int().positive().optional(),
repository: z.string().optional(),
idempotency_key: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
})
export type Payment = z.infer<typeof paymentSchema>

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<typeof databaseSchema>
4 changes: 3 additions & 1 deletion lib/middleware/with-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import type { DbClient } from "lib/db/db-client"
import { createDatabase } from "lib/db/db-client"
import type { Middleware } from "winterspec"

const sharedDb = createDatabase()

export const withDb: Middleware<
{},
{
db: DbClient
}
> = async (req, ctx, next) => {
if (!ctx.db) {
ctx.db = createDatabase()
ctx.db = sharedDb
}
return next(req, ctx)
}
17 changes: 17 additions & 0 deletions routes/payments/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "cancelled") ?? null
return ctx.json({ payment })
})
17 changes: 17 additions & 0 deletions routes/payments/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "completed") ?? null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Share the payment store across requests

In the real Next/Winterspec handler (pages/api/[[...route]].ts), no shared DB middleware is injected, so withDb creates a new in-memory store whenever ctx.db is absent; only tests/fixtures/start-server.ts supplies a process-wide DB. Because this endpoint looks up a payment created by a previous /payments/send HTTP request, production calls to /payments/complete after sending will return null (and /payments/get or /payments/list will similarly see an empty store), making the new payment lifecycle unusable outside the test fixture.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff80a6e. withDb now falls back to a process-wide shared store when production does not inject ctx.db, while tests can still inject isolated DBs. I also added a regression test that sends and completes a payment through a Winterspec bundle without any injected DB middleware, covering the lifecycle path from the review.

return ctx.json({ payment })
})
17 changes: 17 additions & 0 deletions routes/payments/fail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})(async (req, ctx) => {
const { payment_id } = await req.json()
const payment = ctx.db.updatePaymentStatus(payment_id, "failed") ?? null
return ctx.json({ payment })
})
18 changes: 18 additions & 0 deletions routes/payments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
payment_id: z.string().min(1),
}),
jsonResponse: z.object({
payment: paymentSchema.nullable(),
}),
})((req, ctx) => {
const { payment_id } = req.query
const payment =
ctx.db.payments.find((payment) => payment.payment_id === payment_id) ?? null
return ctx.json({ payment })
})
25 changes: 25 additions & 0 deletions routes/payments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { paymentSchema, paymentStatusSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
recipient: z.string().optional(),
status: paymentStatusSchema.optional(),
repository: z.string().optional(),
}),
jsonResponse: z.object({
payments: z.array(paymentSchema),
}),
})((req, ctx) => {
const { recipient, status, repository } = req.query
const payments = ctx.db.payments.filter((payment) => {
return (
(!recipient || payment.recipient === recipient) &&
(!status || payment.status === status) &&
(!repository || payment.repository === repository)
)
})
return ctx.json({ payments })
})
23 changes: 23 additions & 0 deletions routes/payments/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { paymentSchema } from "lib/db/schema"
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

export default withRouteSpec({
methods: ["POST"],
jsonBody: 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(),
}),
jsonResponse: z.object({
payment: paymentSchema,
}),
})(async (req, ctx) => {
const body = await req.json()
const payment = ctx.db.sendPayment(body)
return ctx.json({ payment })
})
106 changes: 106 additions & 0 deletions tests/routes/payments/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, test } from "bun:test"
import { join } from "node:path"
import { Request as EdgeRuntimeRequest } from "@edge-runtime/primitives"
import { getTestServer } from "tests/fixtures/get-test-server"
import { createWinterSpecBundleFromDir } from "winterspec/adapters/node"

test("sends and completes a fake payment", async () => {
const { axios } = await getTestServer()

const { data: sendData } = await axios.post("/payments/send", {
recipient: "contributor@example.com",
amount: 10,
currency: "USD",
bounty_id: "fake-algora-1",
issue_number: 1,
repository: "tscircuit/fake-algora",
})

expect(sendData.payment.payment_id).toBe("0")
expect(sendData.payment.status).toBe("pending")

const { data: completeData } = await axios.post("/payments/complete", {
payment_id: sendData.payment.payment_id,
})

expect(completeData.payment.status).toBe("completed")

const { data: getData } = await axios.get("/payments/get", {
params: { payment_id: sendData.payment.payment_id },
})

expect(getData.payment.status).toBe("completed")
})

test("does not duplicate payments when an idempotency key is reused", async () => {
const { axios } = await getTestServer()

const payload = {
recipient: "contributor@example.com",
amount: 20,
currency: "USD",
idempotency_key: "retry-safe-key",
}

const { data: firstSend } = await axios.post("/payments/send", payload)
const { data: secondSend } = await axios.post("/payments/send", payload)
const { data: listData } = await axios.get("/payments/list")

expect(firstSend.payment.payment_id).toBe(secondSend.payment.payment_id)
expect(listData.payments).toHaveLength(1)
})

test("filters listed payments", async () => {
const { axios } = await getTestServer()

await axios.post("/payments/send", {
recipient: "first@example.com",
amount: 10,
repository: "tscircuit/fake-algora",
})
await axios.post("/payments/send", {
recipient: "second@example.com",
amount: 15,
repository: "tscircuit/other",
})

const { data } = await axios.get("/payments/list", {
params: { repository: "tscircuit/fake-algora" },
})

expect(data.payments).toHaveLength(1)
expect(data.payments[0].recipient).toBe("first@example.com")
})

test("shares payment records when no db middleware is injected", async () => {
const winterspecBundle = await createWinterSpecBundleFromDir(
join(import.meta.dir, "../../../routes"),
)
const idempotencyKey = `production-path-${Math.random().toString(36).slice(2)}`

const sendResponse = await winterspecBundle.makeRequest(
new EdgeRuntimeRequest("http://localhost/payments/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recipient: "production@example.com",
amount: 25,
idempotency_key: idempotencyKey,
}),
}) as any,
)
const sendData = await sendResponse.json()

const completeResponse = await winterspecBundle.makeRequest(
new EdgeRuntimeRequest("http://localhost/payments/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment_id: sendData.payment.payment_id,
}),
}) as any,
)
const completeData = await completeResponse.json()

expect(completeData.payment.status).toBe("completed")
})