diff --git a/tests/int/tickets.int.spec.ts b/tests/int/tickets.int.spec.ts new file mode 100644 index 0000000..32a3376 --- /dev/null +++ b/tests/int/tickets.int.spec.ts @@ -0,0 +1,243 @@ +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); + } + }); +});