feat(operations): reservation actions, surfaces, and cancel flow
This commit is contained in:
parent
1f7d1e47ef
commit
f895f75317
6 changed files with 1175 additions and 0 deletions
251
src/app/(frontend)/operations/reservations/[id]/page.tsx
Normal file
251
src/app/(frontend)/operations/reservations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { LedgerRow, NoticeState, PageShell } from "@/components/frontend/operations";
|
||||
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||
import type { OperationReservation } from "@/payload-types";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
function relationId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||
const id = (value as { id: unknown }).id;
|
||||
return typeof id === "number" ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function relationLabel(value: unknown): string {
|
||||
if (typeof value === "object" && value !== null && "codeName" in (value as { codeName?: unknown })) {
|
||||
const codeName = (value as { codeName?: unknown }).codeName;
|
||||
if (typeof codeName === "string" && codeName) return codeName;
|
||||
}
|
||||
return String(relationId(value) ?? "n/a");
|
||||
}
|
||||
|
||||
interface ReservationDetail {
|
||||
reservation: OperationReservation | null;
|
||||
canCancel: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full detail for a single reservation, fetched through collection access
|
||||
* control (default access + the explicit session user, never overrideAccess).
|
||||
* Returns null for an unknown id so the page can render a not-found notice.
|
||||
* canCancel is true only when the row is still reserved AND the viewer holds
|
||||
* operation-reservations:update.
|
||||
*/
|
||||
export async function getReservationDetail(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
id: string,
|
||||
): Promise<ReservationDetail> {
|
||||
let reservation: OperationReservation | null = null;
|
||||
try {
|
||||
reservation = await payload.findByID({
|
||||
collection: "operation-reservations",
|
||||
id: Number(id),
|
||||
depth: 1,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
reservation = null;
|
||||
}
|
||||
|
||||
const canCancel =
|
||||
reservation !== null &&
|
||||
reservation.status === "reserved" &&
|
||||
user !== null &&
|
||||
(await hasPermission(payload, user, "operation-reservations:update"));
|
||||
|
||||
return { reservation, canCancel };
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section data-slot="reservation-section" className="flex flex-col gap-2">
|
||||
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function kvRow(label: string, value: React.ReactNode) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
||||
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">{label}</span>
|
||||
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Reservation Detail: Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function ReservationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
|
||||
|
||||
const { reservation, canCancel } = await getReservationDetail(payload, surfaceUser, id);
|
||||
|
||||
if (reservation === null) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<PageShell title="Reservation" meta="ALLOCATION RESERVATIONS // COMMAND VIEW">
|
||||
<NoticeState
|
||||
variant="error"
|
||||
message="Reservation not found."
|
||||
hint="The reservation id is unknown or you lack permission to read it."
|
||||
action={
|
||||
<Link
|
||||
href="/operations/reservations"
|
||||
className="mt-2 border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
|
||||
>
|
||||
Back to reservations
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</PageShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const personnel = reservation.personnel ?? [];
|
||||
const vehicles = reservation.vehicles ?? [];
|
||||
const cargo = reservation.cargo ?? [];
|
||||
const budget = reservation.budget;
|
||||
|
||||
const readoutCells = [
|
||||
{ label: "Key", value: reservation.reservationKey },
|
||||
{ label: "Operation", value: reservation.operationId },
|
||||
{ label: "Status", value: reservation.status },
|
||||
{ label: "Expires", value: reservation.expiresAt ?? "n/a" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<PageShell title={reservation.reservationKey} meta="RESERVATION // DETAIL">
|
||||
<Link
|
||||
href="/operations/reservations"
|
||||
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||
>
|
||||
Back to reservations
|
||||
</Link>
|
||||
|
||||
<LedgerRow cells={readoutCells} />
|
||||
|
||||
<Section title="Personnel">
|
||||
{personnel.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No personnel reserved." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{personnel.map((member) => (
|
||||
<li key={member.id} className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5">
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||
{relationLabel(member.user)} · id {relationId(member.user)}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||
{member.slot ?? "slot"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Vehicles">
|
||||
{vehicles.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No vehicles reserved." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{vehicles.map((vehicle) => (
|
||||
<li
|
||||
key={vehicle.id}
|
||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||
{relationLabel(vehicle.vehicle)} · id {relationId(vehicle.vehicle)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Cargo">
|
||||
{cargo.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No cargo reserved." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{cargo.map((line) => (
|
||||
<li
|
||||
key={line.id}
|
||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||
{relationLabel(line.resource)} · id {relationId(line.resource)}
|
||||
</span>
|
||||
<span className="font-mono text-xs tabular-nums text-white/70">{line.amount}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Budget">
|
||||
{budget?.amount != null || budget?.account != null ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{kvRow("Account", budget.account != null ? `${relationLabel(budget.account)} · id ${relationId(budget.account)}` : "n/a")}
|
||||
{kvRow("Amount", budget.amount ?? "n/a")}
|
||||
</div>
|
||||
) : (
|
||||
<NoticeState variant="empty" message="No budget earmarked." />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Routing">
|
||||
<div className="flex flex-col gap-1">
|
||||
{kvRow("Origin", reservation.origin != null ? relationLabel(reservation.origin) : "n/a")}
|
||||
{kvRow("Destination", reservation.destination != null ? relationLabel(reservation.destination) : "n/a")}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Metadata">
|
||||
<div className="flex flex-col gap-1">
|
||||
{kvRow("Created by", reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a")}
|
||||
{kvRow("Created at", reservation.createdAt)}
|
||||
{kvRow("Expires", reservation.expiresAt ?? "n/a")}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{reservation.status !== "reserved" && (
|
||||
<Section title="Settlement">
|
||||
<pre className="overflow-x rounded border border-white/10 bg-black/40 p-2 font-mono text-[11px] text-white/70">
|
||||
{JSON.stringify(reservation.settlement, null, 2)}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{canCancel && reservation.status === "reserved" ? (
|
||||
<CancelButton reservationId={reservation.id} />
|
||||
) : null}
|
||||
</PageShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/app/(frontend)/operations/reservations/actions.ts
Normal file
85
src/app/(frontend)/operations/reservations/actions.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import type { OperationReservation } from "@/payload-types";
|
||||
import {
|
||||
reserveOperationAssets,
|
||||
settleOperationReservation,
|
||||
type ReserveOperationAssetsInput,
|
||||
} from "@/lib/operations/allocation";
|
||||
|
||||
export interface ActionResult<T> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const { headers } = await import("next/headers");
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await headers(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
return { payload, user: isPayloadUser(authUser) ? authUser : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve people, vehicles, cargo, and treasury budget for an operation.
|
||||
*
|
||||
* Anti-spoofing: the actor is ALWAYS the authenticated session user. The
|
||||
* client-supplied `actorId` is overwritten with `user.id` before the input
|
||||
* reaches the allocation engine, so a caller cannot attribute a reservation
|
||||
* to (or impersonate) another account.
|
||||
*/
|
||||
export async function createReservation(
|
||||
input: ReserveOperationAssetsInput,
|
||||
): Promise<ActionResult<OperationReservation>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!user) return { success: false, error: "Authentication required." };
|
||||
if (!(await hasPermission(payload, user, "operation-reservations:create"))) {
|
||||
return { success: false, error: "forbidden" };
|
||||
}
|
||||
|
||||
const sanitizedInput: ReserveOperationAssetsInput = {
|
||||
...input,
|
||||
actorId: user.id,
|
||||
};
|
||||
|
||||
const row = await reserveOperationAssets(payload, sanitizedInput);
|
||||
return { success: true, data: row };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a still-reserved reservation. Settlement is exactly-once: a second
|
||||
* cancel (or any settle on an already-terminal row) is a clean no-op that
|
||||
* returns the stored row, and an unknown ref maps to a "not found" error.
|
||||
*/
|
||||
export async function cancelReservation(
|
||||
ref: { reservationId?: number; reservationKey?: string },
|
||||
opts?: { reason?: string },
|
||||
): Promise<ActionResult<OperationReservation>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!user) return { success: false, error: "Authentication required." };
|
||||
if (!(await hasPermission(payload, user, "operation-reservations:update"))) {
|
||||
return { success: false, error: "forbidden" };
|
||||
}
|
||||
|
||||
const row = await settleOperationReservation(payload, ref, {
|
||||
outcome: "cancelled",
|
||||
reason: opts?.reason,
|
||||
actorId: user.id,
|
||||
});
|
||||
return { success: true, data: row };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
BookOpen,
|
||||
Boxes,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
Car,
|
||||
Contact,
|
||||
Crosshair,
|
||||
|
|
@ -78,6 +79,11 @@ export const sidebarData = {
|
|||
url: "/operations/ledger",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
title: "Reservations",
|
||||
url: "/operations/reservations",
|
||||
icon: ClipboardList,
|
||||
},
|
||||
],
|
||||
navLogistics: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cancelReservation, type ActionResult } from "@/app/(frontend)/operations/reservations/actions";
|
||||
|
||||
/**
|
||||
* Client-side cancel control for a still-reserved reservation. Dispatches
|
||||
* cancelReservation (a server action) inside a transition, surfaces the
|
||||
* ActionResult error verbatim inline, and refreshes the page on success so
|
||||
* the terminal status renders. Disabled while the cancel is in flight.
|
||||
*/
|
||||
export function CancelButton({ reservationId }: { reservationId: number }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
async function onCancel() {
|
||||
setError(null);
|
||||
const result: ActionResult<import("@/payload-types").OperationReservation> =
|
||||
await cancelReservation({ reservationId }, { reason: "Cancelled by user" });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to cancel reservation.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="reservation-cancel">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="reservation-cancel-button"
|
||||
onClick={() => startTransition(onCancel)}
|
||||
disabled={isPending}
|
||||
className="border border-rose-500/30 bg-rose-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-rose-300 transition-colors hover:border-rose-500/50 hover:text-rose-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Canceling\u2026" : "Cancel"}
|
||||
</button>
|
||||
{error ? (
|
||||
<p data-slot="reservation-cancel-error" className="mt-1 min-w-0 truncate text-xs text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
585
tests/int/operation-reservation-actions.int.spec.ts
Normal file
585
tests/int/operation-reservation-actions.int.spec.ts
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import type { BankAccount, OperationReservation, Structure, User } from "@/payload-types";
|
||||
import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions";
|
||||
import { applyTransaction, createAccount } from "@/lib/banking";
|
||||
|
||||
// Mock next/headers so the action's authenticate() can run outside a request.
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => new Headers(),
|
||||
}));
|
||||
|
||||
let payload: Payload;
|
||||
let authSpy: MockInstance;
|
||||
|
||||
const RUN = `res-actions-${Date.now().toString(36)}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture tracking for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
const reservationIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const accountIds: number[] = [];
|
||||
const gameStructureIds: number[] = [];
|
||||
const blueprintIds: number[] = [];
|
||||
const gameVehicleIds: number[] = [];
|
||||
const vehicleBlueprintIds: number[] = [];
|
||||
const resourceIds: number[] = [];
|
||||
const missionIds: number[] = [];
|
||||
const campaignIds: number[] = [];
|
||||
const mapIds: number[] = [];
|
||||
let serverId: number;
|
||||
|
||||
let commandUser: User;
|
||||
let plainUser: User;
|
||||
let spoofedUser: User;
|
||||
let soldierOne: User;
|
||||
let mapId: number;
|
||||
let campaignId: number;
|
||||
let missionId: number;
|
||||
let resourceAId: number;
|
||||
let normalBlueprintId: number;
|
||||
let vehicleBlueprintId: number;
|
||||
|
||||
const LEXICAL_EMPTY = {
|
||||
root: {
|
||||
children: [{ text: "" }],
|
||||
direction: null,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function relId(value: unknown): number {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as { id: number }).id
|
||||
: (value as number);
|
||||
}
|
||||
|
||||
async function findByKey(key: string): Promise<OperationReservation | null> {
|
||||
const res = await payload.find({
|
||||
collection: "operation-reservations",
|
||||
where: { reservationKey: { equals: key } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return (res.docs[0] as OperationReservation | undefined) ?? null;
|
||||
}
|
||||
|
||||
async function makeUser(label: string, roleDocIds?: number[]): Promise<User> {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}-discord`,
|
||||
displayName: `${RUN} ${label}`,
|
||||
steamId: `${RUN}-steam-${label}`,
|
||||
password: "Test1234",
|
||||
roles: ["user"],
|
||||
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function makeMission(): Promise<number> {
|
||||
const mission = await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
name: `${RUN} Main`,
|
||||
codeName: `${RUN}-main`,
|
||||
summary: "Reservation action test mission",
|
||||
operationType: "main",
|
||||
classification: {
|
||||
map: mapId,
|
||||
missionType: "PvE",
|
||||
campaign: campaignId,
|
||||
startDateTime: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
estimatedDuration: 60,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
authors: [commandUser.id],
|
||||
status: "Scheduled",
|
||||
visibility: "unit",
|
||||
},
|
||||
missionRoles: { maxPlayers: 16 },
|
||||
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
|
||||
briefing: [],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
missionIds.push(mission.id);
|
||||
return mission.id;
|
||||
}
|
||||
|
||||
async function makeOrigin(stock: { resourceId: number; amount: number }[]): Promise<number> {
|
||||
const site = await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: `${RUN} origin ${gameStructureIds.length}`,
|
||||
type: normalBlueprintId,
|
||||
map: mapId,
|
||||
coordinates: [100 + gameStructureIds.length, 100],
|
||||
constructionStatus: "complete",
|
||||
storedResources: stock.map((s, i) => ({
|
||||
resource: s.resourceId,
|
||||
amount: s.amount,
|
||||
gridX: 0,
|
||||
gridY: i,
|
||||
rotated: false,
|
||||
})),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
gameStructureIds.push(site.id);
|
||||
return site.id;
|
||||
}
|
||||
|
||||
async function makeDestination(): Promise<number> {
|
||||
const site = await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: `${RUN} dest ${gameStructureIds.length}`,
|
||||
type: normalBlueprintId,
|
||||
map: mapId,
|
||||
coordinates: [500 + gameStructureIds.length, 500],
|
||||
constructionStatus: "complete",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
gameStructureIds.push(site.id);
|
||||
return site.id;
|
||||
}
|
||||
|
||||
async function makeVehicle(atId: number): Promise<number> {
|
||||
const vehicle = await payload.create({
|
||||
collection: "game-vehicles",
|
||||
data: {
|
||||
name: `${RUN} vic ${gameVehicleIds.length}`,
|
||||
type: vehicleBlueprintId,
|
||||
deployedAt: atId,
|
||||
status: "idle",
|
||||
currentFuel: 1000,
|
||||
currentHealth: 100,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
gameVehicleIds.push(vehicle.id);
|
||||
return vehicle.id;
|
||||
}
|
||||
|
||||
async function makeFundedAccount(amount: number): Promise<BankAccount> {
|
||||
const account = await createAccount(payload, {
|
||||
name: `${RUN} Treasury ${accountIds.length}`,
|
||||
accountType: "treasury",
|
||||
});
|
||||
accountIds.push(account.id);
|
||||
if (amount > 0) {
|
||||
await applyTransaction(payload, {
|
||||
type: "deposit",
|
||||
toAccountId: account.id,
|
||||
amount,
|
||||
memo: `${RUN} test funding`,
|
||||
});
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
authSpy = vi.spyOn(payload, "auth");
|
||||
|
||||
const superRole = await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
roleIds.push(superRole.id);
|
||||
|
||||
commandUser = await makeUser("command", [superRole.id]);
|
||||
plainUser = await makeUser("plain");
|
||||
spoofedUser = await makeUser("spoofed");
|
||||
soldierOne = await makeUser("soldier1");
|
||||
|
||||
const map = await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
mapId = map.id;
|
||||
mapIds.push(map.id);
|
||||
|
||||
const campaign = await payload.create({
|
||||
collection: "campaigns",
|
||||
data: {
|
||||
name: `${RUN} Campaign`,
|
||||
summary: "Reservation action test campaign",
|
||||
status: "concept",
|
||||
campaignMode: "custom",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
campaignId = campaign.id;
|
||||
campaignIds.push(campaign.id);
|
||||
|
||||
missionId = await makeMission();
|
||||
|
||||
const server = await payload.create({
|
||||
collection: "game-servers",
|
||||
data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
serverId = server.id;
|
||||
|
||||
const resource = await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${RUN} Alpha`,
|
||||
codeName: `${RUN}-alpha`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit: 1,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
resourceIds.push(resource.id);
|
||||
resourceAId = resource.id;
|
||||
|
||||
const normalBlueprint = await payload.create({
|
||||
collection: "structures",
|
||||
data: {
|
||||
name: `${RUN} Depot`,
|
||||
codeName: `${RUN}-depot`,
|
||||
approvalStatus: "in_progress",
|
||||
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
||||
category: "logistics",
|
||||
materials: [{ resource: resourceAId, amount: 1 }],
|
||||
constructionDurationMinutes: 1,
|
||||
terrainType: "land",
|
||||
maxHealth: 100,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
normalBlueprintId = normalBlueprint.id;
|
||||
blueprintIds.push(normalBlueprint.id);
|
||||
|
||||
const vehicleBlueprint = await payload.create({
|
||||
collection: "vehicles",
|
||||
data: {
|
||||
name: `${RUN} Truck`,
|
||||
approvalStatus: "in_progress",
|
||||
transportMode: "ground",
|
||||
maxSpeedOnRoad: 60,
|
||||
fuel: { fuelType: resourceAId, fuelCapacity: 1000, fuelConsumptionRate: 0.01 },
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
vehicleBlueprintId = vehicleBlueprint.id;
|
||||
vehicleBlueprintIds.push(vehicleBlueprint.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of reservationIds) {
|
||||
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of gameVehicleIds) {
|
||||
await payload.delete({ collection: "game-vehicles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of gameStructureIds) {
|
||||
await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of vehicleBlueprintIds) {
|
||||
await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of blueprintIds) {
|
||||
await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of resourceIds) {
|
||||
await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of missionIds) {
|
||||
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of campaignIds) {
|
||||
await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of mapIds) {
|
||||
await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
||||
const deleteAccountChain = async (accountId: number) => {
|
||||
const txns = await payload.find({
|
||||
collection: "bank-transactions",
|
||||
where: { or: [{ fromAccount: { equals: accountId } }, { toAccount: { equals: accountId } }] },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const txn of txns.docs) {
|
||||
const entries = await payload.find({
|
||||
collection: "ledger-entries",
|
||||
where: { transaction: { equals: txn.id } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const entry of entries.docs) {
|
||||
await payload.delete({ collection: "ledger-entries", id: entry.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "bank-accounts", id: accountId, overrideAccess: true }).catch(() => {});
|
||||
};
|
||||
|
||||
for (const id of accountIds) {
|
||||
await deleteAccountChain(id);
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
if (serverId) {
|
||||
await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("reservation server actions", () => {
|
||||
describe("createReservation", () => {
|
||||
it("persists a reserved row with all lines and attributes it to the session user", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
const destinationId = await makeDestination();
|
||||
const vehicleId = await makeVehicle(originId);
|
||||
const account = await makeFundedAccount(1000);
|
||||
|
||||
const result = await createReservation({
|
||||
reservationKey: `${RUN}-happy`,
|
||||
operationId: `${RUN}-op-happy`,
|
||||
actorId: commandUser.id,
|
||||
missionId,
|
||||
personnel: [{ userId: soldierOne.id, slot: "Team Lead" }],
|
||||
vehicleIds: [vehicleId],
|
||||
cargo: [{ resourceId: resourceAId, amount: 40 }],
|
||||
budget: { accountId: account.id, amount: 200 },
|
||||
originId,
|
||||
destinationId,
|
||||
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.status).toBe("reserved");
|
||||
|
||||
const row = await findByKey(`${RUN}-happy`);
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.reservationKey).toBe(`${RUN}-happy`);
|
||||
expect(row?.status).toBe("reserved");
|
||||
expect(relId(row?.createdBy)).toBe(commandUser.id);
|
||||
expect(relId(row?.mission)).toBe(missionId);
|
||||
expect(row?.personnel).toHaveLength(1);
|
||||
expect(relId(row?.personnel?.[0]?.user)).toBe(soldierOne.id);
|
||||
expect(row?.vehicles).toHaveLength(1);
|
||||
expect(relId(row?.vehicles?.[0]?.vehicle)).toBe(vehicleId);
|
||||
expect(row?.cargo).toHaveLength(1);
|
||||
expect(relId(row?.cargo?.[0]?.resource)).toBe(resourceAId);
|
||||
expect(row?.cargo?.[0]?.amount).toBe(40);
|
||||
expect(relId(row?.budget?.account)).toBe(account.id);
|
||||
expect(row?.budget?.amount).toBe(200);
|
||||
});
|
||||
|
||||
it("overwrites a client-supplied actorId with the session user (anti-spoofing)", async () => {
|
||||
// The session user is the privileged command user; the input tries to
|
||||
// attribute the reservation to an unrelated, unprivileged account.
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
|
||||
const result = await createReservation({
|
||||
reservationKey: `${RUN}-spoof`,
|
||||
operationId: `${RUN}-op-spoof`,
|
||||
actorId: spoofedUser.id, // must be ignored server-side
|
||||
personnel: [{ userId: soldierOne.id }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const row = await findByKey(`${RUN}-spoof`);
|
||||
expect(row).not.toBeNull();
|
||||
expect(relId(row?.createdBy)).toBe(commandUser.id);
|
||||
expect(relId(row?.createdBy)).not.toBe(spoofedUser.id);
|
||||
});
|
||||
|
||||
it("rejects a user without operation-reservations:create with a forbidden error", async () => {
|
||||
authSpy.mockResolvedValue({ user: plainUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
|
||||
const result = await createReservation({
|
||||
reservationKey: `${RUN}-forbidden`,
|
||||
operationId: `${RUN}-op-forbidden`,
|
||||
actorId: plainUser.id,
|
||||
cargo: [{ resourceId: resourceAId, amount: 5 }],
|
||||
originId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("forbidden");
|
||||
expect(await findByKey(`${RUN}-forbidden`)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the engine capacity error verbatim without writing a row", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
|
||||
const result = await createReservation({
|
||||
reservationKey: `${RUN}-insufficient`,
|
||||
operationId: `${RUN}-op-insufficient`,
|
||||
actorId: commandUser.id,
|
||||
cargo: [{ resourceId: resourceAId, amount: 101 }],
|
||||
originId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Insufficient cargo");
|
||||
expect(result.error).toContain("100 available at the origin");
|
||||
expect(result.error).toContain("101 requested");
|
||||
expect(await findByKey(`${RUN}-insufficient`)).toBeNull();
|
||||
});
|
||||
|
||||
it("is idempotent for a retry with the same reservation key", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
const key = `${RUN}-retry`;
|
||||
const input = {
|
||||
reservationKey: key,
|
||||
operationId: `${RUN}-op-retry`,
|
||||
actorId: commandUser.id,
|
||||
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
||||
originId,
|
||||
};
|
||||
|
||||
const first = await createReservation(input);
|
||||
const second = await createReservation(input);
|
||||
|
||||
expect(first.success).toBe(true);
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data?.id).toBe(first.data?.id);
|
||||
const all = await payload.find({
|
||||
collection: "operation-reservations",
|
||||
where: { reservationKey: { equals: key } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(all.docs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelReservation", () => {
|
||||
it("cancels a reserved reservation exactly once; a second cancel is a clean no-op", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
|
||||
const created = await createReservation({
|
||||
reservationKey: `${RUN}-cancel`,
|
||||
operationId: `${RUN}-op-cancel`,
|
||||
actorId: commandUser.id,
|
||||
cargo: [{ resourceId: resourceAId, amount: 25 }],
|
||||
originId,
|
||||
});
|
||||
reservationIds.push(created.data!.id);
|
||||
expect(created.success).toBe(true);
|
||||
const reservationId = created.data!.id;
|
||||
|
||||
const first = await cancelReservation({ reservationId }, { reason: "plan changed" });
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data?.status).toBe("cancelled");
|
||||
expect(first.data?.settledAt).toBeTruthy();
|
||||
|
||||
// Second settle is a no-op: same terminal status and timestamp.
|
||||
const second = await cancelReservation({ reservationId });
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data?.status).toBe("cancelled");
|
||||
expect(second.data?.settledAt).toBe(first.data?.settledAt);
|
||||
});
|
||||
|
||||
it("cancels by reservation key", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
const key = `${RUN}-cancel-key`;
|
||||
|
||||
const created = await createReservation({
|
||||
reservationKey: key,
|
||||
operationId: `${RUN}-op-cancel-key`,
|
||||
actorId: commandUser.id,
|
||||
cargo: [{ resourceId: resourceAId, amount: 5 }],
|
||||
originId,
|
||||
});
|
||||
reservationIds.push(created.data!.id);
|
||||
|
||||
const cancelled = await cancelReservation({ reservationKey: key });
|
||||
expect(cancelled.success).toBe(true);
|
||||
expect(cancelled.data?.status).toBe("cancelled");
|
||||
expect((await findByKey(key))?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("maps an unknown reservation id to a not-found error", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const missing = await cancelReservation({ reservationId: 999_999_999 });
|
||||
expect(missing.success).toBe(false);
|
||||
expect(missing.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("denies cancel for a user without operation-reservations:update", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const originId = await makeOrigin([{ resourceId: resourceAId, amount: 100 }]);
|
||||
const created = await createReservation({
|
||||
reservationKey: `${RUN}-cancel-denied`,
|
||||
operationId: `${RUN}-op-cancel-denied`,
|
||||
actorId: commandUser.id,
|
||||
cargo: [{ resourceId: resourceAId, amount: 10 }],
|
||||
originId,
|
||||
});
|
||||
reservationIds.push(created.data!.id);
|
||||
|
||||
authSpy.mockResolvedValue({ user: plainUser });
|
||||
const denied = await cancelReservation({ reservationId: created.data!.id });
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.error).toContain("forbidden");
|
||||
});
|
||||
});
|
||||
});
|
||||
201
tests/int/operation-reservation-surfaces.int.spec.ts
Normal file
201
tests/int/operation-reservation-surfaces.int.spec.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import type { OperationReservation, User } from "@/payload-types";
|
||||
import { getReservationList } from "@/app/(frontend)/operations/reservations/page";
|
||||
import { getReservationDetail } from "@/app/(frontend)/operations/reservations/[id]/page";
|
||||
import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions";
|
||||
|
||||
// Mock next/headers so the action's authenticate() can run outside a request.
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => new Headers(),
|
||||
}));
|
||||
|
||||
let payload: Payload;
|
||||
let authSpy: MockInstance;
|
||||
|
||||
const RUN = `res-surfaces-${Date.now().toString(36)}`;
|
||||
|
||||
const reservationIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
|
||||
let superUser: User;
|
||||
let plainUser: User;
|
||||
let soldierOne: User;
|
||||
|
||||
function relId(value: unknown): number {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as { id: number }).id
|
||||
: (value as number);
|
||||
}
|
||||
|
||||
async function makeUser(label: string, roleDocIds?: number[]): Promise<User> {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}-discord`,
|
||||
displayName: `${RUN} ${label}`,
|
||||
steamId: `${RUN}-steam-${label}`,
|
||||
password: "Test1234",
|
||||
roles: ["user"],
|
||||
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Create a minimal (personnel-only) reservation that succeeds without fixtures. */
|
||||
async function createReservedReservation(key: string, operationId: string): Promise<number> {
|
||||
const result = await createReservation({
|
||||
reservationKey: key,
|
||||
operationId,
|
||||
actorId: superUser.id,
|
||||
personnel: [{ userId: soldierOne.id, slot: "Team Lead" }],
|
||||
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const id = result.data!.id;
|
||||
reservationIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
authSpy = vi.spyOn(payload, "auth");
|
||||
|
||||
const superRole = await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
roleIds.push(superRole.id);
|
||||
|
||||
superUser = await makeUser("command", [superRole.id]);
|
||||
plainUser = await makeUser("plain");
|
||||
soldierOne = await makeUser("soldier1");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of reservationIds) {
|
||||
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe("reservation surfaces: getReservationList", () => {
|
||||
it("returns rows and capability true for a permitted viewer", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
await createReservedReservation(`${RUN}-list-1`, `${RUN}-op-a`);
|
||||
|
||||
const data = await getReservationList(payload, { id: superUser.id }, {}, 1);
|
||||
expect(data.capability).toBe(true);
|
||||
expect(data.rows.length).toBeGreaterThan(0);
|
||||
expect(data.rows.some((r) => r.reservationKey === `${RUN}-list-1`)).toBe(true);
|
||||
const listed = data.rows.find((r) => r.reservationKey === `${RUN}-list-1`);
|
||||
expect(listed?.status).toBe("reserved");
|
||||
expect(listed?.expiresAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns empty rows and capability false for a plain user (permission-denied shape)", async () => {
|
||||
authSpy.mockResolvedValue({ user: plainUser });
|
||||
const data = await getReservationList(payload, { id: plainUser.id }, {}, 1);
|
||||
expect(data.capability).toBe(false);
|
||||
expect(data.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("narrows rows by status", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
await createReservedReservation(`${RUN}-filter-r`, `${RUN}-op-filter-r`);
|
||||
const cancelledId = await createReservedReservation(`${RUN}-filter-c`, `${RUN}-op-filter-c`);
|
||||
await cancelReservation({ reservationId: cancelledId });
|
||||
|
||||
const reserved = await getReservationList(payload, { id: superUser.id }, { status: "reserved" }, 1);
|
||||
const reservedRows = reserved.rows.filter((r) => r.reservationKey === `${RUN}-filter-r`);
|
||||
expect(reservedRows).toHaveLength(1);
|
||||
expect(reservedRows[0].status).toBe("reserved");
|
||||
|
||||
const cancelled = await getReservationList(payload, { id: superUser.id }, { status: "cancelled" }, 1);
|
||||
const cancelledRows = cancelled.rows.filter((r) => r.reservationKey === `${RUN}-filter-c`);
|
||||
expect(cancelledRows).toHaveLength(1);
|
||||
expect(cancelledRows[0].status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("narrows rows by operationId", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
await createReservedReservation(`${RUN}-opid-1`, `${RUN}-op-specific`);
|
||||
const data = await getReservationList(payload, { id: superUser.id }, { operationId: `${RUN}-op-specific` }, 1);
|
||||
expect(data.rows).toHaveLength(1);
|
||||
expect(data.rows[0].operationId).toBe(`${RUN}-op-specific`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reservation surfaces: getReservationDetail", () => {
|
||||
it("returns the full reservation and canCancel when reserved and permitted", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
const id = await createReservedReservation(`${RUN}-detail-1`, `${RUN}-op-detail`);
|
||||
|
||||
const detail = await getReservationDetail(payload, { id: superUser.id }, String(id));
|
||||
expect(detail.reservation).not.toBeNull();
|
||||
expect(detail.reservation?.id).toBe(id);
|
||||
expect(detail.reservation?.status).toBe("reserved");
|
||||
expect(detail.reservation?.personnel).toHaveLength(1);
|
||||
expect(relId(detail.reservation?.personnel?.[0]?.user)).toBe(soldierOne.id);
|
||||
expect(detail.canCancel).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a not-found reservation for an unknown id", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
const detail = await getReservationDetail(payload, { id: superUser.id }, "999999999");
|
||||
expect(detail.reservation).toBeNull();
|
||||
expect(detail.canCancel).toBe(false);
|
||||
});
|
||||
|
||||
it("does not allow cancel for a plain user even on a reserved row", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
const id = await createReservedReservation(`${RUN}-detail-2`, `${RUN}-op-detail-2`);
|
||||
|
||||
const detail = await getReservationDetail(payload, { id: plainUser.id }, String(id));
|
||||
expect(detail.reservation).not.toBeNull();
|
||||
expect(detail.canCancel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reservation surfaces: cancelReservation integration", () => {
|
||||
it("cancels a reserved reservation exactly once; second cancel is a clean no-op", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
const id = await createReservedReservation(`${RUN}-cancel-int`, `${RUN}-op-cancel-int`);
|
||||
|
||||
const first = await cancelReservation({ reservationId: id }, { reason: "Cancelled by user" });
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data?.status).toBe("cancelled");
|
||||
expect(first.data?.settledAt).toBeTruthy();
|
||||
|
||||
const second = await cancelReservation({ reservationId: id });
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data?.status).toBe("cancelled");
|
||||
expect(second.data?.settledAt).toBe(first.data?.settledAt);
|
||||
});
|
||||
|
||||
it("denies cancel for a user without operation-reservations:update", async () => {
|
||||
authSpy.mockResolvedValue({ user: superUser });
|
||||
const id = await createReservedReservation(`${RUN}-cancel-denied`, `${RUN}-op-cancel-denied`);
|
||||
|
||||
authSpy.mockResolvedValue({ user: plainUser });
|
||||
const denied = await cancelReservation({ reservationId: id });
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.error).toContain("forbidden");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue