feat: content downloader with sha256 verify and atomic cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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<String, dynamic> 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<File>()
|
||||
.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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user