diff --git a/apps/web/src/router.test.ts b/apps/web/src/router.test.ts
index 77fdcd2..6d5a582 100644
--- a/apps/web/src/router.test.ts
+++ b/apps/web/src/router.test.ts
@@ -1,33 +1,51 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
+vi.mock('@/api', () => ({
+ getProfile: vi.fn().mockResolvedValue({ id: 'p1', full_name: 'Test User', email: '', phone: '', location: '', headline: '', summary: '', languages: [], hard_rules: {} }),
+ 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 makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
- { path: '/', redirect: '/cv' },
+ { path: '/', redirect: '/today' },
+ { path: '/today', name: 'today', component: { template: '
Today
' } },
{ path: '/cv', name: 'cv', component: { template: 'CV
' } },
{ path: '/research', name: 'research', component: { template: 'Research
' } },
{ path: '/applications', name: 'applications', component: { template: 'Applications
' } },
- { path: '/applications/:id', name: 'application-detail', component: { template: 'Detail
' } }
+ { path: '/applications/:id', name: 'application-detail', component: { template: 'Detail
' } },
+ { path: '/welcome', name: 'welcome', component: { template: 'Welcome
' } }
]
})
}
describe('Router tabs', () => {
- it('renders all three tab links', async () => {
+ it('renders all four tab links', async () => {
setActivePinia(createPinia())
const router = makeRouter()
- await router.push('/cv')
+ await router.push('/today')
await router.isReady()
const wrapper = mount(App, { global: { plugins: [router] } })
- const links = wrapper.findAll('nav a')
- expect(links).toHaveLength(3)
- expect(links[0].text()).toBe('CV')
- expect(links[1].text()).toBe('Research')
- expect(links[2].text()).toBe('Applications')
+ await vi.waitFor(() => {
+ const links = wrapper.findAll('nav a')
+ expect(links).toHaveLength(4)
+ expect(links[0].text()).toBe('Today')
+ expect(links[1].text()).toBe('CV')
+ expect(links[2].text()).toBe('Research')
+ expect(links[3].text()).toBe('Applications')
+ })
})
})
\ No newline at end of file
diff --git a/apps/web/src/views/ApplicationDetail.test.ts b/apps/web/src/views/ApplicationDetail.test.ts
index 73eff33..0c9f2a2 100644
--- a/apps/web/src/views/ApplicationDetail.test.ts
+++ b/apps/web/src/views/ApplicationDetail.test.ts
@@ -12,6 +12,7 @@ vi.mock('@/api', () => ({
confirmApproval: vi.fn(),
rejectApproval: vi.fn(),
outboxSend: vi.fn(),
+ interviewPrep: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
diff --git a/apps/web/src/views/Applications.badges.test.ts b/apps/web/src/views/Applications.badges.test.ts
new file mode 100644
index 0000000..c97a51d
--- /dev/null
+++ b/apps/web/src/views/Applications.badges.test.ts
@@ -0,0 +1,112 @@
+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(),
+ batchScore: vi.fn(),
+ getToday: 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(id: string, state: string, company: string): Application {
+ return {
+ id,
+ job_posting_id: 'j-' + id,
+ state: state as Application['state'],
+ score: 80,
+ score_rationale: null,
+ notes: '',
+ state_changed_at: '2026-01-01T00:00:00Z',
+ created_at: '2026-01-01T00:00:00Z',
+ posting: {
+ id: 'j-' + id, source: 'manual_url', external_id: null, url: 'http://x',
+ company, title: 'Engineer', location: 'Remote', description: '',
+ raw: {}, fetched_at: '2026-01-01T00:00:00Z'
+ }
+ }
+}
+
+describe('Applications kanban badges', () => {
+ it('renders nudge dot on cards that have a follow-up nudge', async () => {
+ setActivePinia(createPinia())
+ const api = await import('@/api')
+
+ const apps = [makeApp('app-1', 'sent', 'NudgeCorp'), makeApp('app-2', 'discovered', 'NoNudge')]
+ ;(api.getApplications as ReturnType).mockResolvedValue(apps)
+ ;(api.batchScore as ReturnType).mockResolvedValue({ results: [] })
+ ;(api.getToday as ReturnType).mockResolvedValue({
+ digest: [],
+ nudges: [
+ { application_id: 'app-1', days_since_sent: 9, suggestion: 'Send a follow-up email' }
+ ],
+ pending_approvals: 0
+ })
+
+ const wrapper = mount(Applications)
+ await vi.waitFor(() => {
+ expect(wrapper.text()).toContain('NudgeCorp')
+ })
+
+ // The nudge dot should be rendered as an orange dot (span with bg-orange-500)
+ const dots = wrapper.findAll('.bg-orange-500')
+ expect(dots.length).toBeGreaterThanOrEqual(1)
+
+ // Verify the dot is in the card for NudgeCorp (the sent column)
+ const sentCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'sent')
+ expect(sentCol).toBeTruthy()
+ const sentColumn = sentCol!.element.parentElement!
+ expect(sentColumn.textContent).toContain('NudgeCorp')
+ // The dot should be inside this column
+ expect(sentColumn.querySelector('.bg-orange-500')).toBeTruthy()
+ })
+
+ it('renders red flag badge with tooltip text from batch scoring results', async () => {
+ setActivePinia(createPinia())
+ const api = await import('@/api')
+
+ const apps = [makeApp('app-1', 'discovered', 'ScamCorp')]
+ ;(api.getApplications as ReturnType).mockResolvedValue(apps)
+ ;(api.batchScore as ReturnType).mockResolvedValue({
+ results: [
+ {
+ application_id: 'app-1',
+ score: 30,
+ rationale: {},
+ red_flags: ['Unpaid trial period mentioned', 'Asks for bank details upfront']
+ }
+ ]
+ })
+ ;(api.getToday as ReturnType).mockResolvedValue({
+ digest: [],
+ nudges: [],
+ pending_approvals: 0
+ })
+
+ const wrapper = mount(Applications)
+ await vi.waitFor(() => {
+ expect(wrapper.text()).toContain('ScamCorp')
+ })
+
+ // The warning symbol should be rendered in the card
+ const warningEl = wrapper.find('.text-red-600.font-bold')
+ expect(warningEl.exists()).toBe(true)
+
+ // The title attribute should contain the red flag text
+ const title = warningEl.attributes('title')
+ expect(title).toBeTruthy()
+ expect(title).toContain('Unpaid trial period mentioned')
+ expect(title).toContain('Asks for bank details upfront')
+ })
+})
\ No newline at end of file
diff --git a/apps/web/src/views/Applications.test.ts b/apps/web/src/views/Applications.test.ts
index ce78bed..101f6cd 100644
--- a/apps/web/src/views/Applications.test.ts
+++ b/apps/web/src/views/Applications.test.ts
@@ -7,6 +7,8 @@ import type { Application } from '@/types'
vi.mock('@/api', () => ({
getApplications: vi.fn(),
transitionApplication: vi.fn(),
+ batchScore: vi.fn().mockResolvedValue({ results: [] }),
+ getToday: vi.fn().mockResolvedValue({ digest: [], nudges: [], pending_approvals: 0 }),
HttpError: class HttpError extends Error {
status: number
body: unknown
diff --git a/apps/web/src/views/Welcome.test.ts b/apps/web/src/views/Welcome.test.ts
new file mode 100644
index 0000000..44bb27a
--- /dev/null
+++ b/apps/web/src/views/Welcome.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import Welcome from '@/views/Welcome.vue'
+
+vi.mock('@/api', () => ({
+ importCv: vi.fn(),
+ confirmCvImport: vi.fn(),
+ fetchPostings: 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 makeRouter() {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/welcome', name: 'welcome', component: Welcome },
+ { path: '/today', name: 'today', component: { template: 'Today
' } }
+ ]
+ })
+}
+
+describe('Onboarding wizard', () => {
+ it('shows welcome step and advances through steps to finish', async () => {
+ setActivePinia(createPinia())
+ const router = makeRouter()
+ await router.push('/welcome')
+ await router.isReady()
+
+ const wrapper = mount(Welcome, { global: { plugins: [router] } })
+
+ // Step 0: Welcome
+ expect(wrapper.text()).toContain('Welcome to Jobhunt')
+ expect(wrapper.text()).toContain('Get Started')
+
+ // Advance to step 1 (Import CV)
+ const getStartedBtn = wrapper.find('button')
+ await getStartedBtn.trigger('click')
+ expect(wrapper.text()).toContain('Import Your CV')
+
+ // Skip import -> step 2 (Fetch Postings)
+ const skipLink = wrapper.findAll('button').find((b) => b.text().includes('Skip for now'))
+ expect(skipLink).toBeTruthy()
+ await skipLink!.trigger('click')
+ expect(wrapper.text()).toContain('Fetch Job Postings')
+
+ // Continue -> step 3 (Done)
+ const continueBtn = wrapper.findAll('button').find((b) => b.text().includes('Continue'))
+ expect(continueBtn).toBeTruthy()
+ await continueBtn!.trigger('click')
+ expect(wrapper.text()).toContain('You are all set')
+
+ // Finish -> navigates to /today
+ const finishBtn = wrapper.findAll('button').find((b) => b.text().includes('Go to Today'))
+ expect(finishBtn).toBeTruthy()
+ await finishBtn!.trigger('click')
+ await vi.waitFor(() => {
+ expect(router.currentRoute.value.path).toBe('/today')
+ })
+ })
+})
\ No newline at end of file
diff --git a/apps/web/src/views/Welcome.vue b/apps/web/src/views/Welcome.vue
index d80409b..9e0371c 100644
--- a/apps/web/src/views/Welcome.vue
+++ b/apps/web/src/views/Welcome.vue
@@ -1,5 +1,5 @@