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)}`; const makeUser = async (label: string, roles: NonNullable = ["user"]): Promise => { const tag = `${RUN}-${label}`; return (await payload.create({ collection: "users", data: { username: tag, discordUsername: tag, displayName: label.toUpperCase(), steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles, }, overrideAccess: true, depth: 0, })) as unknown as User; }; const createTicket = async (reporterId: number, title = `${RUN} Issue`): Promise => (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 => (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; 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[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); } }); });