feat(entities/user): access.ts (copie backend) + lib.ts (hasAccess/canSimulate) + 37 tests

This commit is contained in:
Hermann_Kitio 2026-04-17 17:45:40 +03:00
parent 94731edafc
commit ef9a84433e
4 changed files with 421 additions and 0 deletions

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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
}

73
src/entities/user/lib.ts Normal file
View file

@ -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 })
}
/**
* -export direct pas d'alias car le nom est déjà idiomatique.
*/
export { getPlanPermissions }
/**
* -export des types pour les consommateurs.
*/
export type { Feature, Plan }