- `tech-tree-layout.int.spec.ts`: pure layout (wrapping, manual overrides, category bands, forward-edge warnings, cycles, catalog-stability). - `tech-tree-prereq-edits.int.spec.ts`: add/remove/switch prerequisites with cycle rejection and the pin-on-connect stability contract. - `tech-tree-stability.int.spec.ts`: freeze pins every relocated card on a data change, plus edge visuals (per-path colors, per-source lanes). - `tech-categories.int.spec.ts`: CRUD, RBAC, division gating, FK set-null, position round-trip. - `tech-tree.e2e.spec.ts`: nav entry, render, preview/panel edits, both quick-create paths, drag persistence + Auto-arrange, save positions surviving a reload, connect/disconnect, and multi-column wrapping.
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import type { Role, TechCategory, Technology, User } from "@/payload-types";
|
|
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
|
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `techcat-${Date.now().toString(36)}`;
|
|
const TIMEOUT = 30_000;
|
|
|
|
describe("Tech Categories (collection + RBAC + tech tree data model)", () => {
|
|
const roleIds: number[] = [];
|
|
const userIds: number[] = [];
|
|
const categoryIds: number[] = [];
|
|
const technologyIds: number[] = [];
|
|
const createdQualificationIds: number[] = [];
|
|
|
|
let plainUser: User;
|
|
let intelUser: User;
|
|
let superUser: User;
|
|
let categoryManagerUser: User;
|
|
|
|
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
|
|
const role = (await payload.create({
|
|
collection: "roles",
|
|
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Role;
|
|
roleIds.push(role.id);
|
|
return role;
|
|
};
|
|
|
|
const makeUser = async (label: string, roleId: number): Promise<User> => {
|
|
const user = (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `${RUN}-${label}`,
|
|
discordUsername: `${RUN}-${label}`,
|
|
displayName: label.toUpperCase(),
|
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
password: "Test123",
|
|
roleDocs: [roleId],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as User;
|
|
userIds.push(user.id);
|
|
return user;
|
|
};
|
|
|
|
const findOrCreateQualification = async (name: string): Promise<number> => {
|
|
const found = (await payload.find({
|
|
collection: "qualifications",
|
|
where: { name: { equals: name } },
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as { docs: Array<{ id: number }> };
|
|
if (found.docs.length > 0) return found.docs[0].id;
|
|
const created = (await payload.create({
|
|
collection: "qualifications",
|
|
data: { name },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as { id: number };
|
|
createdQualificationIds.push(created.id);
|
|
return created.id;
|
|
};
|
|
|
|
const grantQualification = async (user: User, qualificationId: number) => {
|
|
const profile = (await payload.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: user.id } },
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as { docs: Array<{ id: number }> };
|
|
expect(profile.docs.length).toBeGreaterThan(0);
|
|
await payload.update({
|
|
collection: "profiles",
|
|
id: profile.docs[0].id,
|
|
data: { progression: { qualifications: [qualificationId] } },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
};
|
|
|
|
const expectAccessDenied = async (fn: () => Promise<unknown>) => {
|
|
try {
|
|
await fn();
|
|
} catch (e) {
|
|
const name = (e as { name?: string })?.name ?? "";
|
|
const message = e instanceof Error ? e.message : "";
|
|
expect(
|
|
name === "AccessError" ||
|
|
name === "Forbidden" ||
|
|
/not permitted|not allowed|access denied/i.test(message),
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
throw new Error("Expected operation to be denied");
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
invalidatePermissionCache();
|
|
|
|
const bareRole = await makeRole("bare", { permissions: [] });
|
|
const superRole = await makeRole("super", { isSuperuser: true });
|
|
const managerRole = await makeRole("techcat-manager", {
|
|
permissions: [
|
|
"tech-categories:create",
|
|
"tech-categories:read",
|
|
"tech-categories:update",
|
|
"tech-categories:delete",
|
|
],
|
|
});
|
|
|
|
plainUser = await makeUser("plain", bareRole.id);
|
|
intelUser = await makeUser("intel", bareRole.id);
|
|
superUser = await makeUser("super", superRole.id);
|
|
categoryManagerUser = await makeUser("manager", managerRole.id);
|
|
|
|
const intelligenceId = await findOrCreateQualification("Intelligence");
|
|
await grantQualification(intelUser, intelligenceId);
|
|
}, TIMEOUT);
|
|
|
|
afterAll(async () => {
|
|
if (!payload) return;
|
|
for (const id of technologyIds) {
|
|
await payload.delete({ collection: "technologies", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of categoryIds) {
|
|
await payload
|
|
.delete({ collection: "tech-categories", id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
for (const id of userIds) {
|
|
const profiles = await payload
|
|
.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: id } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const p of profiles?.docs ?? []) {
|
|
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of roleIds) {
|
|
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of createdQualificationIds) {
|
|
await payload
|
|
.delete({ collection: "qualifications", id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
|
|
describe("CRUD (overrideAccess sanity)", () => {
|
|
it("creates a category with name, position, and optional hex color", async () => {
|
|
const category = (await payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-Tier 1`, position: 1, color: "#22d3ee" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as TechCategory;
|
|
expect(category.id).toBeGreaterThan(0);
|
|
expect(category.position).toBe(1);
|
|
expect(category.color).toBe("#22d3ee");
|
|
categoryIds.push(category.id);
|
|
}, TIMEOUT);
|
|
|
|
it("rejects duplicate names (unique)", async () => {
|
|
await expect(
|
|
payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-Tier 1`, position: 2 },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
}),
|
|
).rejects.toThrow();
|
|
}, TIMEOUT);
|
|
|
|
it("rejects non-hex color values", async () => {
|
|
await expect(
|
|
payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-bad-color`, position: 3, color: "not-a-color" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
}),
|
|
).rejects.toThrow();
|
|
}, TIMEOUT);
|
|
});
|
|
|
|
describe("permission gating", () => {
|
|
it("plain users cannot create or read categories (no permission, no qualification)", async () => {
|
|
await expectAccessDenied(() =>
|
|
payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-plain`, position: 1 },
|
|
user: plainUser,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
}),
|
|
);
|
|
await expectAccessDenied(() =>
|
|
payload.find({
|
|
collection: "tech-categories",
|
|
user: plainUser,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
}),
|
|
);
|
|
}, TIMEOUT);
|
|
|
|
it("intelligence-qualified users create and read via the division bypass", async () => {
|
|
const created = (await payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-intel`, position: 2 },
|
|
user: intelUser,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
})) as unknown as TechCategory;
|
|
categoryIds.push(created.id);
|
|
expect(created.id).toBeGreaterThan(0);
|
|
|
|
const found = (await payload.find({
|
|
collection: "tech-categories",
|
|
user: intelUser,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
})) as unknown as { docs: Array<{ id: number }> };
|
|
expect(found.docs.some((d) => d.id === created.id)).toBe(true);
|
|
}, TIMEOUT);
|
|
|
|
it("users holding explicit tech-categories permissions pass without a qualification", async () => {
|
|
const created = (await payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-manager`, position: 3 },
|
|
user: categoryManagerUser,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
})) as unknown as TechCategory;
|
|
categoryIds.push(created.id);
|
|
expect(created.id).toBeGreaterThan(0);
|
|
}, TIMEOUT);
|
|
|
|
it("superusers always pass", async () => {
|
|
const created = (await payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-super`, position: 4 },
|
|
user: superUser,
|
|
overrideAccess: false,
|
|
depth: 0,
|
|
})) as unknown as TechCategory;
|
|
categoryIds.push(created.id);
|
|
expect(created.id).toBeGreaterThan(0);
|
|
}, TIMEOUT);
|
|
|
|
it("the tech-categories admin page follows the intelligence division like technologies", async () => {
|
|
// scopedAdminPageAccess consults DIVISION_ADMIN_QUALIFICATIONS; assert
|
|
// the BEHAVIOR: an intelligence-qualified user passes an admin-panel
|
|
// request for tech-categories, a plain user does not, superusers pass.
|
|
const decision = async (user: User | null): Promise<boolean> => {
|
|
const fn = scopedAdminPageAccess("tech-categories");
|
|
return Boolean(
|
|
await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
|
req: { user, payload, pathname: "/admin/collections/tech-categories" },
|
|
}),
|
|
);
|
|
};
|
|
expect(await decision(intelUser)).toBe(true);
|
|
expect(await decision(plainUser)).toBe(false);
|
|
expect(await decision(superUser)).toBe(true);
|
|
expect(await decision(null)).toBe(false);
|
|
}, TIMEOUT);
|
|
});
|
|
|
|
describe("technologies category relationship", () => {
|
|
it("assigns a category to a technology and reads it back", async () => {
|
|
const category = (await payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-Tier 2`, position: 2 },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as TechCategory;
|
|
categoryIds.push(category.id);
|
|
|
|
const tech = (await payload.create({
|
|
collection: "technologies",
|
|
data: {
|
|
name: `${RUN}-tech`,
|
|
type: "upgrade",
|
|
summary: "Test technology for the tree.",
|
|
category: category.id,
|
|
approvalStatus: "in_progress",
|
|
researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 },
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Technology;
|
|
technologyIds.push(tech.id);
|
|
|
|
const fetched = (await payload.findByID({
|
|
collection: "technologies",
|
|
id: tech.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as Technology;
|
|
expect(fetched.category).toBe(category.id);
|
|
}, TIMEOUT);
|
|
|
|
it("deleting a category leaves the technology with no category (FK set null)", async () => {
|
|
const category = (await payload.create({
|
|
collection: "tech-categories",
|
|
data: { name: `${RUN}-Tier 9`, position: 9 },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as TechCategory;
|
|
|
|
const tech = (await payload.create({
|
|
collection: "technologies",
|
|
data: {
|
|
name: `${RUN}-tech-orphan`,
|
|
type: "asset",
|
|
summary: "Orphaned on category delete.",
|
|
category: category.id,
|
|
approvalStatus: "in_progress",
|
|
researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 },
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Technology;
|
|
technologyIds.push(tech.id);
|
|
|
|
await payload.delete({
|
|
collection: "tech-categories",
|
|
id: category.id,
|
|
overrideAccess: true,
|
|
});
|
|
|
|
const fetched = (await payload.findByID({
|
|
collection: "technologies",
|
|
id: tech.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as Technology;
|
|
expect(fetched.category === null || fetched.category === undefined).toBe(true);
|
|
}, TIMEOUT);
|
|
});
|
|
|
|
describe("tech tree manual positions", () => {
|
|
it("stores a dragged tree position and clears it again", async () => {
|
|
const tech = (await payload.create({
|
|
collection: "technologies",
|
|
data: {
|
|
name: `${RUN}-positioned`,
|
|
type: "upgrade",
|
|
summary: "Manual canvas position round-trip.",
|
|
approvalStatus: "in_progress",
|
|
researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 },
|
|
treePosition: { x: 420, y: 260 },
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Technology;
|
|
technologyIds.push(tech.id);
|
|
|
|
const fetched = (await payload.findByID({
|
|
collection: "technologies",
|
|
id: tech.id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as Technology;
|
|
expect(fetched.treePosition?.x).toBe(420);
|
|
expect(fetched.treePosition?.y).toBe(260);
|
|
|
|
const cleared = (await payload.update({
|
|
collection: "technologies",
|
|
id: tech.id,
|
|
data: { treePosition: { x: null, y: null } },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Technology;
|
|
expect(cleared.treePosition?.x ?? null).toBeNull();
|
|
expect(cleared.treePosition?.y ?? null).toBeNull();
|
|
}, TIMEOUT);
|
|
});
|
|
});
|