1
0
Fork 0
polaris-task-force/tests/int/tech-categories.int.spec.ts
Z8MB1E 5f7fd508a5 test(tech-tree): add integration and e2e coverage
- `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.
2026-10-01 10:04:07 -04:00

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);
});
});