1
0
Fork 0
polaris-task-force/tests/int/tickets.int.spec.ts
Z8MB1E 2be1081e92 test(int): fix pre-existing tickets and market spec failures
tickets: makeUser now assigns roleDocs because getStaffUserIds queries the RBAC relationship and the legacy roles select field is not synced at runtime. Both specs: teardown deletes profiles (and user-notifications in market) before users, since those relationships are NOT NULL.
2026-08-27 14:01:16 -04:00

275 lines
8.8 KiB
TypeScript

import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import type { Ticket, User } from "@/payload-types";
import { plaintextToLexical } from "@/lib/tickets/lexical";
import { getStaffUserIds } from "@/lib/tickets/staff";
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
let payload: Payload;
const RUN = `tk-${Date.now().toString(36)}`;
// getStaffUserIds resolves staff via the RBAC `roleDocs` relationship — the legacy
// `roles` select field is not synced at runtime, so tests must assign role docs.
const getRoleDocId = async (slug: string): Promise<number> => {
const existing = await payload.find({
collection: "roles",
where: { slug: { equals: slug } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs[0]) return existing.docs[0].id;
const created = await payload.create({
collection: "roles",
data: { name: slug, slug },
overrideAccess: true,
depth: 0,
});
return created.id;
};
const makeUser = async (label: string, roleSlugs: string[] = ["user"]): Promise<User> => {
const tag = `${RUN}-${label}`;
const roleDocs = await Promise.all(roleSlugs.map((slug) => getRoleDocId(slug)));
return (await payload.create({
collection: "users",
data: {
username: tag,
discordUsername: tag,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roleDocs,
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
};
const createTicket = async (reporterId: number, title = `${RUN} Issue`): Promise<Ticket> =>
(await payload.create({
collection: "tickets",
data: {
title,
description: plaintextToLexical(`${RUN} description with multiple\nlines of detail.`),
category: "bug_report",
priority: "high",
status: "open",
reporter: reporterId,
activity: [
{ type: "system", content: plaintextToLexical("Ticket created"), created_at: new Date().toISOString() },
],
},
overrideAccess: true,
depth: 0,
})) as unknown as Ticket;
const fetchTicket = async (id: number): Promise<Ticket> =>
(await payload.findByID({
collection: "tickets",
id,
depth: 1,
overrideAccess: true,
})) as unknown as Ticket;
const activityTexts = (ticket: Ticket): string[] =>
(ticket.activity ?? []).map((entry) => convertLexicalToPlaintext({ data: entry.content }));
const idOf = (ref: number | { id: number } | null | undefined): number | null => {
if (ref == null) return null;
return typeof ref === "object" ? ref.id : ref;
};
describe("Tickets", () => {
let reporter: User;
let assignee: User;
let staff: User;
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
reporter = await makeUser("reporter");
assignee = await makeUser("assignee");
staff = await makeUser("staff", ["admin"]);
});
afterAll(async () => {
if (!payload) return;
const tickets = await payload.find({
collection: "tickets",
where: { title: { contains: RUN } },
limit: 100,
depth: 0,
overrideAccess: true,
});
for (const ticket of tickets.docs) {
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
}
for (const user of [reporter, assignee, staff]) {
if (!user) continue;
// profiles.user is NOT NULL — delete the auto-created profile before its user.
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const profile of profiles.docs) {
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true });
}
await payload.delete({ collection: "users", id: user.id, overrideAccess: true });
}
});
it("plaintextToLexical produces a valid round-trippable lexical state", () => {
const state = plaintextToLexical("Hello\nworld\n\nSecond paragraph");
expect(state.root.type).toBe("root");
expect(state.root.children).toHaveLength(2);
const first = state.root.children[0];
expect(first.children).toHaveLength(3); // text, linebreak, text
expect(first.children[1]).toMatchObject({ type: "linebreak" });
const roundTrip = convertLexicalToPlaintext({
data: state as unknown as Parameters<typeof convertLexicalToPlaintext>[0]["data"],
});
expect(roundTrip).toContain("Hello");
expect(roundTrip).toContain("Second paragraph");
// Empty input yields a single empty paragraph.
expect(plaintextToLexical("").root.children).toHaveLength(1);
});
it("getStaffUserIds resolves only admin/developer users", async () => {
const staffIds = await getStaffUserIds(payload);
expect(staffIds).toContain(staff.id);
expect(staffIds).not.toContain(reporter.id);
expect(staffIds).not.toContain(assignee.id);
});
it("creates a ticket with the initial system activity entry", async () => {
const ticket = await createTicket(reporter.id);
expect(ticket.status).toBe("open");
expect(ticket.reporter).toBe(reporter.id);
expect(ticket.closedAt).toBeNull();
const populated = await fetchTicket(ticket.id);
expect(populated.activity ?? []).toHaveLength(1);
expect(activityTexts(populated)).toContain("Ticket created");
});
it("appends a system entry and manages closedAt on status transitions", async () => {
const ticket = await createTicket(reporter.id);
// Open → In Progress: system entry, no closedAt.
await payload.update({
collection: "tickets",
id: ticket.id,
data: { status: "in_progress" },
overrideAccess: true,
depth: 0,
});
let populated = await fetchTicket(ticket.id);
expect(activityTexts(populated)).toContain(
`Status changed from ${STATUS_LABEL.open} to ${STATUS_LABEL.in_progress}`,
);
expect(populated.closedAt).toBeNull();
// In Progress → Resolved: system entry + closedAt set.
await payload.update({
collection: "tickets",
id: ticket.id,
data: { status: "resolved" },
overrideAccess: true,
depth: 0,
});
populated = await fetchTicket(ticket.id);
expect(activityTexts(populated)).toContain(
`Status changed from ${STATUS_LABEL.in_progress} to ${STATUS_LABEL.resolved}`,
);
expect(populated.closedAt).not.toBeNull();
// Reopening clears closedAt.
await payload.update({
collection: "tickets",
id: ticket.id,
data: { status: "open" },
overrideAccess: true,
depth: 0,
});
populated = await fetchTicket(ticket.id);
expect(populated.closedAt).toBeNull();
});
it("appends a system entry when assigned or unassigned", async () => {
const ticket = await createTicket(reporter.id);
await payload.update({
collection: "tickets",
id: ticket.id,
data: { assignee: assignee.id },
overrideAccess: true,
depth: 0,
});
let populated = await fetchTicket(ticket.id);
const texts = activityTexts(populated);
expect(texts.some((t) => t.includes("Ticket assigned to"))).toBe(true);
expect(idOf(populated.assignee)).toBe(assignee.id);
await payload.update({
collection: "tickets",
id: ticket.id,
data: { assignee: null },
overrideAccess: true,
depth: 0,
});
populated = await fetchTicket(ticket.id);
expect(activityTexts(populated)).toContain("Ticket unassigned");
expect(idOf(populated.assignee)).toBeNull();
});
it("preserves client/agent messages appended by actions", async () => {
const ticket = await createTicket(reporter.id);
await payload.update({
collection: "tickets",
id: ticket.id,
data: {
activity: [
...((await fetchTicket(ticket.id)).activity ?? []),
{
type: "client_message",
content: plaintextToLexical("A reporter reply."),
author: reporter.id,
created_at: new Date().toISOString(),
},
],
},
overrideAccess: true,
depth: 0,
});
const populated = await fetchTicket(ticket.id);
const client = (populated.activity ?? []).find((entry) => entry.type === "client_message");
expect(client).toBeDefined();
if (client?.content) {
expect(convertLexicalToPlaintext({ data: client.content })).toBe("A reporter reply.");
}
expect(idOf(client?.author)).toBe(reporter.id);
});
it("exposes every status from TICKET_STATUSES as a valid select option label", () => {
for (const status of TICKET_STATUSES) {
expect(STATUS_LABEL[status.value]).toBe(status.label);
}
});
});