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:
@ -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,
|
||||
|
||||
@ -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>[];
|
||||
|
||||
Reference in New Issue
Block a user