diff --git a/src/entities/user/__tests__/access.test.ts b/src/entities/user/__tests__/access.test.ts new file mode 100644 index 0000000..f031c12 --- /dev/null +++ b/src/entities/user/__tests__/access.test.ts @@ -0,0 +1,184 @@ +/** + * Tests de parité pour src/entities/user/access.ts. + * + * Ces tests calquent leur structure sur les tests backend équivalents + * (expria-backend/src/lib/__tests__/canUserSimulate.test.ts, + * getPlanPermissions.test.ts, checkFeatureAccess.test.ts) + * pour garantir la parité des comportements entre frontend et backend. + * + * Cf. ADR 005 et TESTS_AUTOMATISES.md (backend) sections 3, 4, 5. + */ + +import { describe, it, expect } from 'vitest' +import { + canUserSimulate, + checkFeatureAccess, + getPlanPermissions, + PLANS, +} from '../access' + +describe('canUserSimulate', () => { + // Plan FREE — dans les limites + it('autorise un utilisateur free avec 0 simulation utilisée', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 0 }) + expect(result.allowed).toBe(true) + }) + + it('autorise un utilisateur free avec 4 simulations utilisées', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 4 }) + expect(result.allowed).toBe(true) + }) + + // Plan FREE — quota atteint + it('bloque un utilisateur free avec 5 simulations utilisées', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 5 }) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('quota_reached') + }) + + it('bloque un utilisateur free avec plus de 5 simulations (sécurité)', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 99 }) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('quota_reached') + }) + + // Plan STANDARD — illimité + it('autorise toujours un utilisateur standard', () => { + const result = canUserSimulate({ plan: 'standard', simulations_used: 999 }) + expect(result.allowed).toBe(true) + }) + + // Plan PREMIUM — illimité + it('autorise toujours un utilisateur premium', () => { + const result = canUserSimulate({ plan: 'premium', simulations_used: 999 }) + expect(result.allowed).toBe(true) + }) + + // Plan inconnu — sécurité défensive + it('bloque un plan inconnu', () => { + const result = canUserSimulate({ plan: 'unknown', simulations_used: 0 }) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('invalid_plan') + }) +}) + +describe('getPlanPermissions', () => { + describe('Plan FREE', () => { + it('retourne les bonnes permissions pour free', () => { + const perms = getPlanPermissions('free') + expect(perms.simulations_lifetime).toBe(5) + expect(perms.oral_t2_live).toBe(false) + expect(perms.detailed_report).toBe(false) + expect(perms.tips).toBe(false) + expect(perms.dashboard).toBe(false) + expect(perms.exam_mode).toBe(false) + expect(perms.pattern_analysis).toBe(false) + expect(perms.preparation_index).toBe(false) + }) + }) + + describe('Plan STANDARD', () => { + it('retourne les bonnes permissions pour standard', () => { + const perms = getPlanPermissions('standard') + expect(perms.simulations_lifetime).toBeNull() + expect(perms.oral_t2_live).toBe(false) + expect(perms.detailed_report).toBe(true) + expect(perms.tips).toBe(true) + expect(perms.dashboard).toBe(true) + expect(perms.exam_mode).toBe(false) + expect(perms.pattern_analysis).toBe(false) + expect(perms.preparation_index).toBe(false) + }) + }) + + describe('Plan PREMIUM', () => { + it('retourne les bonnes permissions pour premium', () => { + const perms = getPlanPermissions('premium') + expect(perms.simulations_lifetime).toBeNull() + expect(perms.oral_t2_live).toBe(true) + expect(perms.detailed_report).toBe(true) + expect(perms.tips).toBe(true) + expect(perms.dashboard).toBe(true) + expect(perms.exam_mode).toBe(true) + expect(perms.pattern_analysis).toBe(true) + expect(perms.preparation_index).toBe(true) + }) + }) + + it('lève une erreur pour un plan inconnu', () => { + // @ts-expect-error — test de sécurité défensive + expect(() => getPlanPermissions('unknown')).toThrow() + }) +}) + +describe('checkFeatureAccess', () => { + // basic_report : toujours autorisé (tous plans) + it('free peut accéder au rapport basique', () => { + expect(checkFeatureAccess('free', 'basic_report')).toBe(true) + }) + + // Features BLOQUÉES en FREE + it('free ne peut pas accéder au rapport détaillé', () => { + expect(checkFeatureAccess('free', 'detailed_report')).toBe(false) + }) + + it('free ne peut pas accéder au mode examen', () => { + expect(checkFeatureAccess('free', 'exam_mode')).toBe(false) + }) + + it('free ne peut pas accéder à la T2 live', () => { + expect(checkFeatureAccess('free', 'oral_t2_live')).toBe(false) + }) + + it('free ne peut pas accéder au dashboard', () => { + expect(checkFeatureAccess('free', 'dashboard')).toBe(false) + }) + + // Features STANDARD + it('standard peut accéder au rapport détaillé', () => { + expect(checkFeatureAccess('standard', 'detailed_report')).toBe(true) + }) + + it('standard peut accéder au dashboard', () => { + expect(checkFeatureAccess('standard', 'dashboard')).toBe(true) + }) + + it('standard ne peut PAS accéder au mode examen', () => { + expect(checkFeatureAccess('standard', 'exam_mode')).toBe(false) + }) + + it('standard ne peut PAS accéder à la T2 live', () => { + expect(checkFeatureAccess('standard', 'oral_t2_live')).toBe(false) + }) + + it("standard ne peut PAS accéder à l'analyse des patterns", () => { + expect(checkFeatureAccess('standard', 'pattern_analysis')).toBe(false) + }) + + // Features PREMIUM + it('premium peut accéder au mode examen', () => { + expect(checkFeatureAccess('premium', 'exam_mode')).toBe(true) + }) + + it('premium peut accéder à la T2 live', () => { + expect(checkFeatureAccess('premium', 'oral_t2_live')).toBe(true) + }) + + it("premium peut accéder à l'analyse des patterns", () => { + expect(checkFeatureAccess('premium', 'pattern_analysis')).toBe(true) + }) + + it("premium peut accéder à l'indice de préparation", () => { + expect(checkFeatureAccess('premium', 'preparation_index')).toBe(true) + }) +}) + +describe('PLANS structure', () => { + it("les trois plans ont les mêmes clés", () => { + const freeKeys = Object.keys(PLANS.free).sort() + const standardKeys = Object.keys(PLANS.standard).sort() + const premiumKeys = Object.keys(PLANS.premium).sort() + expect(standardKeys).toEqual(freeKeys) + expect(premiumKeys).toEqual(freeKeys) + }) +}) diff --git a/src/entities/user/__tests__/lib.test.ts b/src/entities/user/__tests__/lib.test.ts new file mode 100644 index 0000000..23f1d2a --- /dev/null +++ b/src/entities/user/__tests__/lib.test.ts @@ -0,0 +1,80 @@ +/** + * Tests pour les alias frontend-idiomatiques de src/entities/user/lib.ts. + * + * Ces tests vérifient que hasAccess et canSimulate se comportent comme + * leurs équivalents backend (checkFeatureAccess et canUserSimulate). + * + * Cf. ADR 005. + */ + +import { describe, it, expect } from 'vitest' +import { hasAccess, canSimulate, getPlanPermissions } from '../lib' + +describe('hasAccess (alias de checkFeatureAccess)', () => { + it('se comporte comme checkFeatureAccess pour free', () => { + expect(hasAccess('free', 'basic_report')).toBe(true) + expect(hasAccess('free', 'detailed_report')).toBe(false) + expect(hasAccess('free', 'exam_mode')).toBe(false) + }) + + it('se comporte comme checkFeatureAccess pour standard', () => { + expect(hasAccess('standard', 'detailed_report')).toBe(true) + expect(hasAccess('standard', 'exam_mode')).toBe(false) + expect(hasAccess('standard', 'oral_t2_live')).toBe(false) + }) + + it('se comporte comme checkFeatureAccess pour premium', () => { + expect(hasAccess('premium', 'exam_mode')).toBe(true) + expect(hasAccess('premium', 'oral_t2_live')).toBe(true) + expect(hasAccess('premium', 'pattern_analysis')).toBe(true) + }) +}) + +describe('canSimulate (alias de canUserSimulate avec signature ergonomique)', () => { + it('autorise un utilisateur free dans les limites', () => { + const result = canSimulate('free', 0) + expect(result.allowed).toBe(true) + }) + + it('autorise un utilisateur free à sa 5e simulation (0-indexed)', () => { + const result = canSimulate('free', 4) + expect(result.allowed).toBe(true) + }) + + it('bloque un utilisateur free au quota atteint', () => { + const result = canSimulate('free', 5) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('quota_reached') + }) + + it('autorise un utilisateur standard sans limite', () => { + const result = canSimulate('standard', 9999) + expect(result.allowed).toBe(true) + }) + + it('autorise un utilisateur premium sans limite', () => { + const result = canSimulate('premium', 9999) + expect(result.allowed).toBe(true) + }) + + it('bloque un plan inconnu', () => { + const result = canSimulate('enterprise', 0) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('invalid_plan') + }) + + it("accepte bien la signature (plan, used) avec les bons arguments", () => { + // Vérifie que l'ordre des arguments est correct + // (un inversement accidentel donnerait un plan='0' ce qui bloquerait tout) + const result = canSimulate('premium', 100) + expect(result.allowed).toBe(true) + }) +}) + +describe('getPlanPermissions (ré-export direct)', () => { + it('retourne les permissions Premium correctes', () => { + const perms = getPlanPermissions('premium') + expect(perms.exam_mode).toBe(true) + expect(perms.oral_t2_live).toBe(true) + }) +}) diff --git a/src/entities/user/access.ts b/src/entities/user/access.ts new file mode 100644 index 0000000..1bb0434 --- /dev/null +++ b/src/entities/user/access.ts @@ -0,0 +1,84 @@ +// SOURCE OF TRUTH: expria-backend/src/lib/access.ts +// Synchronisé le : 2026-04-17 (après audit backend) +// +// RÈGLE ABSOLUE (cf. ADR 004 + ARCHITECTURE.md §10 Règle 2) : +// Ce fichier doit être IDENTIQUE au bit près à expria-backend/src/lib/access.ts. +// Toute modification se fait simultanément dans les deux dépôts, même commit logique. +// +// NE PAS utiliser directement dans le code frontend — passer par lib.ts +// qui expose les alias idiomatiques hasAccess() et canSimulate(). + +export type Plan = 'free' | 'standard' | 'premium' + +export type Feature = + | 'oral_t2_live' + | 'detailed_report' + | 'tips' + | 'dashboard' + | 'exam_mode' + | 'pattern_analysis' + | 'preparation_index' + | 'basic_report' + +export const PLANS = { + free: { + simulations_lifetime: 5, + oral_t2_live: false, + detailed_report: false, + tips: false, + dashboard: false, + exam_mode: false, + pattern_analysis: false, + preparation_index: false, + }, + standard: { + simulations_lifetime: null, + oral_t2_live: false, + detailed_report: true, + tips: true, + dashboard: true, + exam_mode: false, + pattern_analysis: false, + preparation_index: false, + }, + premium: { + simulations_lifetime: null, + oral_t2_live: true, + detailed_report: true, + tips: true, + dashboard: true, + exam_mode: true, + pattern_analysis: true, + preparation_index: true, + }, +} + +export function getPlanPermissions(plan: Plan) { + const perms = PLANS[plan] + if (!perms) { + throw new Error(`Plan inconnu : ${plan}`) + } + return perms +} + +export function canUserSimulate(user: { plan: string; simulations_used: number }): { + allowed: boolean + reason?: string +} { + if (!(user.plan in PLANS)) { + return { allowed: false, reason: 'invalid_plan' } + } + const plan = user.plan as Plan + const perms = PLANS[plan] + if (perms.simulations_lifetime !== null && user.simulations_used >= perms.simulations_lifetime) { + return { allowed: false, reason: 'quota_reached' } + } + return { allowed: true } +} + +export function checkFeatureAccess(plan: Plan, feature: Feature): boolean { + if (feature === 'basic_report') return true + const perms = PLANS[plan] + if (!perms) return false + return perms[feature as keyof typeof perms] === true +} diff --git a/src/entities/user/lib.ts b/src/entities/user/lib.ts new file mode 100644 index 0000000..3e5230c --- /dev/null +++ b/src/entities/user/lib.ts @@ -0,0 +1,73 @@ +/** + * Alias frontend-idiomatiques pour les fonctions d'accès. + * + * Ce fichier est la PORTE D'ENTRÉE pour tout code frontend qui vérifie + * une permission ou un quota. Ne JAMAIS importer directement depuis access.ts + * dans les composants ou les hooks. + * + * Cf. ADR 005 pour la justification des alias et ADR 004 pour la règle + * de synchronisation de access.ts avec le backend. + */ + +import { + canUserSimulate, + checkFeatureAccess, + getPlanPermissions, + type Feature, + type Plan, +} from './access' + +/** + * Vérifie si un plan a accès à une feature booléenne donnée. + * + * Alias idiomatique React de `checkFeatureAccess` (backend). + * La feature 'basic_report' est toujours autorisée (tous plans). + * + * @example + * if (hasAccess(plan, 'exam_mode')) { ... } + * if (hasAccess(plan, 'oral_t2_live')) { ... } + * + * @param plan - Plan de l'utilisateur ('free' | 'standard' | 'premium') + * @param feature - Nom de la feature à vérifier + * @returns true si le plan donne accès à la feature + */ +export const hasAccess = checkFeatureAccess + +/** + * Résultat d'un check de simulation. + */ +export interface SimulationCheck { + allowed: boolean + reason?: 'quota_reached' | 'invalid_plan' | string +} + +/** + * Vérifie si un utilisateur peut lancer une nouvelle simulation. + * + * Alias idiomatique de `canUserSimulate` (backend) avec une signature + * plus ergonomique côté frontend : prend `plan` et `simulationsUsed` + * séparément au lieu d'un objet `user`. + * + * @example + * const { allowed, reason } = canSimulate(plan, simulationsUsed) + * if (!allowed) { + * if (reason === 'quota_reached') openUpgradeModal() + * } + * + * @param plan - Plan de l'utilisateur + * @param simulationsUsed - Nombre de simulations déjà consommées + * @returns Objet `{ allowed, reason? }` + */ +export function canSimulate(plan: string, simulationsUsed: number): SimulationCheck { + return canUserSimulate({ plan, simulations_used: simulationsUsed }) +} + +/** + * Ré-export direct — pas d'alias car le nom est déjà idiomatique. + */ +export { getPlanPermissions } + +/** + * Ré-export des types pour les consommateurs. + */ +export type { Feature, Plan }