diff --git a/bun.lockb b/bun.lockb index 557d79c..9ef2123 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index e525e65..ab063c9 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -2,7 +2,12 @@ import { createStore, type StoreApi } from "zustand/vanilla" import { immer } from "zustand/middleware/immer" import { hoist, type HoistedStoreApi } from "zustand-hoist" -import { databaseSchema, type DatabaseSchema, type Thing } from "./schema.ts" +import { + databaseSchema, + type DatabaseSchema, + type Payment, + type Thing, +} from "./schema.ts" import { combine } from "zustand/middleware" export const createDatabase = () => { @@ -21,4 +26,31 @@ const initializer = combine(databaseSchema.parse({}), (set) => ({ idCounter: state.idCounter + 1, })) }, + addPayment: ( + payment: Omit, + ) => { + set((state) => ({ + payments: [ + ...state.payments, + { + ...payment, + payment_id: `pay_${state.idCounter}`, + status: "pending" as const, + created_at: new Date().toISOString(), + }, + ], + idCounter: state.idCounter + 1, + })) + }, + updatePaymentStatus: ( + payment_id: string, + status: Payment["status"], + sent_at?: string, + ) => { + set((state) => ({ + payments: state.payments.map((p) => + p.payment_id === payment_id ? { ...p, status, sent_at } : p, + ), + })) + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8377516..38ab9c6 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -9,8 +9,20 @@ export const thingSchema = z.object({ }) export type Thing = z.infer +export const paymentSchema = z.object({ + payment_id: z.string(), + recipient_email: z.string().email(), + amount_usd: z.number().positive(), + note: z.string().optional(), + status: z.enum(["pending", "sent", "failed"]).default("pending"), + created_at: z.string(), + sent_at: z.string().optional(), +}) +export type Payment = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), things: z.array(thingSchema).default([]), + payments: z.array(paymentSchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/package.json b/package.json index d03438f..8caed8f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@types/bun": "latest", "@types/react": "18.3.4", "next": "^14.2.5", - "redaxios": "^0.5.1" + "ky": "^1.8.1" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/routes/payments/list.ts b/routes/payments/list.ts new file mode 100644 index 0000000..7badd6a --- /dev/null +++ b/routes/payments/list.ts @@ -0,0 +1,43 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const paymentResponseSchema = z.object({ + payment_id: z.string(), + recipient_email: z.string(), + amount_usd: z.number(), + note: z.string().optional(), + status: z.enum(["pending", "sent", "failed"]), + created_at: z.string(), + sent_at: z.string().optional(), +}) + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + recipient_email: z.string().email().optional(), + status: z.enum(["pending", "sent", "failed"]).optional(), + }), + jsonResponse: z.object({ + ok: z.boolean(), + payments: z.array(paymentResponseSchema), + }), +})(async (req, ctx) => { + const url = new URL(req.url) + const recipient_email = url.searchParams.get("recipient_email") ?? undefined + const status = url.searchParams.get("status") as + | "pending" + | "sent" + | "failed" + | undefined + + let payments = ctx.db.getState().payments + + if (recipient_email) { + payments = payments.filter((p) => p.recipient_email === recipient_email) + } + if (status) { + payments = payments.filter((p) => p.status === status) + } + + return ctx.json({ ok: true, payments }) +}) diff --git a/routes/payments/send.ts b/routes/payments/send.ts new file mode 100644 index 0000000..e803b70 --- /dev/null +++ b/routes/payments/send.ts @@ -0,0 +1,43 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: z.object({ + recipient_email: z.string().email(), + amount_usd: z.number().positive(), + note: z.string().optional(), + }), + jsonResponse: z.object({ + ok: z.boolean(), + payment: z.object({ + payment_id: z.string(), + recipient_email: z.string(), + amount_usd: z.number(), + note: z.string().optional(), + status: z.enum(["pending", "sent", "failed"]), + created_at: z.string(), + sent_at: z.string().optional(), + }), + }), +})(async (req, ctx) => { + const { recipient_email, amount_usd, note } = await req.json() + + ctx.db.addPayment({ recipient_email, amount_usd, note }) + + const payments = ctx.db.getState().payments + const payment = payments[payments.length - 1] + + // Simulate async send: immediately mark as sent in fake mode + ctx.db.updatePaymentStatus( + payment.payment_id, + "sent", + new Date().toISOString(), + ) + + const sent = ctx.db + .getState() + .payments.find((p) => p.payment_id === payment.payment_id)! + + return ctx.json({ ok: true, payment: sent }) +}) diff --git a/tests/fixtures/get-test-server.ts b/tests/fixtures/get-test-server.ts index 7063c41..ffaf284 100644 --- a/tests/fixtures/get-test-server.ts +++ b/tests/fixtures/get-test-server.ts @@ -1,12 +1,11 @@ import { afterEach } from "bun:test" -import { tmpdir } from "node:os" -import defaultAxios from "redaxios" +import ky from "ky" import { startServer } from "./start-server" interface TestFixture { url: string server: any - axios: typeof defaultAxios + ky: typeof ky } export const getTestServer = async (): Promise => { @@ -20,18 +19,17 @@ export const getTestServer = async (): Promise => { }) const url = `http://127.0.0.1:${port}` - const axios = defaultAxios.create({ - baseURL: url, + const kyInstance = ky.create({ + prefixUrl: url, }) afterEach(async () => { await server.stop() - // Here you might want to add logic to drop the test database }) return { url, server, - axios, + ky: kyInstance, } } diff --git a/tests/routes/health.test.ts b/tests/routes/health.test.ts index 935db09..e97fcc3 100644 --- a/tests/routes/health.test.ts +++ b/tests/routes/health.test.ts @@ -2,8 +2,7 @@ import { it, expect } from "bun:test" import { getTestServer } from "tests/fixtures/get-test-server" it("GET /health should return ok", async () => { - const { axios } = await getTestServer() - const res = await axios.get("/health") - expect(res.status).toBe(200) - expect(res.data).toEqual({ ok: true }) + const { ky } = await getTestServer() + const data = await ky.get("health").json() + expect(data).toEqual({ ok: true }) }) diff --git a/tests/routes/payments/send.test.ts b/tests/routes/payments/send.test.ts new file mode 100644 index 0000000..bb93a6d --- /dev/null +++ b/tests/routes/payments/send.test.ts @@ -0,0 +1,45 @@ +import { getTestServer } from "tests/fixtures/get-test-server" +import { test, expect } from "bun:test" + +test("send a payment and verify it appears in list", async () => { + const { ky } = await getTestServer() + + const sendData = await ky + .post("payments/send", { + json: { + recipient_email: "alice@example.com", + amount_usd: 25, + note: "Bounty reward", + }, + }) + .json() + + expect(sendData.ok).toBe(true) + expect(sendData.payment.recipient_email).toBe("alice@example.com") + expect(sendData.payment.amount_usd).toBe(25) + expect(sendData.payment.status).toBe("sent") + expect(sendData.payment.sent_at).toBeDefined() + + const listData = await ky.get("payments/list").json() + expect(listData.payments).toHaveLength(1) + expect(listData.payments[0].payment_id).toBe(sendData.payment.payment_id) +}) + +test("filter payments by recipient_email", async () => { + const { ky } = await getTestServer() + + await ky.post("payments/send", { + json: { recipient_email: "alice@example.com", amount_usd: 25 }, + }) + await ky.post("payments/send", { + json: { recipient_email: "bob@example.com", amount_usd: 50 }, + }) + + const data = await ky + .get("payments/list", { + searchParams: { recipient_email: "alice@example.com" }, + }) + .json() + expect(data.payments).toHaveLength(1) + expect(data.payments[0].recipient_email).toBe("alice@example.com") +}) diff --git a/tests/routes/things/create.test.ts b/tests/routes/things/create.test.ts index 4ea7077..66f9a5d 100644 --- a/tests/routes/things/create.test.ts +++ b/tests/routes/things/create.test.ts @@ -2,14 +2,13 @@ import { getTestServer } from "tests/fixtures/get-test-server" import { test, expect } from "bun:test" test("create a thing", async () => { - const { axios } = await getTestServer() + const { ky } = await getTestServer() - axios.post("/things/create", { - name: "Thing1", - description: "Thing1 Description", + await ky.post("things/create", { + json: { name: "Thing1", description: "Thing1 Description" }, }) - const { data } = await axios.get("/things/list") + const data = await ky.get("things/list").json() expect(data.things).toHaveLength(1) })