import 'dart:convert'; import 'dart:io'; import 'package:block_seasons/data/remote/content_downloader.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { late Directory tmp; setUp(() async { tmp = await Directory.systemTemp.createTemp('bs_cache_'); }); tearDown(() async { if (await tmp.exists()) await tmp.delete(recursive: true); }); String shaOf(String body) => sha256.convert(utf8.encode(body)).toString(); // Minimal pack body (repository parses later; downloader only verifies // bytes). const packBody = '{"schemaVersion":1,"seasonId":"season_002"}'; Map manifestJson({String? sha, int version = 1}) => { 'schemaVersion': 1, 'minAppBuild': 1, 'current': 'season_002', 'seasons': [ { 'seasonId': 'season_002', 'version': version, 'packUrl': 'seasons/season_002/pack.json', 'sha256': sha ?? shaOf(packBody), }, ], }; MockClient okClient({String? sha, int version = 1}) => MockClient((request) async { if (request.url.path.endsWith('manifest.json')) { return http.Response( jsonEncode(manifestJson(sha: sha, version: version)), 200, headers: {'content-type': 'application/json'}, ); } if (request.url.path.endsWith('pack.json')) { return http.Response(packBody, 200); } return http.Response('not found', 404); }); test('downloads, verifies, and caches a new season', () async { final downloader = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(), ); final updated = await downloader.sync(); expect(updated, isTrue); final cached = File('${tmp.path}/seasons/season_002/pack.json'); expect(await cached.readAsString(), packBody); expect(downloader.cachedVersion('season_002'), 1); }); test('second sync with same version is a no-op', () async { final downloader = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(), ); await downloader.sync(); expect(await downloader.sync(), isFalse); }); test('checksum mismatch rejects the pack and keeps cache clean', () async { final downloader = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(sha: 'deadbeef'), ); final updated = await downloader.sync(); expect(updated, isFalse); expect( File('${tmp.path}/seasons/season_002/pack.json').existsSync(), isFalse, ); // No stray temp files left behind. final leftovers = tmp .listSync(recursive: true) .whereType() .where((f) => f.path.endsWith('.tmp')); expect(leftovers, isEmpty); }); test('network failure leaves existing cache untouched', () async { final good = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(), ); await good.sync(); final offline = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: MockClient((_) async => throw const SocketException('off')), ); expect(await offline.sync(), isFalse); expect( File('${tmp.path}/seasons/season_002/pack.json').existsSync(), isTrue, ); }); test('version bump re-downloads', () async { await ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(version: 1), ).sync(); final updated = await ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(version: 2), ).sync(); expect(updated, isTrue); }); test('malicious seasonId never escapes the cache dir', () async { final outside = Directory('${tmp.path}/../bs_escape_probe'); addTearDown(() async { if (await outside.exists()) await outside.delete(recursive: true); }); final client = MockClient((request) async { if (request.url.path.endsWith('manifest.json')) { return http.Response( jsonEncode({ 'schemaVersion': 1, 'minAppBuild': 1, 'current': 'x', 'seasons': [ { 'seasonId': '../bs_escape_probe', 'version': 1, 'packUrl': 'seasons/x/pack.json', 'sha256': shaOf(packBody), }, ], }), 200, ); } return http.Response(packBody, 200); }); final downloader = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: client, ); expect(await downloader.sync(), isFalse); expect(await outside.exists(), isFalse); }); test('corrupt cache index is treated as empty and re-downloads', () async { await File('${tmp.path}/cache_index.json').create(recursive: true); await File('${tmp.path}/cache_index.json').writeAsString('{broken'); final downloader = ContentDownloader( baseUrl: 'https://example.com/content', cacheDir: tmp, client: okClient(), ); expect(await downloader.sync(), isTrue); expect(downloader.cachedVersion('season_002'), 1); }); }