import 'package:consumer_app/core/networking/api_client.dart';
import 'package:consumer_app/core/networking/public_read_cache.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test('force refresh revalidates a cached public response', () async {
final store = _MemoryPublicReadCacheStore();
var requests = 0;
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(store: store),
client: MockClient((_) async {
requests++;
return http.Response(
'{"config":{"colors":{"secondary_color":"#20242e"}}}',
200,
headers: {'etag': 'W/"bootstrap-2"'},
);
}),
);
await api.getPublicJson('/bootstrap');
final refreshed = await api.getPublicJson('/bootstrap', forceRefresh: true);
expect(refreshed['config'], isA<Map<String, dynamic>>());
expect(requests, 2);
});
test('fresh public responses are reused without a second request', () async {
final store = _MemoryPublicReadCacheStore();
var requests = 0;
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(store: store),
client: MockClient((_) async {
requests++;
return http.Response(
'{"items":[],"meta":{"total":0}}',
200,
headers: {'etag': 'W/"public-1"'},
);
}),
);
await api.getPublicJson('/discover', queryParameters: {'types': 'places'});
final second = await api.getPublicJson(
'/discover',
queryParameters: {'types': 'places'},
);
expect(second['meta'], isA<Map<String, dynamic>>());
expect(requests, 1);
expect(store.values, hasLength(1));
});
test('identical concurrent public reads share one network request', () async {
final store = _MemoryPublicReadCacheStore();
var requests = 0;
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(store: store),
client: MockClient((_) async {
requests++;
await Future<void>.delayed(const Duration(milliseconds: 10));
return http.Response('{"items":[]}', 200);
}),
);
final results = await Future.wait([
api.getPublicJson('/places/453'),
api.getPublicJson('/places/453'),
]);
expect(results, hasLength(2));
expect(requests, 1);
});
test(
'stale public responses revalidate with validators and accept 304',
() async {
final store = _MemoryPublicReadCacheStore();
var now = DateTime.utc(2026, 9, 21, 12);
final requests = <http.BaseRequest>[];
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(
store: store,
clock: () => now,
freshFor: const Duration(minutes: 1),
staleFor: const Duration(hours: 1),
),
client: MockClient((request) async {
requests.add(request);
if (requests.length == 1) {
return http.Response(
'{"items":[{"id":453}],"meta":{"total":1}}',
200,
headers: {
'etag': 'W/"public-1"',
'last-modified': 'Mon, 21 Sep 2026 11:59:00 GMT',
},
);
}
return http.Response('', 304);
}),
);
final first = await api.getPublicJson('/places/453');
now = now.add(const Duration(minutes: 2));
final second = await api.getPublicJson('/places/453');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(first['items'], hasLength(1));
expect(second['items'], hasLength(1));
expect(requests, hasLength(2));
expect(requests[1].headers['if-none-match'], 'W/"public-1"');
expect(
requests[1].headers['if-modified-since'],
'Mon, 21 Sep 2026 11:59:00 GMT',
);
},
);
test(
'stale public content remains available during a network outage',
() async {
final store = _MemoryPublicReadCacheStore();
var now = DateTime.utc(2026, 9, 21, 12);
var requests = 0;
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(
store: store,
clock: () => now,
freshFor: const Duration(minutes: 1),
staleFor: const Duration(hours: 1),
),
client: MockClient((_) async {
requests++;
if (requests == 1) {
return http.Response('{"items":[{"id":453}]}', 200);
}
throw http.ClientException('offline');
}),
);
await api.getPublicJson('/places/453');
now = now.add(const Duration(minutes: 2));
final fallback = await api.getPublicJson('/places/453');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(fallback['items'], hasLength(1));
expect(requests, 2);
},
);
test('private routes cannot enter the public cache', () async {
final store = _MemoryPublicReadCacheStore();
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(store: store),
client: MockClient((_) async => http.Response('{}', 200)),
);
expect(() => api.getPublicJson('/me'), throwsA(isA<ArgumentError>()));
expect(store.values, isEmpty);
});
test('public cache can be explicitly invalidated and cleared', () async {
final store = _MemoryPublicReadCacheStore();
final api = ApiClient(
baseUrl: Uri.parse('https://example.test/wp-json/meinweimar/v1'),
locale: () => 'de-DE',
publicReadCache: PublicReadCache(store: store),
client: MockClient((_) async => http.Response('{"items":[]}', 200)),
);
await api.getPublicJson('/places/453');
expect(store.values, hasLength(1));
await api.invalidatePublicJson('/places/453');
expect(store.values, isEmpty);
await api.getPublicJson('/places/453');
await api.clearPublicReadCache();
expect(store.values, isEmpty);
});
test('cache entry count is bounded', () async {
final store = _MemoryPublicReadCacheStore();
final cache = PublicReadCache(
store: store,
maxEntries: 1,
clock: () => DateTime.utc(2026, 9, 21, 12),
);
final firstKey = PublicReadCache.keyFor(
Uri.parse('https://example.test/places/1'),
'de-DE',
);
final secondKey = PublicReadCache.keyFor(
Uri.parse('https://example.test/places/2'),
'de-DE',
);
await cache.write(
firstKey,
PublicReadCacheEntry(body: '{"id":1}', storedAt: cache.now),
);
await cache.write(
secondKey,
PublicReadCacheEntry(
body: '{"id":2}',
storedAt: cache.now.add(const Duration(seconds: 1)),
),
);
expect(store.values, hasLength(1));
expect(await cache.read(firstKey), isNull);
expect(await cache.read(secondKey), isNotNull);
});
}
class _MemoryPublicReadCacheStore implements PublicReadCacheStore {
final Map<String, String> values = {};
@override
Future<String?> read(String key) async => values[key];
@override
Future<void> write(String key, String value) async => values[key] = value;
@override
Future<void> remove(String key) async => values.remove(key);
@override
Future<Set<String>> keys() async => values.keys.toSet();
}
