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
104 changes: 104 additions & 0 deletions src/lib/api/response-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Tests for API Response Utilities
*/

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

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

expect(response.status).toBe(200);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.data).toEqual(data);
});

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

const body = await response.json();
expect(body.success).toBe(true);
expect(body.data).toBe('hello');
expect(body.meta).toEqual(meta);
});

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

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

it.each([
[null, null],
[[], []],
[{ nested: { deep: true } }, { nested: { deep: true } }],
[42, 42],
['plain string', 'plain string'],
])('should handle various data types: %p', async (data, expected) => {
const response = apiSuccess(data);
const body = await response.json();

expect(body.success).toBe(true);
expect(body.data).toEqual(expected);
});
});

describe('validationErrorResponse', () => {
it('should return a 400 response with success: false', async () => {
const response = validationErrorResponse('Name is required');

expect(response.status).toBe(400);
const body = await response.json();
expect(body.success).toBe(false);
expect(body.error).toBe('Name is required');
});

it('should include meta when provided', async () => {
const meta = { totalTimeMs: 10 };
const response = validationErrorResponse('Invalid input', meta);

const body = await response.json();
expect(body.success).toBe(false);
expect(body.error).toBe('Invalid input');
expect(body.meta).toEqual(meta);
});

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

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

describe('serviceUnavailableResponse', () => {
it('should return a 503 response with success: false', async () => {
const response = serviceUnavailableResponse('GitHub API not configured');

expect(response.status).toBe(503);
const body = await response.json();
expect(body.success).toBe(false);
expect(body.error).toBe('GitHub API not configured');
});

it('should include meta when provided', async () => {
const meta = { fallbackReason: 'no-token' };
const response = serviceUnavailableResponse('Service unavailable', meta);

const body = await response.json();
expect(body.meta).toEqual(meta);
});

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

expect(body).not.toHaveProperty('meta');
});
});
64 changes: 64 additions & 0 deletions src/lib/api/validation-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Tests for API Validation Utilities
*/

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

describe('validateObject', () => {
it.each([
[null, 'body', 'body is required and must be an object'],
[undefined, 'body', 'body is required and must be an object'],
[0, 'body', 'body is required and must be an object'],
['string', 'field', 'field is required and must be an object'],
[42, 'value', 'value is required and must be an object'],
[false, 'data', 'data is required and must be an object'],
])(
'should return error message for non-object value %p with fieldName %p',
(value, fieldName, expected) => {
expect(validateObject(value, fieldName)).toBe(expected);
}
);

it.each([
[{ key: 'value' }, 'body'],
[{}, 'body'],
[[], 'items'],
[{ nested: { a: 1 } }, 'data'],
])(
'should return null for valid object %p',
(value, fieldName) => {
expect(validateObject(value, fieldName)).toBeNull();
}
);
});

describe('validateRequiredString', () => {
it.each([
[null, 'title', 'title is required'],
[undefined, 'title', 'title is required'],
['', 'name', 'name is required'],
[' ', 'description', 'description is required'],
['\t\n', 'label', 'label is required'],
[0, 'id', 'id is required'],
[false, 'flag', 'flag is required'],
[{}, 'data', 'data is required'],
])(
'should return error for invalid string value %p with fieldName %p',
(value, fieldName, expected) => {
expect(validateRequiredString(value, fieldName)).toBe(expected);
}
);

it.each([
['hello', 'title'],
[' hello ', 'title'],
['a', 'id'],
['Some text here', 'description'],
])(
'should return null for valid non-empty string %p',
(value, fieldName) => {
expect(validateRequiredString(value, fieldName)).toBeNull();
}
);
});
157 changes: 157 additions & 0 deletions src/lib/skills/prerequisites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Tests for Skill Prerequisites
*
* Covers getNextAchievableSkills and related logic.
*/

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

/** Helper to build a SkillProfile from a simple map of skillId → level */
function makeProfile(
skills: Array<{ skillId: string; level: 'beginner' | 'intermediate' | 'advanced' }>
): SkillProfile {
return {
skills: skills.map((s) => ({
skillId: s.skillId,
level: s.level,
source: 'manual' as const,
})),
lastUpdated: '2026-01-01T00:00:00.000Z',
};
}

const emptyProfile: SkillProfile = { skills: [], lastUpdated: '2026-01-01T00:00:00.000Z' };

describe('getNextAchievableSkills', () => {
it('should return all foundation skills (no prerequisites) for an empty profile', () => {
const result = getNextAchievableSkills(emptyProfile);
const resultIds = result.map((s) => s.skillId);

// Foundation skills have no prerequisites
const foundationSkills = SKILL_PREREQUISITES.filter((s) => s.prerequisites.length === 0);
for (const skill of foundationSkills) {
expect(resultIds).toContain(skill.skillId);
}
});

it('should not include skills with unmet prerequisites', () => {
const result = getNextAchievableSkills(emptyProfile);
const resultIds = result.map((s) => s.skillId);

// typescript requires javascript — should not appear without javascript
expect(resultIds).not.toContain('typescript');
// nextjs requires react + typescript — should not appear
expect(resultIds).not.toContain('nextjs');
});

it('should unlock dependent skills when prerequisites are met at beginner level', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);

// typescript requires javascript — now achievable
expect(resultIds).toContain('typescript');
// testing requires javascript — now achievable
expect(resultIds).toContain('testing');
});

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

// javascript is already intermediate — shouldn't be "next achievable"
expect(resultIds).not.toContain('javascript');
});

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

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

it('should include beginner-level skills as still achievable', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);

// javascript at beginner is not intermediate+, so it remains in the list
expect(resultIds).toContain('javascript');
});

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

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

it('should not unlock nextjs when only one of two prerequisites is met', () => {
const profile = makeProfile([{ skillId: 'react', level: 'intermediate' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);

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

it('should handle the same skill appearing multiple times — keep highest level', () => {
// Duplicate entry: beginner then advanced for javascript
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 resultIds = result.map((s) => s.skillId);

// advanced javascript → excluded (intermediate+)
expect(resultIds).not.toContain('javascript');
// typescript dependency on javascript is met (any level)
expect(resultIds).toContain('typescript');
});

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

expect(resultIds).toContain('ci-cd');
});

it('should return an array of SkillNode objects with correct shape', () => {
const result = getNextAchievableSkills(emptyProfile);

expect(result.length).toBeGreaterThan(0);
for (const node of result) {
expect(node).toHaveProperty('skillId');
expect(node).toHaveProperty('displayName');
expect(node).toHaveProperty('prerequisites');
expect(Array.isArray(node.prerequisites)).toBe(true);
}
});

it('should return empty array when all skills are at intermediate+ and no new ones unlocked', () => {
// Mark all SKILL_PREREQUISITES skills as advanced
const allAdvanced = SKILL_PREREQUISITES.map((s) => ({
skillId: s.skillId,
level: 'advanced' as const,
}));
const profile = makeProfile(allAdvanced);
const result = getNextAchievableSkills(profile);

expect(result).toHaveLength(0);
});
});
Loading