From 1e55c11dc9ba1e110bf39204835b5009e865e94a Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 30 Jul 2026 18:36:22 +0000 Subject: [PATCH] W3: add 3 new vitest tests (wizard routing, nudge badge render, red flag tooltip); update existing test mocks for new API functions --- apps/web/src/router.test.ts | 38 ++++-- apps/web/src/views/ApplicationDetail.test.ts | 1 + .../web/src/views/Applications.badges.test.ts | 112 ++++++++++++++++++ apps/web/src/views/Applications.test.ts | 2 + apps/web/src/views/Welcome.test.ts | 70 +++++++++++ apps/web/src/views/Welcome.vue | 3 +- 6 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/views/Applications.badges.test.ts create mode 100644 apps/web/src/views/Welcome.test.ts 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 @@