W3: add 3 new vitest tests (wizard routing, nudge badge render, red flag tooltip); update existing test mocks for new API functions

This commit is contained in:
hermes 2026-07-30 18:36:22 +00:00
parent f43ede9e07
commit 1e55c11dc9
6 changed files with 214 additions and 12 deletions

View file

@ -1,33 +1,51 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue' 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() { function makeRouter() {
return createRouter({ return createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/', redirect: '/cv' }, { path: '/', redirect: '/today' },
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } },
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } }, { path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } }, { path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } }, { path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } } { path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } },
{ path: '/welcome', name: 'welcome', component: { template: '<div>Welcome</div>' } }
] ]
}) })
} }
describe('Router tabs', () => { describe('Router tabs', () => {
it('renders all three tab links', async () => { it('renders all four tab links', async () => {
setActivePinia(createPinia()) setActivePinia(createPinia())
const router = makeRouter() const router = makeRouter()
await router.push('/cv') await router.push('/today')
await router.isReady() await router.isReady()
const wrapper = mount(App, { global: { plugins: [router] } }) const wrapper = mount(App, { global: { plugins: [router] } })
await vi.waitFor(() => {
const links = wrapper.findAll('nav a') const links = wrapper.findAll('nav a')
expect(links).toHaveLength(3) expect(links).toHaveLength(4)
expect(links[0].text()).toBe('CV') expect(links[0].text()).toBe('Today')
expect(links[1].text()).toBe('Research') expect(links[1].text()).toBe('CV')
expect(links[2].text()).toBe('Applications') expect(links[2].text()).toBe('Research')
expect(links[3].text()).toBe('Applications')
})
}) })
}) })

View file

@ -12,6 +12,7 @@ vi.mock('@/api', () => ({
confirmApproval: vi.fn(), confirmApproval: vi.fn(),
rejectApproval: vi.fn(), rejectApproval: vi.fn(),
outboxSend: vi.fn(), outboxSend: vi.fn(),
interviewPrep: vi.fn(),
HttpError: class HttpError extends Error { HttpError: class HttpError extends Error {
status: number status: number
body: unknown body: unknown

View file

@ -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<typeof vi.fn>).mockResolvedValue(apps)
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({ results: [] })
;(api.getToday as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(apps)
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({
results: [
{
application_id: 'app-1',
score: 30,
rationale: {},
red_flags: ['Unpaid trial period mentioned', 'Asks for bank details upfront']
}
]
})
;(api.getToday as ReturnType<typeof vi.fn>).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')
})
})

View file

@ -7,6 +7,8 @@ import type { Application } from '@/types'
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
getApplications: vi.fn(), getApplications: vi.fn(),
transitionApplication: vi.fn(), transitionApplication: vi.fn(),
batchScore: vi.fn().mockResolvedValue({ results: [] }),
getToday: vi.fn().mockResolvedValue({ digest: [], nudges: [], pending_approvals: 0 }),
HttpError: class HttpError extends Error { HttpError: class HttpError extends Error {
status: number status: number
body: unknown body: unknown

View file

@ -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: '<div>Today</div>' } }
]
})
}
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')
})
})
})

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import * as api from '@/api' import * as api from '@/api'
@ -11,7 +11,6 @@ const router = useRouter()
const step = ref(0) const step = ref(0)
const steps = ['Welcome', 'Import CV', 'Fetch Postings', 'Done'] const steps = ['Welcome', 'Import CV', 'Fetch Postings', 'Done']
const stepTitle = computed(() => steps[step.value])
// Step 1: Import CV // Step 1: Import CV
const selectedFile = ref<File | null>(null) const selectedFile = ref<File | null>(null)