Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions frontend/src/app/core/api/ai.api.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
98 changes: 98 additions & 0 deletions frontend/src/app/core/api/applications.api.spec.ts
Original file line number Diff line number Diff line change
@@ -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', '<p>letter</p>', '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: '<p>letter</p>', status: 'APPLIED', notes: undefined,
});
req.flush(application);
});
});
86 changes: 86 additions & 0 deletions frontend/src/app/core/api/companies.api.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading
Loading