implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user