From 7bd0528a170c9fe31ad444c7de14336bfe67e82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 12:17:10 +0200 Subject: [PATCH] chore(arch): refonte de la synchronisation XP/Niveau avec cliquet anti-regression et support offline cache --- back/controllers/user.controller.js | 18 +++ back/routes/user.routes.js | 6 +- back/services/user.service.js | 54 +++++++ back/tests/user.test.js | 105 +++++++++++++ back/utils/levelHelpers.js | 2 +- back/validators/user.validator.js | 7 + front/src/context/WorkoutLogsContext.js | 48 +++++- .../services/__tests__/xpSync.service.test.js | 138 ++++++++++++++++++ front/src/services/xpSync.service.js | 69 +++++++++ 9 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 front/src/services/__tests__/xpSync.service.test.js create mode 100644 front/src/services/xpSync.service.js diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index fe581f1..cc7de36 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -123,4 +123,22 @@ exports.registerPushToken = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * SYNCHRONISER L'XP LOCALE + * Pousse l'XP totale accumulée localement (front, AsyncStorage-first) vers + * user.xp/level backend — Source de Vérité pour tout ce qui est gated côté + * serveur (coffres niveau 11+, conditions de titres). Voir user.service.js + * → syncXp pour le détail du ratchet anti-régression et du recalcul de niveau. + */ +exports.syncXp = async (req, res, next) => { + try { + const { xp } = req.body; + const result = await userService.syncXp(req.user.id, xp); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 6b0a22c..fb62ae3 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken } = require("../validators/user.validator"); +const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken, syncXp } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -28,6 +28,10 @@ router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userCo // Enregistrer (ou effacer) le token Expo Push de l'appareil courant router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken); +// Synchronise l'XP totale locale (front) vers le backend — Source de Vérité +// pour le gating serveur (coffres niveau 11+, conditions de titres). +router.post("/me/sync-xp", auth, validate(syncXp), userController.syncXp); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index 7a01105..a5e3d30 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -154,6 +154,60 @@ class UserService { return updatedUser; } + /** + * Synchronise l'XP totale calculée localement (front, AsyncStorage-first) + * vers user.xp/level backend — Source de Vérité pour tout ce qui est gated + * côté serveur (coffres niveau 11+, conditions de titres comme PERFORM_LEVEL_50). + * + * Sans ce point de synchro explicite, le niveau backend dérive silencieusement + * de celui vécu par le joueur : `finalizeWorkout` calcule sa PROPRE xp + * (anti-cheat serveur, à partir des exercices soumis) plutôt que d'adopter + * l'xp locale — les deux ledgers divergent avec le temps. + * + * Sécurité : XP en lecture seule ratchet (ne redescend jamais) — un sync + * redondant ou tardif ne peut jamais effacer une progression déjà actée. + * Bornée à xpForLevel(MAX_LEVEL) pour rejeter tout payload absurde (la + * validation Joi en amont ne fait qu'un garde-fou grossier). + * + * @param {string} userId + * @param {number} submittedXp — XP cumulée locale (jamais un delta) + * @returns {{ level, xp, rank, newlyUnlockedTitles }} + */ + async syncXp(userId, submittedXp) { + const { levelFromXP, getRankForLevel, xpForLevel, MAX_LEVEL } = require('../utils/levelHelpers'); + + const user = await User.findById(userId).select('xp level rank'); + if (!user) throw new Error('Utilisateur non trouvé.'); + + const clampedXp = Math.max(0, Math.min(submittedXp, xpForLevel(MAX_LEVEL))); + const nextXp = Math.max(user.xp || 0, clampedXp); + + let newlyUnlockedTitles = []; + + if (nextXp !== user.xp) { + user.xp = nextXp; + user.level = levelFromXP(nextXp); + user.rank = getRankForLevel(user.level); + await user.save(); + + // Require tardif : évite le cycle user.service ↔ title.controller au + // chargement des modules (voir même idiome dans reward.controller.js). + try { + const { checkAndUnlockTitles } = require('../controllers/title.controller'); + newlyUnlockedTitles = await checkAndUnlockTitles(userId); + } catch (_) { + // best-effort — la synchro XP elle-même ne doit jamais échouer pour ça. + } + } + + return { + level: user.level, + xp: user.xp, + rank: user.rank, + newlyUnlockedTitles, + }; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/user.test.js b/back/tests/user.test.js index b1632e0..b073c3a 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -75,4 +75,109 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/users/me/sync-xp — syncXp (Section X)', () => { + const { xpForLevel } = require('../utils/levelHelpers'); + + beforeEach(async () => { + await User.updateOne( + { email: 'user@test.fr' }, + { $set: { xp: 0, level: 0, rank: 'Novice', unlockedTitles: [] } }, + ); + }); + + it('✅ Recalcule level/rank côté serveur à partir de xp', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(12) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(12); + expect(res.body.rank).toBe('Initié'); + expect(res.body.xp).toBe(xpForLevel(12)); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(12); + expect(user.xp).toBe(xpForLevel(12)); + }); + + it("✅ Ratchet : un xp inférieur à l'existant ne fait jamais redescendre le niveau", async () => { + await User.updateOne({ email: 'user@test.fr' }, { $set: { xp: xpForLevel(20), level: 20, rank: 'Initié' } }); + + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(5) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(20); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(20); + }); + + it('✅ Idempotent : renvoyer la même valeur ne déclenche pas de re-déblocage', async () => { + await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + const second = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(second.body.newlyUnlockedTitles).not.toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Débloque les titres dont la condition de niveau est franchie', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(res.body.newlyUnlockedTitles).toContain('PERFORM_LEVEL_10'); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.unlockedTitles).toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Clampe un xp aberrant (au-delà du niveau 200) sans planter', async () => { + // En dessous du plafond Joi (garde-fou grossier, 100M) mais bien + // au-delà de xpForLevel(200) (~1.72M) — exerce le clamp du service. + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 50000000 }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(200); + }); + + it('❌ 400 si xp négatif, non numérique ou manquant', async () => { + const negative = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: -5 }); + expect(negative.statusCode).toBe(400); + + const invalid = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 'abc' }); + expect(invalid.statusCode).toBe(400); + + const missing = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({}); + expect(missing.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/users/me/sync-xp').send({ xp: 100 }); + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file diff --git a/back/utils/levelHelpers.js b/back/utils/levelHelpers.js index 2c25388..557652c 100644 --- a/back/utils/levelHelpers.js +++ b/back/utils/levelHelpers.js @@ -51,4 +51,4 @@ function getRankForLevel(level) { return match ? match.rank : 'Novice'; } -module.exports = { xpForLevel, levelFromXP, getRankForLevel }; +module.exports = { xpForLevel, levelFromXP, getRankForLevel, MAX_LEVEL }; diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index 1703eff..88ec6de 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -35,6 +35,13 @@ const userSchemas = { // null explicite = désenregistrement (permissions révoquées côté client) pushToken: Joi.string().max(200).allow(null).required(), }), + + // Synchronisation XP local → backend (voir user.service.js → syncXp). + // Borne haute large mais finie : le clamp fin (xpForLevel(200)) est fait + // côté service, cette borne Joi n'est qu'un garde-fou anti-payload absurde. + syncXp: Joi.object({ + xp: Joi.number().integer().min(0).max(100000000).required(), + }), }; module.exports = userSchemas; \ No newline at end of file diff --git a/front/src/context/WorkoutLogsContext.js b/front/src/context/WorkoutLogsContext.js index 204e358..8b0f178 100644 --- a/front/src/context/WorkoutLogsContext.js +++ b/front/src/context/WorkoutLogsContext.js @@ -1,5 +1,7 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; +import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { AppState } from 'react-native'; import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services/stats.service'; +import { syncXp, retryPendingXpSync } from '../services/xpSync.service'; // Context global pour l'historique des séances finalisées (logs). // Source de vérité unique pour StatsScreen, ExerciseStatsScreen, ProfileScreen. @@ -10,6 +12,7 @@ export function WorkoutLogsProvider({ children }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const appState = useRef(AppState.currentState); const refresh = useCallback(async () => { setLoading(true); @@ -17,6 +20,9 @@ export function WorkoutLogsProvider({ children }) { try { const list = await listLogs(); setItems(list); + // Check-up de cohérence au démarrage (Section X) : pousse le total XP + // local vers le backend, fire-and-forget — voir xpSync.service.js. + syncXp(totalCumulativeXP(list)); } catch (e) { setError(e && e.message ? e.message : 'Erreur de chargement'); } finally { @@ -28,10 +34,31 @@ export function WorkoutLogsProvider({ children }) { refresh(); }, [refresh]); + // Pas de détection réseau active dans ce projet (pas de NetInfo) : le retour + // au premier plan de l'app est le proxy le plus proche de "le réseau est + // peut-être revenu" — ne retente que si un sync précédent a échoué + // (isXpSynced: false), pour ne pas spammer l'API à chaque changement d'onglet. + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + if (appState.current.match(/inactive|background/) && nextState === 'active') { + retryPendingXpSync(totalCumulativeXP(items)); + } + appState.current = nextState; + }); + return () => subscription.remove(); + }, [items]); + const create = useCallback(async (log) => { const item = await addLog(log); // Insertion en tête (logs triés par date desc) - setItems((prev) => [item, ...prev]); + let newTotal; + setItems((prev) => { + const next = [item, ...prev]; + newTotal = totalCumulativeXP(next); + return next; + }); + // Événement clé (Section X) : fin de séance (solo ou Multi) — sync fire-and-forget. + syncXp(newTotal); return item; }, []); @@ -63,7 +90,13 @@ export function WorkoutLogsProvider({ children }) { const addRitual = useCallback(async (ritualId, ritualLabel, durationSeconds, xpEarned) => { const item = await addRitualLog(ritualId, ritualLabel, durationSeconds, xpEarned); if (!item) return null; - setItems((prev) => [item, ...prev]); + let newTotal; + setItems((prev) => { + const next = [item, ...prev]; + newTotal = totalCumulativeXP(next); + return next; + }); + syncXp(newTotal); return item; }, []); @@ -73,7 +106,14 @@ export function WorkoutLogsProvider({ children }) { const addBonusXp = useCallback(async (source, xpEarned) => { const item = await addBonusXpLog(source, xpEarned); if (!item) return null; - setItems((prev) => [item, ...prev]); + let newTotal; + setItems((prev) => { + const next = [item, ...prev]; + newTotal = totalCumulativeXP(next); + return next; + }); + // Événement clé (Section X) : bonus XP (ex: bonus de groupe Multi) — sync fire-and-forget. + syncXp(newTotal); return item; }, []); diff --git a/front/src/services/__tests__/xpSync.service.test.js b/front/src/services/__tests__/xpSync.service.test.js new file mode 100644 index 0000000..6419161 --- /dev/null +++ b/front/src/services/__tests__/xpSync.service.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Mocks manuels : ce fichier tourne sous testEnvironment 'node' (voir +// jest.config.js — pas d'environnement React Native), donc AsyncStorage et +// l'instance axios (../api/api) doivent être entièrement remplacés, jamais +// réellement importés (ils dépendent de bindings natifs indisponibles ici). +const mockGetItem = jest.fn(); +const mockSetItem = jest.fn().mockResolvedValue(undefined); +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: (...args) => mockGetItem(...args), + setItem: (...args) => mockSetItem(...args), +})); + +const mockPost = jest.fn(); +jest.mock('../../api/api', () => ({ + __esModule: true, + default: { post: (...args) => mockPost(...args) }, +})); + +const XP_SYNCED_FLAG_KEY = 'athly:xp:isXpSynced:v1'; + +describe('xpSync.service — synchronisation XP locale → backend (Section X)', () => { + let syncXp; + let retryPendingXpSync; + + beforeEach(() => { + // lastSyncedXp est un state de MODULE (mémoire) — reset complet entre + // chaque test pour ne jamais laisser un test polluer le suivant. + jest.resetModules(); + jest.clearAllMocks(); + mockSetItem.mockResolvedValue(undefined); + ({ syncXp, retryPendingXpSync } = require('../xpSync.service')); + }); + + describe('syncXp — mode connecté', () => { + it('✅ Envoie le total XP arrondi et marque isXpSynced à true', async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 12, xp: 1800, rank: 'Initié', newlyUnlockedTitles: [] } }); + + const result = await syncXp(1799.6); + + expect(mockPost).toHaveBeenCalledWith('/users/me/sync-xp', { xp: 1800 }); + expect(result).toEqual({ success: true, level: 12, xp: 1800, rank: 'Initié', newlyUnlockedTitles: [] }); + expect(mockSetItem).toHaveBeenCalledWith(XP_SYNCED_FLAG_KEY, 'true'); + }); + + it("✅ Renvoie les titres nouvellement débloqués par ce gain d'XP", async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 50, xp: 200000, rank: 'Compétiteur', newlyUnlockedTitles: ['PERFORM_LEVEL_50'] } }); + + const result = await syncXp(200000); + + expect(result.newlyUnlockedTitles).toContain('PERFORM_LEVEL_50'); + }); + + it('✅ Ne rappelle pas l\'API si la valeur est identique au dernier sync réussi (dédup mémoire)', async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 5, xp: 500, rank: 'Novice', newlyUnlockedTitles: [] } }); + + await syncXp(500); + await syncXp(500); + + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('✅ Rappelle bien l\'API si la valeur a changé depuis le dernier sync', async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 5, xp: 500, rank: 'Novice', newlyUnlockedTitles: [] } }); + await syncXp(500); + + mockPost.mockResolvedValue({ data: { success: true, level: 6, xp: 600, rank: 'Novice', newlyUnlockedTitles: [] } }); + await syncXp(600); + + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('❌ xp négatif ou non-fini : ne fait aucun appel réseau', async () => { + await syncXp(-5); + await syncXp(NaN); + await syncXp(undefined); + + expect(mockPost).not.toHaveBeenCalled(); + }); + }); + + describe('syncXp — mode déconnecté (offline)', () => { + it("✅ Échec réseau : renvoie null et marque isXpSynced à false (sans lever d'exception)", async () => { + mockPost.mockRejectedValue(new Error('Network Error')); + + const result = await syncXp(1000); + + expect(result).toBeNull(); + expect(mockSetItem).toHaveBeenCalledWith(XP_SYNCED_FLAG_KEY, 'false'); + }); + + it("✅ Un échec n'active pas la dédup mémoire — le prochain appel retente vraiment", async () => { + mockPost.mockRejectedValueOnce(new Error('Network Error')); + mockPost.mockResolvedValueOnce({ data: { success: true, level: 3, xp: 300, rank: 'Novice', newlyUnlockedTitles: [] } }); + + const first = await syncXp(300); + const second = await syncXp(300); + + expect(first).toBeNull(); + expect(second).not.toBeNull(); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + }); + + describe('retryPendingXpSync — retente au retour réseau / prochain démarrage', () => { + it('✅ Retente si isXpSynced === "false"', async () => { + mockGetItem.mockResolvedValue('false'); + mockPost.mockResolvedValue({ data: { success: true, level: 4, xp: 400, rank: 'Novice', newlyUnlockedTitles: [] } }); + + await retryPendingXpSync(400); + + expect(mockPost).toHaveBeenCalledWith('/users/me/sync-xp', { xp: 400 }); + }); + + it('❌ Ne retente pas si isXpSynced === "true" (déjà à jour)', async () => { + mockGetItem.mockResolvedValue('true'); + + await retryPendingXpSync(400); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("❌ Ne retente pas si le flag n'a jamais été écrit (premier lancement)", async () => { + mockGetItem.mockResolvedValue(null); + + await retryPendingXpSync(400); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('✅ Best-effort : une erreur AsyncStorage ne lève jamais', async () => { + mockGetItem.mockRejectedValue(new Error('storage unavailable')); + + await expect(retryPendingXpSync(400)).resolves.toBeUndefined(); + expect(mockPost).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/front/src/services/xpSync.service.js b/front/src/services/xpSync.service.js new file mode 100644 index 0000000..3abf1b2 --- /dev/null +++ b/front/src/services/xpSync.service.js @@ -0,0 +1,69 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import API from '../api/api'; + +// ─── Synchronisation XP locale → backend (Section X) ─────────────────────────── +// L'app reste local-first pour l'XP (AsyncStorage, réactif, jamais bloqué par +// le réseau) — mais le backend doit rester la Source de Vérité pour tout ce +// qui est gated côté serveur (coffres niveau 11+, conditions de titres comme +// "Le Titan"). Ce service pousse silencieusement le total XP local vers +// POST /api/users/me/sync-xp à chaque événement clé (voir WorkoutLogsContext.js +// et App.js) ; le backend applique un ratchet (n'accepte jamais une baisse), +// donc rejouer ce sync n'importe quand est toujours sans danger. +// +// Pas de détection réseau active (pas de NetInfo dans ce projet) : en cas +// d'échec, isXpSynced passe à false et CHAQUE prochain événement clé (dont le +// prochain démarrage de l'app) retente — cohérent avec le reste de l'app, qui +// n'observe jamais la connectivité activement (voir api.js, mode dégradé +// "tente et dégrade" plutôt que "surveille puis agit"). + +const XP_SYNCED_FLAG_KEY = 'athly:xp:isXpSynced:v1'; + +// Garde en mémoire (pas AsyncStorage) la dernière valeur réellement envoyée +// avec succès cette session — évite de spammer l'API à chaque log ajouté +// pendant une même séance si l'xp n'a pas bougé entre deux appels. +let lastSyncedXp = null; + +/** + * Pousse `totalXP` (cumul local total, jamais un delta) vers le backend. + * Best-effort : ne lève jamais, best pour être appelée en fire-and-forget + * depuis n'importe quel point d'action sans `await` bloquant l'UI. + * + * @param {number} totalXP + * @returns {Promise<{level:number, xp:number, rank:string, newlyUnlockedTitles:string[]}|null>} + */ +export async function syncXp(totalXP) { + if (!Number.isFinite(totalXP) || totalXP < 0) return null; + const rounded = Math.round(totalXP); + if (rounded === lastSyncedXp) return null; // rien de nouveau depuis le dernier sync réussi + + try { + const res = await API.post('/users/me/sync-xp', { xp: rounded }); + lastSyncedXp = rounded; + AsyncStorage.setItem(XP_SYNCED_FLAG_KEY, 'true').catch(() => {}); + return res.data ?? null; + } catch (_) { + AsyncStorage.setItem(XP_SYNCED_FLAG_KEY, 'false').catch(() => {}); + return null; + } +} + +/** + * À appeler au démarrage de l'app (check-up de cohérence) : relit le flag + * laissé par un échec précédent et retente si besoin. Comme `syncXp` est de + * toute façon appelée à chaque événement clé, ceci n'est qu'un filet de + * sécurité supplémentaire pour le cas "aucune action XP depuis le retour du + * réseau" (ex: l'utilisateur rouvre juste l'app sans s'entraîner). + * + * @param {number} currentTotalXP — total XP local ACTUEL (pas la valeur figée + * au moment de l'échec — le ratchet backend prend de toute façon le max). + */ +export async function retryPendingXpSync(currentTotalXP) { + try { + const flag = await AsyncStorage.getItem(XP_SYNCED_FLAG_KEY); + if (flag === 'false') { + await syncXp(currentTotalXP); + } + } catch (_) { + // best-effort + } +}