2026-01-22 21:41:45 +00:00

254 lines
9.2 KiB
TypeScript

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', () => {
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 });
});
it('returns empty projects when resume is missing', async () => {
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.data).toEqual([]);
});
it('returns null profile when resume is missing', async () => {
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).toBeNull();
});
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 () => {
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: '',
},
};
}