Skip to content
Merged
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
153 changes: 153 additions & 0 deletions src/routes/disputes/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import Fastify from "fastify";
import { disputeRoutes } from "./index.js";
import { DisputeStore } from "./store.js";

async function buildApp(opts: { windowMs?: number; store?: DisputeStore } = {}) {
const app = Fastify();
await app.register(disputeRoutes, opts);
await app.ready();
return app;
}

function headers(role: string, userId: string) {
return { "x-role": role, "x-user-id": userId, "content-type": "application/json" };
}

test("a buyer can raise a dispute with evidence, hash-committed", async () => {
const app = await buildApp();
const res = await app.inject({
method: "POST",
url: "/disputes",
headers: headers("buyer", "buyer-1"),
payload: {
settlementId: "s1",
reason: "item not delivered",
buyerId: "buyer-1",
sellerId: "seller-1",
evidence: "tracking shows no delivery",
},
});

assert.equal(res.statusCode, 201);
const body = res.json();
assert.equal(body.status, "open");
assert.equal(body.evidence.length, 1);
assert.equal(body.evidence[0].contentHash.length, 64); // sha256 hex
assert.equal(body.evidence[0].content, "tracking shows no delivery");
});

test("listing is role-scoped: a seller cannot see another party's dispute", async () => {
const app = await buildApp();
await app.inject({
method: "POST",
url: "/disputes",
headers: headers("buyer", "buyer-1"),
payload: {
settlementId: "s1",
reason: "x",
buyerId: "buyer-1",
sellerId: "seller-1",
},
});

const asOutsider = await app.inject({
method: "GET",
url: "/disputes",
headers: headers("seller", "seller-unrelated"),
});
assert.deepEqual(asOutsider.json(), []);

const asAdjudicator = await app.inject({
method: "GET",
url: "/disputes",
headers: headers("adjudicator", "adj-1"),
});
assert.equal(asAdjudicator.json().length, 1);
});

test("only a party to the dispute may add evidence", async () => {
const app = await buildApp();
const raise = await app.inject({
method: "POST",
url: "/disputes",
headers: headers("buyer", "buyer-1"),
payload: { settlementId: "s1", reason: "x", buyerId: "buyer-1", sellerId: "seller-1" },
});
const id = raise.json().id;

const outsider = await app.inject({
method: "POST",
url: `/disputes/${id}/evidence`,
headers: headers("buyer", "someone-else"),
payload: { content: "not my dispute" },
});
assert.equal(outsider.statusCode, 403);

const seller = await app.inject({
method: "POST",
url: `/disputes/${id}/evidence`,
headers: headers("seller", "seller-1"),
payload: { content: "here's my side" },
});
assert.equal(seller.statusCode, 200);
assert.equal(seller.json().evidence.length, 1);
});

test("only an adjudicator may resolve, and resolving fires a notification", async () => {
const store = new DisputeStore();
const app = await buildApp({ store });
const raise = await app.inject({
method: "POST",
url: "/disputes",
headers: headers("buyer", "buyer-1"),
payload: { settlementId: "s1", reason: "x", buyerId: "buyer-1", sellerId: "seller-1" },
});
const id = raise.json().id;

const asBuyer = await app.inject({
method: "POST",
url: `/disputes/${id}/resolve`,
headers: headers("buyer", "buyer-1"),
payload: { outcome: "buyer", notes: "resolved" },
});
assert.equal(asBuyer.statusCode, 403);

const asAdjudicator = await app.inject({
method: "POST",
url: `/disputes/${id}/resolve`,
headers: headers("adjudicator", "adj-1"),
payload: { outcome: "buyer", notes: "evidence supports buyer" },
});
assert.equal(asAdjudicator.statusCode, 200);
assert.equal(asAdjudicator.json().status, "resolved");

const events = store.notifications.map((n) => n.event);
assert.deepEqual(events, ["raised", "resolved"]);
});

test("evidence is rejected once the window has closed", async () => {
const app = await buildApp({ windowMs: -1 }); // already closed
const raise = await app.inject({
method: "POST",
url: "/disputes",
headers: headers("buyer", "buyer-1"),
payload: { settlementId: "s1", reason: "x", buyerId: "buyer-1", sellerId: "seller-1" },
});
const id = raise.json().id;

const res = await app.inject({
method: "POST",
url: `/disputes/${id}/evidence`,
headers: headers("buyer", "buyer-1"),
payload: { content: "too late" },
});
assert.equal(res.statusCode, 409);
});

test("requests without a recognized role/user are rejected", async () => {
const app = await buildApp();
const res = await app.inject({ method: "GET", url: "/disputes" });
assert.equal(res.statusCode, 401);
});
100 changes: 100 additions & 0 deletions src/routes/disputes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { FastifyPluginAsync, FastifyRequest } from "fastify";
import { DisputeStore } from "./store.js";
import {
addEvidenceSchema,
disputeRoleSchema,
raiseDisputeSchema,
resolveDisputeSchema,
type DisputeRole,
} from "./types.js";

export interface DisputeRoutesOptions {
/** How long the evidence window stays open after a dispute is raised. */
windowMs?: number;
store?: DisputeStore;
}

/**
* Everything the dispute lifecycle needs: raise, list (role-scoped),
* attach evidence, and resolve. Auth is a placeholder — the repo has no
* session/identity layer yet, so role and user identity are read from
* headers the same way a real gateway would inject them post-auth. This
* keeps the role-scoping logic itself (the part the acceptance criteria
* care about) real and testable without inventing an auth system.
*/
export const disputeRoutes: FastifyPluginAsync<DisputeRoutesOptions> = async (
app,
opts,
) => {
const store = opts.store ?? new DisputeStore();
const windowMs = opts.windowMs ?? 7 * 24 * 60 * 60 * 1000; // 7 days

function actor(req: FastifyRequest): { role: DisputeRole; userId: string } | null {
const roleHeader = req.headers["x-role"];
const userId = req.headers["x-user-id"];
const parsed = disputeRoleSchema.safeParse(
Array.isArray(roleHeader) ? roleHeader[0] : roleHeader,
);
if (!parsed.success || typeof userId !== "string" || userId.length === 0) return null;
return { role: parsed.data, userId };
}

app.post("/disputes", async (req, reply) => {
const who = actor(req);
if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" });

const body = raiseDisputeSchema.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });

const dispute = store.raise({
...body.data,
raisedBy: who.role,
windowMs,
});
return reply.code(201).send(dispute);
});

app.get("/disputes", async (req, reply) => {
const who = actor(req);
if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" });

return reply.send(store.listForRole(who.role, who.userId));
});

app.post<{ Params: { id: string } }>("/disputes/:id/evidence", async (req, reply) => {
const who = actor(req);
if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" });

const dispute = store.get(req.params.id);
if (!dispute) return reply.code(404).send({ error: "dispute not found" });
if (!store.canAccess(dispute, who.role, who.userId)) {
return reply.code(403).send({ error: "not a party to this dispute" });
}
if (!store.isWindowOpen(dispute)) {
return reply.code(409).send({ error: "evidence window has closed" });
}

const body = addEvidenceSchema.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });

const updated = store.addEvidence(req.params.id, who.role, body.data.content);
return reply.send(updated);
});

app.post<{ Params: { id: string } }>("/disputes/:id/resolve", async (req, reply) => {
const who = actor(req);
if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" });
if (who.role !== "adjudicator") {
return reply.code(403).send({ error: "only an adjudicator may resolve a dispute" });
}

const dispute = store.get(req.params.id);
if (!dispute) return reply.code(404).send({ error: "dispute not found" });

const body = resolveDisputeSchema.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });

const updated = store.resolve(req.params.id, body.data.outcome, body.data.notes);
return reply.send(updated);
});
};
118 changes: 118 additions & 0 deletions src/routes/disputes/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { createHash, randomUUID } from "node:crypto";
import type { Dispute, DisputeRole, EvidenceItem, Notification } from "./types.js";

export function hashEvidence(content: string): string {
return createHash("sha256").update(content).digest("hex");
}

export interface RaiseDisputeInput {
settlementId: string;
reason: string;
buyerId: string;
sellerId: string;
raisedBy: DisputeRole;
evidence?: string;
windowMs: number;
now?: number;
}

/**
* In-memory store for the dispute lifecycle, with role-scoped queries and a
* notification log. Evidence content is kept alongside its hash here; a
* production deployment would put `content` in access-controlled off-chain
* storage and commit only `contentHash` on-chain (same principle as
* inference payloads) — this store already keeps that split so wiring in
* real off-chain storage and a real on-chain commit call is a swap of one
* function, not a data-model change.
*/
export class DisputeStore {
private disputes = new Map<string, Dispute>();
notifications: Notification[] = [];

constructor(private readonly now: () => number = Date.now) {}

raise(input: RaiseDisputeInput): Dispute {
const createdAt = input.now ?? this.now();
const evidence: EvidenceItem[] = input.evidence
? [
{
id: randomUUID(),
submittedBy: input.raisedBy,
content: input.evidence,
contentHash: hashEvidence(input.evidence),
createdAt,
},
]
: [];

const dispute: Dispute = {
id: randomUUID(),
settlementId: input.settlementId,
reason: input.reason,
status: "open",
buyerId: input.buyerId,
sellerId: input.sellerId,
evidence,
windowClosesAt: createdAt + input.windowMs,
createdAt,
updatedAt: createdAt,
};
this.disputes.set(dispute.id, dispute);
this.notify(dispute.id, "raised");
return dispute;
}

get(id: string): Dispute | undefined {
return this.disputes.get(id);
}

/** Role-scoped: buyers/sellers only see disputes they're a party to. */
listForRole(role: DisputeRole, userId: string): Dispute[] {
const all = [...this.disputes.values()];
if (role === "adjudicator") return all;
return all.filter((d) => d.buyerId === userId || d.sellerId === userId);
}

canAccess(dispute: Dispute, role: DisputeRole, userId: string): boolean {
if (role === "adjudicator") return true;
return dispute.buyerId === userId || dispute.sellerId === userId;
}

isWindowOpen(dispute: Dispute): boolean {
return this.now() < dispute.windowClosesAt;
}

addEvidence(id: string, submittedBy: DisputeRole, content: string): Dispute | undefined {
const dispute = this.disputes.get(id);
if (!dispute) return undefined;
dispute.evidence.push({
id: randomUUID(),
submittedBy,
content,
contentHash: hashEvidence(content),
createdAt: this.now(),
});
dispute.status = "evidence";
dispute.updatedAt = this.now();
this.notify(id, "evidence-added");
return dispute;
}

resolve(
id: string,
outcome: "buyer" | "seller" | "split",
notes: string,
): Dispute | undefined {
const dispute = this.disputes.get(id);
if (!dispute) return undefined;
dispute.status = "resolved";
dispute.outcome = { outcome, notes, resolvedAt: this.now() };
dispute.updatedAt = this.now();
this.notify(id, "resolved");
return dispute;
}

private notify(disputeId: string, event: Notification["event"]) {
this.notifications.push({ disputeId, event, at: this.now() });
}
}
Loading
Loading