feat(11-01): PATCH /api/threads/:id/candidates/reorder route + tests

- Import reorderCandidatesSchema and reorderCandidates into threads route
- Add PATCH /:id/candidates/reorder route with Zod validation
- Returns 200 + { success: true } on active thread, 400 on resolved thread
- Add 5 route tests: success, order persists, resolved guard, empty array, missing field
This commit is contained in:
2026-03-16 22:22:31 +01:00
parent f01d71d6b4
commit d6acfcb126
2 changed files with 130 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import { Hono } from "hono";
import { import {
createCandidateSchema, createCandidateSchema,
createThreadSchema, createThreadSchema,
reorderCandidatesSchema,
resolveThreadSchema, resolveThreadSchema,
updateCandidateSchema, updateCandidateSchema,
updateThreadSchema, updateThreadSchema,
@@ -16,6 +17,7 @@ import {
deleteThread, deleteThread,
getAllThreads, getAllThreads,
getThreadWithCandidates, getThreadWithCandidates,
reorderCandidates,
resolveThread, resolveThread,
updateCandidate, updateCandidate,
updateThread, updateThread,
@@ -122,6 +124,21 @@ app.delete("/:threadId/candidates/:candidateId", async (c) => {
return c.json({ success: true }); return c.json({ success: true });
}); });
// Candidate reorder
app.patch(
"/:id/candidates/reorder",
zValidator("json", reorderCandidatesSchema),
(c) => {
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const { orderedIds } = c.req.valid("json");
const result = reorderCandidates(db, threadId, orderedIds);
if (!result.success) return c.json({ error: result.error }, 400);
return c.json({ success: true });
},
);
// Resolution // Resolution
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {

View File

@@ -234,6 +234,119 @@ describe("Thread Routes", () => {
}); });
}); });
describe("PATCH /api/threads/:id/candidates/reorder", () => {
it("with valid orderedIds returns 200 + { success: true }", async () => {
const thread = await createThreadViaAPI(app, "Reorder Test");
const c1 = await createCandidateViaAPI(app, thread.id, {
name: "Candidate A",
categoryId: 1,
});
const c2 = await createCandidateViaAPI(app, thread.id, {
name: "Candidate B",
categoryId: 1,
});
const res = await app.request(
`/api/threads/${thread.id}/candidates/reorder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedIds: [c2.id, c1.id] }),
},
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
});
it("after PATCH reorder, GET thread returns candidates in the new order", async () => {
const thread = await createThreadViaAPI(app, "Order Verify");
const c1 = await createCandidateViaAPI(app, thread.id, {
name: "First",
categoryId: 1,
});
const c2 = await createCandidateViaAPI(app, thread.id, {
name: "Second",
categoryId: 1,
});
const c3 = await createCandidateViaAPI(app, thread.id, {
name: "Third",
categoryId: 1,
});
// Reverse the order
await app.request(`/api/threads/${thread.id}/candidates/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedIds: [c3.id, c2.id, c1.id] }),
});
const res = await app.request(`/api/threads/${thread.id}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.candidates[0].id).toBe(c3.id);
expect(body.candidates[1].id).toBe(c2.id);
expect(body.candidates[2].id).toBe(c1.id);
});
it("on a resolved thread returns 400", async () => {
const thread = await createThreadViaAPI(app, "Resolved Thread");
const candidate = await createCandidateViaAPI(app, thread.id, {
name: "Winner",
categoryId: 1,
});
// Resolve the thread first
await app.request(`/api/threads/${thread.id}/resolve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId: candidate.id }),
});
const res = await app.request(
`/api/threads/${thread.id}/candidates/reorder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedIds: [candidate.id] }),
},
);
expect(res.status).toBe(400);
});
it("with invalid body (empty orderedIds) returns 400", async () => {
const thread = await createThreadViaAPI(app, "Invalid Body");
const res = await app.request(
`/api/threads/${thread.id}/candidates/reorder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedIds: [] }),
},
);
expect(res.status).toBe(400);
});
it("with missing orderedIds field returns 400", async () => {
const thread = await createThreadViaAPI(app, "Missing Field");
const res = await app.request(
`/api/threads/${thread.id}/candidates/reorder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
},
);
expect(res.status).toBe(400);
});
});
describe("POST /api/threads/:id/resolve", () => { describe("POST /api/threads/:id/resolve", () => {
it("with valid candidateId returns 200 + created item", async () => { it("with valid candidateId returns 200 + created item", async () => {
const thread = await createThreadViaAPI(app, "Tent Decision"); const thread = await createThreadViaAPI(app, "Tent Decision");