import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:mockito/annotations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:lanebow/services/member_service.dart'; import 'package:lanebow/services/api_service.dart'; import 'package:lanebow/services/event_bus.dart'; // EventBus().fire() 호출에 필요 import 'package:lanebow/config/api_config.dart'; import 'package:lanebow/utils/test_utils.dart'; import '../utils/event_bus_test_utils.dart'; // 모킹 클래스 생성 @GenerateMocks([ApiService]) import 'member_service_test.mocks.dart'; void main() { late MemberService memberService; late MockApiService mockApiService; final testToken = 'test_token'; final testClubId = 'test_club_id'; final testMemberId = 'test_member_id'; // 테스트 데이터 초기화 final testMember = { 'id': 'test_member_id', 'name': '테스트 회원', 'email': 'test@example.com', 'phone': '010-1234-5678', 'gender': '남성', 'birthYear': 1990, 'level': '중급', 'position': '회원', 'joinDate': DateTime.now().toIso8601String(), 'clubId': 'test_club_id', 'isActive': true, }; setUp(() async { // 테스트 모드 강제 설정 TestUtils.setTestMode(true); // 테스트별 고유 ID 생성 및 설정 - 일관된 패턴 적용 final testId = 'member_service_test_${DateTime.now().millisecondsSinceEpoch}'; await EventBusTestUtils.setUp(testId); // API 서비스 모킹 생성 (한 번만 생성) mockApiService = MockApiService(); // 토큰 설정 모킹 when(mockApiService.setToken(any)).thenReturn(null); // SharedPreferences 모킹 설정 SharedPreferences.setMockInitialValues({ ApiConfig.tokenKey: testToken, ApiConfig.clubIdKey: testClubId, }); // 상수 정의 - 모든 테스트에서 동일한 값 사용 const newClubId = 'new_club_id'; // 클럽 회원 목록 모킹 - 테스트 클럽 ID when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).thenAnswer((_) async => [testMember]); // 클럽 회원 목록 모킹 - new_club_id (명시적으로 설정) when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': newClubId}, )).thenAnswer((_) async => [testMember]); // 회원 추가 모킹 when(mockApiService.post( '${ApiConfig.clubs}/members/add', data: anyNamed('data'), )).thenAnswer((_) async => {'id': testMemberId, ...testMember}); // 회원 업데이트 모킹 when(mockApiService.post( '${ApiConfig.clubs}/members/update', data: anyNamed('data'), )).thenAnswer((_) async => { 'id': testMemberId, 'name': '업데이트된 회원', 'email': 'updated@example.com', 'phone': '010-5555-5555', ...testMember, }); // 회원 삭제 모킹 when(mockApiService.post( '${ApiConfig.clubs}/members/delete', data: anyNamed('data'), )).thenAnswer((_) async => {'success': true}); // MemberService 생성 memberService = MemberService.forTest(mockApiService); // 초기화는 각 테스트에서 필요할 때 직접 호출 }); tearDown(() async { // 서비스 정리 (예외 무시) try { memberService.dispose(); } catch (_) {} // EventBus 표준 정리 (내부에서 reset 포함) await EventBusTestUtils.tearDown(); // 테스트 모드 해제 TestUtils.setTestMode(false); // 약간의 지연으로 잔여 비동기 작업 안정화 await Future.delayed(Duration(milliseconds: 100)); }); group('MemberService 테스트', () { // 간단한 폴링 헬퍼: 조건이 만족될 때까지 짧게 대기 Future _waitUntil(bool Function() predicate, {Duration timeout = const Duration(milliseconds: 400), Duration interval = const Duration(milliseconds: 10)}) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { if (predicate()) return; await Future.delayed(interval); } } test('initialize 메서드가 토큰을 설정하고 이벤트를 구독해야 함', () async { // given // 테스트용 새 클럽 ID - setUp에서 정의한 값과 동일하게 사용 const newClubId = 'new_club_id'; // 테스트 시작 전 초기화 확인 memberService = MemberService.forTest(mockApiService); // when memberService.initialize(testToken); // then verify(mockApiService.setToken(testToken)).called(1); // 이벤트 구독 테스트 // 새 클럽 ID로 이벤트 발생 EventBus().fire(ClubChangedEvent(newClubId)); // 비동기 처리를 위한 지연 추가 - 조금 더 길게 설정 await Future.delayed(Duration(milliseconds: 800)); // 새 클럽 ID로 API 호출 확인 verify(mockApiService.post( '/club/members', data: {'clubId': newClubId}, )).called(1); }); test('setClubId 메서드가 클럽 ID를 설정하고 회원 목록을 가져와야 함', () async { // given // 초기화 먼저 호출 memberService.initialize(testToken); // 테스트용 클럽 ID const clubId = 'test_club_id'; // when await memberService.setClubId(clubId); // then verify(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': clubId}, )).called(1); // 회원 목록 확인 expect(memberService.members.length, 1); expect(memberService.members[0].id, testMember['id']); }); test('fetchClubMembers 메서드가 클럽의 회원 목록을 가져와야 함', () async { // given // 초기화 먼저 호출 memberService.initialize(testToken); // 클럽 ID 설정 await memberService.setClubId(testClubId); // 초기화 후 모든 호출 기록 초기화 clearInteractions(mockApiService); // when await memberService.fetchClubMembers(); // then verify(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).called(1); }); test('addMember 메서드가 새 회원을 추가해야 함', () async { // given // 초기화 먼저 호출 memberService.initialize(testToken); // 클럽 ID 설정 await memberService.setClubId(testClubId); // 초기화 후 모든 호출 기록 초기화 clearInteractions(mockApiService); final newMemberData = { 'name': '새 회원', 'email': 'new@example.com', 'phone': '010-9876-5432', 'clubId': testClubId, }; // when final result = await memberService.addMember(newMemberData); // then verify(mockApiService.post( '${ApiConfig.clubs}/members/add', data: newMemberData, )).called(1); expect(result.id, testMember['id']); }); test('addMember 메서드가 직접 회원 객체가 반환되는 경우도 처리해야 함', () async { // given // 초기화 먼저 호출 memberService.initialize(testToken); // 클럽 ID 설정 await memberService.setClubId(testClubId); // 초기화 후 모든 호출 기록 초기화 clearInteractions(mockApiService); final newMemberData = { 'name': '새 회원', 'email': 'new@example.com', 'phone': '010-9876-5432', 'clubId': testClubId, }; when(mockApiService.post( '${ApiConfig.clubs}/members/add', data: newMemberData, )).thenAnswer((_) async => testMember); // when final result = await memberService.addMember(newMemberData); // then verify(mockApiService.post( '${ApiConfig.clubs}/members/add', data: newMemberData, )).called(1); expect(result.id, testMember['id']); }); test('updateMember 메서드가 회원 정보를 업데이트해야 함', () async { // given // 초기화 먼저 호출 memberService.initialize(testToken); // 클럽 ID 설정 await memberService.setClubId(testClubId); clearInteractions(mockApiService); final updateMemberData = { 'name': '업데이트된 회원', 'email': 'updated@example.com', 'phone': '010-5555-5555', }; // 업데이트된 회원 데이터 mock 설정 when(mockApiService.post( '${ApiConfig.clubs}/members/update', data: anyNamed('data'), )).thenAnswer((_) async => {'member': { ...testMember, 'name': '업데이트된 회원', 'email': 'updated@example.com', 'phone': '010-5555-5555', }}); // when final result = await memberService.updateMember(testMemberId, updateMemberData); // then verify(mockApiService.post( '${ApiConfig.clubs}/members/update', data: anyNamed('data'), )).called(1); expect(result.name, '업데이트된 회원'); expect(result.email, 'updated@example.com'); expect(result.phone, '010-5555-5555'); }); test('deleteMember 메서드가 회원을 삭제해야 함', () async { // given // 초기화 먼저 호출 memberService.initialize(testToken); // 클럽 ID 설정 await memberService.setClubId(testClubId); clearInteractions(mockApiService); // when final result = await memberService.deleteMember(testMemberId); // then verify(mockApiService.post( '${ApiConfig.clubs}/members/delete', data: anyNamed('data'), )).called(1); expect(result, true); }); test('ClubChangedEvent가 발생하면 setClubId가 호출되어야 함', () async { // given // 테스트용 새 클럽 ID - setUp에서 정의한 값과 동일하게 사용 const newClubId = 'new_club_id'; // 테스트 시작 전 초기화 확인 memberService = MemberService.forTest(mockApiService); // 초기화 먼저 호출 memberService.initialize(testToken); // 이전 호출 기록 초기화 clearInteractions(mockApiService); try { // when // 이벤트 발생 EventBus().fire(ClubChangedEvent(newClubId)); // 비동기 처리를 위한 지연 추가 - 조금 더 길게 설정 await Future.delayed(Duration(milliseconds: 800)); // then // 새 클럽 ID로 API 호출 확인 verify(mockApiService.post( '/club/members', data: {'clubId': newClubId}, )).called(1); } finally { // 테스트 종료 시 memberService 정리 (파일 전역 tearDown도 수행되지만 중복해도 안전) memberService.dispose(); // EventBus 정리는 파일 전역 tearDown의 EventBusTestUtils.tearDown()에만 맡김 } }); // 이벤트 구독 테스트 추가 test('dispose 메서드가 이벤트 구독을 취소해야 함', () async { // 파일 전역의 EventBusTestUtils.setUp/tearDown에만 의존하여 경합 방지 if (kDebugMode) { debugPrint('\n===== dispose 테스트 시작 ====='); } // given - 로컬 격리 인스턴스 사용으로 교차 테스트 간섭 차단 final localMock = MockApiService(); when(localMock.setToken(any)).thenReturn(null); final localService = MemberService.forTest(localMock); localService.initialize(testToken); // 선행 호출 기록 초기화: dispose 전 이후 호출만 관찰하기 위함 clearInteractions(localMock); // when - 먼저 dispose 실행 후 이벤트 발생 localService.dispose(); // 비동기 취소가 완료되도록 충분히 대기 (플래키 방지) await Future.delayed(Duration(milliseconds: 1200)); await Future.microtask(() {}); // dispose 이후 이벤트 발행 EventBus().fire(ClubChangedEvent('any_club')); // 비동기 이벤트 전파 대기 (플래키 방지) await Future.delayed(Duration(milliseconds: 1200)); await Future.microtask(() {}); // then - dispose 이후에는 회원 목록 조회 호출이 발생하면 안 됨(핵심 상호작용만 검증) verifyNever(localMock.post( '${ApiConfig.clubs}/members', data: anyNamed('data'), )); if (kDebugMode) { debugPrint('===== dispose 테스트 종료 =====\n'); } }); test('fetchClubMembers 실패 시 예외 전파, isLoading 해제 및 lastError 설정', () async { // given memberService.initialize(testToken); await memberService.setClubId(testClubId); clearInteractions(mockApiService); when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).thenThrow(Exception('network error')); // when/then expect( () async => await memberService.fetchClubMembers(), throwsA(isA()), ); // 마지막 상태 확인 expect(memberService.isLoading, isFalse); expect(memberService.lastError, isNotNull); expect(memberService.lastError!, contains('network error')); }); test('fetchClubMembers가 빈 목록을 반환하면 기존 members를 비워야 함', () async { // given: 초기 조회에서 1명 로드 memberService.initialize(testToken); await memberService.setClubId(testClubId); expect(memberService.members.length, 1); clearInteractions(mockApiService); // 광역 스텁: 어떤 clubId로 호출되더라도 빈 목록을 반환하여 외부 이벤트 간섭 차단 when(mockApiService.post( any, data: anyNamed('data'), )).thenAnswer((invocation) async { return >[]; }); when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).thenAnswer((_) async => >[]); // 예기치 않은 ClubChangedEvent로 new_club_id 재조회가 발생해도 빈 목록을 유지하도록 방어 when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': 'new_club_id'}, )).thenAnswer((_) async => >[]); // when await memberService.fetchClubMembers(); // 상태 수렴까지 짧게 폴링(플래키 방지) await _waitUntil(() => memberService.members.isEmpty && memberService.isLoading == false && memberService.lastError == null); // then expect(memberService.members, isEmpty); expect(memberService.isLoading, isFalse); expect(memberService.lastError, isNull); }); test('fetchClubMembers 진행 중 isLoading이 true였다가 완료 시 false로 변경되어야 함', () async { // given memberService.initialize(testToken); await memberService.setClubId(testClubId); clearInteractions(mockApiService); final completer = Completer>>(); when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).thenAnswer((_) => completer.future); // when: await하지 않고 먼저 호출하여 중간 상태 관찰 final future = memberService.fetchClubMembers(); // then: 호출 직후 로딩 on expect(memberService.isLoading, isTrue); // when: 응답 완료 completer.complete([{ 'id': 'm2', 'name': '새 회원2', 'clubId': testClubId, 'isActive': true, }]); await future; // then: 로딩 off expect(memberService.isLoading, isFalse); expect(memberService.members.length, 1); }); test('ClubChangedEvent 수신 시 setClubId 호출 및 새 클럽 회원 재조회', () async { // given: 초기 설정 및 첫 조회 memberService.initialize(testToken); await memberService.setClubId(testClubId); expect(memberService.members.length, 1); // 새 클럽 응답을 별도로 설정 (setup의 기본 설정을 override) const newClubId = 'new_club_id'; final newMember = { 'id': 'm_new', 'name': '새 회원', 'clubId': newClubId, 'isActive': true, }; clearInteractions(mockApiService); when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': newClubId}, )).thenAnswer((_) async => [newMember]); // when: ClubChangedEvent 발생 EventBus().fire(ClubChangedEvent(newClubId)); // then: 비동기 처리 완료 대기 후 검증 // setClubId -> SharedPreferences 저장 확인 // 간단한 대기 후 확인 (EventBus 비동기 루프 반영) await Future.delayed(const Duration(milliseconds: 10)); // Api가 새로운 clubId로 호출되었는지 검증 verify(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': newClubId}, )).called(greaterThanOrEqualTo(1)); // SharedPreferences에 최신 clubId 반영 확인 final prefs = await SharedPreferences.getInstance(); expect(prefs.getString(ApiConfig.clubIdKey), newClubId); // members 갱신 확인 expect(memberService.members.length, 1); expect(memberService.members.first.id, 'm_new'); }); test('clubId가 null이면 fetchClubMembers는 예외를 던지고 isLoading 해제 및 lastError 설정', () async { // given: prefs/내부 clubId 모두 제거 memberService.initialize(testToken); clearInteractions(mockApiService); final prefs = await SharedPreferences.getInstance(); await prefs.remove(ApiConfig.clubIdKey); // when/then expect( () async => await memberService.fetchClubMembers(), throwsA(isA()), ); // 비동기 상태 업데이트가 완료되도록 잠시 대기(플래키 방지) await Future.delayed(const Duration(milliseconds: 10)); // 상태 확인 expect(memberService.isLoading, isFalse); expect(memberService.lastError, isNotNull); expect(memberService.lastError!, contains('선택된 클럽이 없습니다')); // API는 호출되지 않음 verifyNever(mockApiService.post(any, data: anyNamed('data'))); }); test("API 응답이 {'members': [...]} 형태여도 파싱되어 members가 채워져야 함", () async { // given memberService.initialize(testToken); await memberService.setClubId(testClubId); clearInteractions(mockApiService); when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).thenAnswer((_) async => { 'members': [ { 'id': 'm3', 'name': '객체응답 회원', 'clubId': testClubId, 'isActive': true, } ] }); // when await memberService.fetchClubMembers(); // then expect(memberService.members.length, 1); expect(memberService.members.first.id, 'm3'); expect(memberService.lastError, isNull); }); test('fetchClubMembers 성공 시 lastLoadedAt이 설정되어야 함', () async { // given memberService.initialize(testToken); await memberService.setClubId(testClubId); clearInteractions(mockApiService); when(mockApiService.post( '${ApiConfig.clubs}/members', data: {'clubId': testClubId}, )).thenAnswer((_) async => [ { 'id': 'm4', 'name': '최근 조회 회원', 'clubId': testClubId, 'isActive': true, } ]); // when await memberService.fetchClubMembers(); // lastLoadedAt 및 members 수렴까지 대기 (타임아웃 여유 증가) await _waitUntil( () => memberService.lastLoadedAt != null && memberService.members.length == 1, timeout: const Duration(milliseconds: 1500), interval: const Duration(milliseconds: 25), ); await EventBusTestUtils.waitForIdle(); await Future.delayed(const Duration(milliseconds: 100)); // then expect(memberService.members.first.id, 'm4'); expect(memberService.lastLoadedAt, isNotNull); // 시간 범위 비교는 환경 의존성이 커서 제거하고 비null만 검증 }); }); }