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)
82 lines
No EOL
2.7 KiB
TypeScript
82 lines
No EOL
2.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import Applications from '@/views/Applications.vue'
|
|
import type { Application } from '@/types'
|
|
|
|
vi.mock('@/api', () => ({
|
|
getApplications: vi.fn(),
|
|
transitionApplication: 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 fixture(): Application[] {
|
|
return [
|
|
{
|
|
id: 'app-1',
|
|
job_posting_id: 'j-1',
|
|
state: 'discovered',
|
|
score: null,
|
|
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'
|
|
}
|
|
},
|
|
{
|
|
id: 'app-2',
|
|
job_posting_id: 'j-2',
|
|
state: 'scored',
|
|
score: 85,
|
|
score_rationale: null,
|
|
notes: '',
|
|
state_changed_at: '2026-01-01T00:00:00Z',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
posting: {
|
|
id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y',
|
|
company: 'Globex', title: 'Manager', location: 'Malmo', description: '',
|
|
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
describe('Applications kanban', () => {
|
|
it('groups cards by state from fixture', async () => {
|
|
setActivePinia(createPinia())
|
|
const { getApplications } = await import('@/api')
|
|
;(getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(fixture())
|
|
|
|
const wrapper = mount(Applications)
|
|
// Wait for onMounted load
|
|
await vi.waitFor(() => {
|
|
expect(wrapper.text()).toContain('Acme')
|
|
expect(wrapper.text()).toContain('Globex')
|
|
})
|
|
|
|
// "discovered" column should contain Acme
|
|
const discoveredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'discovered')
|
|
expect(discoveredCol).toBeTruthy()
|
|
const discoveredColumn = discoveredCol!.element.parentElement!
|
|
expect(discoveredColumn.textContent).toContain('Acme')
|
|
expect(discoveredColumn.textContent).not.toContain('Globex')
|
|
|
|
// "scored" column should contain Globex
|
|
const scoredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'scored')
|
|
expect(scoredCol).toBeTruthy()
|
|
const scoredColumn = scoredCol!.element.parentElement!
|
|
expect(scoredColumn.textContent).toContain('Globex')
|
|
})
|
|
}) |