Files
GameTime/server/test/auth_api_test.dart
Blomios 65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00

340 lines
8.8 KiB
Dart

import 'dart:convert';
import 'package:gametime_server/api/auth_api.dart';
import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:shelf/shelf.dart';
import 'package:test/test.dart';
void main() {
test(
'register endpoint returns the created user without password data',
() async {
final users = _FakeUserRepository();
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/register'),
body: jsonEncode({
'email': ' USER@example.com ',
'password': 'password123',
'displayName': ' User ',
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 201);
expect(body, {'userId': 'id-1', 'email': 'user@example.com'});
expect(body.containsKey('password'), isFalse);
expect(body.containsKey('passwordHash'), isFalse);
},
);
test('register endpoint returns 409 when email is already used', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'existing-user',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/register'),
body: jsonEncode({
'email': 'user@example.com',
'password': 'password123',
}),
),
);
expect(response.statusCode, 409);
});
test('register endpoint returns 400 for malformed json payload', () async {
final handler = buildApiHandler(authApi: _authApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/register'),
body: jsonEncode(['not-an-object']),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Request body must be a JSON object.'},
);
});
test('login endpoint returns a token and expiration', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/login'),
body: jsonEncode({
'email': 'user@example.com',
'password': 'password123',
'deviceLabel': 'phone',
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 200);
expect(body['token'], 'clear-token');
expect(body['expiresAt'], '2026-08-18T12:00:00.000Z');
});
test('login endpoint returns 401 for invalid credentials', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final handler = buildApiHandler(authApi: _authApi(users: users));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/login'),
body: jsonEncode({
'email': 'user@example.com',
'password': 'wrong-password',
}),
),
);
expect(response.statusCode, 401);
});
test('login endpoint returns 400 when email is blank', () async {
final handler = buildApiHandler(authApi: _authApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/login'),
body: jsonEncode({'email': ' ', 'password': 'password123'}),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'email must be a non-empty string.'},
);
});
test('logout endpoint revokes the bearer session', () async {
final users = _FakeUserRepository();
await users.insert(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hashed:password123',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
),
);
final sessions = _FakeAuthSessionRepository();
await sessions.insert(
AuthSession(
id: 'session-1',
userId: 'user-1',
tokenHash: 'token-hash:clear-token',
issuedAt: DateTime.utc(2026, 7, 19, 12),
expiresAt: DateTime.utc(2026, 7, 20, 12),
),
);
final handler = buildApiHandler(
authApi: _authApi(users: users, sessions: sessions),
);
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/logout'),
headers: {'authorization': 'Bearer clear-token'},
),
);
expect(response.statusCode, 204);
expect(sessions.revokedSessionIds, ['session-1']);
});
test('logout endpoint returns 401 without bearer token', () async {
final handler = buildApiHandler(authApi: _authApi());
final response = await handler(
Request('POST', Uri.parse('http://localhost/auth/logout')),
);
expect(response.statusCode, 401);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Missing bearer token.'},
);
});
}
AuthApi _authApi({
_FakeUserRepository? users,
_FakeAuthSessionRepository? sessions,
}) {
final userRepository = users ?? _FakeUserRepository();
final sessionRepository = sessions ?? _FakeAuthSessionRepository();
final passwordHasher = _FakePasswordHasher();
final tokens = _FakeTokenService();
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
final ids = _FakeIds();
final authenticateRequest = AuthenticateRequestUseCase(
users: userRepository,
sessions: sessionRepository,
tokens: tokens,
clock: clock,
);
return AuthApi(
registerUser: RegisterUserUseCase(
users: userRepository,
passwordHasher: passwordHasher,
clock: clock,
ids: ids,
),
login: LoginUseCase(
users: userRepository,
sessions: sessionRepository,
passwordHasher: passwordHasher,
tokens: tokens,
clock: clock,
ids: ids,
),
logout: LogoutUseCase(
sessions: sessionRepository,
tokens: tokens,
clock: clock,
),
authenticateRequest: authenticateRequest,
);
}
final class _FakeUserRepository implements UserRepository {
final byId = <String, UserAccount>{};
final byEmail = <String, UserAccount>{};
@override
Future<UserAccount?> findByEmail(String email) async {
return byEmail[email.trim().toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async => byId[id];
@override
Future<void> insert(UserAccount user) async {
byId[user.id] = user;
byEmail[user.email] = user;
}
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {}
}
final class _FakeAuthSessionRepository implements AuthSessionRepository {
final byTokenHash = <String, AuthSession>{};
final revokedSessionIds = <String>[];
@override
Future<void> insert(AuthSession session) async {
byTokenHash[session.tokenHash] = session;
}
@override
Future<AuthSession?> findByTokenHash(String tokenHash) async {
return byTokenHash[tokenHash];
}
@override
Future<void> revoke({
required String sessionId,
required DateTime revokedAt,
}) async {
revokedSessionIds.add(sessionId);
}
}
final class _FakePasswordHasher implements PasswordHasher {
@override
Future<String> hash(String password) async => 'hashed:$password';
@override
Future<bool> verify({
required String password,
required String passwordHash,
}) async {
return passwordHash == 'hashed:$password';
}
}
final class _FakeTokenService implements OpaqueTokenService {
@override
String generateToken() => 'clear-token';
@override
String hashToken(String token) => 'token-hash:$token';
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}