154 lines
No EOL
5.1 KiB
TypeScript
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')
|
|
})
|
|
}) |