Add end-to-end tests for import corridor flows

- Implement full train import journey with six container bookings filling a 54-wagon train.
- Create tests for split offer and rebooking scenarios, handling payment expiry and waiting list promotions.
- Add tests for handling waiting bookings expiration when the train is full.
- Implement tests for reopening booking windows after expired reservations.
- Seed database with necessary corridor data for import flows, including yards, container types, locomotives, and rates.
This commit is contained in:
Marshal
2026-07-22 21:02:17 +00:00
parent 5709801590
commit b0f561a935
13 changed files with 2231 additions and 32 deletions

View File

@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Contract validity",
href: "/dashboard/configuration/contract-validity-periods",
permission: FREIGHT_PERMS.config.contractValidity.view,
},
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
@@ -1463,14 +1458,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
{/* <Route
path="configuration/contract-validity-periods"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<ContractValidityPeriodsPage />
</RequirePermission>
}
/>
/> */}
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route
path="configuration/cargo-types/:id"

View File

@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { canApproveContractStep } from "@/lib/permissions";
type Mutations = ReturnType<typeof useContractMutations>;
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
key={step.id}
step={step}
isNext={actionable && nextPending?.id === step.id}
// Buttons show only to the step's actual approver (matching
// position type): a chief step never offers Approve/Reject to a
// marketing officer. Everyone still sees the "next" highlight.
canAct={canApproveContractStep(user, step.requiredRole)}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
function StepRow({
step,
isNext,
canAct,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
canAct: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
@@ -372,8 +381,11 @@ function StepRow({
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{isNext && step.status === "PENDING" && (
{/* One element type per row: action buttons on the active step (they
already imply "pending & actionable"), a status badge otherwise.
Mixing compact buttons + a badge here made them read as misaligned. */}
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{isNext && canAct && step.status === "PENDING" ? (
<>
<Button
size="compact-sm"
@@ -395,16 +407,17 @@ function StepRow({
Reject
</Button>
</>
) : (
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
)}
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
</Group>
</Group>
);

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { AuthUser } from "@/auth/types";
import { canApproveContractStep } from "./permissions";
const withPositionType = (typeKey: string): AuthUser => ({
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
});
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
const withPermission = (permKey: string): AuthUser => ({
permissionKeys: [permKey],
});
describe("canApproveContractStep", () => {
it("shows to the matching position type only", () => {
const chief = withPositionType("-marketing-chief");
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
// a marketing officer must NOT see the chief step's buttons
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
});
it("lets super/org admins action any step", () => {
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
true,
);
expect(
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
).toBe(true);
});
it("resolves legacy chain roles via their position-type aliases", () => {
const director = withPositionType("operation-director");
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
expect(canApproveContractStep(director, "CEO")).toBe(false);
});
it("honours the role's own legacy approve permission", () => {
const staff = withPermission(
"edr_freight_app:contracts:approve_director",
);
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
});
it("does NOT show to holders of an unrelated approve permission", () => {
// the dropped blanket fallback: a line-staff approver is not a chief
const lineStaff = withPermission(
"edr_freight_app:contracts:approve_line_staff",
);
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
});
it("returns false without a user or role", () => {
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
});
});

View File

@@ -240,12 +240,6 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
config: {
contractValidity: {
view: "edr_freight_app:config:contract_validity:view",
manage: "edr_freight_app:config:contract_validity:manage",
},
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
@@ -420,6 +414,53 @@ export function hasPermission(
return getPermissionKeys(user).includes(key);
}
// Legacy chain roles predate position types; map each to the position types
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
// button visibility matches what the approve/reject endpoint will accept.
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
DIRECTOR: ["director", "operation-director"],
CEO: ["chief", "deputy"],
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
/**
* Can this user action a contract approval step requiring `requiredRole`?
*
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
* to show Approve/Reject only to the step's actual approver — a chief step
* shows only to a chief, a marketing-officer step only to that officer.
*
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
* also lets through anyone holding any contract-approve permission (a fallback
* for delegates whose token omits the position type). That blanket is what made
* every approver see the button, so it is dropped here: the visibility rule is
* admin OR the matching position type (direct / legacy alias) OR the role's own
* legacy approve permission. The server still guards the mutation.
*/
export function canApproveContractStep(
user: AuthUser | null | undefined,
requiredRole: string | null | undefined,
): boolean {
if (!user || !requiredRole) return false;
if (isFreightApprovalAdmin(user)) return true;
const positionTypes = getPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
}
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}