Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
482 lines
16 KiB
TypeScript
482 lines
16 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";
|
|
import { setTicketVoteForUser, updateTicketByAuthor } from "@/lib/tickets/mutations";
|
|
|
|
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,
|
|
});
|
|
const ticketIds = tickets.docs.map((ticket) => ticket.id);
|
|
if (ticketIds.length > 0) {
|
|
const votes = await payload.find({
|
|
collection: "ticket-votes",
|
|
where: { ticket: { in: ticketIds } },
|
|
limit: 500,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const vote of votes.docs) {
|
|
await payload.delete({ collection: "ticket-votes", id: vote.id, 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("auto-transitions waiting_on_reporter → in_progress when reporter replies", async () => {
|
|
const ticket = await createTicket(reporter.id);
|
|
// Set status to waiting_on_reporter
|
|
await payload.update({
|
|
collection: "tickets",
|
|
id: ticket.id,
|
|
data: { status: "waiting_on_reporter" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
let populated = await fetchTicket(ticket.id);
|
|
expect(populated.status).toBe("waiting_on_reporter");
|
|
// Verify no closedAt set by this transition (waiting_on_reporter is not a closing status)
|
|
expect(populated.closedAt).toBeNull();
|
|
|
|
// Reporter replies (activity + status change via raw payload.update — this triggers
|
|
// the beforeChange hook which appends the system entry; the action also includes
|
|
// the auto-resume logic that sets status to in_progress)
|
|
await payload.update({
|
|
collection: "tickets",
|
|
id: ticket.id,
|
|
data: {
|
|
activity: [
|
|
...((await fetchTicket(ticket.id)).activity ?? []),
|
|
{
|
|
type: "client_message",
|
|
content: plaintextToLexical("This is the reporter's reply."),
|
|
author: reporter.id,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
],
|
|
status: "in_progress",
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
|
|
populated = await fetchTicket(ticket.id);
|
|
expect(populated.status).toBe("in_progress");
|
|
expect(populated.closedAt).toBeNull();
|
|
|
|
// System entry from beforeChange hook
|
|
expect(activityTexts(populated)).toContain(
|
|
`Status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
|
|
);
|
|
});
|
|
|
|
it("does not auto-transition when staff replies to waiting_on_reporter", async () => {
|
|
const ticket = await createTicket(reporter.id);
|
|
// Set status to waiting_on_reporter
|
|
await payload.update({
|
|
collection: "tickets",
|
|
id: ticket.id,
|
|
data: { status: "waiting_on_reporter" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
|
|
// Staff replies (activity change only — the action would NOT set status since
|
|
// staff replies don't auto-resume)
|
|
const updated = await payload.update({
|
|
collection: "tickets",
|
|
id: ticket.id,
|
|
data: {
|
|
activity: [
|
|
...((await fetchTicket(ticket.id)).activity ?? []),
|
|
{
|
|
type: "agent_message",
|
|
content: plaintextToLexical("Staff comment."),
|
|
author: staff.id,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
|
|
// Status should NOT auto-transition (staff reply doesn't auto-resume)
|
|
expect(updated.status).toBe("waiting_on_reporter");
|
|
// Only the system entry from the direct payload update, no auto-transition entry
|
|
expect(activityTexts(updated)).not.toContain(
|
|
`Status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
|
|
);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
it("records immutable before and after snapshots when the author edits a ticket", async () => {
|
|
const ticket = await createTicket(reporter.id, `${RUN} Editable`);
|
|
|
|
await updateTicketByAuthor(payload, reporter, {
|
|
ticketId: ticket.id,
|
|
title: `${RUN} Edited title`,
|
|
description: `${RUN} edited description`,
|
|
category: "feature_request",
|
|
priority: "critical",
|
|
});
|
|
|
|
const updated = await fetchTicket(ticket.id);
|
|
expect(updated.title).toBe(`${RUN} Edited title`);
|
|
expect(convertLexicalToPlaintext({ data: updated.description })).toBe(
|
|
`${RUN} edited description`,
|
|
);
|
|
expect(updated.editHistory).toHaveLength(1);
|
|
expect(updated.editHistory?.[0]?.before).toEqual({
|
|
title: `${RUN} Editable`,
|
|
description: `${RUN} description with multiple\nlines of detail.`,
|
|
category: "bug_report",
|
|
priority: "high",
|
|
});
|
|
expect(updated.editHistory?.[0]?.after).toEqual({
|
|
title: `${RUN} Edited title`,
|
|
description: `${RUN} edited description`,
|
|
category: "feature_request",
|
|
priority: "critical",
|
|
});
|
|
expect(activityTexts(updated).some((text) => text.includes("Ticket details edited"))).toBe(
|
|
true,
|
|
);
|
|
|
|
await expect(
|
|
payload.update({
|
|
collection: "tickets",
|
|
id: ticket.id,
|
|
data: { editHistory: [] },
|
|
overrideAccess: true,
|
|
}),
|
|
).rejects.toThrow("Ticket edit history is immutable");
|
|
});
|
|
|
|
it("rejects ticket edits from users other than the author", async () => {
|
|
const ticket = await createTicket(reporter.id, `${RUN} Author only`);
|
|
|
|
await expect(
|
|
updateTicketByAuthor(payload, assignee, {
|
|
ticketId: ticket.id,
|
|
title: `${RUN} Unauthorized edit`,
|
|
description: `${RUN} should not be saved`,
|
|
category: "other",
|
|
priority: "low",
|
|
}),
|
|
).rejects.toThrow("Only the ticket author can edit this ticket");
|
|
|
|
expect((await fetchTicket(ticket.id)).title).toBe(`${RUN} Author only`);
|
|
});
|
|
|
|
it("sets and clears one durable vote per user idempotently", async () => {
|
|
const ticket = await createTicket(reporter.id, `${RUN} Voting`);
|
|
|
|
expect(await setTicketVoteForUser(payload, reporter, ticket.id, true)).toEqual({
|
|
voteCount: 1,
|
|
voted: true,
|
|
});
|
|
expect(await setTicketVoteForUser(payload, reporter, ticket.id, true)).toEqual({
|
|
voteCount: 1,
|
|
voted: true,
|
|
});
|
|
expect(await setTicketVoteForUser(payload, assignee, ticket.id, true)).toEqual({
|
|
voteCount: 2,
|
|
voted: true,
|
|
});
|
|
|
|
await expect(
|
|
payload.create({
|
|
collection: "ticket-votes",
|
|
data: { ticket: ticket.id, user: reporter.id },
|
|
overrideAccess: true,
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
expect(await setTicketVoteForUser(payload, reporter, ticket.id, false)).toEqual({
|
|
voteCount: 1,
|
|
voted: false,
|
|
});
|
|
expect(await setTicketVoteForUser(payload, reporter, ticket.id, false)).toEqual({
|
|
voteCount: 1,
|
|
voted: false,
|
|
});
|
|
});
|
|
|
|
it("rejects voting only when a ticket is canceled", async () => {
|
|
const ticket = await createTicket(reporter.id, `${RUN} Canceled voting`);
|
|
await payload.update({
|
|
collection: "tickets",
|
|
id: ticket.id,
|
|
data: { status: "canceled" },
|
|
overrideAccess: true,
|
|
});
|
|
|
|
await expect(setTicketVoteForUser(payload, reporter, ticket.id, true)).rejects.toThrow(
|
|
"Canceled tickets cannot receive votes",
|
|
);
|
|
});
|
|
});
|