jobhunt-platform/apps/web/src/views/ApplicationDetail.test.ts
hermes bc81cb2398 T3: apps/web frontend shell (Vue 3 + Vite + TypeScript + Pinia + vue-router + Tailwind)
Implements the four views per the task card:

1. CV Editor (/cv):
   - Profile form (name/email/phone/location/headline/summary) with save
   - Section list editor: add/edit/delete sections with kind select,
     title/org/dates, bullet list editor with per-bullet AI-assist
     button calling POST /profile/sections/{id}/ai-assist, suggestions
     shown with accept/dismiss
   - Render CV button -> POST /profile/render-cv, shows URL link

2. Research (/research):
   - Table of postings (GET /postings) with company/title/location/
     source/fetched_at
   - Add by URL input -> POST /postings
   - Score button per row -> shows score result

3. Applications (/applications):
   - Kanban board grouped by state (10 columns per data-model states)
   - Cards show company/title/score
   - HTML5 drag-and-drop between columns -> POST /applications/{id}/transition
   - Optimistic update, revert on 409 with toast showing reason
   - Click card to navigate to detail view

4. Application detail (/applications/:id):
   - Posting info, state, score
   - Artifacts list
   - Cover-letter editor (textarea) -> save calls POST /applications/{id}/artifacts/
     cover-letter, critique rendered as cards with severity color coding
   - Approval widget: select artifact + action -> request approval ->
     I confirm button -> Send button (disabled until confirmed; shows
     409 errors as toasts)

Tech stack:
- Vue 3 + Vite + TypeScript (strict, noUnusedLocals/Parameters)
- Pinia for state (toast store)
- vue-router with lazy-loaded views
- Tailwind CSS configured locally (no CDN), PostCSS + autoprefixer
- API base from VITE_API_BASE defaulting to http://localhost:8000/api
- Typed API client module (src/api/index.ts) matching the contract
- Domain types (src/types/index.ts) from data-model.md

Tests (vitest, all passing):
- router.test.ts: router renders 3 tab links (CV, Research, Applications)
- Applications.test.ts: kanban groups cards by state from fixture
- ApplicationDetail.test.ts: Send disabled until confirmed; 409 error toast

Build: npm run build passes (vue-tsc --noEmit + vite build)
Tests: npm run test passes (4 tests, 3 files)
2026-07-30 18:00:55 +00:00

153 lines
No EOL
5 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(),
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')
})
})