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
115 changes: 115 additions & 0 deletions src/lib/api/response-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Tests for API response utilities.
*
* Covers apiSuccess, validationErrorResponse, and serviceUnavailableResponse
* response shape and HTTP status codes.
*/

import { describe, it, expect } from 'vitest';
import { apiSuccess, validationErrorResponse, serviceUnavailableResponse } from './response-utils';

describe('apiSuccess', () => {
it('should return success:true with the provided data', async () => {
const response = apiSuccess({ id: 1, name: 'Test' });
const body = await response.json();

expect(body.success).toBe(true);
expect(body.data).toEqual({ id: 1, name: 'Test' });
});

it('should default to HTTP 200 status', () => {
const response = apiSuccess({});
expect(response.status).toBe(200);
});

it('should include meta when provided', async () => {
const meta = { totalTimeMs: 42, aiEnabled: true };
const response = apiSuccess({ result: 'ok' }, meta);
const body = await response.json();

expect(body.meta).toEqual(meta);
});

it('should omit meta key when meta is not provided', async () => {
const response = apiSuccess({ result: 'ok' });
const body = await response.json();

expect(body).not.toHaveProperty('meta');
});

it('should handle array data', async () => {
const data = [1, 2, 3];
const response = apiSuccess(data);
const body = await response.json();

expect(body.data).toEqual([1, 2, 3]);
});

it('should handle null data', async () => {
const response = apiSuccess(null);
const body = await response.json();

expect(body.success).toBe(true);
expect(body.data).toBeNull();
});
});

describe('validationErrorResponse', () => {
it('should return success:false with the error message', async () => {
const response = validationErrorResponse('name is required');
const body = await response.json();

expect(body.success).toBe(false);
expect(body.error).toBe('name is required');
});

it('should return HTTP 400 status', () => {
const response = validationErrorResponse('invalid input');
expect(response.status).toBe(400);
});

it('should include meta when provided', async () => {
const meta = { field: 'email' };
const response = validationErrorResponse('email is required', meta);
const body = await response.json();

expect(body.meta).toEqual(meta);
});

it('should omit meta key when meta is not provided', async () => {
const response = validationErrorResponse('bad request');
const body = await response.json();

expect(body).not.toHaveProperty('meta');
});
});

describe('serviceUnavailableResponse', () => {
it('should return success:false with the error message', async () => {
const response = serviceUnavailableResponse('GitHub API not configured');
const body = await response.json();

expect(body.success).toBe(false);
expect(body.error).toBe('GitHub API not configured');
});

it('should return HTTP 503 status', () => {
const response = serviceUnavailableResponse('service down');
expect(response.status).toBe(503);
});

it('should include meta when provided', async () => {
const meta = { retryAfter: 60 };
const response = serviceUnavailableResponse('unavailable', meta);
const body = await response.json();

expect(body.meta).toEqual(meta);
});

it('should omit meta key when meta is not provided', async () => {
const response = serviceUnavailableResponse('unavailable');
const body = await response.json();

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

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

describe('validateObject', () => {
it('should return null for a valid plain object', () => {
expect(validateObject({ key: 'value' }, 'body')).toBeNull();
});

it('should return null for an empty object', () => {
expect(validateObject({}, 'body')).toBeNull();
});

it.each([
{ value: null, desc: 'null' },
{ value: undefined, desc: 'undefined' },
{ value: 0, desc: 'zero' },
{ value: false, desc: 'false' },
{ value: '', desc: 'empty string' },
])('should return error for $desc', ({ value }) => {
const result = validateObject(value, 'Request body');
expect(result).toBe('Request body is required and must be an object');
});

it.each([
{ value: 'hello', desc: 'string' },
{ value: 42, desc: 'number' },
{ value: true, desc: 'boolean' },
])('should return error for non-object $desc', ({ value }) => {
const result = validateObject(value, 'body');
expect(result).toBe('body is required and must be an object');
});

it('should return null for an array (arrays are objects)', () => {
// typeof [] === 'object', so arrays pass the check
expect(validateObject([], 'body')).toBeNull();
});

it('should use the provided fieldName in the error message', () => {
const result = validateObject(null, 'Custom Field');
expect(result).toContain('Custom Field');
});
});

describe('validateRequiredString', () => {
it('should return null for a valid non-empty string', () => {
expect(validateRequiredString('hello', 'name')).toBeNull();
});

it('should return null for a string with leading/trailing spaces (non-empty after trim)', () => {
expect(validateRequiredString(' hello ', 'name')).toBeNull();
});

it.each([
{ value: null, desc: 'null' },
{ value: undefined, desc: 'undefined' },
{ value: 0, desc: 'zero' },
{ value: false, desc: 'false' },
{ value: {}, desc: 'object' },
])('should return error for $desc', ({ value }) => {
const result = validateRequiredString(value, 'title');
expect(result).toBe('title is required');
});

it.each([
{ value: '', desc: 'empty string' },
{ value: ' ', desc: 'whitespace only' },
{ value: '\t', desc: 'tab character' },
{ value: '\n', desc: 'newline character' },
])('should return error for $desc', ({ value }) => {
const result = validateRequiredString(value, 'message');
expect(result).toBe('message is required');
});

it('should use the provided fieldName in the error message', () => {
const result = validateRequiredString('', 'User Name');
expect(result).toBe('User Name 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 prerequisite utilities.
*
* Covers getNextAchievableSkills across empty profiles,
* foundation skills, cascading prerequisites, and already-mastered skills.
*/

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

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

describe('getNextAchievableSkills', () => {
describe('empty profile', () => {
it('should return only foundation skills (no prerequisites)', () => {
const result = getNextAchievableSkills(makeProfile([]));
const ids = result.map((s) => s.skillId);

// Foundation skills have empty prerequisites
const foundations = SKILL_PREREQUISITES
.filter((s) => s.prerequisites.length === 0)
.map((s) => s.skillId);

expect(ids.sort()).toEqual(foundations.sort());
});

it('should not include skills that have prerequisites', () => {
const result = getNextAchievableSkills(makeProfile([]));
for (const skill of result) {
expect(skill.prerequisites).toHaveLength(0);
}
});
});

describe('skills already at intermediate or advanced are excluded', () => {
it('should exclude a skill the user already has at intermediate', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'intermediate' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

it('should exclude a skill the user already has at advanced', () => {
const profile = makeProfile([{ skillId: 'python', level: 'advanced' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

it('should keep a skill the user has at beginner level (still achievable)', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

describe('prerequisite resolution', () => {
it('should unlock typescript when javascript is in the profile', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

it('should not include typescript when javascript is not in the profile', () => {
const result = getNextAchievableSkills(makeProfile([]));
const ids = result.map((s) => s.skillId);

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

it('should unlock react when javascript, html, and css are all in the profile', () => {
const profile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'html', level: 'beginner' },
{ skillId: 'css', level: 'beginner' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

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

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

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

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

describe('cascading prerequisites (nextjs requires react + typescript)', () => {
it('should not unlock nextjs without react and typescript', () => {
const profile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'html', level: 'beginner' },
{ skillId: 'css', level: 'beginner' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

it('should unlock nextjs when react and typescript are both in profile', () => {
const profile = makeProfile([
{ skillId: 'react', level: 'beginner' },
{ skillId: 'typescript', level: 'beginner' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

it('should exclude nextjs once user reaches intermediate nextjs', () => {
const profile = makeProfile([
{ skillId: 'react', level: 'beginner' },
{ skillId: 'typescript', level: 'beginner' },
{ skillId: 'nextjs', level: 'intermediate' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

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

describe('duplicate skills in profile', () => {
it('should use the highest level when a skillId appears multiple times', () => {
// User has javascript at beginner AND intermediate — should use intermediate
const profile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'javascript', level: 'intermediate' },
]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

// At intermediate, javascript itself is excluded
expect(ids).not.toContain('javascript');
// But its dependents should be unlocked
expect(ids).toContain('typescript');
});
});

describe('ci-cd requires both git and testing', () => {
it('should not unlock ci-cd without both git and testing', () => {
const profile = makeProfile([{ skillId: 'git', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const ids = result.map((s) => s.skillId);

expect(ids).not.toContain('ci-cd');
});

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

expect(ids).toContain('ci-cd');
});
});
});
Loading