Files
bowlingManager/mobile/test/services/member_service_test.dart
T

624 lines
20 KiB
Dart

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<void> _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<void>.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<Exception>()),
);
// 마지막 상태 확인
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 <Map<String, dynamic>>[];
});
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': testClubId},
)).thenAnswer((_) async => <Map<String, dynamic>>[]);
// 예기치 않은 ClubChangedEvent로 new_club_id 재조회가 발생해도 빈 목록을 유지하도록 방어
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': 'new_club_id'},
)).thenAnswer((_) async => <Map<String, dynamic>>[]);
// 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<List<Map<String, dynamic>>>();
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<void>.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<Exception>()),
);
// 비동기 상태 업데이트가 완료되도록 잠시 대기(플래키 방지)
await Future<void>.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<void>.delayed(const Duration(milliseconds: 100));
// then
expect(memberService.members.first.id, 'm4');
expect(memberService.lastLoadedAt, isNotNull);
// 시간 범위 비교는 환경 의존성이 커서 제거하고 비null만 검증
});
});
}