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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- Issue #168: `handleResolved` used to mark every milestone of a resolved
-- escrow RELEASED without writing `releasedAmount`, leaving the column at its
-- `0` default. `escrow.resolve` sets each on-chain milestone's
-- `released_amount` to its full `amount` in the same transaction, so every
-- aggregate built on `releasedAmount` (earnings, payment history, audit
-- totals) under-counted arbitrated escrows until the handler was fixed.
--
-- The indexer cursor has already moved past those events, so a code change
-- alone cannot repair rows that were indexed before it. Backfill them here.
--
-- Scoped to jobs with a RESOLVED dispute because that is exactly the path
-- `handleResolved` mirrors: `escrow.resolve` is only callable while Disputed,
-- and it releases every milestone in full. A normal completion writes
-- `releasedAmount` through `handleMilestoneReleased`, so it is left untouched.
-- `<>` rather than `= 0` also repairs a milestone that had a partial release
-- recorded before the dispute froze the escrow.
UPDATE "Milestone" AS m
SET "releasedAmount" = m."amount"
FROM "Job" AS j
WHERE m."jobId" = j.id
AND m.status = 'RELEASED'
AND m."releasedAmount" <> m."amount"
AND j.status = 'COMPLETED'
AND EXISTS (
SELECT 1
FROM "Dispute" AS d
WHERE d."jobId" = j.id
AND d.status = 'RESOLVED'
);
5 changes: 5 additions & 0 deletions backend/src/__tests__/adminIndexerRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const h = vi.hoisted(() => ({
delete: vi.fn(),
restore: vi.fn(),
dispatch: vi.fn(),
userFindUnique: vi.fn(),
}));

vi.mock("../config", () => ({
Expand All @@ -27,6 +28,8 @@ vi.mock("../db", () => ({
findMany: h.findMany,
delete: h.delete,
},
// `requireAuth` loads the caller's current session before the admin guard.
user: { findUnique: h.userFindUnique },
},
}));

Expand Down Expand Up @@ -65,6 +68,8 @@ beforeEach(() => {
h.delete.mockReset();
h.restore.mockReset();
h.dispatch.mockReset();
h.userFindUnique.mockReset();
h.userFindUnique.mockResolvedValue({ id: "auth_user", sessions: [] });
});

async function tokenFor(address: string): Promise<string> {
Expand Down
15 changes: 14 additions & 1 deletion backend/src/__tests__/disputeRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ const h = vi.hoisted(() => ({
job: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
}));

vi.mock("../db", () => ({ prisma: { dispute: h.dispute, job: h.job } }));
vi.mock("../db", () => ({ prisma: { dispute: h.dispute, job: h.job, user: h.user } }));

// `getAsset` is wrapped rather than replaced so the registry stays real for
// every test; one test overrides it with a non-7-decimal asset, which the
Expand Down Expand Up @@ -135,9 +138,19 @@ beforeEach(() => {
h.dispute.findUnique,
h.dispute.findFirst,
h.job.findUnique,
h.user.findUnique,
]) {
fn.mockReset();
}
// `requireAuth` loads the caller's current session before the request handler.
h.user.findUnique.mockImplementation(
({ where, select }: { where: { stellarAddress: string }; select?: { sessions?: unknown } }) =>
Promise.resolve(
select?.sessions
? { id: `auth_${where.stellarAddress}`, sessions: [] }
: { id: `user_${where.stellarAddress}`, stellarAddress: where.stellarAddress },
),
);
});

/** A bearer token the `requireAuth` middleware accepts for `address`. */
Expand Down
6 changes: 5 additions & 1 deletion backend/src/__tests__/escrowRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ const builtTxs = vi.hoisted(() => [] as unknown[]);

const h = vi.hoisted(() => ({
job: { update: vi.fn(), findUnique: vi.fn() },
user: { findUnique: vi.fn() },
buildCreateArgs: vi.fn(),
}));

vi.mock("../db", () => ({ prisma: { job: h.job } }));
vi.mock("../db", () => ({ prisma: { job: h.job, user: h.user } }));

vi.mock("../lib/escrowArgs", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/escrowArgs")>();
Expand Down Expand Up @@ -117,7 +118,10 @@ beforeEach(() => {
builtTxs.length = 0;
h.job.update.mockReset();
h.job.findUnique.mockReset();
h.user.findUnique.mockReset();
h.buildCreateArgs.mockReset();
// `requireAuth` loads the caller's current session before the request handler.
h.user.findUnique.mockResolvedValue({ id: "auth_user", sessions: [] });
});

/** A bearer token the `requireAuth` middleware accepts for `address`. */
Expand Down
35 changes: 35 additions & 0 deletions backend/src/__tests__/indexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,10 @@ describe("dispatchEvent", () => {
expect(jobs[0].status).toBe("COMPLETED");
expect(jobs[0].disputes[0]).toMatchObject({ status: "RESOLVED" });
expect(jobs[0].milestones.map((m) => m.status)).toEqual(["RELEASED", "RELEASED"]);
// `escrow.resolve` sets every on-chain milestone's released_amount to its
// full amount; the mirror has to do the same, or a resolved escrow's
// milestones look RELEASED while having paid out nothing (issue #168).
expect(jobs[0].milestones.map((m) => m.releasedAmount)).toEqual(["0.5", "0.5"]);
// One transaction on-chain, so one instant off-chain.
expect(jobs[0].milestones.map((m) => m.releasedAt)).toEqual([
jobs[0].disputes[0].resolvedAt,
Expand All @@ -1156,13 +1160,44 @@ describe("dispatchEvent", () => {
expect(jobs[0].disputes[0].resolvedAt).toBeInstanceOf(Date);
});

it("overwrites a part-released milestone's amount when the dispute resolves", async () => {
// A milestone can be paid down before the dispute freezes the escrow. The
// resolution is terminal and the contract marks it fully released, so the
// mirror must replace the running total with the milestone's full amount
// rather than leave the partial value behind.
const jobs = [
fakeJob({
status: "DISPUTED",
milestones: [
{
id: "m0",
index: 0,
status: "PENDING",
releasedAt: null,
amount: "10",
releasedAmount: "4",
isStale: false,
},
],
}),
];

await dispatchEvent(makeTopicEvent("resolved", 7n), makeStore(jobs));

expect(jobs[0].milestones[0]).toMatchObject({
status: "RELEASED",
releasedAmount: "10",
});
});

it("treats dispute_resolved like resolved", async () => {
const jobs = [fakeJob({ status: "DISPUTED" })];

await dispatchEvent(makeTopicEvent("dispute_resolved", 7n), makeStore(jobs));

expect(jobs[0].status).toBe("COMPLETED");
expect(jobs[0].milestones[0].status).toBe("RELEASED");
expect(jobs[0].milestones[0].releasedAmount).toBe("0.5");
});

it("refunds the job, leaving the milestones untouched", async () => {
Expand Down
39 changes: 32 additions & 7 deletions backend/src/__tests__/jobAssign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ describe("PUT /api/jobs/:id/assign", () => {
db.job.findUnique.mockReset();
db.job.update.mockReset();
db.user.findUnique.mockReset();
// `requireAuth` loads the caller's current session before the handler runs.
// A route-level freelancer lookup (no session select) still defaults to null.
db.user.findUnique.mockImplementation(
({ where, select }: { where: { stellarAddress: string }; select?: { sessions?: unknown } }) =>
Promise.resolve(
select?.sessions
? { id: `auth_${where.stellarAddress}`, sessions: [] }
: null,
),
);
});

async function tokenFor(address: string): Promise<string> {
Expand Down Expand Up @@ -183,7 +193,14 @@ describe("PUT /api/jobs/:id/assign", () => {
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Job is already assigned to a freelancer");
expect(db.user.findUnique).not.toHaveBeenCalled();
// The auth lookup is expected; the handler must not go on to look the new
// freelancer up.
expect(
db.user.findUnique.mock.calls.some(
([args]) =>
(args as { where?: { stellarAddress?: string } }).where?.stellarAddress === FREELANCER,
),
).toBe(false);
expect(db.job.update).not.toHaveBeenCalled();
});

Expand All @@ -208,7 +225,8 @@ describe("PUT /api/jobs/:id/assign", () => {
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Client cannot be assigned as freelancer to their own job");
expect(db.user.findUnique).not.toHaveBeenCalled();
// Only the auth lookup runs; the handler rejects before resolving a profile.
expect(db.user.findUnique).toHaveBeenCalledTimes(1);
expect(db.job.update).not.toHaveBeenCalled();
});

Expand All @@ -219,7 +237,10 @@ describe("PUT /api/jobs/:id/assign", () => {
client: { stellarAddress: CLIENT },
freelancerId: null,
});
db.user.findUnique.mockResolvedValue(null);
db.user.findUnique.mockImplementation(
({ select }: { select?: { sessions?: unknown } }) =>
Promise.resolve(select?.sessions ? { id: "auth_client", sessions: [] } : null),
);
const token = await tokenFor(CLIENT);

const res = await fetch(`${baseUrl}/api/jobs/job_1/assign`, {
Expand All @@ -244,10 +265,14 @@ describe("PUT /api/jobs/:id/assign", () => {
client: { stellarAddress: CLIENT },
freelancerId: null,
});
db.user.findUnique.mockResolvedValue({
id: "freelancer_user_id",
stellarAddress: FREELANCER,
});
db.user.findUnique.mockImplementation(
({ select }: { select?: { sessions?: unknown } }) =>
Promise.resolve(
select?.sessions
? { id: "auth_client", sessions: [] }
: { id: "freelancer_user_id", stellarAddress: FREELANCER },
),
);
db.job.update.mockResolvedValue({
id: "job_1",
clientId: "client_user_id",
Expand Down
12 changes: 10 additions & 2 deletions backend/src/__tests__/jobsRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ beforeEach(() => {
fn.mockReset();
}
h.job.findMany.mockResolvedValue([]);
h.user.findUnique.mockResolvedValue({ id: "user_client" });
// `requireAuth` asks for the caller's session; the save/unsave handlers ask
// for the caller's row. Distinguish the two by the `select` shape.
h.user.findUnique.mockImplementation(({ select }: { select?: { sessions?: unknown } }) =>
Promise.resolve(select?.sessions ? { id: "user_client", sessions: [] } : { id: "user_client" }),
);
h.job.findUnique.mockResolvedValue({ id: "job_1" });
});

Expand Down Expand Up @@ -253,7 +257,11 @@ describe("POST /api/jobs/:id/save", () => {
});

it("404s an authenticated address with no user record", async () => {
h.user.findUnique.mockResolvedValue(null);
// Authentication still succeeds (the token is valid); it is the save handler
// that has no row to key the bookmark on.
h.user.findUnique.mockImplementation(({ select }: { select?: { sessions?: unknown } }) =>
Promise.resolve(select?.sessions ? { id: "user_client", sessions: [] } : null),
);

const res = await sendWithToken("POST", "/job_1/save", CLIENT);

Expand Down
8 changes: 7 additions & 1 deletion backend/src/__tests__/messagesRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ const h = vi.hoisted(() => ({
job: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
}));

vi.mock("../db", () => ({ prisma: { message: h.message, job: h.job } }));
vi.mock("../db", () => ({ prisma: { message: h.message, job: h.job, user: h.user } }));

let baseUrl: string;
let server: Server;
Expand Down Expand Up @@ -68,9 +71,12 @@ beforeEach(() => {
h.message.updateMany,
h.message.groupBy,
h.job.findUnique,
h.user.findUnique,
]) {
fn.mockReset();
}
// `requireAuth` loads the caller's current session on every authenticated call.
h.user.findUnique.mockResolvedValue({ id: "auth_user", sessions: [] });
});

async function tokenFor(address: string): Promise<string> {
Expand Down
12 changes: 10 additions & 2 deletions backend/src/__tests__/milestones.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ beforeEach(() => {
h.user.upsert.mockReset();
h.deliverable.create.mockReset();
h.deliverable.findMany.mockReset();
// `requireAuth` loads the caller's current session before the handler runs.
h.user.findUnique.mockImplementation(({ where }: { where: { stellarAddress: string } }) =>
Promise.resolve({
id: `user_${where.stellarAddress}`,
stellarAddress: where.stellarAddress,
sessions: [],
}),
);
});

describe("Milestone Deliverables API (Issue #65)", () => {
Expand Down Expand Up @@ -176,7 +184,7 @@ describe("Milestone Deliverables API (Issue #65)", () => {
id: "deliv_1",
milestoneId: "m_1",
fileName: "design_spec.pdf",
fileUrl: "data:application/pdf;base64,JVBERi0xL...",
fileUrl: "https://example.com/design_spec.pdf",
fileSize: 10240,
fileType: "application/pdf",
description: "Complete design spec for milestone 1",
Expand All @@ -191,7 +199,7 @@ describe("Milestone Deliverables API (Issue #65)", () => {
},
body: JSON.stringify({
fileName: "design_spec.pdf",
fileUrl: "data:application/pdf;base64,JVBERi0xL...",
fileUrl: "https://example.com/design_spec.pdf",
fileSize: 10240,
fileType: "application/pdf",
description: "Complete design spec for milestone 1",
Expand Down
7 changes: 5 additions & 2 deletions backend/src/__tests__/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ vi.mock("../db", () => {
user: {
findUnique: vi.fn(async ({ where }: { where: { stellarAddress?: string; id?: string } }) => {
if (where.stellarAddress) {
return fakeUsers.find((u) => u.stellarAddress === where.stellarAddress) ?? null;
const user = fakeUsers.find((u) => u.stellarAddress === where.stellarAddress);
// `requireAuth` reads the current session alongside the user row.
return user ? { ...user, sessions: [] } : null;
}
if (where.id) {
return fakeUsers.find((u) => u.id === where.id) ?? null;
const user = fakeUsers.find((u) => u.id === where.id);
return user ? { ...user, sessions: [] } : null;
}
return null;
}),
Expand Down
23 changes: 19 additions & 4 deletions backend/src/__tests__/proposals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ beforeEach(() => {
h.proposal.findMany.mockReset();
h.proposal.create.mockReset();
h.proposal.updateMany.mockReset();
// `requireAuth` loads the caller's current session before the handler runs.
// The assign route also looks a freelancer up by address, without a session
// select, so keep that lookup separate.
h.user.findUnique.mockImplementation(
({ where, select }: { where: { stellarAddress: string }; select?: { sessions?: unknown } }) =>
Promise.resolve(
select?.sessions
? { id: `auth_${where.stellarAddress}`, sessions: [] }
: { id: `user_${where.stellarAddress}`, stellarAddress: where.stellarAddress },
),
);
});

describe("Job Proposals API (Issue #63)", () => {
Expand Down Expand Up @@ -268,10 +279,14 @@ describe("Job Proposals API (Issue #63)", () => {
status: "CREATED",
client: { stellarAddress: CLIENT },
});
h.user.findUnique.mockResolvedValue({
id: "freelancer_user_id",
stellarAddress: FREELANCER,
});
h.user.findUnique.mockImplementation(
({ where, select }: { where: { stellarAddress: string }; select?: { sessions?: unknown } }) =>
Promise.resolve(
select?.sessions
? { id: `auth_${where.stellarAddress}`, sessions: [] }
: { id: "freelancer_user_id", stellarAddress: FREELANCER },
),
);
h.job.update.mockResolvedValue({
id: "job_1",
freelancerId: "freelancer_user_id",
Expand Down
Loading
Loading