diff --git a/src/hooks/__tests__/useDisputeDetail.test.tsx b/src/hooks/__tests__/useDisputeDetail.test.tsx new file mode 100644 index 0000000..85f9b3e --- /dev/null +++ b/src/hooks/__tests__/useDisputeDetail.test.tsx @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../../mocks/server"; +import { http, HttpResponse } from "msw"; +import React from "react"; +import { useDisputeDetail } from "../useDisputeDetail"; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe("useDisputeDetail", () => { + it("returns correct shape for an OPEN dispute", async () => { + const { result } = renderHook(() => useDisputeDetail("dispute-001"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isError).toBe(false); + + const data = result.current.data!; + expect(data.status).toBe("open"); + expect(data.escrowOnChainStatus).toBe("HELD"); + expect(data.stellarExplorerUrl).toContain("stellar.expert"); + expect(data.resolutionTxHash).toBeNull(); + }); + + it("returns correct shape for a RESOLVED dispute", async () => { + const { result } = renderHook(() => useDisputeDetail("dispute-002"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isError).toBe(false); + + const data = result.current.data!; + expect(data.status).toBe("resolved"); + expect(data.escrowOnChainStatus).toBe("RELEASED"); + expect(data.resolutionTxHash).toBe("abc123def456789"); + }); + + it("isNotFound is true on 404", async () => { + server.use( + http.get("/api/disputes/bad-id", () => + HttpResponse.json({ message: "Not found" }, { status: 404 }), + ), + ); + + const { result } = renderHook(() => useDisputeDetail("bad-id"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.isNotFound).toBe(true); + }); +}); diff --git a/src/hooks/useDisputeDetail.ts b/src/hooks/useDisputeDetail.ts new file mode 100644 index 0000000..0aa0758 --- /dev/null +++ b/src/hooks/useDisputeDetail.ts @@ -0,0 +1,34 @@ +import { useApiQuery } from "./useApiQuery"; + +// On-chain enrichment extends the existing dispute shape +export interface DisputeOnChain { + escrowOnChainStatus: string; + stellarExplorerUrl: string; + resolutionTxHash: string | null; +} + +export interface DisputeDetail extends DisputeOnChain { + id: string; + adoptionId: string; + raisedBy: string; + reason: string; + description: string; + status: "open" | "under_review" | "resolved" | "closed"; + resolution: string | null; + createdAt: string; + updatedAt: string; +} + +export function useDisputeDetail(disputeId: string) { + return useApiQuery( + ["dispute", disputeId], + async () => { + const res = await fetch(`/api/disputes/${disputeId}`); + if (!res.ok) { + const err = await res.json(); + throw Object.assign(new Error(err.message), { status: res.status }); + } + return res.json(); + }, + ); +} \ No newline at end of file diff --git a/src/mocks/handlers/dispute.ts b/src/mocks/handlers/dispute.ts index 81caac3..26a8477 100644 --- a/src/mocks/handlers/dispute.ts +++ b/src/mocks/handlers/dispute.ts @@ -1,154 +1,193 @@ // TODO: No backend model yet — align field names when Dispute is added to Prisma schema. import { http, HttpResponse, delay } from "msw"; - // ─── Types ──────────────────────────────────────────────────────────────────── type DisputeStatus = "open" | "under_review" | "resolved" | "closed"; interface Evidence { - id: string; - type: "document" | "photo" | "statement"; - url: string; - submittedBy: string; - submittedAt: string; + id: string; + type: "document" | "photo" | "statement"; + url: string; + submittedBy: string; + submittedAt: string; } interface TimelineEvent { - event: string; - actor: string; - timestamp: string; + event: string; + actor: string; + timestamp: string; } interface Dispute { - id: string; - adoptionId: string; - raisedBy: string; - reason: string; - description: string; - status: DisputeStatus; - evidence: Evidence[]; - timeline: TimelineEvent[]; - resolution: string | null; - createdAt: string; - updatedAt: string; + id: string; + adoptionId: string; + raisedBy: string; + reason: string; + description: string; + status: DisputeStatus; + evidence: Evidence[]; + timeline: TimelineEvent[]; + resolution: string | null; + createdAt: string; + updatedAt: string; + escrowOnChainStatus: string; + stellarExplorerUrl: string; + resolutionTxHash: string | null; } // ─── Seed data ──────────────────────────────────────────────────────────────── const MOCK_DISPUTES: Dispute[] = [ - { - id: "dispute-001", - adoptionId: "adoption-002", - raisedBy: "user-buyer-2", - reason: "misrepresentation", - description: "Pet's health condition was not accurately described in the listing.", - status: "open", - evidence: [ - { - id: "ev-001", - type: "document", - url: "/mock-files/vet-report-ev001.pdf", - submittedBy: "user-buyer-2", - submittedAt: "2026-03-23T11:00:00.000Z", - }, - ], - timeline: [ - { - event: "Dispute raised", - actor: "user-buyer-2", - timestamp: "2026-03-23T10:45:00.000Z", - }, - { - event: "Evidence submitted", - actor: "user-buyer-2", - timestamp: "2026-03-23T11:00:00.000Z", - }, - ], - resolution: null, - createdAt: "2026-03-23T10:45:00.000Z", - updatedAt: "2026-03-23T11:00:00.000Z", - }, + { + id: "dispute-001", + adoptionId: "adoption-002", + raisedBy: "user-buyer-2", + reason: "misrepresentation", + description: + "Pet's health condition was not accurately described in the listing.", + status: "open", + evidence: [ + { + id: "ev-001", + type: "document", + url: "/mock-files/vet-report-ev001.pdf", + submittedBy: "user-buyer-2", + submittedAt: "2026-03-23T11:00:00.000Z", + }, + ], + timeline: [ + { + event: "Dispute raised", + actor: "user-buyer-2", + timestamp: "2026-03-23T10:45:00.000Z", + }, + { + event: "Evidence submitted", + actor: "user-buyer-2", + timestamp: "2026-03-23T11:00:00.000Z", + }, + ], + resolution: null, + createdAt: "2026-03-23T10:45:00.000Z", + updatedAt: "2026-03-23T11:00:00.000Z", + escrowOnChainStatus: "HELD", + stellarExplorerUrl: + "https://stellar.expert/explorer/testnet/tx/mock-open-tx", + resolutionTxHash: null, + }, + { + id: "dispute-002", + adoptionId: "adoption-003", + raisedBy: "user-buyer-3", + reason: "non_delivery", + description: "Pet was not delivered after payment was confirmed.", + status: "resolved", + evidence: [], + timeline: [ + { + event: "Dispute raised", + actor: "user-buyer-3", + timestamp: "2026-03-20T09:00:00.000Z", + }, + { + event: "Resolved: refund issued", + actor: "admin-001", + timestamp: "2026-03-21T14:00:00.000Z", + }, + ], + resolution: "Refund issued to buyer", + createdAt: "2026-03-20T09:00:00.000Z", + updatedAt: "2026-03-21T14:00:00.000Z", + escrowOnChainStatus: "RELEASED", + stellarExplorerUrl: + "https://stellar.expert/explorer/testnet/tx/mock-resolved-tx", + resolutionTxHash: "abc123def456789", + }, ]; // ─── Helpers ────────────────────────────────────────────────────────────────── function getDelay(request: Request): number { - return Number(new URL(request.url).searchParams.get("delay") ?? 0); + return Number(new URL(request.url).searchParams.get("delay") ?? 0); } // ─── Handlers ───────────────────────────────────────────────────────────────── export const disputeHandlers = [ - // GET /api/disputes — list all open disputes - http.get("/api/disputes", async ({ request }) => { - await delay(getDelay(request)); - return HttpResponse.json(MOCK_DISPUTES); - }), + // GET /api/disputes — list all open disputes + http.get("/api/disputes", async ({ request }) => { + await delay(getDelay(request)); + return HttpResponse.json(MOCK_DISPUTES); + }), - // GET /api/disputes/:id — get a single dispute with evidence and timeline - http.get("/api/disputes/:id", async ({ request, params }) => { - await delay(getDelay(request)); - const found = MOCK_DISPUTES.find((d) => d.id === params.id); - if (!found) { - return HttpResponse.json( - { message: `Dispute '${params.id}' not found` }, - { status: 404 }, - ); - } - return HttpResponse.json(found); - }), + // GET /api/disputes/:id — get a single dispute with evidence and timeline + http.get("/api/disputes/:id", async ({ request, params }) => { + await delay(getDelay(request)); + const found = MOCK_DISPUTES.find((d) => d.id === params.id); + if (!found) { + return HttpResponse.json( + { message: `Dispute '${params.id}' not found` }, + { status: 404 }, + ); + } + return HttpResponse.json(found); + }), - // POST /api/disputes — raise a new dispute - http.post("/api/disputes", async ({ request }) => { - await delay(getDelay(request)); - const body = (await request.json()) as { - adoptionId: string; - raisedBy: string; - reason: string; - description: string; - }; - const created: Dispute = { - id: `dispute-${Date.now()}`, - adoptionId: body.adoptionId, - raisedBy: body.raisedBy, - reason: body.reason, - description: body.description, - status: "open", - evidence: [], - timeline: [ - { - event: "Dispute raised", - actor: body.raisedBy, - timestamp: new Date().toISOString(), - }, - ], - resolution: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - return HttpResponse.json(created, { status: 201 }); - }), + // POST /api/disputes — raise a new dispute + http.post("/api/disputes", async ({ request }) => { + await delay(getDelay(request)); + const body = (await request.json()) as { + adoptionId: string; + raisedBy: string; + reason: string; + description: string; + }; + const created: Dispute = { + id: `dispute-${Date.now()}`, + adoptionId: body.adoptionId, + raisedBy: body.raisedBy, + reason: body.reason, + description: body.description, + status: "open", + evidence: [], + timeline: [ + { + event: "Dispute raised", + actor: body.raisedBy, + timestamp: new Date().toISOString(), + }, + ], + resolution: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + return HttpResponse.json(created, { status: 201 }); + }), - // PATCH /api/disputes/:id/resolve — mark a dispute as resolved - http.patch("/api/disputes/:id/resolve", async ({ request, params }) => { - await delay(getDelay(request)); - const body = (await request.json()) as { resolution: string; resolvedBy: string }; - const base = MOCK_DISPUTES.find((d) => d.id === params.id) ?? MOCK_DISPUTES[0]; - return HttpResponse.json({ - ...base, - id: params.id as string, - status: "resolved", - resolution: body.resolution, - timeline: [ - ...base.timeline, - { - event: `Resolved: ${body.resolution}`, - actor: body.resolvedBy, - timestamp: new Date().toISOString(), - }, - ], - updatedAt: new Date().toISOString(), - }); - }), + // PATCH /api/disputes/:id/resolve — mark a dispute as resolved + http.patch("/api/disputes/:id/resolve", async ({ request, params }) => { + await delay(getDelay(request)); + const body = (await request.json()) as { + resolution: string; + resolvedBy: string; + }; + const base = + MOCK_DISPUTES.find((d) => d.id === params.id) ?? MOCK_DISPUTES[0]; + return HttpResponse.json({ + ...base, + id: params.id as string, + status: "resolved", + resolution: body.resolution, + timeline: [ + ...base.timeline, + { + event: `Resolved: ${body.resolution}`, + actor: body.resolvedBy, + timestamp: new Date().toISOString(), + }, + ], + updatedAt: new Date().toISOString(), + }); + }), ];