From 1de20b2f7bf72d9dc3078cbc97affbf851be0d7e Mon Sep 17 00:00:00 2001 From: Yuwang Cai Date: Sun, 6 Oct 2024 15:02:39 +0800 Subject: [PATCH 01/13] feat: backend API for creating item --- services/item/src/items/controller.ts | 6 +- services/item/src/items/repository.ts | 4 +- services/item/tests/items/create-item.test.ts | 98 +++++++++++++++++++ services/item/tests/utils.ts | 24 +++-- 4 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 services/item/tests/items/create-item.test.ts diff --git a/services/item/src/items/controller.ts b/services/item/src/items/controller.ts index bac8a887..fb21a9b6 100644 --- a/services/item/src/items/controller.ts +++ b/services/item/src/items/controller.ts @@ -42,8 +42,8 @@ itemsController.post( }), ), async (c) => { - const dto = c.req.valid("json"); - const item = await itemsService.createItem(dto, c.var.user); - return c.json(item, 201); + const body = c.req.valid("json"); + const result = await itemsService.createItem(body, c.var.user); + return c.json(result, 201); }, ); diff --git a/services/item/src/items/repository.ts b/services/item/src/items/repository.ts index f441d151..c2de11da 100644 --- a/services/item/src/items/repository.ts +++ b/services/item/src/items/repository.ts @@ -1,6 +1,6 @@ import { ItemStatus, type Item, type SingleItem } from "@/types"; import { itemsCollection } from "@/utils/db"; -import type { Filter, FindOptions } from "mongodb"; +import { UUID, type Filter, type FindOptions } from "mongodb"; /** * Data access layer for items. @@ -34,7 +34,7 @@ type InsertOneDto = { async function insertOne(dto: InsertOneDto) { const { insertedId } = await itemsCollection.insertOne({ ...dto, - id: crypto.randomUUID(), + id: new UUID().toString(), type: "single", status: ItemStatus.FOR_SALE, created_at: new Date().toISOString(), diff --git a/services/item/tests/items/create-item.test.ts b/services/item/tests/items/create-item.test.ts new file mode 100644 index 00000000..3bc4b084 --- /dev/null +++ b/services/item/tests/items/create-item.test.ts @@ -0,0 +1,98 @@ +import { ItemStatus, type Item } from "@/types"; +import { itemsCollection } from "@/utils/db"; +import { expect, it } from "bun:test"; +import { request } from "../utils"; + +type ExpectedResponse = Item; + +const ORIGINAL_COUNT = 31; +const me = { + id: 1, + nickname: "test", + avatar_url: "https://example.com/test.jpg", +}; +const MY_JWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmlja25hbWUiOiJ0ZXN0IiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdGVzdC5qcGciLCJpYXQiOjE3MjgxOTc0OTUsIm5iZiI6MTcyODE5NzQ5NSwiZXhwIjozNDU2Mzk0OTgxfQ.IWELaGDOCNYDOei6KQxMSm4FOjCiGKXgMZqhWMLnMx8"; + +it("creates a new item", async () => { + const res = await request("/", { + method: "POST", + body: JSON.stringify({ + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: [], + }), + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }); + const body = (await res.json()) as ExpectedResponse; + + expect(res.status).toEqual(201); + expect(body).toMatchObject({ + id: expect.any(String), + type: "single", + seller: me, + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: [], + status: ItemStatus.FOR_SALE, + created_at: expect.any(String), + deleted_at: null, + }); + + const currentCount = await itemsCollection.estimatedDocumentCount(); + expect(currentCount).toEqual(ORIGINAL_COUNT + 1); + + await itemsCollection.deleteOne({ name: "Test item name" }); +}); + +it("returns 400 if the request body is invalid", async () => { + const res = await request("/", { + method: "POST", + body: JSON.stringify({ + name: "", + description: "", + price: -1, + photo_urls: [], + }), + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }); + + expect(res.status).toEqual(400); +}); + +it("returns 401 if the user is not authenticated", async () => { + const res = await request("/", { + method: "POST", + body: JSON.stringify({ + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: [], + }), + }); + + expect(res.status).toEqual(401); +}); + +it("returns 401 if the JWT is invalid", async () => { + const res = await request("/", { + method: "POST", + body: JSON.stringify({ + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: [], + }), + headers: { + Cookie: "access_token=invalid", + }, + }); + + expect(res.status).toEqual(401); +}); diff --git a/services/item/tests/utils.ts b/services/item/tests/utils.ts index 2908c12f..c33b38bb 100644 --- a/services/item/tests/utils.ts +++ b/services/item/tests/utils.ts @@ -4,14 +4,24 @@ import app from "@/index"; * Fake a request to the server. */ export async function request(endpoint: string, init: RequestInit = {}) { - const res = await app.request(endpoint, init, { - server: { - requestIP: () => ({ - address: "localhost", - port: 8080, - }), + const res = await app.request( + endpoint, + { + ...init, + headers: { + "Content-Type": "application/json", + ...init.headers, + }, }, - }); + { + server: { + requestIP: () => ({ + address: "localhost", + port: 8080, + }), + }, + }, + ); return res; } From ff6adeb8cbea2a3d89dfaa9aa48b13036ba3c07d Mon Sep 17 00:00:00 2001 From: Yuwang Cai Date: Sun, 6 Oct 2024 15:49:15 +0800 Subject: [PATCH 02/13] feat: frontend form for publishing item --- services/web/src/app/api/items/route.ts | 21 +++ services/web/src/app/page.tsx | 14 +- .../web/src/components/item/publish-item.tsx | 152 ++++++++++++++++++ services/web/src/components/ui/dialog.tsx | 122 ++++++++++++++ services/web/src/components/ui/textarea.tsx | 24 +++ 5 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 services/web/src/components/item/publish-item.tsx create mode 100644 services/web/src/components/ui/dialog.tsx create mode 100644 services/web/src/components/ui/textarea.tsx diff --git a/services/web/src/app/api/items/route.ts b/services/web/src/app/api/items/route.ts index d3a16d8d..df4e955d 100644 --- a/services/web/src/app/api/items/route.ts +++ b/services/web/src/app/api/items/route.ts @@ -1,3 +1,4 @@ +import { ItemStatus } from "@/types"; import { NextRequest, NextResponse } from "next/server"; const mockAccounts = [ @@ -388,3 +389,23 @@ export async function GET(request: NextRequest) { { status: 200 }, ); } + +export async function POST(request: NextRequest) { + const body = await request.json(); + return NextResponse.json( + { + id: crypto.randomUUID(), + type: "single", + seller: { + id: 1, + nickname: "Johnny", + avatar_url: "https://avatars.githubusercontent.com/u/78269445?v=4", + }, + ...body, + status: ItemStatus.FOR_SALE, + created_at: new Date().toISOString(), + deleted_at: null, + }, + { status: 201 }, + ); +} diff --git a/services/web/src/app/page.tsx b/services/web/src/app/page.tsx index 01a93bfd..96ca7ecc 100644 --- a/services/web/src/app/page.tsx +++ b/services/web/src/app/page.tsx @@ -1,4 +1,5 @@ import { ItemCardList } from "@/components/item"; +import { PublishItem } from "@/components/item/publish-item"; import { ItemStatus } from "@/types"; import type { Metadata } from "next"; @@ -9,11 +10,14 @@ export const metadata: Metadata = { export default function Home() { return (
-
-

Marketplace

-

- We found something you might be interested in! -

+
+
+

Marketplace

+

+ We found something you might be interested in! +

+
+
diff --git a/services/web/src/components/item/publish-item.tsx b/services/web/src/components/item/publish-item.tsx new file mode 100644 index 00000000..895c4946 --- /dev/null +++ b/services/web/src/components/item/publish-item.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useToast } from "@/hooks/use-toast"; +import type { SingleItem } from "@/types"; +import { ClientRequester } from "@/utils/requester/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2Icon, PlusIcon, XIcon } from "lucide-react"; +import { useState, type FormEvent } from "react"; +import * as v from "valibot"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; + +const formSchema = v.object({ + name: v.pipe(v.string(), v.minLength(1), v.maxLength(50)), + description: v.pipe(v.string(), v.minLength(1), v.maxLength(500)), + price: v.pipe(v.string(), v.transform(Number), v.minValue(0.01)), +}); + +export function PublishItem() { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { toast } = useToast(); + + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: async (event: FormEvent) => { + event.preventDefault(); + + const target = event.target as HTMLFormElement; + const formData = Object.fromEntries(new FormData(target)); + + const { name, description, price } = v.parse(formSchema, formData); + + return await new ClientRequester().post("/items", { + name, + description, + price, + photo_urls: [], + }); + }, + onSuccess: () => { + setIsDialogOpen(false); + + queryClient.invalidateQueries({ queryKey: ["items"] }); + + toast({ + title: "Item published", + description: "Your item has been published successfully.", + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: "Failed to publish item", + description: error.message, + }); + }, + }); + + return ( + + + + + + + Publish an item + + Got something you no longer need? Publish it here and let others + know! + + +
+
+ + +
+
+ +