Files
Client/test/push/push_avatar_test.dart
T

212 lines
6.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:marianum_mobile/push/push_avatar.dart';
final Uint8List _fakeBytes = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, //
]);
http.Response _imageResponse() => http.Response.bytes(
_fakeBytes,
200,
headers: {'content-type': 'image/png'},
);
/// Renders a solid 40×20 PNG via dart:ui — a real decodable source image.
Future<Uint8List> _generatePng() async {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawRect(
const ui.Rect.fromLTWH(0, 0, 40, 20),
ui.Paint()..color = const ui.Color(0xFF993333),
);
final image = await recorder.endRecording().toImage(40, 20);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
return data!.buffer.asUint8List();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('push_avatar_test');
});
tearDown(() async {
await tempDir.delete(recursive: true);
});
PushAvatarStore store({
required Future<http.Response> Function(String chatToken) fetch,
Duration timeout = const Duration(seconds: 4),
}) => PushAvatarStore(
cacheDirProvider: () async => tempDir,
fetch: fetch,
fetchTimeout: timeout,
);
group('maskAvatarCircular', () {
test('output is a square PNG sized to the shorter edge', () async {
final source = await _generatePng();
final masked = await maskAvatarCircular(source);
expect(masked, isNotNull);
// PNG magic bytes.
expect(masked!.sublist(0, 8), [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
]);
final codec = await ui.instantiateImageCodec(masked);
final frame = await codec.getNextFrame();
expect(frame.image.width, 20);
expect(frame.image.height, 20);
});
test('is deterministic for identical input', () async {
final source = await _generatePng();
final a = await maskAvatarCircular(source);
final b = await maskAvatarCircular(source);
expect(a, isNotNull);
expect(a, equals(b));
});
test('undecodable bytes yield null (caller falls back to raw)', () async {
expect(await maskAvatarCircular(_fakeBytes), isNull);
});
});
group('roomAvatarIcon', () {
test('fetches, caches, and serves from cache afterwards', () async {
var fetchCount = 0;
final s = store(
fetch: (_) async {
fetchCount++;
return _imageResponse();
},
);
final first = await s.roomAvatarIcon('iconToken1');
// Fake bytes are undecodable → mask falls back to the raw bytes.
expect(first.icon, _fakeBytes);
expect(first.late, isNull);
expect(fetchCount, 1);
final second = await s.roomAvatarIcon('iconToken1');
expect(second.icon, _fakeBytes);
expect(fetchCount, 1);
});
test('svg placeholder is rejected and not cached', () async {
final s = store(
fetch: (_) async => http.Response(
'<svg xmlns="http://www.w3.org/2000/svg"/>',
200,
headers: {'content-type': 'image/svg+xml'},
),
);
final lookup = await s.roomAvatarIcon('iconToken2');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
expect(File('${tempDir.path}/iconToken2').existsSync(), isFalse);
});
test('definitive fetch error yields no icon and no late future', () async {
final s = store(fetch: (_) async => throw const SocketException('down'));
final lookup = await s.roomAvatarIcon('iconToken3');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
});
test('fetch error falls back to a stale cache entry', () async {
final file = File('${tempDir.path}/iconToken4');
await file.writeAsBytes(_fakeBytes);
await file.setLastModified(
DateTime.now().subtract(const Duration(days: 20)),
);
final s = store(fetch: (_) async => throw const SocketException('down'));
final lookup = await s.roomAvatarIcon('iconToken4');
expect(lookup.icon, _fakeBytes);
});
test(
'timeout returns a late future that delivers exactly one result',
() async {
final completer = Completer<http.Response>();
var fetchCount = 0;
final s = store(
fetch: (_) {
fetchCount++;
return completer.future;
},
timeout: const Duration(milliseconds: 50),
);
final lookup = await s.roomAvatarIcon('iconToken5');
expect(lookup.icon, isNull);
expect(lookup.late, isNotNull);
completer.complete(_imageResponse());
final lateBytes = await lookup.late;
expect(lateBytes, _fakeBytes);
// The processed cache now serves directly — no second fetch.
final second = await s.roomAvatarIcon('iconToken5');
expect(second.icon, _fakeBytes);
expect(second.late, isNull);
expect(fetchCount, 1);
},
);
test('non-200 responses yield null', () async {
final s = store(fetch: (_) async => http.Response('gone', 404));
final lookup = await s.roomAvatarIcon('iconToken6');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
});
});
group('fileNameForToken / isFresh / looksLikeSvg', () {
test('url-safe tokens are used verbatim', () {
expect(PushAvatarStore.fileNameForToken('abc123_XY-z'), 'abc123_XY-z');
});
test('unsafe tokens are encoded deterministically and stay distinct', () {
final a = PushAvatarStore.fileNameForToken('a/b');
expect(a, PushAvatarStore.fileNameForToken('a/b'));
expect(a, isNot(PushAvatarStore.fileNameForToken('a.b')));
expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(a), isTrue);
});
test('isFresh boundary', () {
final now = DateTime(2026, 7, 5, 12);
expect(
PushAvatarStore.isFresh(now.subtract(const Duration(days: 13)), now),
isTrue,
);
expect(
PushAvatarStore.isFresh(
now.subtract(const Duration(days: 14, hours: 1)),
now,
),
isFalse,
);
});
test('svg detection', () {
expect(
PushAvatarStore.looksLikeSvg(
Uint8List.fromList(' <svg xmlns="..."/>'.codeUnits),
),
isTrue,
);
expect(PushAvatarStore.looksLikeSvg(_fakeBytes), isFalse);
});
});
}