import 'package:flutter_test/flutter_test.dart'; import 'package:lunchpick/data/datasources/remote/naver_search_service.dart'; import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart'; import 'package:lunchpick/data/api/naver_api_client.dart'; import 'package:lunchpick/data/api/naver/naver_local_search_api.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/core/errors/network_exceptions.dart'; // Mock 클래스들 class MockNaverApiClient extends NaverApiClient { final Map _mockResponses = {}; final Map _mockExceptions = {}; // Mock 설정 메서드들 void setSearchResponse({ required String query, double? latitude, double? longitude, required List results, }) { final key = _generateKey(query, latitude, longitude); _mockResponses[key] = results; } void setSearchException({ required String query, double? latitude, double? longitude, required Exception exception, }) { final key = _generateKey(query, latitude, longitude); _mockExceptions[key] = exception; } String _generateKey(String query, double? latitude, double? longitude) { return '$query-$latitude-$longitude'; } // 호출 추적 final List> callHistory = []; bool disposeCalled = false; @override Future> searchLocal({ required String query, double? latitude, double? longitude, int display = 20, int start = 1, String sort = 'random', }) async { // 호출 기록 callHistory.add({ 'query': query, 'latitude': latitude, 'longitude': longitude, 'display': display, 'sort': sort, }); final key = _generateKey(query, latitude, longitude); if (_mockExceptions.containsKey(key)) { throw _mockExceptions[key]!; } if (_mockResponses.containsKey(key)) { return _mockResponses[key] as List; } return []; } @override void dispose() { disposeCalled = true; } } class MockNaverMapParser extends NaverMapParser { final Map _mockResponses = {}; final Map _mockExceptions = {}; void setParseResponse(String url, Restaurant restaurant) { _mockResponses[url] = restaurant; } void setParseException(String url, Exception exception) { _mockExceptions[url] = exception; } // 호출 추적 bool disposeCalled = false; final List parseCallHistory = []; @override Future parseRestaurantFromUrl( String url, { double? userLatitude, double? userLongitude, }) async { parseCallHistory.add(url); if (_mockExceptions.containsKey(url)) { throw _mockExceptions[url]!; } if (_mockResponses.containsKey(url)) { return _mockResponses[url]!; } throw NaverMapParseException('No mock response set for URL: $url'); } @override void dispose() { disposeCalled = true; } } void main() { late NaverSearchService service; late MockNaverApiClient mockApiClient; late MockNaverMapParser mockMapParser; setUp(() { mockApiClient = MockNaverApiClient(); mockMapParser = MockNaverMapParser(); service = NaverSearchService( apiClient: mockApiClient, mapParser: mockMapParser, ); }); group('NaverSearchService', () { group('getRestaurantFromUrl', () { const testUrl = 'https://map.naver.com/p/restaurant/1234567890'; final testRestaurant = Restaurant( id: '1', name: 'Test Restaurant', category: '한식', subCategory: '백반', roadAddress: '서울시 강남구 테헤란로 123', jibunAddress: '서울시 강남구 역삼동 123-45', latitude: 37.123456, longitude: 127.123456, source: DataSource.NAVER, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); test('URL에서 식당 정보를 성공적으로 가져온다', () async { // Arrange mockMapParser.setParseResponse(testUrl, testRestaurant); // Act final result = await service.getRestaurantFromUrl(testUrl); // Assert expect(result, equals(testRestaurant)); expect(mockMapParser.parseCallHistory, contains(testUrl)); }); test('NaverMapParseException을 그대로 전파한다', () async { // Arrange final exception = NaverMapParseException('파싱 실패'); mockMapParser.setParseException(testUrl, exception); // Act & Assert expect( () async => await service.getRestaurantFromUrl(testUrl), throwsA(isA()), ); }); test('NetworkException을 그대로 전파한다', () async { // Arrange const exception = ServerException(message: '네트워크 오류', statusCode: 500); mockMapParser.setParseException(testUrl, exception); // Act & Assert expect( () async => await service.getRestaurantFromUrl(testUrl), throwsA(isA()), ); }); test('일반 예외를 NetworkException으로 래핑한다', () async { // Arrange final exception = Exception('알 수 없는 오류'); mockMapParser.setParseException(testUrl, exception); // Act & Assert expect( () async => await service.getRestaurantFromUrl(testUrl), throwsA( allOf( isA(), predicate( (e) => e.message.contains('식당 정보를 가져올 수 없습니다') && e.originalError.toString() == exception.toString(), ), ), ), ); }); }); group('searchNearbyRestaurants', () { const testQuery = '김치찌개'; const testLatitude = 37.123456; const testLongitude = 127.123456; final testSearchResults = [ NaverLocalSearchResult.fromJson({ 'title': '김치찌개 맛집', 'category': '한식>찌개', 'description': '맛있는 김치찌개', 'telephone': '02-1234-5678', 'address': '서울시 강남구 역삼동', 'roadAddress': '서울시 강남구 테헤란로 123', 'mapx': 127123456, 'mapy': 37123456, 'link': 'https://map.naver.com/p/restaurant/1234567890', }), ]; test('검색 결과를 Restaurant 리스트로 변환한다', () async { // Arrange mockApiClient.setSearchResponse( query: testQuery, latitude: testLatitude, longitude: testLongitude, results: testSearchResults, ); // Act final results = await service.searchNearbyRestaurants( query: testQuery, latitude: testLatitude, longitude: testLongitude, ); // Assert expect(results.length, equals(1)); expect(results.first.name, equals('김치찌개 맛집')); expect(results.first.category, equals('한식')); expect(results.first.subCategory, equals('찌개')); // API 호출 확인 expect(mockApiClient.callHistory.length, equals(1)); expect(mockApiClient.callHistory.first['query'], equals(testQuery)); expect(mockApiClient.callHistory.first['sort'], equals('random')); }); test('빈 검색 결과를 처리한다', () async { // Arrange mockApiClient.setSearchResponse( query: testQuery, latitude: null, longitude: null, results: [], ); // Act final results = await service.searchNearbyRestaurants(query: testQuery); // Assert expect(results, isEmpty); }); test('NetworkException을 그대로 전파한다', () async { // Arrange const exception = ServerException(message: '네트워크 오류', statusCode: 500); mockApiClient.setSearchException( query: testQuery, latitude: null, longitude: null, exception: exception, ); // Act & Assert expect( () async => await service.searchNearbyRestaurants(query: testQuery), throwsA(isA()), ); }); test('일반 예외를 NetworkException으로 래핑한다', () async { // Arrange final exception = Exception('알 수 없는 오류'); mockApiClient.setSearchException( query: testQuery, latitude: null, longitude: null, exception: exception, ); // Act & Assert expect( () async => await service.searchNearbyRestaurants(query: testQuery), throwsA( allOf( isA(), predicate( (e) => e.message.contains('식당 검색에 실패했습니다') && e.originalError.toString() == exception.toString(), ), ), ), ); }); }); group('searchRestaurantDetails', () { const testName = '김치찌개 맛집'; const testAddress = '서울시 강남구 역삼동'; const testLatitude = 37.123456; const testLongitude = 127.123456; final testSearchResults = [ NaverLocalSearchResult.fromJson({ 'title': testName, 'category': '한식>찌개', 'description': '맛있는 김치찌개', 'telephone': '02-1234-5678', 'address': testAddress, 'roadAddress': '서울시 강남구 테헤란로 123', 'mapx': 127123456, 'mapy': 37123456, 'link': 'https://map.naver.com/p/restaurant/1234567890', }), ]; test('정확히 일치하는 식당을 찾는다', () async { // Arrange mockApiClient.setSearchResponse( query: '서울시 강남구 $testName', latitude: testLatitude, longitude: testLongitude, results: testSearchResults, ); // Act final result = await service.searchRestaurantDetails( name: testName, address: testAddress, latitude: testLatitude, longitude: testLongitude, ); // Assert expect(result, isNotNull); expect(result!.name, equals(testName)); }); test('검색 결과가 없으면 null을 반환한다', () async { // Arrange mockApiClient.setSearchResponse( query: testName, latitude: null, longitude: null, results: [], ); // Act final result = await service.searchRestaurantDetails(name: testName); // Assert expect(result, isNull); }); test('유사도가 낮은 결과는 null을 반환한다', () async { // Arrange final unmatchedResults = [ NaverLocalSearchResult.fromJson({ 'title': '완전히 다른 식당', 'category': '양식', 'description': '파스타', 'telephone': '02-9999-9999', 'address': '서울시 종로구', 'roadAddress': '서울시 종로구 세종대로 99', 'mapx': 127999999, 'mapy': 37999999, 'link': 'https://map.naver.com/p/restaurant/9999999999', }), ]; mockApiClient.setSearchResponse( query: testName, latitude: null, longitude: null, results: unmatchedResults, ); // Act final result = await service.searchRestaurantDetails(name: testName); // Assert expect(result, isNull); }); test('네이버 지도 URL이 있으면 상세 정보를 파싱한다', () async { // Arrange final detailedRestaurant = Restaurant( id: '2', name: testName, category: '한식', subCategory: '찌개', description: '업데이트된 설명', businessHours: '09:00 - 21:00', naverPlaceId: '1234567890', roadAddress: '서울시 강남구 테헤란로 123', jibunAddress: '서울시 강남구 역삼동 123-45', latitude: testLatitude, longitude: testLongitude, source: DataSource.NAVER, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); mockApiClient.setSearchResponse( query: testName, latitude: null, longitude: null, results: testSearchResults, ); mockMapParser.setParseResponse( 'https://map.naver.com/p/restaurant/1234567890', detailedRestaurant, ); // Act final result = await service.searchRestaurantDetails(name: testName); // Assert expect(result, isNotNull); expect(result!.description, equals('업데이트된 설명')); expect(result.businessHours, equals('09:00 - 21:00')); expect(result.naverPlaceId, equals('1234567890')); }); test('상세 파싱 실패해도 기본 정보를 반환한다', () async { // Arrange mockApiClient.setSearchResponse( query: testName, latitude: null, longitude: null, results: testSearchResults, ); mockMapParser.setParseException( 'https://map.naver.com/p/restaurant/1234567890', Exception('파싱 실패'), ); // Act final result = await service.searchRestaurantDetails(name: testName); // Assert expect(result, isNotNull); expect(result!.name, equals(testName)); }); }); group('_findBestMatch', () { test('정확히 일치하는 결과를 우선 반환한다', () { // Arrange const targetName = '김치찌개 맛집'; final results = [ NaverLocalSearchResult.fromJson({ 'title': '다른 식당', 'category': '한식', 'description': '', 'telephone': '', 'address': '', 'roadAddress': '', 'mapx': 0, 'mapy': 0, 'link': '', }), NaverLocalSearchResult.fromJson({ 'title': targetName, 'category': '한식', 'description': '', 'telephone': '', 'address': '', 'roadAddress': '', 'mapx': 0, 'mapy': 0, 'link': '', }), ]; // Act final result = service.findBestMatchForTesting(targetName, results); // Assert expect(result?.title, equals(targetName)); }); test('빈 리스트에서는 null을 반환한다', () { // Act final result = service.findBestMatchForTesting('test', []); // Assert expect(result, isNull); }); }); group('_calculateSimilarity', () { test('동일한 문자열은 1.0을 반환한다', () { // Act final similarity = service.calculateSimilarityForTesting('테스트', '테스트'); // Assert expect(similarity, greaterThan(0.7)); }); test('포함 관계가 있으면 0.8을 반환한다', () { // Act final similarity = service.calculateSimilarityForTesting( '김치찌개', '김치찌개 맛집', ); // Assert expect(similarity, equals(0.8)); }); test('완전히 다른 문자열은 낮은 유사도를 반환한다', () { // Act final similarity = service.calculateSimilarityForTesting('한식', '양식'); // Assert expect(similarity, lessThan(0.5)); }); test('빈 문자열은 0.0을 반환한다', () { // Act final similarity = service.calculateSimilarityForTesting('', 'test'); // Assert expect(similarity, equals(0.0)); }); test('특수문자를 제거하고 비교한다', () { // Act final similarity = service.calculateSimilarityForTesting( '김치찌개@#\$', '김치찌개!!!', ); // Assert // 특수문자가 제거된 후 동일한 문자열이므로 0.8 이상이어야 함 expect(similarity, greaterThanOrEqualTo(0.8)); }); }); group('dispose', () { test('dispose가 정상적으로 호출된다', () { // Act service.dispose(); // Assert expect(mockApiClient.disposeCalled, isTrue); expect(mockMapParser.disposeCalled, isTrue); }); }); }); }