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:
parent
f43ede9e07
commit
1e55c11dc9
6 changed files with 214 additions and 12 deletions
|
|
@ -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] } })
|
||||||
const links = wrapper.findAll('nav a')
|
await vi.waitFor(() => {
|
||||||
expect(links).toHaveLength(3)
|
const links = wrapper.findAll('nav a')
|
||||||
expect(links[0].text()).toBe('CV')
|
expect(links).toHaveLength(4)
|
||||||
expect(links[1].text()).toBe('Research')
|
expect(links[0].text()).toBe('Today')
|
||||||
expect(links[2].text()).toBe('Applications')
|
expect(links[1].text()).toBe('CV')
|
||||||
|
expect(links[2].text()).toBe('Research')
|
||||||
|
expect(links[3].text()).toBe('Applications')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
112
apps/web/src/views/Applications.badges.test.ts
Normal file
112
apps/web/src/views/Applications.badges.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
70
apps/web/src/views/Welcome.test.ts
Normal file
70
apps/web/src/views/Welcome.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue