jest.mock('../../services/edocu/elements') import { ApiResponse } from '../../services/api/apisauce' import { setupClient } from '../../setup.client' import { MainStore } from '../../stores' import { TICKET_TYPE } from '../constants' import { retryTransient, Ticket } from './ticket' const local = 'https://localhost/' const mockOk = (data: T): ApiResponse => ({ ok: true, problem: null, originalError: null, data, }) const mockErr = (problem: string): ApiResponse => ({ ok: false, // eslint-disable-next-line @typescript-eslint/no-explicit-any problem: problem as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any originalError: { message: problem } as any, }) describe('retryTransient', () => { beforeEach(() => { jest.useFakeTimers() }) afterEach(() => { jest.useRealTimers() }) it('returns immediately on success', async () => { const fn = jest.fn().mockResolvedValueOnce(mockOk({ x: 1 })) const p = retryTransient(fn) await jest.runAllTimersAsync() const result = await p expect(result.ok).toBe(true) expect(fn).toHaveBeenCalledTimes(1) }) it('retries on NETWORK_ERROR and eventually succeeds', async () => { const fn = jest .fn() .mockResolvedValueOnce(mockErr('NETWORK_ERROR')) .mockResolvedValueOnce(mockErr('TIMEOUT_ERROR')) .mockResolvedValueOnce(mockOk({ x: 2 })) const p = retryTransient(fn) await jest.runAllTimersAsync() const result = await p expect(result.ok).toBe(true) expect(fn).toHaveBeenCalledTimes(3) }) it('does NOT retry on non-transient problem', async () => { const fn = jest.fn().mockResolvedValueOnce(mockErr('CLIENT_ERROR')) const p = retryTransient(fn) await jest.runAllTimersAsync() const result = await p expect(result.ok).toBe(false) expect(fn).toHaveBeenCalledTimes(1) }) it('gives up after N attempts on persistent transient errors', async () => { const fn = jest.fn().mockResolvedValue(mockErr('NETWORK_ERROR')) const p = retryTransient(fn) await jest.runAllTimersAsync() const result = await p expect(result.ok).toBe(false) expect(fn).toHaveBeenCalledTimes(3) }) }) describe('Ticket.setStatus', () => { let mainStore: MainStore beforeEach(async () => { jest.clearAllMocks() jest.useFakeTimers() mainStore = setupClient({ stage: 'stage', baseUrl: local }) await mainStore.initializePlatformServices() }) afterEach(() => { jest.useRealTimers() }) const makeTicket = () => { const ticket = mainStore.elementStore.instantiateElement('ticket-hash', { _type: TICKET_TYPE, organization: 'org', hash: 'ticket-hash', // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) as Ticket return ticket } it('throws when patchTicket fails after retries', async () => { const ticket = makeTicket() mainStore.tickets.patchTicket = jest .fn() .mockResolvedValue(mockErr('NETWORK_ERROR')) const p = ticket.setStatus('Close' as never).catch((e) => e) await jest.runAllTimersAsync() const result = await p expect(result).toBeInstanceOf(Error) expect((result as Error).message).toMatch(/Failed to set ticket status/) expect(mainStore.tickets.patchTicket).toHaveBeenCalledTimes(3) }) it('does not throw on a recoverable failure', async () => { const ticket = makeTicket() const setTicketDataSpy = jest.spyOn(ticket, 'setTicketData') mainStore.tickets.patchTicket = jest .fn() .mockResolvedValueOnce(mockErr('NETWORK_ERROR')) .mockResolvedValueOnce( mockOk({ status: { value: 'Close' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ) const p = ticket.setStatus('Close' as never) await jest.runAllTimersAsync() await p expect(mainStore.tickets.patchTicket).toHaveBeenCalledTimes(2) expect(setTicketDataSpy).toHaveBeenCalled() }) it('does not retry on non-transient failure (e.g. 4xx)', async () => { const ticket = makeTicket() mainStore.tickets.patchTicket = jest .fn() .mockResolvedValue(mockErr('CLIENT_ERROR')) const p = ticket.setStatus('Close' as never).catch((e) => e) await jest.runAllTimersAsync() const result = await p expect(result).toBeInstanceOf(Error) expect(mainStore.tickets.patchTicket).toHaveBeenCalledTimes(1) }) }) describe('Ticket.update', () => { let mainStore: MainStore beforeEach(async () => { jest.clearAllMocks() jest.useFakeTimers() mainStore = setupClient({ stage: 'stage', baseUrl: local }) await mainStore.initializePlatformServices() }) afterEach(() => { jest.useRealTimers() }) const makeTicket = () => { return mainStore.elementStore.instantiateElement('ticket-hash', { _type: TICKET_TYPE, organization: 'org', hash: 'ticket-hash', // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) as Ticket } it('throws when special-attribute update fails after retries', async () => { const ticket = makeTicket() mainStore.tickets.update = jest .fn() .mockResolvedValue(mockErr('NETWORK_ERROR')) // Avoid hitting the real super.update path // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(Object.getPrototypeOf(Object.getPrototypeOf(ticket)) as any).update = jest .fn() .mockResolvedValue(true) const p = ticket.update({ description: 'hi' }).catch((e) => e) await jest.runAllTimersAsync() const result = await p expect(result).toBeInstanceOf(Error) expect((result as Error).message).toMatch(/Failed to update ticket/) expect(mainStore.tickets.update).toHaveBeenCalledTimes(3) }) it('skips the special-attribute call when no special attributes are provided', async () => { const ticket = makeTicket() mainStore.tickets.update = jest.fn() // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(Object.getPrototypeOf(Object.getPrototypeOf(ticket)) as any).update = jest .fn() .mockResolvedValue(true) await ticket.update({ files: [] }) expect(mainStore.tickets.update).not.toHaveBeenCalled() }) })