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(),
+ });
+ }),
];