import 'package:gametime_server/application/application.dart'; import 'package:gametime_server/domain/domain.dart'; import 'package:test/test.dart'; void main() { late _FakeUserRepository users; late _FakeAuthSessionRepository sessions; late _FakePasswordHasher passwordHasher; late _FakeTokenService tokens; late _FakeClock clock; late _FakeIds ids; setUp(() { users = _FakeUserRepository(); sessions = _FakeAuthSessionRepository(); passwordHasher = _FakePasswordHasher(); tokens = _FakeTokenService(); clock = _FakeClock(DateTime.utc(2026, 7, 19, 12)); ids = _FakeIds(); }); test('register creates a user with a hashed password', () async { final useCase = RegisterUserUseCase( users: users, passwordHasher: passwordHasher, clock: clock, ids: ids, ); final user = await useCase.execute( email: ' USER@Example.COM ', password: 'password123', displayName: ' Anthony ', ); expect(user.id, 'id-1'); expect(user.email, 'user@example.com'); expect(user.passwordHash, 'hashed:password123'); expect(user.displayName, 'Anthony'); expect(users.byId[user.id], user); }); test('register rejects an already used email', () async { users.add( UserAccount( id: 'existing-user', email: 'user@example.com', passwordHash: 'hash', createdAt: clock.now(), updatedAt: clock.now(), ), ); final useCase = RegisterUserUseCase( users: users, passwordHasher: passwordHasher, clock: clock, ids: ids, ); await expectLater( useCase.execute(email: 'USER@example.com', password: 'password123'), throwsA(isA()), ); }); test('login returns a clear token once and stores only its hash', () async { users.add( UserAccount( id: 'user-1', email: 'user@example.com', passwordHash: 'hashed:password123', createdAt: clock.now(), updatedAt: clock.now(), ), ); tokens.nextToken = 'clear-token'; final useCase = LoginUseCase( users: users, sessions: sessions, passwordHasher: passwordHasher, tokens: tokens, clock: clock, ids: ids, ); final result = await useCase.execute( email: 'user@example.com', password: 'password123', userAgent: 'test-agent', deviceLabel: 'phone', ); expect(result.token, 'clear-token'); expect(result.expiresAt, DateTime.utc(2026, 8, 18, 12)); expect(sessions.byTokenHash.keys.single, 'token-hash:clear-token'); expect(sessions.byTokenHash.values.single.tokenHash, isNot('clear-token')); }); test('login rejects a bad password', () async { users.add( UserAccount( id: 'user-1', email: 'user@example.com', passwordHash: 'hashed:password123', createdAt: clock.now(), updatedAt: clock.now(), ), ); final useCase = LoginUseCase( users: users, sessions: sessions, passwordHasher: passwordHasher, tokens: tokens, clock: clock, ids: ids, ); await expectLater( useCase.execute(email: 'user@example.com', password: 'wrong-password'), throwsA(isA()), ); }); test('authenticate returns the user for a valid token', () async { final user = _user(clock); users.add(user); sessions.add( AuthSession( id: 'session-1', userId: user.id, tokenHash: 'token-hash:valid-token', issuedAt: clock.now(), expiresAt: clock.now().add(const Duration(days: 1)), ), ); final useCase = _authenticateUseCase(users, sessions, tokens, clock); final authenticated = await useCase.execute(token: 'valid-token'); expect(authenticated.user.id, user.id); expect(authenticated.session.id, 'session-1'); }); test('authenticate rejects expired, revoked and unknown tokens', () async { final user = _user(clock); users.add(user); sessions ..add( AuthSession( id: 'expired', userId: user.id, tokenHash: 'token-hash:expired-token', issuedAt: clock.now().subtract(const Duration(days: 2)), expiresAt: clock.now().subtract(const Duration(days: 1)), ), ) ..add( AuthSession( id: 'revoked', userId: user.id, tokenHash: 'token-hash:revoked-token', issuedAt: clock.now(), expiresAt: clock.now().add(const Duration(days: 1)), revokedAt: clock.now(), ), ); final useCase = _authenticateUseCase(users, sessions, tokens, clock); await expectLater( useCase.execute(token: 'expired-token'), throwsA(isA()), ); await expectLater( useCase.execute(token: 'revoked-token'), throwsA(isA()), ); await expectLater( useCase.execute(token: 'unknown-token'), throwsA(isA()), ); }); test('logout revokes an active token', () async { final user = _user(clock); users.add(user); sessions.add( AuthSession( id: 'session-1', userId: user.id, tokenHash: 'token-hash:valid-token', issuedAt: clock.now(), expiresAt: clock.now().add(const Duration(days: 1)), ), ); final useCase = LogoutUseCase( sessions: sessions, tokens: tokens, clock: clock, ); await useCase.execute(token: 'valid-token'); expect(sessions.revokedSessionIds, ['session-1']); }); } AuthenticateRequestUseCase _authenticateUseCase( _FakeUserRepository users, _FakeAuthSessionRepository sessions, _FakeTokenService tokens, _FakeClock clock, ) { return AuthenticateRequestUseCase( users: users, sessions: sessions, tokens: tokens, clock: clock, ); } UserAccount _user(_FakeClock clock) { return UserAccount( id: 'user-1', email: 'user@example.com', passwordHash: 'hashed:password123', createdAt: clock.now(), updatedAt: clock.now(), ); } final class _FakeUserRepository implements UserRepository { final byId = {}; final byEmail = {}; void add(UserAccount user) { byId[user.id] = user; byEmail[user.email] = user; } @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 => add(user); @override Future updatePasswordHash({ required String userId, required String passwordHash, required DateTime updatedAt, }) async { final user = byId[userId]; if (user == null) { return; } add( UserAccount( id: user.id, email: user.email, passwordHash: passwordHash, displayName: user.displayName, createdAt: user.createdAt, updatedAt: updatedAt, disabledAt: user.disabledAt, ), ); } } final class _FakeAuthSessionRepository implements AuthSessionRepository { final byTokenHash = {}; final revokedSessionIds = []; void add(AuthSession session) { byTokenHash[session.tokenHash] = session; } @override Future findByTokenHash(String tokenHash) async { return byTokenHash[tokenHash]; } @override Future insert(AuthSession session) async => add(session); @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 { String nextToken = 'token'; @override String generateToken() => nextToken; @override String hashToken(String token) => 'token-hash:$token'; } final class _FakeClock implements Clock { _FakeClock(this.value); DateTime value; @override DateTime now() => value; } final class _FakeIds implements IdGenerator { var _next = 0; @override String newId() { _next += 1; return 'id-$_next'; } }