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
89 changes: 89 additions & 0 deletions src/lib/api/response-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { apiSuccess, validationErrorResponse, serviceUnavailableResponse } from './response-utils';

describe('apiSuccess', () => {
it('returns a 200 response by default', () => {
const response = apiSuccess({ id: 1 });
expect(response.status).toBe(200);
});

it('returns success: true with data', async () => {
const data = { id: 1, name: 'test' };
const response = apiSuccess(data);
const body = await response.json();
expect(body).toEqual({ success: true, data });
});

it('includes meta when provided', async () => {
const data = { value: 42 };
const meta = { totalTimeMs: 100 };
const response = apiSuccess(data, meta);
const body = await response.json();
expect(body).toEqual({ success: true, data, meta });
});

it('does not include meta key when meta is not provided', async () => {
const response = apiSuccess({ x: 1 });
const body = await response.json();
expect(body).not.toHaveProperty('meta');
});

it('handles null data', async () => {
const response = apiSuccess(null);
const body = await response.json();
expect(body).toEqual({ success: true, data: null });
});

it('handles array data', async () => {
const data = [1, 2, 3];
const response = apiSuccess(data);
const body = await response.json();
expect(body).toEqual({ success: true, data });
});
});

describe('validationErrorResponse', () => {
it('returns a 400 status', () => {
const response = validationErrorResponse('title is required');
expect(response.status).toBe(400);
});

it('returns success: false with the error message', async () => {
const response = validationErrorResponse('title is required');
const body = await response.json();
expect(body).toEqual({ success: false, error: 'title is required' });
});

it('includes meta when provided', async () => {
const meta = { totalTimeMs: 50 };
const response = validationErrorResponse('invalid input', meta);
const body = await response.json();
expect(body).toEqual({ success: false, error: 'invalid input', meta });
});

it('does not include meta key when meta is not provided', async () => {
const response = validationErrorResponse('error');
const body = await response.json();
expect(body).not.toHaveProperty('meta');
});
});

describe('serviceUnavailableResponse', () => {
it('returns a 503 status', () => {
const response = serviceUnavailableResponse('GitHub API not configured');
expect(response.status).toBe(503);
});

it('returns success: false with the error message', async () => {
const response = serviceUnavailableResponse('Service unavailable');
const body = await response.json();
expect(body).toEqual({ success: false, error: 'Service unavailable' });
});

it('includes meta when provided', async () => {
const meta = { aiEnabled: false, fallbackReason: 'no token' };
const response = serviceUnavailableResponse('AI not configured', meta);
const body = await response.json();
expect(body).toEqual({ success: false, error: 'AI not configured', meta });
});
});
60 changes: 60 additions & 0 deletions src/lib/api/validation-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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'],
['string', 'body', 'body is required and must be an object'],
[42, 'body', 'body is required and must be an object'],
[true, 'body', 'body is required and must be an object'],
[0, 'item', 'item is required and must be an object'],
])('returns error for %p with fieldName %p', (value, fieldName, expected) => {
expect(validateObject(value, fieldName)).toBe(expected);
});

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

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

it('returns null for an array (typeof array is object)', () => {
expect(validateObject([1, 2, 3], 'items')).toBeNull();
});

it('uses the fieldName in the error message', () => {
const error = validateObject(null, 'Request body');
expect(error).toBe('Request body is required and must be an object');
});
});

describe('validateRequiredString', () => {
it.each([
[null, 'title', 'title is required'],
[undefined, 'title', 'title is required'],
['', 'title', 'title is required'],
[' ', 'title', 'title is required'],
['\t', 'name', 'name is required'],
[42, 'title', 'title is required'],
[true, 'title', 'title is required'],
[{}, 'title', 'title is required'],
])('returns error for %p with fieldName %p', (value, fieldName, expected) => {
expect(validateRequiredString(value, fieldName)).toBe(expected);
});

it('returns null for a non-empty string', () => {
expect(validateRequiredString('hello', 'title')).toBeNull();
});

it('returns null for a string with leading/trailing spaces that has non-whitespace content', () => {
expect(validateRequiredString(' hello ', 'title')).toBeNull();
});

it('uses the fieldName in the error message', () => {
const error = validateRequiredString('', 'Challenge title');
expect(error).toBe('Challenge title is required');
});
});
139 changes: 139 additions & 0 deletions src/lib/skills/prerequisites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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: new Date().toISOString(),
};
}

describe('getNextAchievableSkills', () => {
it('returns all foundation skills (no prerequisites) for an empty profile', () => {
const profile = makeProfile([]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);

const foundations = SKILL_PREREQUISITES.filter((s) => s.prerequisites.length === 0).map((s) => s.skillId);
for (const id of foundations) {
expect(resultIds).toContain(id);
}
});

it('does not include skills whose prerequisites are not met', () => {
// typescript requires javascript — profile has no javascript
const profile = makeProfile([]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);
expect(resultIds).not.toContain('typescript');
});

it('includes a skill once its prerequisites are met', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);
expect(resultIds).toContain('typescript');
expect(resultIds).toContain('nodejs');
expect(resultIds).toContain('testing');
});

it('excludes skills already at intermediate or above', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'intermediate' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);
// javascript at intermediate — should not be suggested
expect(resultIds).not.toContain('javascript');
});

it('still shows a skill if user is only at beginner level (not intermediate+)', () => {
const profile = makeProfile([{ skillId: 'javascript', level: 'beginner' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);
// javascript beginner — still recommended
expect(resultIds).toContain('javascript');
});

it('excludes skills already at advanced', () => {
const profile = makeProfile([{ skillId: 'python', level: 'advanced' }]);
const result = getNextAchievableSkills(profile);
const resultIds = result.map((s) => s.skillId);
expect(resultIds).not.toContain('python');
});

it('unlocks react only when all three prerequisites (javascript, html, css) are present', () => {
// Missing css
const partialProfile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'html', level: 'beginner' },
]);
const partial = getNextAchievableSkills(partialProfile);
expect(partial.map((s) => s.skillId)).not.toContain('react');

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

it('unlocks nextjs only when react and typescript are present', () => {
const profile = makeProfile([
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'html', level: 'beginner' },
{ skillId: 'css', level: 'beginner' },
{ skillId: 'react', level: 'beginner' },
{ skillId: 'typescript', level: 'beginner' },
]);
const result = getNextAchievableSkills(profile);
expect(result.map((s) => s.skillId)).toContain('nextjs');
});

it('handles duplicate skills by using the highest level', () => {
// If javascript appears twice, the highest level should win
const profile: SkillProfile = {
skills: [
{ skillId: 'javascript', level: 'beginner', source: 'manual' },
{ skillId: 'javascript', level: 'intermediate', source: 'github' },
],
lastUpdated: new Date().toISOString(),
};
const result = getNextAchievableSkills(profile);
// intermediate level → should be excluded
expect(result.map((s) => s.skillId)).not.toContain('javascript');
// prerequisites are met for typescript
expect(result.map((s) => s.skillId)).toContain('typescript');
});

it('returns SkillNode objects with correct shape', () => {
const profile = makeProfile([]);
const result = getNextAchievableSkills(profile);
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('unlocks ci-cd only when both git and testing prerequisites are present', () => {
const profile = makeProfile([
{ skillId: 'git', level: 'beginner' },
// testing NOT present
]);
const partial = getNextAchievableSkills(profile);
expect(partial.map((s) => s.skillId)).not.toContain('ci-cd');

const fullProfile = makeProfile([
{ skillId: 'git', level: 'beginner' },
{ skillId: 'javascript', level: 'beginner' },
{ skillId: 'testing', level: 'beginner' },
]);
const full = getNextAchievableSkills(fullProfile);
expect(full.map((s) => s.skillId)).toContain('ci-cd');
});
});
Loading