Bare `mock.module('axios', () => ({ default: { stubs } }))` is
process-global last-write-wins and drops `axios.create`, `request`,
`isAxiosError`, etc. that real consumers need. In CI's alphabetical
file order, that produces dozens of polluted failures (AgentsPlatformView,
schedule API, memory-stores API, etc.) that don't reproduce on WSL2.
Introduce `tests/mocks/axios.ts` with `setupAxiosMock()` — `require('axios')`
inside the factory, spread real shape, route each verb through a per-suite
`useStubs` flag. beforeAll flips on, afterAll flips off; the spread
fall-through eliminates cross-file leakage.
Refactored 12 axios mockers in tests/, plus the bare `@anthropic/ink` mocks
in ultrareviewCommand and onboarding suites (same pollution pattern broke
AgentsPlatformView's Box/Text rendering).
Verified: 5339/5345 tests pass locally; remaining 6 failures are
pre-existing isolation issues unrelated to this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
/**
|
|
* Regression tests for triggersApi.ts
|
|
*
|
|
* Key invariants under test:
|
|
* - updateTrigger MUST use POST, not PATCH (binary literal: update: POST /v1/code/triggers/{id})
|
|
* - All CRUD endpoints hit /v1/code/triggers (not /v1/agents)
|
|
* - 401/403/404/429/5xx classified correctly
|
|
* - withRetry retries only 5xx, not 4xx
|
|
*/
|
|
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
mock,
|
|
test,
|
|
} from 'bun:test'
|
|
import { debugMock } from '../../../../tests/mocks/debug.js'
|
|
import { logMock } from '../../../../tests/mocks/log.js'
|
|
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
|
|
|
mock.module('src/utils/log.ts', logMock)
|
|
mock.module('src/utils/debug.ts', debugMock)
|
|
|
|
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
|
const mockAccessToken = 'test-token-triggers'
|
|
const mockOrgUUID = 'org-uuid-triggers'
|
|
|
|
mock.module('src/utils/auth.js', () => ({
|
|
getClaudeAIOAuthTokens: () => ({ accessToken: mockAccessToken }),
|
|
}))
|
|
mock.module('src/services/oauth/client.js', () => ({
|
|
getOrganizationUUID: async () => mockOrgUUID,
|
|
}))
|
|
mock.module('src/constants/oauth.js', () => ({
|
|
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
|
}))
|
|
mock.module('src/utils/teleport/api.js', () => ({
|
|
getOAuthHeaders: (token: string) => ({
|
|
Authorization: `Bearer ${token}`,
|
|
'anthropic-version': '2023-06-01',
|
|
}),
|
|
}))
|
|
|
|
// ── Axios mock ──────────────────────────────────────────────────────────────
|
|
const axiosGetMock = mock(async () => ({}))
|
|
const axiosPostMock = mock(async () => ({}))
|
|
const axiosDeleteMock = mock(async () => ({}))
|
|
|
|
const axiosIsAxiosError = mock((err: unknown) => {
|
|
return (
|
|
typeof err === 'object' &&
|
|
err !== null &&
|
|
'isAxiosError' in err &&
|
|
(err as { isAxiosError: boolean }).isAxiosError === true
|
|
)
|
|
})
|
|
|
|
const axiosHandle = setupAxiosMock()
|
|
axiosHandle.stubs.get = axiosGetMock
|
|
axiosHandle.stubs.post = axiosPostMock
|
|
axiosHandle.stubs.delete = axiosDeleteMock
|
|
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
|
|
|
// ── Lazy import after mocks ─────────────────────────────────────────────────
|
|
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
|
|
// so that if launchSchedule.test.ts runs first and replaces the mock, this file's
|
|
// own beforeAll re-registers the real implementation under that same key.
|
|
let listTriggers: typeof import('../triggersApi.js').listTriggers
|
|
let getTrigger: typeof import('../triggersApi.js').getTrigger
|
|
let createTrigger: typeof import('../triggersApi.js').createTrigger
|
|
let updateTrigger: typeof import('../triggersApi.js').updateTrigger
|
|
let deleteTrigger: typeof import('../triggersApi.js').deleteTrigger
|
|
let runTrigger: typeof import('../triggersApi.js').runTrigger
|
|
|
|
beforeAll(async () => {
|
|
axiosHandle.useStubs = true
|
|
const mod = await import('../triggersApi.js')
|
|
listTriggers = mod.listTriggers
|
|
getTrigger = mod.getTrigger
|
|
createTrigger = mod.createTrigger
|
|
updateTrigger = mod.updateTrigger
|
|
deleteTrigger = mod.deleteTrigger
|
|
runTrigger = mod.runTrigger
|
|
})
|
|
|
|
afterAll(() => {
|
|
axiosHandle.useStubs = false
|
|
})
|
|
|
|
beforeEach(() => {
|
|
axiosGetMock.mockClear()
|
|
axiosPostMock.mockClear()
|
|
axiosDeleteMock.mockClear()
|
|
})
|
|
|
|
afterEach(() => {})
|
|
|
|
// ── REGRESSION: updateTrigger MUST use POST not PATCH ──────────────────────
|
|
describe('updateTrigger regression: must use POST not PATCH', () => {
|
|
test('updateTrigger calls POST /v1/code/triggers/{id} (not PATCH)', async () => {
|
|
const updated = {
|
|
trigger_id: 'trg_upd',
|
|
cron_expression: '0 10 * * *',
|
|
enabled: true,
|
|
prompt: 'Updated prompt',
|
|
}
|
|
axiosPostMock.mockResolvedValueOnce({ data: updated, status: 200 })
|
|
|
|
await updateTrigger('trg_upd', { enabled: false })
|
|
|
|
// POST must have been called
|
|
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
|
// axiosPatchMock must NOT have been called (no patch mock registered)
|
|
// The URL must contain the trigger id
|
|
const calls = axiosPostMock.mock.calls as unknown as [
|
|
string,
|
|
unknown,
|
|
unknown,
|
|
][]
|
|
const url = calls[0]?.[0] as string
|
|
expect(url).toContain('trg_upd')
|
|
expect(url).toContain('/v1/code/triggers/')
|
|
// Verify the URL does NOT end in /run (which is the runTrigger endpoint)
|
|
expect(url).not.toMatch(/\/run$/)
|
|
})
|
|
})
|
|
|
|
// ── listTriggers ──────────────────────────────────────────────────────────
|
|
describe('listTriggers', () => {
|
|
test('returns triggers on 200', async () => {
|
|
const triggers = [
|
|
{
|
|
trigger_id: 'trg_1',
|
|
cron_expression: '0 9 * * 1',
|
|
enabled: true,
|
|
prompt: 'Weekly standup',
|
|
agent_id: 'agt_1',
|
|
next_run: '2026-05-05T09:00:00Z',
|
|
},
|
|
]
|
|
axiosGetMock.mockResolvedValueOnce({
|
|
data: { data: triggers },
|
|
status: 200,
|
|
})
|
|
|
|
const result = await listTriggers()
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]!.trigger_id).toBe('trg_1')
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
|
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
|
expect(calls[0]?.[0]).toContain('/v1/code/triggers')
|
|
})
|
|
|
|
test('returns empty array on empty response', async () => {
|
|
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
|
const result = await listTriggers()
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
test('throws 401 with friendly message', async () => {
|
|
const err = Object.assign(new Error('Unauthorized'), {
|
|
isAxiosError: true,
|
|
response: { status: 401, data: {} },
|
|
})
|
|
axiosGetMock.mockRejectedValueOnce(err)
|
|
axiosIsAxiosError.mockImplementation(
|
|
(e: unknown) =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'isAxiosError' in e &&
|
|
(e as { isAxiosError: boolean }).isAxiosError === true,
|
|
)
|
|
await expect(listTriggers()).rejects.toThrow(/login|authenticate/i)
|
|
})
|
|
|
|
test('throws 403 with subscription message', async () => {
|
|
const err = Object.assign(new Error('Forbidden'), {
|
|
isAxiosError: true,
|
|
response: { status: 403, data: {} },
|
|
})
|
|
axiosGetMock.mockRejectedValueOnce(err)
|
|
axiosIsAxiosError.mockImplementation(
|
|
(e: unknown) =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'isAxiosError' in e &&
|
|
(e as { isAxiosError: boolean }).isAxiosError === true,
|
|
)
|
|
await expect(listTriggers()).rejects.toThrow(/subscription|pro|max|team/i)
|
|
})
|
|
|
|
test('retries on 5xx and eventually throws', async () => {
|
|
const make5xx = () =>
|
|
Object.assign(new Error('Server Error'), {
|
|
isAxiosError: true,
|
|
response: { status: 500, data: {} },
|
|
})
|
|
axiosGetMock
|
|
.mockRejectedValueOnce(make5xx())
|
|
.mockRejectedValueOnce(make5xx())
|
|
.mockRejectedValueOnce(make5xx())
|
|
axiosIsAxiosError.mockImplementation(
|
|
(e: unknown) =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'isAxiosError' in e &&
|
|
(e as { isAxiosError: boolean }).isAxiosError === true,
|
|
)
|
|
await expect(listTriggers()).rejects.toThrow()
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(3)
|
|
}, 15000)
|
|
|
|
test('honors Retry-After header on 5xx', async () => {
|
|
const serverErr = Object.assign(new Error('Service Unavailable'), {
|
|
isAxiosError: true,
|
|
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
|
|
})
|
|
axiosGetMock
|
|
.mockRejectedValueOnce(serverErr)
|
|
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
|
axiosIsAxiosError.mockImplementation(
|
|
(e: unknown) =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'isAxiosError' in e &&
|
|
(e as { isAxiosError: boolean }).isAxiosError === true,
|
|
)
|
|
const result = await listTriggers()
|
|
expect(result).toHaveLength(0)
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
// ── getTrigger ──────────────────────────────────────────────────────────
|
|
describe('getTrigger', () => {
|
|
test('calls GET /v1/code/triggers/{id}', async () => {
|
|
const trigger = {
|
|
trigger_id: 'trg_get',
|
|
cron_expression: '0 8 * * *',
|
|
enabled: true,
|
|
prompt: 'Daily report',
|
|
}
|
|
axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
|
|
|
const result = await getTrigger('trg_get')
|
|
expect(result.trigger_id).toBe('trg_get')
|
|
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
|
expect(calls[0]?.[0]).toContain('trg_get')
|
|
})
|
|
|
|
test('throws 404 with not found message', async () => {
|
|
const err = Object.assign(new Error('Not Found'), {
|
|
isAxiosError: true,
|
|
response: { status: 404, data: {} },
|
|
})
|
|
axiosGetMock.mockRejectedValueOnce(err)
|
|
axiosIsAxiosError.mockImplementation(
|
|
(e: unknown) =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'isAxiosError' in e &&
|
|
(e as { isAxiosError: boolean }).isAxiosError === true,
|
|
)
|
|
await expect(getTrigger('nonexistent')).rejects.toThrow(/not found/i)
|
|
})
|
|
})
|
|
|
|
// ── createTrigger ─────────────────────────────────────────────────────────
|
|
describe('createTrigger', () => {
|
|
test('sends POST /v1/code/triggers with cron_expression and prompt', async () => {
|
|
const trigger = {
|
|
trigger_id: 'trg_new',
|
|
cron_expression: '0 9 * * *',
|
|
enabled: true,
|
|
prompt: 'Create daily report',
|
|
}
|
|
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 201 })
|
|
|
|
const result = await createTrigger({
|
|
cron_expression: '0 9 * * *',
|
|
prompt: 'Create daily report',
|
|
})
|
|
expect(result.trigger_id).toBe('trg_new')
|
|
const calls = axiosPostMock.mock.calls as unknown as [
|
|
string,
|
|
unknown,
|
|
unknown,
|
|
][]
|
|
const url = calls[0]?.[0] as string
|
|
const body = calls[0]?.[1] as Record<string, unknown>
|
|
expect(url).toContain('/v1/code/triggers')
|
|
expect(url).not.toContain('/v1/agents')
|
|
expect(body.cron_expression).toBe('0 9 * * *')
|
|
expect(body.prompt).toBe('Create daily report')
|
|
})
|
|
})
|
|
|
|
// ── deleteTrigger ─────────────────────────────────────────────────────────
|
|
describe('deleteTrigger', () => {
|
|
test('calls DELETE /v1/code/triggers/{id}', async () => {
|
|
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
|
|
|
await deleteTrigger('trg_del')
|
|
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
|
|
const url = calls[0]?.[0] as string
|
|
expect(url).toContain('trg_del')
|
|
expect(url).toContain('/v1/code/triggers/')
|
|
})
|
|
})
|
|
|
|
// ── runTrigger ───────────────────────────────────────────────────────────
|
|
describe('runTrigger', () => {
|
|
test('calls POST /v1/code/triggers/{id}/run', async () => {
|
|
axiosPostMock.mockResolvedValueOnce({
|
|
data: { run_id: 'run_trg_1' },
|
|
status: 200,
|
|
})
|
|
|
|
const result = await runTrigger('trg_run')
|
|
expect(result.run_id).toBe('run_trg_1')
|
|
const calls = axiosPostMock.mock.calls as unknown as [
|
|
string,
|
|
unknown,
|
|
unknown,
|
|
][]
|
|
const url = calls[0]?.[0] as string
|
|
expect(url).toMatch(/trg_run\/run$/)
|
|
})
|
|
})
|
|
|
|
// ── 429 Retry-After ──────────────────────────────────────────────────────
|
|
describe('429 rate-limit: not retried (non-5xx)', () => {
|
|
test('throws immediately on 429 without retry', async () => {
|
|
const err = Object.assign(new Error('Too Many Requests'), {
|
|
isAxiosError: true,
|
|
response: { status: 429, data: {}, headers: { 'retry-after': '60' } },
|
|
})
|
|
axiosGetMock.mockRejectedValueOnce(err)
|
|
axiosIsAxiosError.mockImplementation(
|
|
(e: unknown) =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'isAxiosError' in e &&
|
|
(e as { isAxiosError: boolean }).isAxiosError === true,
|
|
)
|
|
await expect(listTriggers()).rejects.toThrow()
|
|
// Must NOT have retried — 429 is not a 5xx
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|