Files
GameTime/server/test/auth_api_test.dart
Blomios c1dce3cae7 feat(server): tests API, contrats OpenAPI et vérification d'intégration (ticket #53)
Ajoute la spécification openapi.yaml documentant les 12 endpoints
réels du serveur (health, auth, sync, shares), les tests API auth
(test/auth_api_test.dart couvrant register/login/logout) et la
checklist de vérification d'intégration (docs/integration-checklist.md).
dart analyze clean, dart test 29/29 vert. Dernier ticket du chantier
serveur (#47 à #53), tous terminés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:56:29 +02:00

290 lines
7.5 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('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('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']);
});
}
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';
}
}