tests
This commit is contained in:
parent
9c1252c7fd
commit
e1ee291337
253
orchestrator/src/server/api/routes/onboarding.test.ts
Normal file
253
orchestrator/src/server/api/routes/onboarding.test.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Onboarding API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 () => {
|
||||
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: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { writeFile, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Profile API routes', () => {
|
||||
@ -17,6 +19,10 @@ describe.sequential('Profile API routes', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
@ -24,10 +30,206 @@ describe.sequential('Profile API routes', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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()));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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 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);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
507
orchestrator/src/server/services/rxresume-client.test.ts
Normal file
507
orchestrator/src/server/services/rxresume-client.test.ts
Normal file
@ -0,0 +1,507 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { RxResumeClient } from './rxresume-client.js';
|
||||
|
||||
describe('RxResumeClient', () => {
|
||||
describe('verifyCredentials (static)', () => {
|
||||
it('returns ok: true for successful login', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ identifier: 'test@example.com', password: 'password123' }),
|
||||
})
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok: false with status 401 for invalid credentials', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => JSON.stringify({ message: 'InvalidCredentials' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'wrong@example.com',
|
||||
'badpassword',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.message).toBe('InvalidCredentials');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok: false with error message for other HTTP errors', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => JSON.stringify({ error: 'Internal Server Error' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.message).toBe('Internal Server Error');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok: false with statusMessage from response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => JSON.stringify({ statusMessage: 'Account suspended' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.message).toBe('Account suspended');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles network errors gracefully', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('Network timeout'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.message).toBe('Network timeout');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles non-JSON error response body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: async () => 'Bad Gateway',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(502);
|
||||
// Should handle gracefully even if body is not JSON
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles empty response body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(404);
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles string response directly', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => '"Direct string error"',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.message).toBe('Direct string error');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses default baseURL when not provided', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await RxResumeClient.verifyCredentials('test@example.com', 'password123');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://v4.rxresu.me/api/auth/login',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance methods', () => {
|
||||
let client: RxResumeClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new RxResumeClient('https://mock.rxresume.test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('returns access token on successful login', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ accessToken: 'mock-token-123' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const token = await client.login('test@example.com', 'password123');
|
||||
|
||||
expect(token).toBe('mock-token-123');
|
||||
});
|
||||
|
||||
it('handles token in data.accessToken format', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { accessToken: 'nested-token' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const token = await client.login('test@example.com', 'password123');
|
||||
|
||||
expect(token).toBe('nested-token');
|
||||
});
|
||||
|
||||
it('handles token field instead of accessToken', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ token: 'alt-token-field' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const token = await client.login('test@example.com', 'password123');
|
||||
|
||||
expect(token).toBe('alt-token-field');
|
||||
});
|
||||
|
||||
it('throws error on login failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'Unauthorized',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.login('wrong@example.com', 'badpass')).rejects.toThrow(
|
||||
'Login failed: HTTP 401'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when token is not found in response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ user: { id: '123' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.login('test@example.com', 'password123')).rejects.toThrow(
|
||||
'could not locate access token'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('returns resume id on successful creation', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 'resume-id-123' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const id = await client.create({ basics: { name: 'Test' } }, 'mock-token');
|
||||
|
||||
expect(id).toBe('resume-id-123');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/import',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles id in nested data.resume.id format', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { resume: { id: 'nested-resume-id' } } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const id = await client.create({}, 'mock-token');
|
||||
|
||||
expect(id).toBe('nested-resume-id');
|
||||
});
|
||||
|
||||
it('throws error on creation failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'Invalid resume data',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.create({}, 'mock-token')).rejects.toThrow('Create failed: HTTP 400');
|
||||
});
|
||||
|
||||
it('throws error when id is not found in response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.create({}, 'mock-token')).rejects.toThrow(
|
||||
'could not locate resume id'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('print', () => {
|
||||
it('returns print URL on success', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: 'https://pdf.rxresume.test/print/123' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const url = await client.print('resume-123', 'mock-token');
|
||||
|
||||
expect(url).toBe('https://pdf.rxresume.test/print/123');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/print/resume-123',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles href field instead of url', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ href: 'https://alt-url.test' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const url = await client.print('resume-123', 'mock-token');
|
||||
|
||||
expect(url).toBe('https://alt-url.test');
|
||||
});
|
||||
|
||||
it('throws error on print failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Resume not found',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.print('nonexistent', 'mock-token')).rejects.toThrow(
|
||||
'Print failed: HTTP 404'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when URL is not found in response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'queued' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.print('resume-123', 'mock-token')).rejects.toThrow(
|
||||
'could not locate URL'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes resume ID in URL', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: 'https://test.com' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await client.print('resume with spaces', 'mock-token');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/print/resume%20with%20spaces',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('completes successfully on 200 response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.delete('resume-123', 'mock-token')).resolves.toBeUndefined();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/resume-123',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes successfully on 204 No Content', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false, // 204 is technically not "ok" in some implementations
|
||||
status: 204,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.delete('resume-123', 'mock-token')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error on delete failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => 'Forbidden',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.delete('resume-123', 'mock-token')).rejects.toThrow(
|
||||
'Delete failed: HTTP 403'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes resume ID in URL', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await client.delete('resume/with/slashes', 'mock-token');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/resume%2Fwith%2Fslashes',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default baseURL', () => {
|
||||
it('uses https://v4.rxresu.me by default', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ accessToken: 'token' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const client = new RxResumeClient();
|
||||
await client.login('test@example.com', 'password');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://v4.rxresu.me/api/auth/login',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,8 +11,8 @@ export type FilterKeys<T, Condition> = {
|
||||
|
||||
export const idSchema = z
|
||||
.string()
|
||||
.length(24)
|
||||
.cuid2()
|
||||
.length(24)
|
||||
.describe("Unique identifier for the item (CUID2 format)");
|
||||
|
||||
export const itemSchema = z.object({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user