Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
/**
|
|
* Offline verification: end-of-course flow (Session 11 → training done → post → finished).
|
|
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-end-to-end-course-flow.ts
|
|
*
|
|
* Does NOT call AI. Uses programService + journey permission rules + storage helpers.
|
|
*/
|
|
import { PROGRAM_SESSION_COUNT } from '../src/data/program/sessions';
|
|
import {
|
|
completeSession,
|
|
getProgramProgress,
|
|
isProgramCompleted,
|
|
resetProgramProgress
|
|
} from '../src/services/programService';
|
|
import {
|
|
getLatestByPhase,
|
|
isPhaseCompleted,
|
|
saveAssessment,
|
|
type AssessmentSubmission
|
|
} from '../src/services/assessmentService';
|
|
import { PCQ24_QUESTIONS } from '../src/config/pcq24';
|
|
|
|
// Minimal localStorage for Node
|
|
const store = new Map<string, string>();
|
|
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => {
|
|
store.set(k, v);
|
|
},
|
|
removeItem: (k: string) => {
|
|
store.delete(k);
|
|
},
|
|
clear: () => store.clear(),
|
|
key: () => null,
|
|
get length() {
|
|
return store.size;
|
|
}
|
|
};
|
|
|
|
type LearningProgress = {
|
|
user_id: string;
|
|
current_step: string;
|
|
pre_test_completed_at: string | null;
|
|
training_started_at: string | null;
|
|
training_completed_at: string | null;
|
|
post_test_completed_at: string | null;
|
|
};
|
|
|
|
const journeyKey = (userId: string) => `learning-progress:${userId}`;
|
|
|
|
const readJourney = (userId: string): LearningProgress => {
|
|
const raw = localStorage.getItem(journeyKey(userId));
|
|
if (!raw) {
|
|
return {
|
|
user_id: userId,
|
|
current_step: 'registered',
|
|
pre_test_completed_at: null,
|
|
training_started_at: null,
|
|
training_completed_at: null,
|
|
post_test_completed_at: null
|
|
};
|
|
}
|
|
return JSON.parse(raw) as LearningProgress;
|
|
};
|
|
|
|
const writeJourney = (progress: LearningProgress) => {
|
|
localStorage.setItem(journeyKey(progress.user_id), JSON.stringify(progress));
|
|
};
|
|
|
|
/** Mirrors useLearningJourney permissions */
|
|
const permissionsOf = (progress: LearningProgress) => ({
|
|
canTakePreTest: !progress.pre_test_completed_at,
|
|
canOpenTraining: Boolean(progress.pre_test_completed_at),
|
|
canTakePostTest: Boolean(
|
|
progress.pre_test_completed_at &&
|
|
progress.training_completed_at &&
|
|
!progress.post_test_completed_at
|
|
),
|
|
isCompleted: Boolean(progress.post_test_completed_at)
|
|
});
|
|
|
|
/** Mirrors HomeRedirect */
|
|
const resolveRoot = (progress: LearningProgress): string => {
|
|
if (!progress.pre_test_completed_at) return '/assessment/pre';
|
|
if (!progress.training_completed_at) return '/chat';
|
|
if (!progress.post_test_completed_at) return '/assessment/post';
|
|
return '/finished';
|
|
};
|
|
|
|
/** Mirrors ChatPage Session 11 completion side-effects (without React) */
|
|
const applyFinalSessionCompletion = (userId: string, sessionOrder: number) => {
|
|
const updated = completeSession(userId, sessionOrder);
|
|
const isFinal =
|
|
sessionOrder >= PROGRAM_SESSION_COUNT || Boolean(updated.completedAt);
|
|
if (isFinal) {
|
|
const now = new Date().toISOString();
|
|
const base = readJourney(userId);
|
|
if (!base.training_completed_at) {
|
|
writeJourney({
|
|
...base,
|
|
training_started_at: base.training_started_at || now,
|
|
training_completed_at: now
|
|
});
|
|
}
|
|
}
|
|
return { updated, isFinal };
|
|
};
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const assert = (name: string, condition: boolean) => {
|
|
if (condition) {
|
|
passed += 1;
|
|
console.log(`PASS: ${name}`);
|
|
return;
|
|
}
|
|
failed += 1;
|
|
console.error(`FAIL: ${name}`);
|
|
};
|
|
|
|
const USER = 'e2e-flow-user';
|
|
store.clear();
|
|
resetProgramProgress(USER);
|
|
writeJourney({
|
|
user_id: USER,
|
|
current_step: 'pre_test_done',
|
|
pre_test_completed_at: new Date().toISOString(),
|
|
training_started_at: new Date().toISOString(),
|
|
training_completed_at: null,
|
|
post_test_completed_at: null
|
|
});
|
|
|
|
// --- Root routing before training complete ---
|
|
assert('root → /chat when training incomplete', resolveRoot(readJourney(USER)) === '/chat');
|
|
assert(
|
|
'post locked before training_completed_at',
|
|
permissionsOf(readJourney(USER)).canTakePostTest === false
|
|
);
|
|
|
|
// --- Complete sessions 1..10 (sequential), then 11 ---
|
|
for (let order = 1; order <= 10; order++) {
|
|
completeSession(USER, order);
|
|
}
|
|
assert('after 10 sessions program not complete', isProgramCompleted(USER) === false);
|
|
assert(
|
|
'training still incomplete after 10',
|
|
readJourney(USER).training_completed_at === null
|
|
);
|
|
|
|
const final = applyFinalSessionCompletion(USER, 11);
|
|
assert('session 11 marks program completedAt', Boolean(final.updated.completedAt));
|
|
assert('session 11 isFinal', final.isFinal === true);
|
|
assert('training_completed_at set after session 11', Boolean(readJourney(USER).training_completed_at));
|
|
assert(
|
|
'post unlocked after training_completed_at',
|
|
permissionsOf(readJourney(USER)).canTakePostTest === true
|
|
);
|
|
assert('root → /assessment/post after training', resolveRoot(readJourney(USER)) === '/assessment/post');
|
|
assert(
|
|
'all 11 sessions listed completed',
|
|
getProgramProgress(USER).completedSessions.length === PROGRAM_SESSION_COUNT
|
|
);
|
|
|
|
// --- Refresh persistence (re-read storage) ---
|
|
const journeyAfterReload = readJourney(USER);
|
|
const programAfterReload = getProgramProgress(USER);
|
|
assert('persist training_completed_at', Boolean(journeyAfterReload.training_completed_at));
|
|
assert('persist program completedAt', Boolean(programAfterReload.completedAt));
|
|
assert(
|
|
'persist post unlock',
|
|
permissionsOf(journeyAfterReload).canTakePostTest === true
|
|
);
|
|
|
|
// --- Post-test submission (scoring storage) + journey mark ---
|
|
const answers = PCQ24_QUESTIONS.map((q) => ({
|
|
questionId: q.id,
|
|
selectedValue: '4'
|
|
}));
|
|
await saveAssessment({ userId: USER, phase: 'post', answers });
|
|
assert('post assessment stored', isPhaseCompleted(USER, 'post') === true);
|
|
|
|
const now = new Date().toISOString();
|
|
writeJourney({
|
|
...readJourney(USER),
|
|
current_step: 'completed',
|
|
post_test_completed_at: now
|
|
});
|
|
assert('root → /finished after post', resolveRoot(readJourney(USER)) === '/finished');
|
|
assert('permissions.isCompleted', permissionsOf(readJourney(USER)).isCompleted === true);
|
|
assert('canTakePostTest false after post', permissionsOf(readJourney(USER)).canTakePostTest === false);
|
|
|
|
// --- Duplicate post guard (existing storage + journey) ---
|
|
const beforeDup = getLatestByPhase(USER, 'post') as AssessmentSubmission;
|
|
assert('existing post submission present', beforeDup !== null);
|
|
assert(
|
|
'duplicate blocked by journey permission',
|
|
permissionsOf(readJourney(USER)).canTakePostTest === false
|
|
);
|
|
assert(
|
|
'duplicate blocked by isPhaseCompleted',
|
|
isPhaseCompleted(USER, 'post') === true
|
|
);
|
|
|
|
// --- Finished refresh ---
|
|
assert('finished still available after reload', resolveRoot(readJourney(USER)) === '/finished');
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) process.exit(1);
|