Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import request from 'supertest';
import express from 'express';
import learningRouter from '../routes/learningRoutes';
import { RoadmapService } from '../services/roadmapService';
import { ProgressService } from '../services/progressService';
import { runStepValidators } from '../engine/engine';
import { ValidatorResult } from '../engine/types';
import { ProjectService } from '../../projects/services/projectService';
import { Roadmap } from '../format/roadmapTypes';

jest.mock('../services/roadmapService');
jest.mock('../services/progressService');
jest.mock('../engine/engine');
jest.mock('../../projects/services/projectService');

Expand Down Expand Up @@ -180,5 +182,89 @@ describe('LearningController', () => {
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'boom' });
});

it('records the attempt and verdict in the progress store', async () => {
const res = await request(app).post('/api/learning/validate').send(validBody);

expect(res.status).toBe(200);
expect(ProgressService.recordValidation).toHaveBeenCalledWith(
'project-1',
'example-roadmap',
'step-one',
true,
res.body.checkedAt
);
});

it('still returns the verdict when progress recording fails', async () => {
(ProgressService.recordValidation as jest.Mock).mockImplementation(() => {
throw new Error('disk full');
});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

const res = await request(app).post('/api/learning/validate').send(validBody);

expect(res.status).toBe(200);
expect(res.body.stepPassed).toBe(true);
errorSpy.mockRestore();
});
});

describe('GET /api/learning/progress/:projectId/:roadmapId', () => {
it('returns the stored progress for the pair', async () => {
const progress = {
projectId: 'project-1',
roadmapId: 'example-roadmap',
steps: { 'step-one': { passed: true, attempts: 2, revealedHints: 1 } },
};
(ProgressService.getProgress as jest.Mock).mockReturnValue(progress);

const res = await request(app).get('/api/learning/progress/project-1/example-roadmap');

expect(res.status).toBe(200);
expect(res.body).toEqual(progress);
expect(ProgressService.getProgress).toHaveBeenCalledWith('project-1', 'example-roadmap');
});
});

describe('PUT /api/learning/progress/:projectId/:roadmapId/hints', () => {
it('stores the absolute revealed-hints count and returns 204', async () => {
const res = await request(app)
.put('/api/learning/progress/project-1/example-roadmap/hints')
.send({ stepId: 'step-one', revealedHints: 2 });

expect(res.status).toBe(204);
expect(ProgressService.recordRevealedHints).toHaveBeenCalledWith(
'project-1',
'example-roadmap',
'step-one',
2
);
});

it.each([
['missing stepId', { revealedHints: 1 }, 'stepId'],
['empty stepId', { stepId: '', revealedHints: 1 }, 'stepId'],
['missing revealedHints', { stepId: 'step-one' }, 'revealedHints'],
['negative revealedHints', { stepId: 'step-one', revealedHints: -1 }, 'revealedHints'],
['non-integer revealedHints', { stepId: 'step-one', revealedHints: 1.5 }, 'revealedHints'],
])('returns 400 on %s', async (_name, body, field) => {
const res = await request(app)
.put('/api/learning/progress/project-1/example-roadmap/hints')
.send(body);

expect(res.status).toBe(400);
expect(res.body.error).toContain(`"${field}"`);
expect(ProgressService.recordRevealedHints).not.toHaveBeenCalled();
});
});

describe('DELETE /api/learning/progress/:projectId/:roadmapId', () => {
it('resets the pair and returns 204', async () => {
const res = await request(app).delete('/api/learning/progress/project-1/example-roadmap');

expect(res.status).toBe(204);
expect(ProgressService.resetProgress).toHaveBeenCalledWith('project-1', 'example-roadmap');
});
});
});
60 changes: 60 additions & 0 deletions backend/src/modules/learning/controllers/learningController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { RoadmapService } from '../services/roadmapService';
import { ProgressService } from '../services/progressService';
import { runStepValidators } from '../engine/engine';
import { ValidatorResult } from '../engine/types';
import { ProjectService } from '../../projects/services/projectService';
Expand Down Expand Up @@ -96,9 +97,68 @@ export class LearningController {
results,
checkedAt: new Date().toISOString(),
};
try {
ProgressService.recordValidation(
projectId as string,
roadmap.id,
step.id,
response.stepPassed,
response.checkedAt
);
} catch (recordErr: unknown) {
// Recording is bookkeeping — the learner's verdict must still go out.
console.error('[learning] Failed to record validation progress:', recordErr);
}
res.json(response);
} catch (err: unknown) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
}

public static async getProgress(req: Request, res: Response): Promise<void> {
try {
res.json(
ProgressService.getProgress(req.params.projectId as string, req.params.roadmapId as string)
);
} catch (err: unknown) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
}

public static async recordRevealedHints(req: Request, res: Response): Promise<void> {
try {
const { stepId, revealedHints } = (req.body ?? {}) as Record<string, unknown>;
if (typeof stepId !== 'string' || stepId.length === 0) {
res.status(400).json({ error: '"stepId" is required and must be a string' });
return;
}
if (typeof revealedHints !== 'number' || !Number.isInteger(revealedHints) || revealedHints < 0) {
res.status(400).json({ error: '"revealedHints" is required and must be a non-negative integer' });
return;
}
// No existence check against the roadmap: progress is local, non-sensitive
// data, and hint reveals must stay cheap fire-and-forget writes.
ProgressService.recordRevealedHints(
req.params.projectId as string,
req.params.roadmapId as string,
stepId,
revealedHints
);
res.status(204).end();
} catch (err: unknown) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
}

public static async resetProgress(req: Request, res: Response): Promise<void> {
try {
ProgressService.resetProgress(
req.params.projectId as string,
req.params.roadmapId as string
);
res.status(204).end();
} catch (err: unknown) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
}
}
3 changes: 3 additions & 0 deletions backend/src/modules/learning/routes/learningRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ const router = Router();
router.get('/roadmaps', LearningController.listRoadmaps);
router.get('/roadmaps/:id', LearningController.getRoadmap);
router.post('/validate', LearningController.validate);
router.get('/progress/:projectId/:roadmapId', LearningController.getProgress);
router.put('/progress/:projectId/:roadmapId/hints', LearningController.recordRevealedHints);
router.delete('/progress/:projectId/:roadmapId', LearningController.resetProgress);

export default router;
143 changes: 143 additions & 0 deletions backend/src/modules/learning/services/progressService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { ProgressService } from './progressService';

describe('ProgressService', () => {
let dir: string;
let file: string;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'torollo-progress-'));
file = path.join(dir, 'progress.json');
(ProgressService as unknown as { storeRecovered: boolean }).storeRecovered = false;
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

it('returns empty steps when nothing was ever recorded', () => {
const progress = ProgressService.getProgress('project-1', 'roadmap-1', file);

expect(progress).toEqual({ projectId: 'project-1', roadmapId: 'roadmap-1', steps: {} });
expect(fs.existsSync(file)).toBe(false);
});

it('counts attempts and keeps the latest verdict across validations', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', false, '2026-07-16T10:05:00.000Z', file);

const progress = ProgressService.getProgress('project-1', 'roadmap-1', file);

expect(progress.steps['step-a']).toEqual({
passed: false,
attempts: 2,
revealedHints: 0,
lastCheckedAt: '2026-07-16T10:05:00.000Z',
});
});

it('persists across service calls via the file (survives a fresh read)', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-b', 2, file);

const stored = JSON.parse(fs.readFileSync(file, 'utf-8'));

expect(stored.version).toBe(1);
expect(stored.entries).toHaveLength(1);
expect(stored.entries[0].projectId).toBe('project-1');
expect(stored.entries[0].roadmapId).toBe('roadmap-1');
expect(stored.entries[0].steps['step-a'].passed).toBe(true);
expect(stored.entries[0].steps['step-b'].revealedHints).toBe(2);
expect(fs.existsSync(`${file}.tmp`)).toBe(false);
});

it('stores revealed hints as an absolute count', () => {
ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-a', 1, file);
ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-a', 3, file);

const progress = ProgressService.getProgress('project-1', 'roadmap-1', file);

expect(progress.steps['step-a']).toEqual({ passed: false, attempts: 0, revealedHints: 3 });
});

it('keys steps by stable id, independent of any ordering', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-c', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:01:00.000Z', file);

const progress = ProgressService.getProgress('project-1', 'roadmap-1', file);

expect(progress.steps['step-a'].passed).toBe(true);
expect(progress.steps['step-c'].passed).toBe(true);
});

it('resetProgress removes only the targeted (project, roadmap) entry', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-2', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-2', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);

ProgressService.resetProgress('project-1', 'roadmap-1', file);

expect(ProgressService.getProgress('project-1', 'roadmap-1', file).steps).toEqual({});
expect(ProgressService.getProgress('project-1', 'roadmap-2', file).steps).not.toEqual({});
expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({});
});

it('deleteProjectProgress removes every roadmap entry of the project, nothing else', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-2', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-2', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);

ProgressService.deleteProjectProgress('project-1', file);

expect(ProgressService.getProgress('project-1', 'roadmap-1', file).steps).toEqual({});
expect(ProgressService.getProgress('project-1', 'roadmap-2', file).steps).toEqual({});
expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({});
});

describe('unreadable store recovery', () => {
let errorSpy: jest.SpyInstance;

beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
errorSpy.mockRestore();
});

it('moves a corrupt file aside, starts fresh and reports the recovery once', () => {
fs.writeFileSync(file, 'not json at all {');

const first = ProgressService.getProgress('project-1', 'roadmap-1', file);
expect(first.steps).toEqual({});
expect(first.storeRecovered).toBe(true);
expect(fs.readFileSync(`${file}.corrupt`, 'utf-8')).toBe('not json at all {');

const second = ProgressService.getProgress('project-1', 'roadmap-1', file);
expect(second.storeRecovered).toBeUndefined();
});

it('treats an unknown store version like corruption — never guesses', () => {
fs.writeFileSync(file, JSON.stringify({ version: 99, entries: [] }));

const progress = ProgressService.getProgress('project-1', 'roadmap-1', file);

expect(progress.steps).toEqual({});
expect(progress.storeRecovered).toBe(true);
expect(fs.existsSync(`${file}.corrupt`)).toBe(true);
});

it('keeps working after recovery: new writes land in a fresh store', () => {
fs.writeFileSync(file, '{broken');

ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);

const progress = ProgressService.getProgress('project-1', 'roadmap-1', file);
expect(progress.steps['step-a'].passed).toBe(true);
// The recovery happened on the write path — still reported on the next read.
expect(progress.storeRecovered).toBe(true);
});
});
});
Loading
Loading