274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
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';
|
|
|
|
describe.sequential('Onboarding API routes', () => {
|
|
let server: Server;
|
|
let baseUrl: string;
|
|
let closeDb: () => void;
|
|
let tempDir: string;
|
|
let originalFetch: typeof global.fetch;
|
|
|
|
beforeEach(async () => {
|
|
originalFetch = global.fetch;
|
|
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await stopServer({ server, closeDb, tempDir });
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
describe('POST /api/onboarding/validate/openrouter', () => {
|
|
it('returns invalid when no API key is provided and none in env', async () => {
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
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).toContain('missing');
|
|
});
|
|
|
|
it('returns invalid when API key is empty string', async () => {
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apiKey: ' ' }),
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(body.data.valid).toBe(false);
|
|
expect(body.data.message).toContain('missing');
|
|
});
|
|
|
|
it('validates an invalid API key against OpenRouter', async () => {
|
|
global.fetch = vi.fn((input, init) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url.startsWith('https://openrouter.ai/api/v1/key')) {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 401,
|
|
json: async () => ({ error: { message: 'invalid api key' } }),
|
|
} as Response);
|
|
}
|
|
return originalFetch(input, init);
|
|
});
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apiKey: 'sk-or-invalid-key-12345' }),
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(body.success).toBe(true);
|
|
// Should be invalid because the key is fake
|
|
expect(body.data.valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/onboarding/validate/rxresume', () => {
|
|
it('returns invalid when no credentials are provided and none in env', async () => {
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
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).toContain('missing');
|
|
});
|
|
|
|
it('returns invalid when only email is provided', async () => {
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'test@example.com' }),
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(body.data.valid).toBe(false);
|
|
expect(body.data.message).toContain('missing');
|
|
});
|
|
|
|
it('returns invalid when only password is provided', async () => {
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: 'testpass' }),
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(body.data.valid).toBe(false);
|
|
expect(body.data.message).toContain('missing');
|
|
});
|
|
|
|
it('validates invalid credentials against RxResume', async () => {
|
|
vi.spyOn(RxResumeClient, 'verifyCredentials').mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
message: 'InvalidCredentials',
|
|
});
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: 'nonexistent@test.com',
|
|
password: 'wrongpassword123',
|
|
}),
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(body.success).toBe(true);
|
|
// Should be invalid because credentials are fake
|
|
expect(body.data.valid).toBe(false);
|
|
});
|
|
|
|
it('handles whitespace-only credentials', async () => {
|
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: ' ', password: ' ' }),
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(body.data.valid).toBe(false);
|
|
expect(body.data.message).toContain('missing');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/onboarding/validate/resume', () => {
|
|
it('returns invalid when no resume file exists', 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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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: '',
|
|
},
|
|
};
|
|
}
|