feat(online): session compte, stockage sécurisé et adapter API (ticket #64)

Ajoute l'adapter API distant (infrastructure/remote/auth_api.dart,
http_api_client.dart) et le stockage sécurisé du token d'auth
(infrastructure/security/secure_storage_auth_token_store.dart), câblés
dans app_bootstrap.dart. Étend le modèle Drift (migration
schemaVersion 9→10) et la couche application (ports, use cases,
entités) pour la session de compte. flutter pub get OK (ajout http,
flutter_secure_storage), build_runner OK, dart format appliqué,
analyze propre (mêmes infos préexistantes), 109/109 tests verts,
build APK debug validé. Premier ticket du chantier "Ajouter les
features serveur au client" (#63).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:25:49 +02:00
parent a557708273
commit e60c8bbdb8
19 changed files with 1799 additions and 7 deletions

View File

@ -1107,6 +1107,150 @@ void main() {
},
);
test('AuthUseCases register stores token and account session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final tokenStore = _FakeAuthTokenStore();
final accountRepository = _FakeOnlineAccountRepository();
final remoteAuthApi = _FakeRemoteAuthApi();
final useCase = _authUseCase(
tokenStore: tokenStore,
accountRepository: accountRepository,
remoteAuthApi: remoteAuthApi,
clock: clock,
);
final session = await useCase.register(
email: 'USER@example.com',
password: 'password123',
displayName: 'User',
);
expect(session.serverUserId, 'server-user-1');
expect(session.email, 'user@example.com');
expect(session.displayName, 'User');
expect(session.isLoggedIn, isTrue);
expect(tokenStore.token, 'token-1');
expect(accountRepository.session, session);
});
test('AuthUseCases register propagates email already used error', () async {
final remoteAuthApi = _FakeRemoteAuthApi()
..registerException = const RemoteAuthException(
RemoteAuthFailure.emailAlreadyUsed,
);
await expectLater(
_authUseCase(
remoteAuthApi: remoteAuthApi,
).register(email: 'user@example.com', password: 'password123'),
throwsA(
isA<RemoteAuthException>().having(
(error) => error.failure,
'failure',
RemoteAuthFailure.emailAlreadyUsed,
),
),
);
});
test('AuthUseCases login stores token and account session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final tokenStore = _FakeAuthTokenStore();
final accountRepository = _FakeOnlineAccountRepository();
final remoteAuthApi = _FakeRemoteAuthApi();
final useCase = _authUseCase(
tokenStore: tokenStore,
accountRepository: accountRepository,
remoteAuthApi: remoteAuthApi,
clock: clock,
);
final session = await useCase.login(
email: 'user@example.com',
password: 'password123',
);
expect(session.serverUserId, 'server-user-1');
expect(session.isLoggedIn, isTrue);
expect(tokenStore.token, 'token-1');
expect(tokenStore.expiresAt, DateTime.utc(2026, 8, 16, 12));
expect(accountRepository.session?.lastAuthenticatedAt, clock.now());
});
test('AuthUseCases login propagates invalid credentials', () async {
final remoteAuthApi = _FakeRemoteAuthApi()
..loginException = const RemoteAuthException(
RemoteAuthFailure.invalidCredentials,
);
await expectLater(
_authUseCase(
remoteAuthApi: remoteAuthApi,
).login(email: 'user@example.com', password: 'wrong-password'),
throwsA(
isA<RemoteAuthException>().having(
(error) => error.failure,
'failure',
RemoteAuthFailure.invalidCredentials,
),
),
);
});
test('AuthUseCases logout clears local token even if remote fails', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final tokenStore = _FakeAuthTokenStore()
..token = 'token-1'
..expiresAt = DateTime.utc(2026, 8, 16, 12);
final accountRepository = _FakeOnlineAccountRepository()
..session = UserAccountSession(
id: 'session-1',
serverUserId: 'server-user-1',
email: 'user@example.com',
isLoggedIn: true,
createdAt: DateTime.utc(2026, 7, 17, 11),
updatedAt: DateTime.utc(2026, 7, 17, 11),
);
final remoteAuthApi = _FakeRemoteAuthApi()
..logoutException = const RemoteAuthException(RemoteAuthFailure.network);
await _authUseCase(
tokenStore: tokenStore,
accountRepository: accountRepository,
remoteAuthApi: remoteAuthApi,
clock: clock,
).logout();
expect(tokenStore.token, isNull);
expect(accountRepository.session?.isLoggedIn, isFalse);
expect(remoteAuthApi.logoutCalls, 1);
});
test('AuthUseCases currentSession reads local cache only', () async {
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
final accountRepository = _FakeOnlineAccountRepository()
..session = UserAccountSession(
id: 'session-1',
serverUserId: 'server-user-1',
email: 'user@example.com',
isLoggedIn: true,
createdAt: DateTime.utc(2026, 7, 17, 11),
updatedAt: DateTime.utc(2026, 7, 17, 11),
);
final remoteAuthApi = _FakeRemoteAuthApi();
final session = await _authUseCase(
tokenStore: tokenStore,
accountRepository: accountRepository,
remoteAuthApi: remoteAuthApi,
).currentSession();
expect(session?.email, 'user@example.com');
expect(remoteAuthApi.registerCalls, 0);
expect(remoteAuthApi.loginCalls, 0);
expect(remoteAuthApi.logoutCalls, 0);
});
test('score result enforces manual xor stopwatch values', () {
expect(
() => ActiveSetResult(
@ -1209,6 +1353,114 @@ final class _FakeIds implements IdGenerator {
}
}
AuthUseCases _authUseCase({
_FakeAuthTokenStore? tokenStore,
_FakeOnlineAccountRepository? accountRepository,
_FakeRemoteAuthApi? remoteAuthApi,
_FakeClock? clock,
}) {
return AuthUseCases(
tokenStore: tokenStore ?? _FakeAuthTokenStore(),
accountRepository: accountRepository ?? _FakeOnlineAccountRepository(),
remoteAuthApi: remoteAuthApi ?? _FakeRemoteAuthApi(),
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
);
}
final class _FakeAuthTokenStore implements AuthTokenStore {
String? token;
DateTime? expiresAt;
@override
Future<void> clearToken() async {
token = null;
expiresAt = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
this.expiresAt = expiresAt;
}
}
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
UserAccountSession? session;
var clearCalls = 0;
@override
Future<void> clearSession() async {
clearCalls += 1;
session = null;
}
@override
Future<UserAccountSession?> currentSession() async => session;
@override
Future<void> saveSession(UserAccountSession session) async {
this.session = session;
}
}
final class _FakeRemoteAuthApi implements RemoteAuthApi {
RemoteAuthException? registerException;
RemoteAuthException? loginException;
RemoteAuthException? logoutException;
var registerCalls = 0;
var loginCalls = 0;
var logoutCalls = 0;
@override
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
}) async {
registerCalls += 1;
final exception = registerException;
if (exception != null) {
throw exception;
}
return _authResult(email);
}
@override
Future<RemoteAuthResult> login({
required String email,
required String password,
}) async {
loginCalls += 1;
final exception = loginException;
if (exception != null) {
throw exception;
}
return _authResult(email);
}
@override
Future<void> logout(String token) async {
logoutCalls += 1;
final exception = logoutException;
if (exception != null) {
throw exception;
}
}
RemoteAuthResult _authResult(String email) {
return RemoteAuthResult(
userId: 'server-user-1',
email: email.trim().toLowerCase(),
token: 'token-1',
expiresAt: DateTime.utc(2026, 8, 16, 12),
);
}
}
ExerciseUseCases _exerciseUseCase(
_FakeExerciseRepository repository, {
_FakeProgramRepository? programRepository,

View File

@ -115,7 +115,14 @@ void main() {
final class _FakeBootstrap implements AppDependencies {
_FakeBootstrap(_FakeActiveSessionRepository activeRepository)
: activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
: authUseCases = AuthUseCases(
tokenStore: _FakeAuthTokenStore(),
accountRepository: _FakeOnlineAccountRepository(),
remoteAuthApi: _FakeRemoteAuthApi(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
),
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
@ -170,6 +177,9 @@ final class _FakeBootstrap implements AppDependencies {
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
);
@override
final AuthUseCases authUseCases;
@override
final ExerciseUseCases exerciseUseCases;
@ -249,6 +259,50 @@ final class _FakeIds implements IdGenerator {
}
}
final class _FakeAuthTokenStore implements AuthTokenStore {
@override
Future<void> clearToken() async {}
@override
Future<String?> readToken() async => null;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {}
}
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
@override
Future<void> clearSession() async {}
@override
Future<UserAccountSession?> currentSession() async => null;
@override
Future<void> saveSession(UserAccountSession session) async {}
}
final class _FakeRemoteAuthApi implements RemoteAuthApi {
@override
Future<RemoteAuthResult> login({
required String email,
required String password,
}) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}
@override
Future<void> logout(String token) async {}
@override
Future<RemoteAuthResult> register({
required String email,
required String password,
String? displayName,
}) async {
throw const RemoteAuthException(RemoteAuthFailure.network);
}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final restStates = <ActiveRestState>[];