From e1ee291337437e2573e9b046b02261e3cb14ebd4 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 21:39:05 +0000 Subject: [PATCH] tests --- .../src/server/api/routes/onboarding.test.ts | 253 +++++++++ .../src/server/api/routes/profile.test.ts | 202 +++++++ .../server/services/rxresume-client.test.ts | 507 ++++++++++++++++++ orchestrator/src/shared/rxresume-schema.ts | 2 +- 4 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 orchestrator/src/server/api/routes/onboarding.test.ts create mode 100644 orchestrator/src/server/services/rxresume-client.test.ts diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts new file mode 100644 index 0000000..4a2b38c --- /dev/null +++ b/orchestrator/src/server/api/routes/onboarding.test.ts @@ -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: '', + }, + }; +} diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 52ec3e4..72919be 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -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: '', + }, + }; +} diff --git a/orchestrator/src/server/services/rxresume-client.test.ts b/orchestrator/src/server/services/rxresume-client.test.ts new file mode 100644 index 0000000..59123dc --- /dev/null +++ b/orchestrator/src/server/services/rxresume-client.test.ts @@ -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(); + }); + }); +}); diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts index e7d7f28..8514398 100644 --- a/orchestrator/src/shared/rxresume-schema.ts +++ b/orchestrator/src/shared/rxresume-schema.ts @@ -11,8 +11,8 @@ export type FilterKeys = { export const idSchema = z .string() - .length(24) .cuid2() + .length(24) .describe("Unique identifier for the item (CUID2 format)"); export const itemSchema = z.object({