feat(quotes): expose is_no_quote on PATCH /api/v1/quotes/[id]
Adds the new field to the PATCHABLE_FIELDS whitelist, validates type as boolean, and dispatches to a new setIsNoQuote service helper. Tested via mocked route handler.
This commit is contained in:
parent
1ef24ffe2f
commit
bf91b601aa
3 changed files with 76 additions and 12 deletions
|
|
@ -2,9 +2,11 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
const setQuoteNotesMock = vi.fn();
|
||||
const setIsNoQuoteMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/quotes-service", () => ({
|
||||
setQuoteNotes: (...args: unknown[]) => setQuoteNotesMock(...args),
|
||||
setIsNoQuote: (...args: unknown[]) => setIsNoQuoteMock(...args),
|
||||
}));
|
||||
|
||||
import { PATCH } from "./route";
|
||||
|
|
@ -101,3 +103,38 @@ describe("PATCH /api/v1/quotes/[id]", () => {
|
|||
expect(setQuoteNotesMock).toHaveBeenCalledWith("q-123", "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/quotes/[id] is_no_quote", () => {
|
||||
beforeEach(() => {
|
||||
setIsNoQuoteMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts is_no_quote: true and calls setIsNoQuote", async () => {
|
||||
setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: true });
|
||||
const res = await callPATCH({ is_no_quote: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", true);
|
||||
});
|
||||
|
||||
it("accepts is_no_quote: false and calls setIsNoQuote", async () => {
|
||||
setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: false });
|
||||
const res = await callPATCH({ is_no_quote: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", false);
|
||||
});
|
||||
|
||||
it("rejects is_no_quote: \"yes\" (non-boolean)", async () => {
|
||||
const res = await callPATCH({ is_no_quote: "yes" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(setIsNoQuoteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can patch is_no_quote and quote_notes in the same call", async () => {
|
||||
setQuoteNotesMock.mockResolvedValue({ id: "q-123", quote_notes: "Out of scope" });
|
||||
setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: true });
|
||||
const res = await callPATCH({ is_no_quote: true, quote_notes: "Out of scope" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", true);
|
||||
expect(setQuoteNotesMock).toHaveBeenCalledWith("q-123", "Out of scope");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { setQuoteNotes } from "@/lib/server/quotes-service";
|
||||
import { setQuoteNotes, setIsNoQuote } from "@/lib/server/quotes-service";
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/quotes/[id]
|
||||
|
|
@ -19,11 +19,11 @@ import { setQuoteNotes } from "@/lib/server/quotes-service";
|
|||
* operator-facing and the AGENT_API_KEY is reserved for cron.
|
||||
*
|
||||
* Body (JSON):
|
||||
* { quote_notes?: string }
|
||||
* { quote_notes?: string, is_no_quote?: boolean }
|
||||
*
|
||||
* Response:
|
||||
* 200 — { quote }
|
||||
* 400 — { error } (quote_notes is not a string)
|
||||
* 400 — { error } (quote_notes is not a string, is_no_quote is not a boolean)
|
||||
* 404 — { error } (quote not found)
|
||||
* 500 — { error } (PB failure, surfaced verbatim)
|
||||
*
|
||||
|
|
@ -33,7 +33,7 @@ import { setQuoteNotes } from "@/lib/server/quotes-service";
|
|||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCHABLE_FIELDS = ["quote_notes"] as const;
|
||||
const PATCHABLE_FIELDS = ["quote_notes", "is_no_quote"] as const;
|
||||
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
|
||||
|
||||
function isPatchableField(key: string): key is PatchableField {
|
||||
|
|
@ -74,6 +74,13 @@ export async function PATCH(
|
|||
);
|
||||
}
|
||||
|
||||
if ("is_no_quote" in body && typeof body.is_no_quote !== "boolean") {
|
||||
return NextResponse.json(
|
||||
{ error: "is_no_quote must be a boolean" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Caller token for future audit logging. The route does NOT enforce
|
||||
// auth here — the agent-auth check is reserved for the cron endpoints.
|
||||
const callerToken = req.headers.get("authorization") ?? undefined;
|
||||
|
|
@ -82,17 +89,25 @@ export async function PATCH(
|
|||
void callerToken;
|
||||
|
||||
try {
|
||||
let quote: Awaited<ReturnType<typeof setQuoteNotes>> | undefined;
|
||||
|
||||
if (typeof body.quote_notes === "string") {
|
||||
const quote = await setQuoteNotes(quoteId, body.quote_notes);
|
||||
return NextResponse.json({ quote });
|
||||
quote = await setQuoteNotes(quoteId, body.quote_notes);
|
||||
}
|
||||
if (typeof body.is_no_quote === "boolean") {
|
||||
quote = await setIsNoQuote(quoteId, body.is_no_quote);
|
||||
}
|
||||
|
||||
// No whitelisted fields present — nothing to do. Return 400 so
|
||||
// callers don't think they updated something silently.
|
||||
return NextResponse.json(
|
||||
{ error: "No patchable fields supplied" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!quote) {
|
||||
// No whitelisted fields present — nothing to do. Return 400 so
|
||||
// callers don't think they updated something silently.
|
||||
return NextResponse.json(
|
||||
{ error: "No patchable fields supplied" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ quote });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update quote";
|
||||
// PB throws a 404 when the record doesn't exist; surface that
|
||||
|
|
|
|||
|
|
@ -2793,3 +2793,15 @@ export async function setQuoteNotes(
|
|||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the no-quote flag on a quote. Reverts to false (a real
|
||||
* quote) when called with `false`. Stamps `updated` automatically.
|
||||
*/
|
||||
export async function setIsNoQuote(
|
||||
quoteId: string,
|
||||
value: boolean,
|
||||
): Promise<Quote> {
|
||||
const pb = await getAdminPocketBase();
|
||||
return pb.collection(QUOTES_COLLECTION).update<Quote>(quoteId, { is_no_quote: value });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue