import { describe, it, expect } from 'vitest'; import { prismaMock } from '../test/setup'; import { createTicket, updateTicket, closeStale, bulkAction } from './ticketService'; const existing = { id: 'tid', displayId: 'V100000000', title: 'Old title', overview: 'overview', severity: 3, status: 'OPEN' as const, categoryId: 'c1', typeId: 't1', itemId: 'i1', assigneeId: null, createdById: 'u1', createdAt: new Date(), updatedAt: new Date(), resolvedAt: null, category: { id: 'c1', name: 'Cat' }, type: { id: 't1', name: 'Type' }, item: { id: 'i1', name: 'Item' }, assignee: null, }; describe('ticketService.createTicket', () => { it('generates a displayId and writes CREATED audit', async () => { // First call is generateDisplayId's uniqueness probe — must be null prismaMock.ticket.findUnique.mockResolvedValueOnce(null); prismaMock.ticket.create.mockResolvedValue({ ...existing }); // Second call is the post-create read-with-include prismaMock.ticket.findUnique.mockResolvedValueOnce({ ...existing }); await createTicket( { title: 'New', overview: 'body', severity: 2, categoryId: 'c1', typeId: 't1', itemId: 'i1', }, 'u1', ); expect(prismaMock.ticket.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ title: 'New', createdById: 'u1', displayId: expect.stringMatching(/^V\d{9}$/), }), }), ); expect(prismaMock.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: 'CREATED' }) }), ); }); }); describe('ticketService.updateTicket', () => { it('rejects non-admin trying to CLOSE', async () => { await expect( updateTicket('tid', { status: 'CLOSED' }, { id: 'u1', role: 'AGENT' }), ).rejects.toMatchObject({ status: 403 }); }); it('writes STATUS_CHANGED + SEVERITY_CHANGED audits; resolvedAt set on RESOLVED', async () => { prismaMock.ticket.findFirst.mockResolvedValue(existing); prismaMock.ticket.update.mockResolvedValue({ ...existing, status: 'RESOLVED' as const, severity: 1, }); prismaMock.ticket.findUnique.mockResolvedValue({ ...existing, status: 'RESOLVED' as const }); await updateTicket( 'tid', { status: 'RESOLVED', severity: 1 }, { id: 'u2', role: 'AGENT' }, ); expect(prismaMock.ticket.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'RESOLVED', resolvedAt: expect.any(Date) }), }), ); expect(prismaMock.auditLog.createMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.arrayContaining([ expect.objectContaining({ action: 'STATUS_CHANGED' }), expect.objectContaining({ action: 'SEVERITY_CHANGED' }), ]), }), ); }); it('clears resolvedAt when moving away from RESOLVED', async () => { prismaMock.ticket.findFirst.mockResolvedValue({ ...existing, status: 'RESOLVED' as const }); prismaMock.ticket.update.mockResolvedValue({ ...existing, status: 'IN_PROGRESS' as const }); prismaMock.ticket.findUnique.mockResolvedValue({ ...existing, status: 'IN_PROGRESS' as const }); await updateTicket( 'tid', { status: 'IN_PROGRESS' }, { id: 'u2', role: 'AGENT' }, ); expect(prismaMock.ticket.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ resolvedAt: null }) }), ); }); it('404s when ticket missing', async () => { prismaMock.ticket.findFirst.mockResolvedValue(null); await expect( updateTicket('missing', { title: 'x' }, { id: 'u1', role: 'ADMIN' }), ).rejects.toMatchObject({ status: 404 }); }); }); describe('ticketService.bulkAction', () => { it('rejects non-admin attempting close', async () => { await expect( bulkAction({ action: 'close', ids: ['t1', 't2'] }, { id: 'u1', role: 'AGENT' }), ).rejects.toMatchObject({ status: 403 }); }); it('rejects USER role entirely', async () => { await expect( bulkAction( { action: 'setSeverity', ids: ['t1'], value: 2 }, { id: 'u1', role: 'USER' }, ), ).rejects.toMatchObject({ status: 403 }); }); it('updates many + writes audit entries for each ticket', async () => { prismaMock.ticket.findMany.mockResolvedValue([{ id: 't1' }, { id: 't2' }] as never); prismaMock.ticket.updateMany.mockResolvedValue({ count: 2 }); const result = await bulkAction( { action: 'setSeverity', ids: ['t1', 't2'], value: 1 }, { id: 'u1', role: 'AGENT' }, ); expect(result.updated).toBe(2); expect(prismaMock.ticket.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: { severity: 1 } }), ); expect(prismaMock.auditLog.createMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.arrayContaining([ expect.objectContaining({ ticketId: 't1', action: 'SEVERITY_CHANGED' }), expect.objectContaining({ ticketId: 't2', action: 'SEVERITY_CHANGED' }), ]), }), ); }); }); describe('ticketService.closeStale', () => { it('closes RESOLVED tickets older than cutoff and returns count', async () => { prismaMock.ticket.updateMany.mockResolvedValue({ count: 3 }); const count = await closeStale(14); expect(count).toBe(3); expect(prismaMock.ticket.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: 'RESOLVED' }), data: { status: 'CLOSED' }, }), ); }); });