Replace the maps:create/update/delete catch-all for map features: roads, zones, and nodes each take a per-collection author permission, structure placement takes maps:place (still co-gated with the logistics qualification), and GeoJSON import requires all three author permissions. The map view page keys edit UI per feature type; admin role seed grants the new values. Tests updated for the permission split.
178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
|
import config from "@payload-config";
|
|
import { getPayload, type Payload } from "payload";
|
|
import type { Map as MapDoc, Resource } from "@/payload-types";
|
|
import { checkResourcesInRange } from "@/lib/map/placement";
|
|
import { nodeIconOption } from "@/lib/map/nodeIcons";
|
|
import { saveMapNode } from "@/app/(frontend)/logistics/map/actions";
|
|
|
|
vi.mock("next/headers", () => ({
|
|
headers: async () => new Headers(),
|
|
}));
|
|
|
|
describe("generic marker nodes (no resources)", () => {
|
|
let payload: Payload;
|
|
let map: MapDoc;
|
|
let copper: Resource;
|
|
let authSpy: ReturnType<typeof vi.spyOn>;
|
|
let mapper: { id: number };
|
|
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config });
|
|
|
|
map = (await payload.create({
|
|
collection: "maps",
|
|
data: {
|
|
name: `Generic Node Map ${Date.now()}`,
|
|
worldSizeWidth: 8192,
|
|
worldSizeHeight: 8192,
|
|
basemapMode: "tiles",
|
|
tileUrlTemplate: "http://localhost/{z}/{x}/{y}.png",
|
|
isActive: true,
|
|
worldMap: false,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as MapDoc;
|
|
|
|
const mapperRole = (await payload.create({
|
|
collection: "roles",
|
|
data: {
|
|
name: `Generic Node Mapper ${Date.now()}`,
|
|
slug: `generic-mapper-${Date.now()}`,
|
|
permissions: ["resource-nodes:author"],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as { id: number };
|
|
|
|
mapper = (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `generic-node-mapper-${Date.now()}`,
|
|
discordUsername: `generic-node-mapper-${Date.now()}`,
|
|
displayName: "Generic Node Mapper",
|
|
steamId: `generic-node-${Date.now()}`,
|
|
password: "generic-node-pass",
|
|
roleDocs: [mapperRole.id],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as { id: number };
|
|
|
|
copper = (await payload.create({
|
|
collection: "resources",
|
|
data: {
|
|
name: `Copper ${Date.now()}`,
|
|
codeName: `generic_copper_${Date.now()}`,
|
|
approvalStatus: "in_progress",
|
|
type: "physical",
|
|
baseValue: 1,
|
|
rarity: "common",
|
|
unitOfMeasure: "kg",
|
|
massPerUnit: 1,
|
|
gridWidth: 1,
|
|
gridHeight: 1,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Resource;
|
|
});
|
|
|
|
it("persists a node with an empty resource list", async () => {
|
|
const node = await payload.create({
|
|
collection: "resource-nodes",
|
|
data: {
|
|
name: "Landmark marker",
|
|
map: map.id,
|
|
resources: [],
|
|
position: [1000, 1000],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
|
|
expect(node.resources ?? []).toEqual([]);
|
|
});
|
|
|
|
it("checkResourcesInRange never matches resource-less nodes", () => {
|
|
const requirement = [{ resource: copper.id, range: 500 }];
|
|
const genericNode = { id: 1, position: [1000, 1000] as [number, number], resources: [] };
|
|
const realNode = { id: 2, position: [1200, 1000] as [number, number], resources: [copper.id] };
|
|
|
|
const withGenericOnly = checkResourcesInRange([genericNode], requirement, [1100, 1000]);
|
|
expect(withGenericOnly.allowed).toBe(false);
|
|
|
|
const withRealNode = checkResourcesInRange([genericNode, realNode], requirement, [1100, 1000]);
|
|
expect(withRealNode.allowed).toBe(true);
|
|
});
|
|
|
|
it("saveMapNode persists a chosen icon and rejects unknown ones", async () => {
|
|
authSpy = vi.spyOn(payload, "auth").mockResolvedValue({
|
|
user: mapper as never,
|
|
} as never);
|
|
|
|
try {
|
|
const flagged = await saveMapNode({
|
|
mapId: map.id,
|
|
name: "Flag marker",
|
|
resourceIds: [],
|
|
position: [3000, 3000],
|
|
icon: "flag",
|
|
});
|
|
expect(flagged.success).toBe(true);
|
|
const flaggedNode = await payload.findByID({
|
|
collection: "resource-nodes",
|
|
id: (flagged.data as { id: number }).id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(flaggedNode.icon).toBe("flag");
|
|
|
|
const rejected = await saveMapNode({
|
|
mapId: map.id,
|
|
name: "Evil marker",
|
|
resourceIds: [],
|
|
position: [3000, 3000],
|
|
icon: "rocket",
|
|
});
|
|
expect(rejected.success).toBe(false);
|
|
expect(rejected.error).toContain("icon");
|
|
} finally {
|
|
authSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("nodeIconOption falls back to the default marker", () => {
|
|
expect(nodeIconOption("flag").value).toBe("flag");
|
|
expect(nodeIconOption("nonsense").value).toBe("marker");
|
|
expect(nodeIconOption(null).value).toBe("marker");
|
|
});
|
|
|
|
it("saveMapNode accepts an empty resource list as a plain marker", async () => {
|
|
authSpy = vi.spyOn(payload, "auth").mockResolvedValue({
|
|
user: mapper as never,
|
|
} as never);
|
|
|
|
try {
|
|
const result = await saveMapNode({
|
|
mapId: map.id,
|
|
name: "Authored marker",
|
|
resourceIds: [],
|
|
position: [2000, 2000],
|
|
richness: 100,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
const created = await payload.findByID({
|
|
collection: "resource-nodes",
|
|
id: (result.data as { id: number }).id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(created.resources ?? []).toEqual([]);
|
|
} finally {
|
|
authSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|