From 7b542ba23c3b85ca75f83342ea64dbc57d563249 Mon Sep 17 00:00:00 2001 From: aashish-g03 Date: Wed, 16 Oct 2024 01:42:49 +0530 Subject: [PATCH] feat: add request caching service (#280) feat: add tests for request caching service (#280) fix: fix box import and tests for request caching service (#280) --- .../services/request_cache_service.dart | 107 +++++ .../services/request_cache_service_test.dart | 364 ++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 lib/shared/services/request_cache_service.dart create mode 100644 test/shared/services/request_cache_service_test.dart diff --git a/lib/shared/services/request_cache_service.dart b/lib/shared/services/request_cache_service.dart new file mode 100644 index 00000000..8d9e03e5 --- /dev/null +++ b/lib/shared/services/request_cache_service.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:collection/collection.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart' as http; + +class RequestCacheService { + final T Function(Map) fromJson; + final Map Function(T) toJson; + final Duration cacheDuration; + final String networkCacheKey; + final http.Client httpClient; + final bool checkForUpdates; + final Box cacheBox; + + RequestCacheService({ + required this.fromJson, + required this.toJson, + required this.cacheBox, + this.cacheDuration = const Duration(minutes: 1), + this.networkCacheKey = 'network_cache', + http.Client? httpClient, + this.checkForUpdates = false, + }) : httpClient = httpClient ?? http.Client(); + + /// Fetches data from the cache and API. + Stream fetchData(String url) async* { + final cacheKey = url; + bool cacheEmitted = false; + + try { + // Check for cached data + final cachedEntry = await cacheBox.get(cacheKey); + if (cachedEntry != null && cachedEntry is Map) { + final cachedMap = Map.from(cachedEntry); + final cachedTimestamp = + DateTime.parse(cachedMap['timestamp'] as String); + final cachedDataJson = + Map.from(cachedMap['data'] ?? {}); + final cachedData = fromJson(cachedDataJson); + // Emit cached data + yield cachedData; + cacheEmitted = true; + + final now = DateTime.now(); + // Decide whether to fetch new data based on cache validity + if (now.difference(cachedTimestamp) < cacheDuration && + !checkForUpdates) { + // Cache is still valid, but we'll fetch new data to check for updates + return; + } + } + + // Fetch data from the network + try { + final response = await httpClient.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final dataMap = jsonDecode(response.body) as Map; + final data = fromJson(dataMap); + + // Compare with cached data + bool isDataUpdated = true; + if (cachedEntry != null && cachedEntry is Map) { + final cachedDataJson = + Map.from(cachedEntry['data'] ?? {}); + // Use DeepCollectionEquality for deep comparison + const equalityChecker = DeepCollectionEquality(); + if (equalityChecker.equals(cachedDataJson, dataMap)) { + isDataUpdated = false; + } + } + + // Cache the new data with a timestamp + final cacheEntry = { + 'data': toJson(data), + 'timestamp': DateTime.now().toIso8601String(), + }; + await cacheBox.put(cacheKey, cacheEntry); + + if (isDataUpdated) { + yield data; + } + } else { + throw Exception( + 'Failed to load data from $url. Status code: ${response.statusCode}', + ); + } + } catch (e) { + if (!cacheEmitted) { + // No cached data was emitted before, so we need to throw an error + throw Exception('Error fetching data from $url: $e'); + } + // Else, we have already emitted cached data, so we can silently fail or log the error + } + } catch (e) { + if (!cacheEmitted) { + // No cached data was emitted before, so we need to throw an error + throw Exception('Error fetching data from $url: $e'); + } + // Else, we have already emitted cached data, so we can silently fail or log the error + log('Error fetching data from $url: $e'); + } + } +} diff --git a/test/shared/services/request_cache_service_test.dart b/test/shared/services/request_cache_service_test.dart new file mode 100644 index 00000000..b517b0ff --- /dev/null +++ b/test/shared/services/request_cache_service_test.dart @@ -0,0 +1,364 @@ +import 'dart:convert'; + +import 'package:didpay/shared/services/request_cache_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; + +class TestData { + final int id; + final String name; + + TestData({required this.id, required this.name}); + + factory TestData.fromJson(Map json) { + return TestData( + id: json['id'] as int, + name: json['name'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + }; +} + +// Mock class for Hive's Box +class MockBox extends Mock implements Box {} + +void main() { + // Register fallback values for any arguments that are needed + setUpAll(() { + registerFallbackValue({}); + registerFallbackValue(null); + }); + + late MockBox mockBox; + late RequestCacheService service; + late http.Client mockClient; + + setUp(() { + mockBox = MockBox(); + + // Default behavior for mockClient + mockClient = MockClient((request) async { + return http.Response('Not Found', 404); + }); + }); + + group('RequestCacheService Tests', () { + test('fetchData emits data from network when no cache exists', () async { + // Arrange + const testUrl = 'https://example.com/data'; + final mockResponseData = {'id': 1, 'name': 'Test Item'}; + + // Mock box.get returns null (no cached data) + when(() => mockBox.get(testUrl)).thenReturn(null); + // Mock box.put to do nothing + when(() => mockBox.put(testUrl, any())).thenAnswer((_) async {}); + + // Mock HTTP client returns mockResponseData + mockClient = MockClient((request) async { + if (request.url.toString() == testUrl) { + return http.Response(jsonEncode(mockResponseData), 200); + } + return http.Response('Not Found', 404); + }); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + ); + + // Act + final dataStream = service.fetchData(testUrl); + + // Assert + await expectLater( + dataStream, + emits( + predicate((data) => + data.id == mockResponseData['id'] && + data.name == mockResponseData['name']), + ), + ); + + // Verify that box.put was called to cache the data + verify(() => mockBox.put(testUrl, any())).called(1); + }); + + test( + 'fetchData emits cached data when cache is valid and data is unchanged', + () async { + // Arrange + const testUrl = 'https://example.com/data'; + final mockCachedData = {'id': 1, 'name': 'Cached Item'}; + final now = DateTime.now(); + + // Mock box.get returns cached data + when(() => mockBox.get(testUrl)).thenReturn({ + 'data': mockCachedData, + 'timestamp': now.toIso8601String(), + }); + + // Mock box.put to do nothing + when(() => mockBox.put(testUrl, any())).thenAnswer((_) async {}); + + // Mock HTTP client returns the same data as cached + mockClient = MockClient((request) async { + return http.Response(jsonEncode(mockCachedData), 200); + }); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + checkForUpdates: false, // Do not check for updates + ); + + // Act + final dataStream = service.fetchData(testUrl); + + // Assert + await expectLater( + dataStream, + emits( + predicate((data) => + data.id == mockCachedData['id'] && + data.name == mockCachedData['name']), + ), + ); + + // Verify that box.put was not called + verifyNever(() => mockBox.put(any(), any())); + }); + + test( + 'fetchData emits new data when network data is different from cached data', + () async { + // Arrange + const testUrl = 'https://example.com/data'; + final mockCachedData = {'id': 1, 'name': 'Old Item'}; + final mockResponseData = {'id': 1, 'name': 'Updated Item'}; + final now = DateTime.now(); + + // Mock box.get returns cached data + when(() => mockBox.get(testUrl)).thenReturn({ + 'data': mockCachedData, + 'timestamp': now.toIso8601String(), + }); + + // Mock box.put to do nothing + when(() => mockBox.put(testUrl, any())).thenAnswer((_) async {}); + + // Mock HTTP client returns updated data + mockClient = MockClient((request) async { + return http.Response(jsonEncode(mockResponseData), 200); + }); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + checkForUpdates: true, // Check for updates + ); + + // Act + final dataStream = service.fetchData(testUrl); + + // Assert + await expectLater( + dataStream, + emitsInOrder([ + predicate((data) => + data.id == mockCachedData['id'] && + data.name == mockCachedData['name']), + predicate((data) => + data.id == mockResponseData['id'] && + data.name == mockResponseData['name']), + ]), + ); + + // Verify that box.put was called to update the cache + verify(() => mockBox.put(testUrl, any())).called(1); + }); + + test('fetchData throws error when no cache exists and network fails', + () async { + // Arrange + const testUrl = 'https://example.com/data'; + + // Mock box.get returns null (no cached data) + when(() => mockBox.get(testUrl)).thenReturn(null); + + // Mock HTTP client returns an error + mockClient = MockClient((request) async { + return http.Response('Server Error', 500); + }); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + ); + + // Act & Assert + await expectLater( + service.fetchData(testUrl), + emitsError(isA()), + ); + }); + + test('fetchData emits cached data when network fails', () async { + // Arrange + const testUrl = 'https://example.com/data'; + final mockCachedData = {'id': 1, 'name': 'Cached Item'}; + final now = DateTime.now(); + + // Mock box.get returns cached data + when(() => mockBox.get(testUrl)).thenReturn({ + 'data': mockCachedData, + 'timestamp': now.toIso8601String(), + }); + + // Mock HTTP client returns an error + mockClient = MockClient((request) async { + return http.Response('Server Error', 500); + }); + + // Mock box.put to do nothing + when(() => mockBox.put(testUrl, any())).thenAnswer((_) async {}); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + checkForUpdates: true, // Check for updates + ); + + // Act + final dataStream = service.fetchData(testUrl); + + // Assert + await expectLater( + dataStream, + emits( + predicate((data) => + data.id == mockCachedData['id'] && + data.name == mockCachedData['name']), + ), + ); + + // Verify that box.put was not called since the network failed + verifyNever(() => mockBox.put(any(), any())); + }); + + test('fetchData fetches new data when cache is expired', () async { + // Arrange + const testUrl = 'https://example.com/data'; + final mockCachedData = {'id': 1, 'name': 'Old Item'}; + final mockResponseData = {'id': 1, 'name': 'New Item'}; + final expiredTime = DateTime.now().subtract(Duration(minutes: 10)); + + // Mock box.get returns expired cached data + when(() => mockBox.get(testUrl)).thenReturn({ + 'data': mockCachedData, + 'timestamp': expiredTime.toIso8601String(), + }); + + // Mock box.put to do nothing + when(() => mockBox.put(testUrl, any())).thenAnswer((_) async {}); + + // Mock HTTP client returns new data + mockClient = MockClient((request) async { + return http.Response(jsonEncode(mockResponseData), 200); + }); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + cacheDuration: Duration(minutes: 1), // Short cache duration + checkForUpdates: true, // Check for updates + ); + + // Act + final dataStream = service.fetchData(testUrl); + + // Assert + await expectLater( + dataStream, + emitsInOrder([ + predicate((data) => + data.id == mockCachedData['id'] && + data.name == mockCachedData['name']), + predicate((data) => + data.id == mockResponseData['id'] && + data.name == mockResponseData['name']), + ]), + ); + + // Verify that box.put was called to update the cache + verify(() => mockBox.put(testUrl, any())).called(1); + }); + + test('fetchData skips network call when checkForUpdates is false', + () async { + // Arrange + const testUrl = 'https://example.com/data'; + final mockCachedData = {'id': 1, 'name': 'Cached Item'}; + final now = DateTime.now(); + + // Mock box.get returns cached data + when(() => mockBox.get(testUrl)).thenReturn({ + 'data': mockCachedData, + 'timestamp': now.toIso8601String(), + }); + + // Mock HTTP client returns an error to ensure it's not called + mockClient = MockClient((request) async { + // If this is called, the test should fail + return http.Response('Should not be called', 500); + }); + + // Mock box.put to do nothing + when(() => mockBox.put(testUrl, any())).thenAnswer((_) async {}); + + service = RequestCacheService( + fromJson: TestData.fromJson, + toJson: (data) => data.toJson(), + cacheBox: mockBox, + httpClient: mockClient, + checkForUpdates: false, // Do not check for updates + ); + + // Act + final dataStream = service.fetchData(testUrl); + + // Assert + await expectLater( + dataStream, + emits( + predicate((data) => + data.id == mockCachedData['id'] && + data.name == mockCachedData['name']), + ), + ); + + // Verify that HTTP client was not called + // Since we're using MockClient from http/testing.dart, we cannot use verify on it + // But since the test passes without errors, we can infer that the client was not called + // Alternatively, we could switch to using a mock client that supports verification + }); + }); +}