Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/lib/api/response-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Tests for API response utilities.
*
* Covers all response builders: apiSuccess, validationErrorResponse, serviceUnavailableResponse.
* Uses vi.mock to isolate from Next.js runtime.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock NextResponse before importing response-utils
vi.mock('next/server', () => ({
NextResponse: {
json: vi.fn((body: unknown, init?: { status?: number }) => ({
_body: body,
_status: init?.status ?? 200,
})),
},
}));

import { apiSuccess, validationErrorResponse, serviceUnavailableResponse } from './response-utils';
import { NextResponse } from 'next/server';

const mockNextResponseJson = vi.mocked(NextResponse.json);

describe('apiSuccess', () => {
beforeEach(() => {
mockNextResponseJson.mockClear();
});

it('should return a success response with data', () => {
const data = { id: 1, name: 'test' };
apiSuccess(data);

expect(mockNextResponseJson).toHaveBeenCalledOnce();
const [body, init] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: true, data });
expect(init).toBeUndefined();
});

it('should include meta when provided', () => {
const data = { value: 42 };
const meta = { totalTimeMs: 123, aiEnabled: true };
apiSuccess(data, meta);

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: true, data, meta });
});

it('should not include meta key when meta is not provided', () => {
apiSuccess({ value: 'test' });

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).not.toHaveProperty('meta');
});

it('should handle array data', () => {
const data = [1, 2, 3];
apiSuccess(data);

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: true, data });
});

it('should handle null data', () => {
apiSuccess(null);

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: true, data: null });
});
});

describe('validationErrorResponse', () => {
beforeEach(() => {
mockNextResponseJson.mockClear();
});

it('should return a 400 error response with the given message', () => {
validationErrorResponse('title is required');

expect(mockNextResponseJson).toHaveBeenCalledOnce();
const [body, init] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: false, error: 'title is required' });
expect(init).toEqual({ status: 400 });
});

it('should include meta when provided', () => {
const meta = { totalTimeMs: 5 };
validationErrorResponse('field is required', meta);

const [body, init] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: false, error: 'field is required', meta });
expect(init).toEqual({ status: 400 });
});

it('should not include meta key when meta is not provided', () => {
validationErrorResponse('some error');

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).not.toHaveProperty('meta');
});
});

describe('serviceUnavailableResponse', () => {
beforeEach(() => {
mockNextResponseJson.mockClear();
});

it('should return a 503 error response with the given message', () => {
serviceUnavailableResponse('GitHub API not configured');

expect(mockNextResponseJson).toHaveBeenCalledOnce();
const [body, init] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: false, error: 'GitHub API not configured' });
expect(init).toEqual({ status: 503 });
});

it('should include meta when provided', () => {
const meta = { totalTimeMs: 10 };
serviceUnavailableResponse('Service unavailable', meta);

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).toEqual({ success: false, error: 'Service unavailable', meta });
});

it('should not include meta key when meta is not provided', () => {
serviceUnavailableResponse('error message');

const [body] = mockNextResponseJson.mock.calls[0];
expect(body).not.toHaveProperty('meta');
});
});
78 changes: 78 additions & 0 deletions src/lib/api/validation-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Tests for API validation utilities.
*
* Covers all validation rules for validateObject and validateRequiredString.
*/

import { describe, it, expect } from 'vitest';
import { validateObject, validateRequiredString } from './validation-utils';

describe('validateObject', () => {
describe('valid objects', () => {
it.each([
{ value: {}, desc: 'empty object' },
{ value: { key: 'value' }, desc: 'object with properties' },
{ value: [], desc: 'array (is an object)' },
{ value: { nested: { a: 1 } }, desc: 'nested object' },
])('should return null for $desc', ({ value }) => {
expect(validateObject(value, 'body')).toBeNull();
});
});

describe('invalid values', () => {
it.each([
{ value: null, desc: 'null' },
{ value: undefined, desc: 'undefined' },
{ value: 0, desc: 'number zero' },
{ value: 42, desc: 'positive number' },
{ value: '', desc: 'empty string' },
{ value: 'hello', desc: 'non-empty string' },
{ value: false, desc: 'false boolean' },
{ value: true, desc: 'true boolean' },
])('should return error message for $desc', ({ value }) => {
const result = validateObject(value, 'Request body');
expect(result).toBe('Request body is required and must be an object');
});
});

it('should include the fieldName in the error message', () => {
expect(validateObject(null, 'myField')).toBe('myField is required and must be an object');
expect(validateObject(null, 'settings')).toBe('settings is required and must be an object');
});
});

describe('validateRequiredString', () => {
describe('valid strings', () => {
it.each([
{ value: 'hello', desc: 'simple string' },
{ value: 'a', desc: 'single character' },
{ value: ' hello ', desc: 'string with surrounding whitespace (non-empty content)' },
{ value: 'hello world', desc: 'string with spaces in middle' },
{ value: '123', desc: 'numeric string' },
])('should return null for $desc', ({ value }) => {
expect(validateRequiredString(value, 'field')).toBeNull();
});
});

describe('invalid values', () => {
it.each([
{ value: null, desc: 'null' },
{ value: undefined, desc: 'undefined' },
{ value: '', desc: 'empty string' },
{ value: ' ', desc: 'whitespace-only string' },
{ value: '\t', desc: 'tab-only string' },
{ value: '\n', desc: 'newline-only string' },
{ value: 0, desc: 'number' },
{ value: false, desc: 'boolean false' },
{ value: {}, desc: 'object' },
])('should return error message for $desc', ({ value }) => {
const result = validateRequiredString(value as unknown as string, 'title');
expect(result).toBe('title is required');
});
});

it('should include the fieldName in the error message', () => {
expect(validateRequiredString('', 'username')).toBe('username is required');
expect(validateRequiredString('', 'email')).toBe('email is required');
});
});
186 changes: 186 additions & 0 deletions src/lib/skills/prerequisites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Tests for skill prerequisites utilities.
*
* Covers getNextAchievableSkills logic including empty profiles,
* foundation skills, prerequisite chains, and already-advanced skills.
*/

import { describe, it, expect } from 'vitest';
import { getNextAchievableSkills, SKILL_PREREQUISITES } from './prerequisites';
import type { SkillProfile, UserSkill } from './types';

function makeProfile(skills: Array<{ skillId: string; level: 'beginner' | 'intermediate' | 'advanced' }>): SkillProfile {
return {
skills: skills.map((s): UserSkill => ({
skillId: s.skillId,
level: s.level,
source: 'manual',
})),
lastUpdated: '2026-01-01T00:00:00.000Z',
};
}

describe('getNextAchievableSkills', () => {
describe('empty profile', () => {
it('should return only foundation skills (no prerequisites) for an empty profile', () => {
const profile = makeProfile([]);
const result = getNextAchievableSkills(profile);

// All returned skills should have empty prerequisites
for (const skill of result) {
expect(skill.prerequisites).toHaveLength(0);
}

// Verify known foundation skills are included
const ids = result.map((s) => s.skillId);
expect(ids).toContain('javascript');
expect(ids).toContain('python');
expect(ids).toContain('html');
expect(ids).toContain('sql');
expect(ids).toContain('git');
});

it('should not include skills with prerequisites when profile is empty', () => {
const profile = makeProfile([]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

// These require prerequisites
expect(ids).not.toContain('typescript'); // requires javascript
expect(ids).not.toContain('nextjs'); // requires react, typescript
expect(ids).not.toContain('docker'); // requires nodejs
});
});

describe('prerequisite satisfaction', () => {
it('should unlock skills when prerequisites are present at beginner level', () => {
// 'typescript' requires 'javascript'
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

expect(ids).toContain('typescript');
});

it('should unlock css when html is present', () => {
const profile = makeProfile([{ skillId: 'html', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

expect(ids).toContain('css');
});

it('should unlock react only when all prerequisites (javascript, html, css) are met', () => {
// React requires javascript, html, css — missing css
const partialProfile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'html', level: 'beginner' },
]);
const partialResult = getNextAchievableSkills(partialProfile);
expect(partialResult.map((s) => s.skillId)).not.toContain('react');

// All prerequisites present
const fullProfile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'html', level: 'beginner' },
{ skillId: 'css', level: 'beginner' },
]);
const fullResult = getNextAchievableSkills(fullProfile);
expect(fullResult.map((s) => s.skillId)).toContain('react');
});

it('should unlock ci-cd when both git and testing are present', () => {
// ci-cd requires git and testing
const profile = makeProfile([
{ skillId: 'git', level: 'beginner' },
{ skillId: 'testing', level: 'beginner' },
]);
const result = getNextAchievableSkills(profile);
expect(result.map((s) => s.skillId)).toContain('ci-cd');
});
});

describe('filtering already-achieved skills', () => {
it('should exclude skills already at intermediate level', () => {
const profile = makeProfile([
{ skillId: 'javascript', level: 'intermediate' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

// javascript is already intermediate — should be excluded
expect(ids).not.toContain('javascript');
});

it('should exclude skills already at advanced level', () => {
const profile = makeProfile([
{ skillId: 'javascript', level: 'advanced' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

expect(ids).not.toContain('javascript');
});

it('should include skills still at beginner level even if they have no unmet prerequisites', () => {
// javascript at beginner — still "achievable" (user can work toward intermediate)
const profile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

expect(ids).toContain('javascript');
});
});

describe('duplicate skills — takes highest level', () => {
it('should use the highest level when a skill appears multiple times', () => {
// If javascript appears twice with different levels, take the higher (advanced)
const profile: SkillProfile = {
skills: [
{ skillId: 'javascript', level: 'beginner', source: 'github' },
{ skillId: 'javascript', level: 'advanced', source: 'manual' },
],
lastUpdated: '2026-01-01T00:00:00.000Z',
};
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

// advanced level — should be excluded from "achievable" list
expect(ids).not.toContain('javascript');
});

it('should use the highest level when duplicates keep skill below intermediate', () => {
const profile: SkillProfile = {
skills: [
{ skillId: 'javascript', level: 'beginner', source: 'github' },
{ skillId: 'javascript', level: 'beginner', source: 'manual' },
],
lastUpdated: '2026-01-01T00:00:00.000Z',
};
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

// Still beginner — should remain achievable
expect(ids).toContain('javascript');
});
});

describe('SKILL_PREREQUISITES data integrity', () => {
it('should have unique skill IDs in the prerequisites list', () => {
const ids = SKILL_PREREQUISITES.map((s) => s.skillId);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});

it('should only reference valid skill IDs in prerequisites', () => {
const allIds = new Set(SKILL_PREREQUISITES.map((s) => s.skillId));
for (const skill of SKILL_PREREQUISITES) {
for (const prereq of skill.prerequisites) {
expect(allIds.has(prereq), `Unknown prerequisite '${prereq}' for skill '${skill.skillId}'`).toBe(true);
}
}
});
});
});
Loading