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