Files
deflock-app/test/services/provider_tile_cache_store_test.dart
Doug Borg 91e5177056 Detect config drift in cached tile providers and replace stale instances
When a user edits a tile type's URL template, max zoom, or API key
without changing IDs, the cached DeflockTileProvider would keep the old
frozen config. Now _getOrCreateProvider() computes a config fingerprint
and replaces the provider when drift is detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:34:01 -07:00

518 lines
17 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:deflockapp/services/provider_tile_cache_store.dart';
import 'package:deflockapp/services/provider_tile_cache_manager.dart';
import 'package:deflockapp/services/service_policy.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
await ProviderTileCacheManager.resetAll();
});
group('ProviderTileCacheStore', () {
late ProviderTileCacheStore store;
setUp(() {
store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
);
});
test('isSupported is true', () {
expect(store.isSupported, isTrue);
});
test('getTile returns null for uncached URL', () async {
final result = await store.getTile('https://tile.example.com/1/2/3.png');
expect(result, isNull);
});
test('putTile and getTile round-trip', () async {
const url = 'https://tile.example.com/1/2/3.png';
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
final staleAt = DateTime.utc(2026, 3, 1);
final metadata = CachedMapTileMetadata(
staleAt: staleAt,
lastModified: DateTime.utc(2026, 2, 20),
etag: '"abc123"',
);
await store.putTile(url: url, metadata: metadata, bytes: bytes);
final cached = await store.getTile(url);
expect(cached, isNotNull);
expect(cached!.bytes, equals(bytes));
expect(
cached.metadata.staleAt.millisecondsSinceEpoch,
equals(staleAt.millisecondsSinceEpoch),
);
expect(cached.metadata.etag, equals('"abc123"'));
expect(cached.metadata.lastModified, isNotNull);
});
test('putTile without bytes updates metadata only', () async {
const url = 'https://tile.example.com/1/2/3.png';
final bytes = Uint8List.fromList([1, 2, 3]);
final metadata1 = CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 3, 1),
lastModified: null,
etag: '"v1"',
);
// Write with bytes first
await store.putTile(url: url, metadata: metadata1, bytes: bytes);
// Update metadata only
final metadata2 = CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 4, 1),
lastModified: null,
etag: '"v2"',
);
await store.putTile(url: url, metadata: metadata2);
final cached = await store.getTile(url);
expect(cached, isNotNull);
expect(cached!.bytes, equals(bytes)); // bytes unchanged
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
});
test('handles null lastModified and etag', () async {
const url = 'https://tile.example.com/simple.png';
final bytes = Uint8List.fromList([10, 20, 30]);
final metadata = CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 3, 1),
lastModified: null,
etag: null,
);
await store.putTile(url: url, metadata: metadata, bytes: bytes);
final cached = await store.getTile(url);
expect(cached, isNotNull);
expect(cached!.metadata.lastModified, isNull);
expect(cached.metadata.etag, isNull);
});
test('creates cache directory lazily on first putTile', () async {
final subDir = p.join(tempDir.path, 'lazy', 'nested');
final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir);
// Directory should not exist yet
expect(await Directory(subDir).exists(), isFalse);
await lazyStore.putTile(
url: 'https://example.com/tile.png',
metadata: CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 3, 1),
lastModified: null,
etag: null,
),
bytes: Uint8List.fromList([1]),
);
// Directory should now exist
expect(await Directory(subDir).exists(), isTrue);
});
test('clear deletes all cached tiles', () async {
// Write some tiles
for (var i = 0; i < 5; i++) {
await store.putTile(
url: 'https://example.com/$i.png',
metadata: CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 3, 1),
lastModified: null,
etag: null,
),
bytes: Uint8List.fromList([i]),
);
}
// Verify tiles exist
expect(await store.getTile('https://example.com/0.png'), isNotNull);
// Clear
await store.clear();
// Directory should be gone
expect(await Directory(tempDir.path).exists(), isFalse);
// getTile should return null (directory gone)
expect(await store.getTile('https://example.com/0.png'), isNull);
});
});
group('ProviderTileCacheStore TTL override', () {
test('overrideFreshAge bumps staleAt forward', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
overrideFreshAge: const Duration(days: 7),
);
const url = 'https://tile.example.com/osm.png';
// Server says stale in 1 hour, but policy requires 7 days
final serverMetadata = CachedMapTileMetadata(
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
lastModified: null,
etag: null,
);
await store.putTile(
url: url,
metadata: serverMetadata,
bytes: Uint8List.fromList([1, 2, 3]),
);
final cached = await store.getTile(url);
expect(cached, isNotNull);
// staleAt should be ~7 days from now, not 1 hour
final expectedMin = DateTime.timestamp().add(const Duration(days: 6));
expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue);
});
test('without overrideFreshAge, server staleAt is preserved', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
// No overrideFreshAge
);
const url = 'https://tile.example.com/bing.png';
final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0);
final serverMetadata = CachedMapTileMetadata(
staleAt: serverStaleAt,
lastModified: null,
etag: null,
);
await store.putTile(
url: url,
metadata: serverMetadata,
bytes: Uint8List.fromList([1, 2, 3]),
);
final cached = await store.getTile(url);
expect(cached, isNotNull);
expect(
cached!.metadata.staleAt.millisecondsSinceEpoch,
equals(serverStaleAt.millisecondsSinceEpoch),
);
});
});
group('ProviderTileCacheStore isolation', () {
test('separate directories do not interfere', () async {
final dirA = p.join(tempDir.path, 'provider_a', 'type_1');
final dirB = p.join(tempDir.path, 'provider_b', 'type_1');
final storeA = ProviderTileCacheStore(cacheDirectory: dirA);
final storeB = ProviderTileCacheStore(cacheDirectory: dirB);
const url = 'https://tile.example.com/shared-url.png';
final metadata = CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 3, 1),
lastModified: null,
etag: null,
);
await storeA.putTile(
url: url,
metadata: metadata,
bytes: Uint8List.fromList([1, 1, 1]),
);
await storeB.putTile(
url: url,
metadata: metadata,
bytes: Uint8List.fromList([2, 2, 2]),
);
final cachedA = await storeA.getTile(url);
final cachedB = await storeB.getTile(url);
expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1])));
expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2])));
});
});
group('ProviderTileCacheManager', () {
test('getOrCreate returns same instance for same key', () {
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final storeA = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
final storeB = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
expect(identical(storeA, storeB), isTrue);
});
test('getOrCreate returns different instances for different keys', () {
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final storeA = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
final storeB = ProviderTileCacheManager.getOrCreate(
providerId: 'bing',
tileTypeId: 'satellite',
policy: const ServicePolicy(),
);
expect(identical(storeA, storeB), isFalse);
});
test('passes overrideFreshAge from policy.minCacheTtl', () {
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final store = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy.osmTileServer(),
);
expect(store.overrideFreshAge, equals(const Duration(days: 7)));
});
test('custom maxCacheBytes is applied', () {
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final store = ProviderTileCacheManager.getOrCreate(
providerId: 'big',
tileTypeId: 'tiles',
policy: const ServicePolicy(),
maxCacheBytes: 1024 * 1024 * 1024, // 1 GB
);
expect(store.maxCacheBytes, equals(1024 * 1024 * 1024));
});
test('resetAll clears all stores from registry', () async {
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final storeBefore = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
ProviderTileCacheManager.getOrCreate(
providerId: 'bing',
tileTypeId: 'satellite',
policy: const ServicePolicy(),
);
await ProviderTileCacheManager.resetAll();
// After reset, must set base dir again before creating stores
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final storeAfter = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
// New instance should be created (not the old cached one)
expect(identical(storeBefore, storeAfter), isFalse);
});
test('unregister removes store from registry', () {
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
final store1 = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
ProviderTileCacheManager.unregister('osm', 'street');
// Should create a new instance after unregistering
final store2 = ProviderTileCacheManager.getOrCreate(
providerId: 'osm',
tileTypeId: 'street',
policy: const ServicePolicy(),
);
expect(identical(store1, store2), isFalse);
});
});
group('ProviderTileCacheStore eviction', () {
/// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes.
/// Sets deterministic modification times (1 second apart) so eviction
/// ordering is stable across platforms without relying on wall-clock delays.
Future<void> fillCache(
ProviderTileCacheStore store, {
required int count,
required int bytesPerTile,
String prefix = '',
}) async {
final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42));
final metadata = CachedMapTileMetadata(
staleAt: DateTime.utc(2026, 3, 1),
lastModified: null,
etag: null,
);
final baseTime = DateTime.utc(2026, 1, 1);
for (var i = 0; i < count; i++) {
await store.putTile(
url: 'https://tile.example.com/$prefix$i.png',
metadata: metadata,
bytes: bytes,
);
// Set deterministic mtime so eviction order is stable across platforms.
final key = ProviderTileCacheStore.keyFor(
'https://tile.example.com/$prefix$i.png',
);
final tileFile = File(p.join(store.cacheDirectory, '$key.tile'));
final metaFile = File(p.join(store.cacheDirectory, '$key.meta'));
final mtime = baseTime.add(Duration(seconds: i));
await tileFile.setLastModified(mtime);
await metaFile.setLastModified(mtime);
}
}
test('eviction reduces cache when exceeding maxCacheBytes', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
maxCacheBytes: 500,
);
// Write tiles that exceed the limit
await fillCache(store, count: 10, bytesPerTile: 100);
// Explicitly trigger eviction (bypasses throttle)
await store.forceEviction();
final sizeAfter = await store.estimatedSizeBytes;
expect(sizeAfter, lessThanOrEqualTo(500),
reason: 'Eviction should reduce cache to at or below limit');
});
test('eviction targets 80% of maxCacheBytes', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
maxCacheBytes: 1000,
);
await fillCache(store, count: 10, bytesPerTile: 200);
await store.forceEviction();
final sizeAfter = await store.estimatedSizeBytes;
// Target is 80% of 1000 = 800 bytes
expect(sizeAfter, lessThanOrEqualTo(800),
reason: 'Eviction should target 80% of maxCacheBytes');
});
test('oldest-modified tiles are evicted first', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
maxCacheBytes: 500,
);
// Write old tiles first (these should be evicted)
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_');
// Write newer tiles (these should survive)
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_');
await store.forceEviction();
// Newest tile should still be present
final newestTile = await store.getTile('https://tile.example.com/new_4.png');
expect(newestTile, isNotNull,
reason: 'Newest tiles should survive eviction');
// Oldest tile should have been evicted
final oldestTile = await store.getTile('https://tile.example.com/old_0.png');
expect(oldestTile, isNull,
reason: 'Oldest tiles should be evicted first');
});
test('orphan .meta files are cleaned up during eviction', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
maxCacheBytes: 500,
);
// Write a tile to create the directory
await fillCache(store, count: 1, bytesPerTile: 50);
// Manually create an orphan .meta file (no matching .tile)
final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta'));
await orphanMetaFile.writeAsString('{"staleAt":0}');
expect(await orphanMetaFile.exists(), isTrue);
// Write enough tiles to exceed the limit, then force eviction
await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_');
await store.forceEviction();
// The orphan .meta file should have been cleaned up
expect(await orphanMetaFile.exists(), isFalse,
reason: 'Orphan .meta file should be cleaned up during eviction');
});
test('evicted tiles have their .meta files removed too', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
maxCacheBytes: 300,
);
await fillCache(store, count: 10, bytesPerTile: 100);
await store.forceEviction();
// After eviction, count remaining .tile and .meta files
final dir = Directory(tempDir.path);
final files = await dir.list().toList();
final tileFiles = files
.whereType<File>()
.where((f) => f.path.endsWith('.tile'))
.length;
final metaFiles = files
.whereType<File>()
.where((f) => f.path.endsWith('.meta'))
.length;
// Every remaining .tile should have a matching .meta (1:1)
expect(metaFiles, equals(tileFiles),
reason: '.meta count should match .tile count after eviction');
});
test('no eviction when cache is under limit', () async {
final store = ProviderTileCacheStore(
cacheDirectory: tempDir.path,
maxCacheBytes: 100000, // 100KB — way more than we'll write
);
await fillCache(store, count: 3, bytesPerTile: 50);
final sizeBefore = await store.estimatedSizeBytes;
await store.forceEviction();
final sizeAfter = await store.estimatedSizeBytes;
expect(sizeAfter, equals(sizeBefore),
reason: 'No eviction needed when under limit');
});
});
}