This commit is contained in:
DaKheera47 2026-01-23 12:25:00 +00:00
parent 3f37029dfd
commit fa13709738
6 changed files with 147 additions and 259 deletions

View File

@ -1,7 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { startServer, stopServer } from './test-utils.js';
import { RxResumeClient } from '@server/services/rxresume-client.js';
@ -154,67 +152,19 @@ describe.sequential('Onboarding API routes', () => {
});
describe('GET /api/onboarding/validate/resume', () => {
it('returns invalid when no resume file exists', async () => {
it('returns invalid when rxresumeBaseResumeId is not configured', async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBeTruthy();
expect(body.data.message).toContain('No base resume selected');
});
it('returns invalid when resume file is empty', async () => {
// Create an empty resume file
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, '');
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
});
it('returns invalid when resume file is invalid JSON', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, 'not valid json {{{');
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toBeTruthy();
});
it('returns invalid with field path when resume does not match schema', async () => {
const resumePath = join(tempDir, 'resume.json');
// Valid JSON but missing required fields
await writeFile(resumePath, JSON.stringify({ foo: 'bar' }));
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
// Should include field path in error message
expect(body.data.message).toBeTruthy();
});
it('returns valid when resume file is valid and matches schema', async () => {
const resumePath = join(tempDir, 'resume.json');
const validResume = createMinimalValidResume();
await writeFile(resumePath, JSON.stringify(validResume));
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
});
// Note: Further validation tests require mocking getSetting and getResume
// which is complex in integration tests. The validation logic is covered
// by unit tests in profile.test.ts and the service tests.
});
});

View File

@ -1,9 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { writeFile, stat } from 'fs/promises';
import { join } from 'path';
import { startServer, stopServer } from './test-utils.js';
// Mock the rxresume-v4 service
vi.mock('../../services/rxresume-v4.js', () => ({
getResume: vi.fn(),
listResumes: vi.fn(),
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
constructor() {
super('RxResume credentials not configured.');
this.name = 'RxResumeCredentialsError';
}
},
}));
// Mock the profile service
vi.mock('../../services/profile.js', () => ({
getProfile: vi.fn(),
clearProfileCache: vi.fn(),
}));
// Mock the settings repository
vi.mock('../../repositories/settings.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
getSetting: vi.fn(),
};
});
import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js';
import { getProfile, clearProfileCache } from '../../services/profile.js';
import { getSetting } from '../../repositories/settings.js';
describe.sequential('Profile API routes', () => {
let server: Server;
let baseUrl: string;
@ -11,6 +40,7 @@ describe.sequential('Profile API routes', () => {
let tempDir: string;
beforeEach(async () => {
vi.clearAllMocks();
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
@ -18,73 +48,88 @@ describe.sequential('Profile API routes', () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns empty projects when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
describe('GET /api/profile/projects', () => {
it('returns projects when profile is configured', async () => {
const mockProfile = {
sections: {
projects: {
items: [
{ id: 'proj1', name: 'Project 1', description: 'Desc 1', date: '2024', visible: true },
{ id: 'proj2', name: 'Project 2', description: 'Desc 2', date: '2023', visible: false },
],
},
},
};
vi.mocked(getProfile).mockResolvedValue(mockProfile);
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toEqual([]);
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBe(2);
});
it('returns error when profile is not configured', async () => {
vi.mocked(getProfile).mockRejectedValue(new Error('Base resume not configured.'));
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(false);
expect(body.success).toBe(false);
expect(body.error).toContain('Base resume not configured');
});
});
it('returns null profile when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
describe('GET /api/profile', () => {
it('returns full profile when configured', async () => {
const mockProfile = {
basics: { name: 'Test User', headline: 'Developer' },
sections: { summary: { content: 'A summary' } },
};
vi.mocked(getProfile).mockResolvedValue(mockProfile);
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toBeNull();
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toEqual(mockProfile);
});
it('returns error when profile is not configured', async () => {
vi.mocked(getProfile).mockRejectedValue(new Error('Base resume not configured.'));
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(res.ok).toBe(false);
expect(body.success).toBe(false);
expect(body.error).toContain('Base resume not configured');
});
});
it('returns base resume projects', async () => {
// Create valid resume file first
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
it('returns full base resume profile', async () => {
// Create valid resume file first
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
const res = await fetch(`${baseUrl}/api/profile`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data).toBeDefined();
expect(typeof body.data).toBe('object');
});
describe('GET /api/profile/status', () => {
it('returns exists: false when resume file does not exist', async () => {
it('returns exists: false when rxresumeBaseResumeId is not configured', async () => {
vi.mocked(getSetting).mockResolvedValue(null);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toBeTruthy();
expect(body.data.error).toContain('No base resume selected');
});
it('returns exists: false when resume file is empty', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, '');
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.data.exists).toBe(false);
});
it('returns exists: true when valid resume file exists', async () => {
const resumePath = join(tempDir, 'resume.json');
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
it('returns exists: true when resume is accessible', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: { basics: { name: 'Test' } },
} as any);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
@ -94,160 +139,38 @@ describe.sequential('Profile API routes', () => {
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
});
});
describe('POST /api/profile/upload', () => {
it('rejects request without profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const body = await res.json();
it('returns exists: false when RxResume credentials are missing', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError());
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects array as profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: [] }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects primitive as profile payload', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: 'not an object' }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid profile payload');
});
it('rejects invalid resume with detailed field path in error', async () => {
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: { foo: 'bar' } }),
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain('Invalid resume JSON');
// Should include field path in error message
expect(body.error).toMatch(/Field "[^"]+"/);
});
it('accepts valid resume and creates file', async () => {
const validResume = createMinimalValidResume();
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: validResume }),
});
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
// Verify file was created
const resumePath = join(tempDir, 'resume.json');
const fileInfo = await stat(resumePath);
expect(fileInfo.isFile()).toBe(true);
expect(fileInfo.size).toBeGreaterThan(0);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain('credentials not configured');
});
it('overwrites existing resume file', async () => {
const resumePath = join(tempDir, 'resume.json');
const oldResume = createMinimalValidResume();
oldResume.basics.name = 'Old Name';
await writeFile(resumePath, JSON.stringify(oldResume));
it('returns exists: false when resume data is empty', async () => {
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
vi.mocked(getResume).mockResolvedValue({
id: 'test-resume-id',
data: null,
} as any);
const newResume = createMinimalValidResume();
newResume.basics.name = 'New Name';
const res = await fetch(`${baseUrl}/api/profile/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: newResume }),
});
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
// Verify profile was updated
const profileRes = await fetch(`${baseUrl}/api/profile`);
const profileBody = await profileRes.json();
expect(profileBody.data.basics.name).toBe('New Name');
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain('empty or invalid');
});
});
// Note: POST /api/profile/refresh tests skipped because basic auth blocks POST in test environment
// The endpoint is tested indirectly through the profile service tests
});
/**
* Creates a minimal valid RxResume v4 schema compliant JSON
*/
function createMinimalValidResume() {
return {
basics: {
name: 'Test User',
headline: 'Software Developer',
email: 'test@example.com',
phone: '',
location: '',
url: { label: '', href: '' },
customFields: [],
picture: {
url: '',
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] },
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
custom: {},
},
metadata: {
template: 'rhyhorn',
layout: [[['summary'], ['skills']]],
css: { value: '', visible: false },
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
typography: {
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: '',
},
};
}

View File

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generatePdf } from './pdf.js';
import { getProfile } from './profile.js';
// Define mock data in hoisted block
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
@ -85,6 +85,11 @@ vi.mock('../repositories/settings.js', () => ({
getAllSettings: vi.fn().mockResolvedValue({}),
}));
// Mock the profile service - getProfile now fetches from v4 API
vi.mock('./profile.js', () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile),
}));
vi.mock('./projectSelection.js', () => ({
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
}));
@ -138,7 +143,7 @@ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
describe('PDF Service Skills Validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
vi.mocked(getProfile).mockResolvedValue(mockProfile);
mockRxResumeClient.clearLastCreateData();
});
@ -194,7 +199,7 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile));
vi.mocked(getProfile).mockResolvedValueOnce(invalidProfile);
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json');
@ -225,7 +230,7 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds);
await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json');
@ -262,7 +267,7 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds);
await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json');
@ -291,7 +296,7 @@ describe('PDF Service Skills Validation', () => {
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithValidId));
vi.mocked(getProfile).mockResolvedValueOnce(profileWithValidId);
await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json');

View File

@ -88,6 +88,11 @@ vi.mock('../repositories/settings.js', () => ({
getAllSettings: vi.fn().mockResolvedValue({}),
}));
// Mock the profile service - getProfile now fetches from v4 API
vi.mock('./profile.js', () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile),
}));
vi.mock('./projectSelection.js', () => ({
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
}));

View File

@ -222,6 +222,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ accessToken: 'mock-token-123' }),
});
vi.stubGlobal('fetch', mockFetch);
@ -235,6 +236,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ data: { accessToken: 'nested-token' } }),
});
vi.stubGlobal('fetch', mockFetch);
@ -248,6 +250,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ token: 'alt-token-field' }),
});
vi.stubGlobal('fetch', mockFetch);
@ -274,6 +277,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ user: { id: '123' } }),
});
vi.stubGlobal('fetch', mockFetch);
@ -489,6 +493,7 @@ describe('RxResumeClient', () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn() },
json: async () => ({ accessToken: 'token' }),
});
vi.stubGlobal('fetch', mockFetch);

View File

@ -51,7 +51,7 @@ describe('Tailoring Flow', () => {
skills: ['React', 'TypeScript', 'Vitest']
}),
'Senior TypeScript Developer', // Original JD
expect.any(String), // Profile path
undefined, // Deprecated profile path
'project-a,project-c' // The manually selected projects
);
});
@ -78,7 +78,7 @@ describe('Tailoring Flow', () => {
skills: []
}),
'Junior Java Developer',
expect.any(String),
undefined, // Deprecated profile path
undefined // No projects selected
);
});