jobhunt-platform/apps/web/src/views/ApplicationDetail.test.ts

154 lines
No EOL
5.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import type { Application, Artifact, Approval } from '@/types'
// Mock the api module inline so vi.mock factory is self-contained
vi.mock('@/api', () => ({
getApplications: vi.fn(),
getArtifacts: vi.fn(),
createCoverLetter: vi.fn(),
createApproval: vi.fn(),
confirmApproval: vi.fn(),
rejectApproval: vi.fn(),
outboxSend: vi.fn(),
interviewPrep: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeApp(): Application {
return {
id: 'app-1',
job_posting_id: 'j-1',
state: 'drafting',
score: 90,
score_rationale: null,
notes: '',
state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
posting: {
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}
}
function makeArtifact(): Artifact {
return {
id: 'art-1',
application_id: 'app-1',
kind: 'cover_letter',
filename: 'cover.pdf',
content_hash: 'abcdef0123456789',
storage_path: '/tmp/cover.pdf',
version: 1,
origin: 'user_drafted',
created_at: '2026-01-01T00:00:00Z'
}
}
function makeApproval(confirmed: boolean): Approval {
return {
id: 'appr-1',
application_id: 'app-1',
artifact_id: 'art-1',
artifact_hash: 'abcdef0123456789',
action: 'send_email',
confirmed_by_user: confirmed,
confirmed_at: confirmed ? '2026-01-01T00:00:00Z' : null,
expires_at: '2026-01-02T00:00:00Z',
created_at: '2026-01-01T00:00:00Z'
}
}
async function mountDetail(app: Application, artifacts: Artifact[]) {
const pinia = createPinia()
setActivePinia(pinia)
const api = await import('@/api')
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue([app])
;(api.getArtifacts as ReturnType<typeof vi.fn>).mockResolvedValue(artifacts)
const ApplicationDetail = (await import('@/views/ApplicationDetail.vue')).default
const wrapper = mount(ApplicationDetail, { props: { id: 'app-1' } })
await flushPromises()
return { wrapper, pinia }
}
/** Find the Send button (has class ml-2 to distinguish from the Save button) */
function findSendBtn(wrapper: ReturnType<typeof mount>): ReturnType<typeof wrapper.find> {
return wrapper.find('button.ml-2')
}
describe('Approval widget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('disables Send until confirmed', async () => {
const { wrapper } = await mountDetail(makeApp(), [makeArtifact()])
const api = await import('@/api')
// Request approval (not confirmed yet)
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(false))
// Select artifact and action
const selects = wrapper.findAll('select')
await selects[0].setValue('art-1')
await selects[1].setValue('send_email')
// Click Request Approval (bg-gray-700)
await wrapper.find('button.bg-gray-700').trigger('click')
await flushPromises()
// Send button should be disabled because not confirmed
const sendBtn = findSendBtn(wrapper)
expect(sendBtn.exists()).toBe(true)
expect(sendBtn.attributes('disabled')).toBeDefined()
// Now confirm
;(api.confirmApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
const confirmBtn = wrapper.find('button.bg-green-600')
await confirmBtn.trigger('click')
await flushPromises()
// Send button should now be enabled
expect(sendBtn.attributes('disabled')).toBeUndefined()
})
it('shows error toast on 409 send failure', async () => {
const { wrapper, pinia } = await mountDetail(makeApp(), [makeArtifact()])
const api = await import('@/api')
// Setup: approval already confirmed
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
const selects = wrapper.findAll('select')
await selects[0].setValue('art-1')
await selects[1].setValue('send_email')
await wrapper.find('button.bg-gray-700').trigger('click')
await flushPromises()
// Simulate 409 on send
const httpErr = new api.HttpError(409, { error: { code: 'HASH_MISMATCH', message: 'Hash mismatch' } })
;(api.outboxSend as ReturnType<typeof vi.fn>).mockRejectedValue(httpErr)
const sendBtn = findSendBtn(wrapper)
await sendBtn.trigger('click')
await flushPromises()
// Check the toast store for the error message
const toastState = pinia.state.value.toast
expect(toastState).toBeTruthy()
expect(toastState.toasts.length).toBeGreaterThanOrEqual(1)
const errorToasts = toastState.toasts.filter((t: { type: string }) => t.type === 'error')
expect(errorToasts.length).toBeGreaterThanOrEqual(1)
expect(errorToasts[0].message).toContain('Hash mismatch')
})
})