From 34ebad390ff9cfaaaaafcf5dcd5f18b88121673e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 11 Aug 2026 13:49:03 -0300 Subject: [PATCH] fix(uplifts): align revise validation contract --- .../_components/uplift-request-form.tsx | 5 +- .../vendor-portal/lib/uplift-validation.ts | 27 +++++++++-- src/test/app/v/uplift-request-form.test.tsx | 48 +++++++++++++++++++ .../vendor-portal/uplift-workflow.test.ts | 27 +++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src/test/app/v/uplift-request-form.test.tsx diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx index f6d0e901..22ddbffe 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -37,7 +37,7 @@ export function UpliftRequestForm({ const [evidenceFile, setEvidenceFile] = useState(); const [formError, setFormError] = useState(""); - const requiresApproval = isApprovalRequired(requested, currentNte); + const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte); const submit = (event: React.FormEvent) => { event.preventDefault(); @@ -45,6 +45,7 @@ export function UpliftRequestForm({ const validationError = validateUpliftAmount(requested, currentNte, reason, { file: evidenceFile, hasExisting: hasExistingEvidence, + mode, }); setFormError(validationError); if (validationError) { @@ -92,7 +93,7 @@ export function UpliftRequestForm({ placeholder="0.00" /> - {requiresApproval && ( + {showApprovalFields && ( <>
diff --git a/src/domain/vendor-portal/lib/uplift-validation.ts b/src/domain/vendor-portal/lib/uplift-validation.ts index d4cd5fb0..b8fafe04 100644 --- a/src/domain/vendor-portal/lib/uplift-validation.ts +++ b/src/domain/vendor-portal/lib/uplift-validation.ts @@ -4,11 +4,26 @@ export type UpliftFormValues = { evidenceFile?: File; }; -export function isApprovalRequired(requested: string, currentNte: number | undefined): boolean { +export type UpliftMode = "create" | "revise"; + +export type UpliftValidationOptions = { + file?: File; + hasExisting?: boolean; + mode?: UpliftMode; +}; + +export function isApprovalRequired( + requested: string, + currentNte: number | undefined, + mode: UpliftMode = "create", +): boolean { const amount = Number.parseFloat(requested); if (!Number.isFinite(amount)) { return currentNte == null; } + if (mode === "revise") { + return true; + } return currentNte == null || amount > currentNte; } @@ -16,19 +31,23 @@ export function validateUpliftAmount( requested: string, currentNte: number | undefined, reason: string, - evidence: { file?: File; hasExisting?: boolean }, + options: UpliftValidationOptions = {}, ): string { + const mode = options.mode ?? "create"; const amount = Number.parseFloat(requested); if (!Number.isFinite(amount) || amount <= 0) { return "Enter a valid requested NTE amount."; } - if (!isApprovalRequired(requested, currentNte)) { + if (mode === "revise" && currentNte != null && amount <= currentNte) { + return `The revised NTE must be greater than the current NTE ($${currentNte.toFixed(2)}).`; + } + if (!isApprovalRequired(requested, currentNte, mode)) { return ""; } if (!reason.trim()) { return "Please include a reason for the uplift request."; } - if (!evidence.file && !evidence.hasExisting) { + if (!options.file && !options.hasExisting) { return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence."; } return ""; diff --git a/src/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx new file mode 100644 index 00000000..91cfc13e --- /dev/null +++ b/src/test/app/v/uplift-request-form.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form"; + +function renderForm(overrides: Partial> = {}) { + return render( + , + ); +} + +describe("UpliftRequestForm revise mode", () => { + it("shows reason and evidence fields regardless of amount", () => { + renderForm({ initialRequested: "900" }); + + expect(screen.getByLabelText(/reason/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/evidence/i)).toBeInTheDocument(); + }); + + it("blocks submit and does not call onSubmit when revised NTE is at or below current NTE", () => { + const onSubmit = vi.fn(); + renderForm({ initialRequested: "1000", onSubmit }); + + fireEvent.click(screen.getByRole("button", { name: /submit revision/i })); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument(); + }); + + it("reports the current NTE threshold in the actionable error", () => { + renderForm({ initialRequested: "900", currentNte: 1000 }); + + fireEvent.click(screen.getByRole("button", { name: /submit revision/i })); + + expect(screen.getByText(/\$1000\.00/)).toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/vendor-portal/uplift-workflow.test.ts b/src/test/domain/vendor-portal/uplift-workflow.test.ts index 772b2f29..787216a7 100644 --- a/src/test/domain/vendor-portal/uplift-workflow.test.ts +++ b/src/test/domain/vendor-portal/uplift-workflow.test.ts @@ -22,6 +22,33 @@ describe("vendor uplift workflow contract", () => { expect(validateUpliftAmount("110", 100, "Emergency work", { hasExisting: true })).toBe(""); }); + it("keeps create-mode requests at or below current NTE as NoApprovalRequired", () => { + expect(validateUpliftAmount("90", 100, "", { mode: "create" })).toBe(""); + expect(validateUpliftAmount("100", 100, "", { mode: "create" })).toBe(""); + }); + + it("rejects revise-mode requests at or below current NTE with an actionable error", () => { + expect( + validateUpliftAmount("90", 100, "Reason", { hasExisting: true, mode: "revise" }), + ).toMatch(/must be greater than the current NTE/i); + expect( + validateUpliftAmount("100", 100, "Reason", { hasExisting: true, mode: "revise" }), + ).toMatch(/must be greater than the current NTE/i); + }); + + it("still requires approval details in revise mode when current NTE is unknown", () => { + expect(validateUpliftAmount("110", undefined, "", { mode: "revise" })).toMatch(/reason/i); + expect(validateUpliftAmount("110", undefined, "Emergency work", { mode: "revise" })).toMatch( + /evidence/i, + ); + expect( + validateUpliftAmount("110", undefined, "Emergency work", { + hasExisting: true, + mode: "revise", + }), + ).toBe(""); + }); + it("uses a secure UUID request key", () => { expect(createUpliftRequestKey()).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,