From f307bce95a1e71ea0f3288eef0019e45b8d1b919 Mon Sep 17 00:00:00 2001 From: CMaintz Date: Wed, 30 Sep 2026 14:50:35 +0200 Subject: [PATCH] test(frontend): specs for core API services, auth interceptor/guards and shared components --- frontend/src/app/core/api/ai.api.spec.ts | 111 ++++++++++++++++ .../src/app/core/api/applications.api.spec.ts | 98 ++++++++++++++ .../src/app/core/api/companies.api.spec.ts | 86 ++++++++++++ frontend/src/app/core/api/jobs.api.spec.ts | 124 ++++++++++++++++++ frontend/src/app/core/auth/auth.guard.spec.ts | 60 +++++++++ .../app/core/auth/auth.interceptor.spec.ts | 73 +++++++++++ .../app/core/auth/onboarding.guard.spec.ts | 30 +++++ .../jb-segmented.component.spec.ts | 38 ++++++ .../match-badge/match-badge.component.spec.ts | 62 +++++++++ .../status-chip/status-chip.component.spec.ts | 58 ++++++++ 10 files changed, 740 insertions(+) create mode 100644 frontend/src/app/core/api/ai.api.spec.ts create mode 100644 frontend/src/app/core/api/applications.api.spec.ts create mode 100644 frontend/src/app/core/api/companies.api.spec.ts create mode 100644 frontend/src/app/core/api/jobs.api.spec.ts create mode 100644 frontend/src/app/core/auth/auth.guard.spec.ts create mode 100644 frontend/src/app/core/auth/auth.interceptor.spec.ts create mode 100644 frontend/src/app/core/auth/onboarding.guard.spec.ts create mode 100644 frontend/src/app/shared/components/jb-segmented/jb-segmented.component.spec.ts create mode 100644 frontend/src/app/shared/components/match-badge/match-badge.component.spec.ts create mode 100644 frontend/src/app/shared/components/status-chip/status-chip.component.spec.ts diff --git a/frontend/src/app/core/api/ai.api.spec.ts b/frontend/src/app/core/api/ai.api.spec.ts new file mode 100644 index 00000000..5c5b7368 --- /dev/null +++ b/frontend/src/app/core/api/ai.api.spec.ts @@ -0,0 +1,111 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { AiApiService, AiCredentialStatus, AnalysisResponse, GenerateDocumentRequest } from './ai.api'; +import { StructuredDocument } from '../models/structured-document.model'; + +describe('AiApiService', () => { + let api: AiApiService; + let backend: HttpTestingController; + + const doc = { documentType: 'COVER_LETTER' } as unknown as StructuredDocument; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + api = TestBed.inject(AiApiService); + backend = TestBed.inject(HttpTestingController); + }); + + afterEach(() => backend.verify()); + + it('omits templateId from the render-model request when none is chosen', () => { + api.getCvRenderModel().subscribe(); + const bare = backend.expectOne(r => r.url === '/api/v1/ai/cv/render-model'); + expect(bare.request.params.keys()).toEqual([]); + bare.flush(doc); + + api.getCvRenderModel('modern').subscribe(); + const withTemplate = backend.expectOne(r => r.url === '/api/v1/ai/cv/render-model'); + expect(withTemplate.request.params.get('templateId')).toBe('modern'); + withTemplate.flush(doc); + }); + + it('posts a document generation request as-is and returns the structured document', () => { + const request: GenerateDocumentRequest = { + jobId: 'j1', documentType: 'COVER_LETTER', targetLanguage: 'da', lengthPreference: 'SHORT', + }; + let result: StructuredDocument | undefined; + api.generateDocument(request).subscribe(r => (result = r)); + + const req = backend.expectOne('/api/v1/ai/generate-document'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(request); + req.flush(doc); + expect(result).toEqual(doc); + }); + + it('wraps a structured document with its job when saving', () => { + api.saveStructuredDocument(doc, 'j1').subscribe(); + + const req = backend.expectOne('/api/v1/ai/documents/structured'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ document: doc, jobId: 'j1' }); + req.flush(doc); + }); + + it('analyzes a CV version and the master profile through the same endpoint', () => { + const analysis = { suggestions: ['quantify impact'], score: 64, rawResponse: '' } as AnalysisResponse; + let result: AnalysisResponse | undefined; + + api.analyzeCv('cv-1', 'j1').subscribe(r => (result = r)); + const byVersion = backend.expectOne('/api/v1/ai/analyze'); + expect(byVersion.request.body).toEqual({ cvVersionId: 'cv-1', jobId: 'j1' }); + byVersion.flush(analysis); + expect(result).toEqual(analysis); + + api.analyze({ jobId: 'j1' }).subscribe(); + const byProfile = backend.expectOne('/api/v1/ai/analyze'); + expect(byProfile.request.body).toEqual({ jobId: 'j1' }); + byProfile.flush(analysis); + }); + + it('stores a credential with PUT and clears it with DELETE', () => { + const status: AiCredentialStatus = { + configured: true, provider: 'OPENAI', hint: 'abcd', model: 'gpt-4o', updatedAt: null, storageAvailable: true, + }; + let result: AiCredentialStatus | undefined; + api.setCredential('OPENAI', 'sk-secret', 'gpt-4o').subscribe(r => (result = r)); + + const put = backend.expectOne('/api/v1/ai/credentials'); + expect(put.request.method).toBe('PUT'); + expect(put.request.body).toEqual({ provider: 'OPENAI', apiKey: 'sk-secret', model: 'gpt-4o' }); + put.flush(status); + expect(result).toEqual(status); + + api.clearCredential().subscribe(); + expect(backend.expectOne('/api/v1/ai/credentials').request.method).toBe('DELETE'); + }); + + it('sends raw CV text for parsing and a refine request for refinement', () => { + api.parseCv('Jane Doe\nEngineer').subscribe(); + const parse = backend.expectOne('/api/v1/ai/parse-cv'); + expect(parse.request.body).toEqual({ rawCvText: 'Jane Doe\nEngineer' }); + parse.flush({}); + + const refine = { currentContent: 'old', userMessage: 'shorter', sectionKey: 'profile' }; + api.refine(refine).subscribe(); + const req = backend.expectOne('/api/v1/ai/refine'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(refine); + req.flush({ refinedContent: 'new', modelUsed: 'gpt-4o' }); + }); + + it('triggers the skill-gap analysis with an empty POST body', () => { + api.analyzeSkillGaps().subscribe(); + + const req = backend.expectOne('/api/v1/ai/skill-gaps'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({}); + req.flush({ gaps: [], jobsAnalyzed: 0 }); + }); +}); diff --git a/frontend/src/app/core/api/applications.api.spec.ts b/frontend/src/app/core/api/applications.api.spec.ts new file mode 100644 index 00000000..de51e339 --- /dev/null +++ b/frontend/src/app/core/api/applications.api.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ApplicationTimelineEntry, ApplicationsApiService } from './applications.api'; +import { Application } from '../models/application.model'; + +describe('ApplicationsApiService', () => { + let api: ApplicationsApiService; + let backend: HttpTestingController; + + const application = { id: 'a1', jobId: 'j1', status: 'APPLIED' } as Application; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + api = TestBed.inject(ApplicationsApiService); + backend = TestBed.inject(HttpTestingController); + }); + + afterEach(() => backend.verify()); + + it('lists applications', () => { + let result: Application[] | undefined; + api.getAll().subscribe(r => (result = r)); + + const req = backend.expectOne('/api/v1/applications'); + expect(req.request.method).toBe('GET'); + req.flush([application]); + expect(result).toEqual([application]); + }); + + describe('create', () => { + it('builds the payload from positional arguments', () => { + api.create('j1', 'cv-1', 'referred by Anna').subscribe(); + + const req = backend.expectOne('/api/v1/applications'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ jobId: 'j1', cvVersionId: 'cv-1', notes: 'referred by Anna' }); + req.flush(application); + }); + + it('sends a payload object unchanged', () => { + const payload = { jobId: 'j1', status: 'PREPARING' as const, matchScore: 72, coverLetterText: 'Hej' }; + let created: Application | undefined; + api.create(payload).subscribe(r => (created = r)); + + const req = backend.expectOne('/api/v1/applications'); + expect(req.request.body).toBe(payload); + req.flush(application); + expect(created).toEqual(application); + }); + }); + + it('reads the timeline of one application', () => { + const entries: ApplicationTimelineEntry[] = [ + { at: '2026-01-01T10:00:00Z', fromStatus: null, toStatus: 'SAVED', employerResponse: false, notes: null }, + { at: '2026-01-03T10:00:00Z', fromStatus: 'SAVED', toStatus: 'APPLIED', employerResponse: false, notes: null }, + ]; + let result: ApplicationTimelineEntry[] | undefined; + api.timeline('a1').subscribe(r => (result = r)); + + backend.expectOne('/api/v1/applications/a1/timeline').flush(entries); + expect(result).toEqual(entries); + }); + + it('patches the status with its notes', () => { + api.updateStatus('a1', 'INTERVIEW', 'first round Tuesday').subscribe(); + + const req = backend.expectOne('/api/v1/applications/a1/status'); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ status: 'INTERVIEW', notes: 'first round Tuesday' }); + req.flush(application); + }); + + it('patches recruiter info and outcome on their own sub-resources', () => { + api.updateRecruiterInfo('a1', { recruiterName: 'Kim' }).subscribe(); + const recruiter = backend.expectOne('/api/v1/applications/a1/recruiter'); + expect(recruiter.request.method).toBe('PATCH'); + expect(recruiter.request.body).toEqual({ recruiterName: 'Kim' }); + recruiter.flush(application); + + api.updateOutcome('a1', { outcomeLessons: 'show more system design' }).subscribe(); + const outcome = backend.expectOne('/api/v1/applications/a1/outcome'); + expect(outcome.request.method).toBe('PATCH'); + expect(outcome.request.body).toEqual({ outcomeLessons: 'show more system design' }); + outcome.flush(application); + }); + + it('attaches a generated document by id, with content and status in the body', () => { + api.attachGeneratedDocument('a1', 'doc-9', '

letter

', 'APPLIED').subscribe(); + + const req = backend.expectOne('/api/v1/applications/a1/generated-documents/doc-9'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + generatedContent: '

letter

', status: 'APPLIED', notes: undefined, + }); + req.flush(application); + }); +}); diff --git a/frontend/src/app/core/api/companies.api.spec.ts b/frontend/src/app/core/api/companies.api.spec.ts new file mode 100644 index 00000000..eac06c3a --- /dev/null +++ b/frontend/src/app/core/api/companies.api.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { CompaniesApiService, OutreachContact, OutreachTarget } from './companies.api'; + +describe('CompaniesApiService', () => { + let api: CompaniesApiService; + let backend: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + api = TestBed.inject(CompaniesApiService); + backend = TestBed.inject(HttpTestingController); + }); + + afterEach(() => backend.verify()); + + it('searches with an empty query and first page by default', () => { + api.search().subscribe(); + + const req = backend.expectOne(r => r.url === '/api/v1/companies'); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('q')).toBe(''); + expect(req.request.params.get('page')).toBe('0'); + expect(req.request.params.get('size')).toBe('20'); + req.flush([]); + }); + + it('excludes companies hiring right now from outreach targets unless asked', () => { + api.outreachTargets().subscribe(); + const byDefault = backend.expectOne(r => r.url === '/api/v1/companies/outreach-targets'); + expect(byDefault.request.params.get('limit')).toBe('20'); + expect(byDefault.request.params.get('includeHiringNow')).toBe('false'); + byDefault.flush([]); + + let targets: OutreachTarget[] | undefined; + api.outreachTargets(5, true).subscribe(r => (targets = r)); + const included = backend.expectOne(r => r.url === '/api/v1/companies/outreach-targets'); + expect(included.request.params.get('limit')).toBe('5'); + expect(included.request.params.get('includeHiringNow')).toBe('true'); + const target: OutreachTarget = { + companyId: 'c1', companyName: 'Acme', website: null, score: 0.8, + reasons: [{ code: 'techMatch', args: { tech: 'Java' } }], + lastPostedAt: null, matchedTechnologies: ['Java'], hasOpenRole: true, + }; + included.flush([target]); + expect(targets).toEqual([target]); + }); + + it('tracks outreach with an explicit null company id for an untracked company', () => { + api.trackOutreach(null, 'Small Shop ApS').subscribe(); + + const req = backend.expectOne('/api/v1/companies/outreach'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ companyId: null, companyName: 'Small Shop ApS', contactName: undefined }); + req.flush({}); + }); + + it('patches only the fields being changed', () => { + let updated: OutreachContact | undefined; + api.updateOutreach('o1', { status: 'CONTACTED', followUpDue: '2026-10-07' }).subscribe(r => (updated = r)); + + const req = backend.expectOne('/api/v1/companies/outreach/o1'); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ status: 'CONTACTED', followUpDue: '2026-10-07' }); + const contact = { id: 'o1', companyName: 'Acme', status: 'CONTACTED' } as OutreachContact; + req.flush(contact); + expect(updated).toEqual(contact); + }); + + it('untracks outreach with DELETE', () => { + api.untrackOutreach('o1').subscribe(); + expect(backend.expectOne('/api/v1/companies/outreach/o1').request.method).toBe('DELETE'); + }); + + it('reads and saves research notes on the company resource', () => { + api.getResearch('c1').subscribe(); + backend.expectOne('/api/v1/companies/c1/research').flush({ notes: null, updatedAt: null }); + + api.saveResearch('c1', 'Uses Kotlin; hiring in Aarhus').subscribe(); + const save = backend.expectOne('/api/v1/companies/c1/research'); + expect(save.request.method).toBe('PUT'); + expect(save.request.body).toEqual({ notes: 'Uses Kotlin; hiring in Aarhus' }); + save.flush({ notes: 'Uses Kotlin; hiring in Aarhus', updatedAt: '2026-09-30T08:00:00Z' }); + }); +}); diff --git a/frontend/src/app/core/api/jobs.api.spec.ts b/frontend/src/app/core/api/jobs.api.spec.ts new file mode 100644 index 00000000..569d5fc3 --- /dev/null +++ b/frontend/src/app/core/api/jobs.api.spec.ts @@ -0,0 +1,124 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { JobsApiService } from './jobs.api'; +import { Job, JobSearchResult, MatchResult } from '../models/job.model'; + +describe('JobsApiService', () => { + let api: JobsApiService; + let backend: HttpTestingController; + + const job = { id: 'j1', url: 'https://example.com/j1', title: 'Backend Engineer' } as Job; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + api = TestBed.inject(JobsApiService); + backend = TestBed.inject(HttpTestingController); + }); + + afterEach(() => backend.verify()); + + it('pages the job list with default page and size', () => { + let result: { content: Job[]; totalElements: number } | undefined; + api.getJobs().subscribe(r => (result = r)); + + const req = backend.expectOne(r => r.url === '/api/v1/jobs'); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('page')).toBe('0'); + expect(req.request.params.get('size')).toBe('20'); + req.flush({ content: [job], totalElements: 1 }); + + expect(result).toEqual({ content: [job], totalElements: 1 }); + }); + + it('searches without a categories param when none are chosen', () => { + api.search('java', 2, 10).subscribe(); + + const req = backend.expectOne(r => r.url === '/api/v1/jobs/search'); + expect(req.request.params.get('q')).toBe('java'); + expect(req.request.params.get('page')).toBe('2'); + expect(req.request.params.get('size')).toBe('10'); + expect(req.request.params.has('categories')).toBeFalse(); + req.flush({ jobs: [], total: 0, page: 2, size: 10 } satisfies JobSearchResult); + }); + + it('repeats the categories param once per chosen category', () => { + let result: JobSearchResult | undefined; + api.search('java', 0, 20, ['BACKEND', 'DEVOPS']).subscribe(r => (result = r)); + + const req = backend.expectOne(r => r.url === '/api/v1/jobs/search'); + expect(req.request.params.getAll('categories')).toEqual(['BACKEND', 'DEVOPS']); + req.flush({ jobs: [job], total: 1, page: 0, size: 20 }); + + expect(result?.jobs).toEqual([job]); + }); + + it('fetches similar jobs and recommendations with a limit', () => { + api.getSimilar('j1').subscribe(); + const similar = backend.expectOne(r => r.url === '/api/v1/jobs/j1/similar'); + expect(similar.request.params.get('limit')).toBe('5'); + similar.flush([]); + + let recs: MatchResult[] | undefined; + api.getRecommendations(3).subscribe(r => (recs = r)); + const rec = backend.expectOne(r => r.url === '/api/v1/jobs/recommendations'); + expect(rec.request.params.get('limit')).toBe('3'); + const match = { jobId: 'j1', job, totalScore: 81, matchLabel: 'STRONG' } as MatchResult; + rec.flush([match]); + expect(recs).toEqual([match]); + }); + + it('sends the ignore reason only when one is given', () => { + api.ignore('j1', 'too far').subscribe(); + const withReason = backend.expectOne(r => r.url === '/api/v1/jobs/j1/ignore'); + expect(withReason.request.method).toBe('POST'); + expect(withReason.request.params.get('reason')).toBe('too far'); + withReason.flush(null); + + api.ignore('j2').subscribe(); + const withoutReason = backend.expectOne(r => r.url === '/api/v1/jobs/j2/ignore'); + expect(withoutReason.request.params.keys()).toEqual([]); + withoutReason.flush(null); + }); + + it('saves with POST and unsaves with DELETE on the same resource', () => { + api.save('j1').subscribe(); + const save = backend.expectOne('/api/v1/jobs/j1/save'); + expect(save.request.method).toBe('POST'); + expect(save.request.body).toEqual({}); + save.flush(null); + + api.unsave('j1').subscribe(); + expect(backend.expectOne('/api/v1/jobs/j1/save').request.method).toBe('DELETE'); + }); + + it('submits feedback type as a query param with an empty body', () => { + api.submitFeedback('j1', 'FEWER_LIKE_THIS').subscribe(); + + const req = backend.expectOne(r => r.url === '/api/v1/jobs/j1/feedback'); + expect(req.request.method).toBe('POST'); + expect(req.request.params.get('type')).toBe('FEWER_LIKE_THIS'); + expect(req.request.body).toEqual({}); + req.flush({}); + }); + + it('looks a posting up by its URL', () => { + api.lookupByUrl('https://jobs.example.com/42?ref=a').subscribe(); + + const req = backend.expectOne(r => r.url === '/api/v1/jobs/lookup'); + expect(req.request.params.get('url')).toBe('https://jobs.example.com/42?ref=a'); + req.flush(job); + }); + + it('posts a manually added job as the body', () => { + const payload = { title: 'Dev', companyName: 'Acme', description: 'Build things', salaryMin: 50000 }; + let created: Job | undefined; + api.addManual(payload).subscribe(r => (created = r)); + + const req = backend.expectOne('/api/v1/jobs/manual'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(payload); + req.flush(job); + expect(created).toEqual(job); + }); +}); diff --git a/frontend/src/app/core/auth/auth.guard.spec.ts b/frontend/src/app/core/auth/auth.guard.spec.ts new file mode 100644 index 00000000..f79eb478 --- /dev/null +++ b/frontend/src/app/core/auth/auth.guard.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree, provideRouter } from '@angular/router'; +import { authGuard } from './auth.guard'; +import { AuthService } from './auth.service'; + +/** + * On a fresh page load Firebase has not yet restored the persisted session, so the guard + * must wait for it before deciding — otherwise a signed-in user is bounced to /login. + */ +describe('authGuard', () => { + let authenticated: boolean; + let resolveAuthReady: () => void; + let authService: jasmine.SpyObj; + + beforeEach(() => { + authenticated = false; + authService = jasmine.createSpyObj('AuthService', ['whenAuthReady', 'isAuthenticated']); + authService.whenAuthReady.and.returnValue(new Promise(resolve => (resolveAuthReady = resolve))); + authService.isAuthenticated.and.callFake(() => authenticated); + + TestBed.configureTestingModule({ + providers: [provideRouter([]), { provide: AuthService, useValue: authService }], + }); + }); + + function runGuard(): Promise { + const route = {} as ActivatedRouteSnapshot; + const state = {} as RouterStateSnapshot; + return TestBed.runInInjectionContext(() => authGuard(route, state)) as Promise; + } + + it('lets a signed-in user through', async () => { + authenticated = true; + const result = runGuard(); + resolveAuthReady(); + + expect(await result).toBeTrue(); + }); + + it('redirects a signed-out user to /login', async () => { + const result = runGuard(); + resolveAuthReady(); + + const tree = await result; + expect(tree instanceof UrlTree).toBeTrue(); + expect(TestBed.inject(Router).serializeUrl(tree as UrlTree)).toBe('/login'); + }); + + it('does not read the session before Firebase has restored it', async () => { + authenticated = true; + const result = runGuard(); + await Promise.resolve(); + + expect(authService.isAuthenticated).not.toHaveBeenCalled(); + + resolveAuthReady(); + expect(await result).toBeTrue(); + expect(authService.isAuthenticated).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/app/core/auth/auth.interceptor.spec.ts b/frontend/src/app/core/auth/auth.interceptor.spec.ts new file mode 100644 index 00000000..46226505 --- /dev/null +++ b/frontend/src/app/core/auth/auth.interceptor.spec.ts @@ -0,0 +1,73 @@ +import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { authInterceptor } from './auth.interceptor'; +import { AuthService } from './auth.service'; + +/** + * Every API call leaves through this interceptor, so it decides whether the backend sees + * a Firebase identity at all. A signed-out session must still reach the network — the + * public endpoints (login, LinkedIn exchange) depend on it. + */ +describe('authInterceptor', () => { + let token: string | null; + let http: HttpClient; + let backend: HttpTestingController; + + beforeEach(() => { + token = null; + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + { provide: AuthService, useValue: { getIdToken: () => Promise.resolve(token) } }, + ], + }); + http = TestBed.inject(HttpClient); + backend = TestBed.inject(HttpTestingController); + }); + + afterEach(() => backend.verify()); + + it('attaches the Firebase ID token as a bearer header', fakeAsync(() => { + token = 'id-token-123'; + http.get('/api/v1/jobs').subscribe(); + flushMicrotasks(); + + const req = backend.expectOne('/api/v1/jobs'); + expect(req.request.headers.get('Authorization')).toBe('Bearer id-token-123'); + req.flush([]); + })); + + it('sends the request unauthenticated when there is no signed-in user', fakeAsync(() => { + http.post('/api/v1/auth/linkedin', { code: 'c' }).subscribe(); + flushMicrotasks(); + + const req = backend.expectOne('/api/v1/auth/linkedin'); + expect(req.request.headers.has('Authorization')).toBeFalse(); + expect(req.request.body).toEqual({ code: 'c' }); + req.flush({}); + })); + + it('holds the request until the token has resolved', fakeAsync(() => { + token = 'late'; + http.get('/api/v1/dashboard').subscribe(); + + expect(backend.match('/api/v1/dashboard')).toEqual([]); + flushMicrotasks(); + + const req = backend.expectOne('/api/v1/dashboard'); + expect(req.request.headers.get('Authorization')).toBe('Bearer late'); + req.flush({}); + })); + + it('passes the response through untouched', fakeAsync(() => { + token = 't'; + let body: unknown; + http.get('/api/v1/jobs/1').subscribe(res => (body = res)); + flushMicrotasks(); + + backend.expectOne('/api/v1/jobs/1').flush({ id: '1', title: 'Engineer' }); + expect(body).toEqual({ id: '1', title: 'Engineer' }); + })); +}); diff --git a/frontend/src/app/core/auth/onboarding.guard.spec.ts b/frontend/src/app/core/auth/onboarding.guard.spec.ts new file mode 100644 index 00000000..c014ce0e --- /dev/null +++ b/frontend/src/app/core/auth/onboarding.guard.spec.ts @@ -0,0 +1,30 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree, provideRouter } from '@angular/router'; +import { onboardingGuard } from './onboarding.guard'; +import { AuthService } from './auth.service'; + +/** Onboarding is a one-way door: once finished, the route sends the user on to the dashboard. */ +describe('onboardingGuard', () => { + function runGuard(onboardingComplete: boolean): boolean | UrlTree { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: AuthService, useValue: { isOnboardingComplete: () => onboardingComplete } }, + ], + }); + const route = {} as ActivatedRouteSnapshot; + const state = {} as RouterStateSnapshot; + return TestBed.runInInjectionContext(() => onboardingGuard(route, state)) as boolean | UrlTree; + } + + it('allows onboarding while it is still unfinished', () => { + expect(runGuard(false)).toBeTrue(); + }); + + it('redirects to the dashboard once onboarding is complete', () => { + const result = runGuard(true); + + expect(result instanceof UrlTree).toBeTrue(); + expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/dashboard'); + }); +}); diff --git a/frontend/src/app/shared/components/jb-segmented/jb-segmented.component.spec.ts b/frontend/src/app/shared/components/jb-segmented/jb-segmented.component.spec.ts new file mode 100644 index 00000000..94d90e2f --- /dev/null +++ b/frontend/src/app/shared/components/jb-segmented/jb-segmented.component.spec.ts @@ -0,0 +1,38 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { JbSegmentedComponent } from './jb-segmented.component'; + +describe('JbSegmentedComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [JbSegmentedComponent] }); + fixture = TestBed.createComponent(JbSegmentedComponent); + fixture.componentRef.setInput('options', ['Week', 'Month', 'Year']); + fixture.componentRef.setInput('value', 'Month'); + fixture.detectChanges(); + }); + + function buttons(): HTMLButtonElement[] { + return Array.from(fixture.nativeElement.querySelectorAll('button')); + } + + it('renders one button per option, in order', () => { + expect(buttons().map(b => b.textContent?.trim())).toEqual(['Week', 'Month', 'Year']); + }); + + it('highlights only the selected option', () => { + const highlighted = buttons().map(b => b.style.background === 'var(--jb-surface)'); + expect(highlighted).toEqual([false, true, false]); + }); + + it('emits the clicked option without changing its own value', () => { + const emitted: string[] = []; + fixture.componentInstance.changed.subscribe(v => emitted.push(v)); + + buttons()[2].click(); + fixture.detectChanges(); + + expect(emitted).toEqual(['Year']); + expect(buttons()[1].style.background).toBe('var(--jb-surface)'); + }); +}); diff --git a/frontend/src/app/shared/components/match-badge/match-badge.component.spec.ts b/frontend/src/app/shared/components/match-badge/match-badge.component.spec.ts new file mode 100644 index 00000000..d612ee29 --- /dev/null +++ b/frontend/src/app/shared/components/match-badge/match-badge.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MatchBadgeComponent, matchColor, matchLabelKey } from './match-badge.component'; + +/** + * The grade comes from the backend; the badge only colours and names it. It must never + * re-derive a grade from the score, or the two drift when a threshold moves. + */ +describe('match badge helpers', () => { + it('names each grade by its translation key', () => { + expect(matchLabelKey('EXCELLENT')).toBe('match.grade.excellent'); + expect(matchLabelKey('MODERATE')).toBe('match.grade.moderate'); + }); + + it('treats a missing grade as weak', () => { + expect(matchLabelKey(undefined)).toBe('match.grade.weak'); + expect(matchColor(undefined)).toEqual(matchColor('WEAK')); + }); + + it('gives the top grades distinct colours', () => { + expect(matchColor('EXCELLENT').fg).toBe('var(--jb-success)'); + expect(matchColor('STRONG').fg).toBe('var(--jb-accent-2)'); + expect(matchColor('EXCELLENT')).not.toEqual(matchColor('STRONG')); + }); +}); + +describe('MatchBadgeComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [MatchBadgeComponent, TranslateModule.forRoot()] }); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + match: { grade: { strong: 'Strong', weak: 'Weak' }, scoreTooltip: 'Match score {{score}}' }, + }); + translate.use('en'); + fixture = TestBed.createComponent(MatchBadgeComponent); + }); + + function badge(): HTMLElement { + return fixture.nativeElement.querySelector('span'); + } + + it('shows the grade, not the number, with the score on hover', () => { + fixture.componentRef.setInput('label', 'STRONG'); + fixture.componentRef.setInput('score', 74); + fixture.detectChanges(); + + expect(badge().textContent?.trim()).toBe('Strong'); + expect(badge().title).toBe('Match score 74'); + expect(badge().style.color).toBe('var(--jb-accent-2)'); + }); + + it('does not upgrade a high score whose grade is weak', () => { + fixture.componentRef.setInput('label', 'WEAK'); + fixture.componentRef.setInput('score', 95); + fixture.detectChanges(); + + expect(badge().textContent?.trim()).toBe('Weak'); + expect(badge().style.color).toBe('var(--jb-text-dim)'); + }); +}); diff --git a/frontend/src/app/shared/components/status-chip/status-chip.component.spec.ts b/frontend/src/app/shared/components/status-chip/status-chip.component.spec.ts new file mode 100644 index 00000000..6a40d43f --- /dev/null +++ b/frontend/src/app/shared/components/status-chip/status-chip.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { STAGE_LABEL_KEY, STAGE_TONE, StatusChipComponent, stageLabelKey, stageTone } from './status-chip.component'; + +/** + * Every list, board and detail view renders a stage through this chip, so the mapping is + * the single source of a stage's colour and label. + */ +describe('stage mapping', () => { + it('gives every stage both a label and a tone', () => { + expect(Object.keys(STAGE_TONE).sort()).toEqual(Object.keys(STAGE_LABEL_KEY).sort()); + }); + + it('maps known stages', () => { + expect(stageLabelKey('RECRUITER_CONTACT')).toBe('pipeline.stage.screen'); + expect(stageTone('OFFER')).toBe('success'); + expect(stageTone('REJECTED')).toBe('danger'); + }); + + it('falls back to the raw status and a neutral tone for an unknown stage', () => { + expect(stageLabelKey('GHOSTED')).toBe('GHOSTED'); + expect(stageTone('GHOSTED')).toBe('neutral'); + }); +}); + +describe('StatusChipComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [StatusChipComponent, TranslateModule.forRoot()] }); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { pipeline: { stage: { interview: 'Interview', offer: 'Offer' } } }); + translate.use('en'); + fixture = TestBed.createComponent(StatusChipComponent); + }); + + function pill(): HTMLElement { + return fixture.nativeElement.querySelector('jb-pill span'); + } + + it('renders the translated stage in a pill of its tone', () => { + fixture.componentRef.setInput('status', 'INTERVIEW'); + fixture.detectChanges(); + + expect(pill().textContent?.trim()).toBe('Interview'); + expect(pill().className).toBe('pill pill-accent'); + }); + + it('follows a status change', () => { + fixture.componentRef.setInput('status', 'INTERVIEW'); + fixture.detectChanges(); + fixture.componentRef.setInput('status', 'OFFER'); + fixture.detectChanges(); + + expect(pill().textContent?.trim()).toBe('Offer'); + expect(pill().className).toBe('pill pill-success'); + }); +});