fix(uplifts): align revise validation contract

This commit is contained in:
Alexandre Brandizzi 2026-08-11 13:49:03 -03:00
parent 889812f90f
commit 34ebad390f
4 changed files with 101 additions and 6 deletions

View file

@ -37,7 +37,7 @@ export function UpliftRequestForm({
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
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"
/>
</div>
{requiresApproval && (
{showApprovalFields && (
<>
<div className="vp-row">
<strong>

View file

@ -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 "";

View file

@ -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<React.ComponentProps<typeof UpliftRequestForm>> = {}) {
return render(
<UpliftRequestForm
mode="revise"
currentNte={1000}
initialRequested="1000"
initialReason=""
hasExistingEvidence={false}
submitting={false}
uploadingPercent={undefined}
errorMessage=""
onSubmit={vi.fn()}
onCancel={vi.fn()}
{...overrides}
/>,
);
}
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();
});
});

View file

@ -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,