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 = {}; final byEmail = {}; @override Future findByEmail(String email) async { return byEmail[email.trim().toLowerCase()]; } @override Future findById(String id) async => byId[id]; @override Future insert(UserAccount user) async { byId[user.id] = user; byEmail[user.email] = user; } @override Future updatePasswordHash({ required String userId, required String passwordHash, required DateTime updatedAt, }) async {} } final class _FakeAuthSessionRepository implements AuthSessionRepository { final byTokenHash = {}; final revokedSessionIds = []; @override Future insert(AuthSession session) async { byTokenHash[session.tokenHash] = session; } @override Future findByTokenHash(String tokenHash) async { return byTokenHash[tokenHash]; } @override Future revoke({ required String sessionId, required DateTime revokedAt, }) async { revokedSessionIds.add(sessionId); } } final class _FakePasswordHasher implements PasswordHasher { @override Future hash(String password) async => 'hashed:$password'; @override Future 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'; } }