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,178 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/push/chat_thread_store.dart';
|
||||
|
||||
ThreadMessage _msg(int nid, {String sender = 'Max', String text = 'hi'}) =>
|
||||
ThreadMessage(nid: nid, sender: sender, text: text, timestampMs: nid);
|
||||
|
||||
void main() {
|
||||
group('appendThreadMessage', () {
|
||||
test('appends in order', () {
|
||||
var messages = <ThreadMessage>[];
|
||||
messages = appendThreadMessage(messages, _msg(1));
|
||||
messages = appendThreadMessage(messages, _msg(2));
|
||||
expect(messages.map((m) => m.nid), [1, 2]);
|
||||
});
|
||||
|
||||
test('caps the history, dropping the oldest', () {
|
||||
var messages = <ThreadMessage>[];
|
||||
for (var nid = 1; nid <= kChatThreadCap + 3; nid++) {
|
||||
messages = appendThreadMessage(messages, _msg(nid));
|
||||
}
|
||||
expect(messages, hasLength(kChatThreadCap));
|
||||
expect(messages.first.nid, 4);
|
||||
expect(messages.last.nid, kChatThreadCap + 3);
|
||||
});
|
||||
|
||||
test('a redelivered nid replaces the old entry instead of duplicating', () {
|
||||
var messages = [_msg(1), _msg(2)];
|
||||
messages = appendThreadMessage(messages, _msg(1, text: 'edited'));
|
||||
expect(messages.map((m) => m.nid), [2, 1]);
|
||||
expect(messages.last.text, 'edited');
|
||||
});
|
||||
});
|
||||
|
||||
group('threadAfterIncoming (dismiss/read reset)', () {
|
||||
final existing = [_msg(1), _msg(2)];
|
||||
final incoming = _msg(3);
|
||||
|
||||
test('active notification → new message stacks onto history', () {
|
||||
final result = threadAfterIncoming(existing, incoming, true);
|
||||
expect(result.map((m) => m.nid), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('inactive notification → thread restarts with only the new one', () {
|
||||
final result = threadAfterIncoming(existing, incoming, false);
|
||||
expect(result.map((m) => m.nid), [3]);
|
||||
});
|
||||
|
||||
test('unknown active state (probe failed) → stacks defensively', () {
|
||||
final result = threadAfterIncoming(existing, incoming, null);
|
||||
expect(result.map((m) => m.nid), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('reset still honours the cap for a burst of messages', () {
|
||||
final result = threadAfterIncoming(existing, incoming, false, cap: 1);
|
||||
expect(result.map((m) => m.nid), [3]);
|
||||
});
|
||||
});
|
||||
|
||||
group('removeThreadNid', () {
|
||||
test('removes only the matching message', () {
|
||||
final remaining = removeThreadNid([_msg(1), _msg(2), _msg(3)], 2);
|
||||
expect(remaining.map((m) => m.nid), [1, 3]);
|
||||
});
|
||||
|
||||
test('deleting the last message empties the thread (caller cancels)', () {
|
||||
final remaining = removeThreadNid([_msg(7)], 7);
|
||||
expect(remaining, isEmpty);
|
||||
});
|
||||
|
||||
test('unknown nid leaves the thread untouched', () {
|
||||
final remaining = removeThreadNid([_msg(1)], 99);
|
||||
expect(remaining.map((m) => m.nid), [1]);
|
||||
});
|
||||
});
|
||||
|
||||
group('conversationHeader', () {
|
||||
test('1:1 chat (single sender, no room) gets no title', () {
|
||||
final header = conversationHeader([
|
||||
_msg(1, sender: 'Max'),
|
||||
_msg(2, sender: 'Max'),
|
||||
]);
|
||||
expect(header.conversationTitle, isNull);
|
||||
expect(header.groupConversation, isFalse);
|
||||
});
|
||||
|
||||
test('a known room name becomes the title exactly once', () {
|
||||
final header = conversationHeader([
|
||||
ThreadMessage(
|
||||
nid: 1,
|
||||
sender: 'Max',
|
||||
text: 'hi',
|
||||
timestampMs: 1,
|
||||
roomName: 'Projektraum',
|
||||
),
|
||||
_msg(2, sender: 'Max'),
|
||||
]);
|
||||
expect(header.conversationTitle, 'Projektraum');
|
||||
expect(header.groupConversation, isTrue);
|
||||
});
|
||||
|
||||
test('multiple senders without room name fall back to last sender', () {
|
||||
final header = conversationHeader([
|
||||
_msg(1, sender: 'Max'),
|
||||
_msg(2, sender: 'Anna'),
|
||||
]);
|
||||
expect(header.conversationTitle, 'Anna');
|
||||
expect(header.groupConversation, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('ThreadMessage roomName migration', () {
|
||||
test('entries without roomName field read as null', () {
|
||||
final restored = ThreadMessage.fromJson({
|
||||
'nid': 1,
|
||||
'sender': 'Max',
|
||||
'text': 'hi',
|
||||
'timestampMs': 5,
|
||||
});
|
||||
expect(restored.roomName, isNull);
|
||||
});
|
||||
|
||||
test('roomName round-trips when present', () {
|
||||
final restored = ThreadMessage.fromJson(
|
||||
const ThreadMessage(
|
||||
nid: 1,
|
||||
sender: 'Max',
|
||||
text: 'hi',
|
||||
timestampMs: 5,
|
||||
roomName: 'Raum',
|
||||
).toJson(),
|
||||
);
|
||||
expect(restored.roomName, 'Raum');
|
||||
});
|
||||
});
|
||||
|
||||
group('stable notification identity', () {
|
||||
test('same token always yields the same 31-bit id', () {
|
||||
final a = stableChatNotificationId('abc123');
|
||||
expect(a, stableChatNotificationId('abc123'));
|
||||
expect(a, greaterThanOrEqualTo(0));
|
||||
expect(a, lessThanOrEqualTo(0x7fffffff));
|
||||
});
|
||||
|
||||
test('different tokens yield different ids and tags', () {
|
||||
expect(
|
||||
stableChatNotificationId('abc123'),
|
||||
isNot(stableChatNotificationId('xyz789')),
|
||||
);
|
||||
expect(chatNotificationTag('abc123'), 'talk_abc123');
|
||||
});
|
||||
});
|
||||
|
||||
group('ThreadMessage json', () {
|
||||
test('round-trips', () {
|
||||
final restored = ThreadMessage.fromJson(
|
||||
ThreadMessage(
|
||||
nid: 5,
|
||||
sender: 'Max',
|
||||
text: 'Hallo',
|
||||
timestampMs: 1234,
|
||||
).toJson(),
|
||||
);
|
||||
expect(restored.nid, 5);
|
||||
expect(restored.sender, 'Max');
|
||||
expect(restored.text, 'Hallo');
|
||||
expect(restored.timestampMs, 1234);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatThreadStore.docIdForToken', () {
|
||||
test('url-safe tokens are used verbatim, others encoded', () {
|
||||
expect(ChatThreadStore.docIdForToken('abc_1-2'), 'abc_1-2');
|
||||
final encoded = ChatThreadStore.docIdForToken('a/b');
|
||||
expect(encoded, isNot(contains('/')));
|
||||
expect(encoded, ChatThreadStore.docIdForToken('a/b'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/push/push_registration.dart';
|
||||
import 'package:marianum_mobile/push/push_registration_store.dart';
|
||||
import 'package:marianum_mobile/push/push_registration_type.dart';
|
||||
import 'package:marianum_mobile/push/push_secure_storage.dart';
|
||||
|
||||
class _MemoryStorage implements FlutterSecureStorageLike {
|
||||
final Map<String, String> values = {};
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async => values[key];
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) async {
|
||||
if (value == null) {
|
||||
values.remove(key);
|
||||
} else {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async => values.remove(key);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('talk user agents', () {
|
||||
// Quoted from nextcloud/server lib/public/IRequest.php.
|
||||
final uaTalkAndroid = RegExp(
|
||||
r'^Mozilla/5\.0 \(Android\) Nextcloud-Talk v([^ ]*).*$',
|
||||
);
|
||||
final uaTalkIos = RegExp(
|
||||
r'^Mozilla/5\.0 \(iOS\) Nextcloud-Talk v([^ ]*).*$',
|
||||
);
|
||||
|
||||
test('android UA matches USER_AGENT_TALK_ANDROID', () {
|
||||
expect(
|
||||
uaTalkAndroid.hasMatch(PushRegistration.talkUserAgentAndroid),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
uaTalkIos.hasMatch(PushRegistration.talkUserAgentAndroid),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ios UA matches USER_AGENT_TALK_IOS', () {
|
||||
expect(uaTalkIos.hasMatch(PushRegistration.talkUserAgentIos), isTrue);
|
||||
expect(
|
||||
uaTalkAndroid.hasMatch(PushRegistration.talkUserAgentIos),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('pushTokenVariant', () {
|
||||
test('general uses the raw token, talk appends the suffix', () {
|
||||
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
|
||||
expect(pushTokenVariant('tok', PushRegistrationType.talk), 'tok#talk');
|
||||
});
|
||||
|
||||
test('variants always differ (NC would delete same-hash siblings)', () {
|
||||
const token = 'any-token';
|
||||
expect(
|
||||
pushTokenVariant(token, PushRegistrationType.general),
|
||||
isNot(pushTokenVariant(token, PushRegistrationType.talk)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('PushRegistrationStore', () {
|
||||
test('pre-dual entries are read as the general registration', () async {
|
||||
final storage = _MemoryStorage();
|
||||
// State written by a pre-dual app version (no type suffixes).
|
||||
storage.values.addAll({
|
||||
'push_device_identifier': 'legacy-device',
|
||||
'push_server_public_key_pem': 'legacy-key',
|
||||
'push_registered_fcm_token': 'legacy-token',
|
||||
'push_registered_proxy_server': 'https://old/push-proxy/',
|
||||
'push_registered_nc_base_url': 'https://cloud',
|
||||
'push_last_registration_at': '2026-07-01T10:00:00.000',
|
||||
'push_last_registration_error': '',
|
||||
});
|
||||
final store = PushRegistrationStore(storage);
|
||||
|
||||
expect(
|
||||
await store.deviceIdentifier(PushRegistrationType.general),
|
||||
'legacy-device',
|
||||
);
|
||||
expect(await store.isRegistered(PushRegistrationType.general), isTrue);
|
||||
expect(
|
||||
await store.registeredProxyServer(PushRegistrationType.general),
|
||||
'https://old/push-proxy/',
|
||||
);
|
||||
expect(
|
||||
await store.lastRegistrationAt(PushRegistrationType.general),
|
||||
DateTime(2026, 7, 1, 10),
|
||||
);
|
||||
|
||||
// The talk registration is genuinely absent — the self-heal adds it.
|
||||
expect(await store.deviceIdentifier(PushRegistrationType.talk), isNull);
|
||||
expect(await store.isRegistered(PushRegistrationType.talk), isFalse);
|
||||
});
|
||||
|
||||
test('per-type values stay independent', () async {
|
||||
final storage = _MemoryStorage();
|
||||
final store = PushRegistrationStore(storage);
|
||||
|
||||
await store.save(
|
||||
type: PushRegistrationType.general,
|
||||
deviceIdentifier: 'dev-general',
|
||||
serverPublicKeyPem: 'server-key',
|
||||
fcmToken: 'token',
|
||||
proxyServer: 'https://a/push-proxy/',
|
||||
ncBaseUrl: 'https://cloud',
|
||||
);
|
||||
await store.save(
|
||||
type: PushRegistrationType.talk,
|
||||
deviceIdentifier: 'dev-talk',
|
||||
serverPublicKeyPem: 'server-key',
|
||||
fcmToken: 'token',
|
||||
proxyServer: 'https://a/push-proxy/',
|
||||
ncBaseUrl: 'https://cloud',
|
||||
);
|
||||
await store.saveLastRegistrationAttempt(
|
||||
type: PushRegistrationType.talk,
|
||||
at: DateTime(2026, 7, 5, 12),
|
||||
error: 'HTTP 404',
|
||||
);
|
||||
|
||||
expect(
|
||||
await store.deviceIdentifier(PushRegistrationType.general),
|
||||
'dev-general',
|
||||
);
|
||||
expect(
|
||||
await store.deviceIdentifier(PushRegistrationType.talk),
|
||||
'dev-talk',
|
||||
);
|
||||
// Shared server key: last write wins, both read the same value.
|
||||
expect(await store.serverPublicKeyPem(), 'server-key');
|
||||
expect(
|
||||
await store.lastRegistrationError(PushRegistrationType.general),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await store.lastRegistrationError(PushRegistrationType.talk),
|
||||
'HTTP 404',
|
||||
);
|
||||
});
|
||||
|
||||
test('clear removes both registrations and shared keys', () async {
|
||||
final storage = _MemoryStorage();
|
||||
final store = PushRegistrationStore(storage);
|
||||
for (final type in PushRegistrationType.values) {
|
||||
await store.save(
|
||||
type: type,
|
||||
deviceIdentifier: 'dev',
|
||||
serverPublicKeyPem: 'key',
|
||||
fcmToken: 'token',
|
||||
proxyServer: 'https://a/',
|
||||
ncBaseUrl: 'https://cloud',
|
||||
);
|
||||
await store.saveLastRegistrationAttempt(
|
||||
type: type,
|
||||
at: DateTime(2026),
|
||||
error: 'x',
|
||||
);
|
||||
}
|
||||
await store.saveNativeAuthContext(
|
||||
username: 'user',
|
||||
baseUrl: 'https://cloud',
|
||||
);
|
||||
|
||||
await store.clear();
|
||||
expect(storage.values, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/push/chat_thread_store.dart';
|
||||
import 'package:marianum_mobile/push/push_actions.dart';
|
||||
|
||||
void main() {
|
||||
group('PushActions.finishReply', () {
|
||||
const token = 'chat1';
|
||||
final thread = [
|
||||
const ThreadMessage(nid: 1, sender: 'Max', text: 'Hi', timestampMs: 1),
|
||||
const ThreadMessage(nid: 2, sender: 'Max', text: 'Da?', timestampMs: 2),
|
||||
];
|
||||
|
||||
test(
|
||||
'successful reply removes the notification via chat cleanup',
|
||||
() async {
|
||||
var cleanups = 0;
|
||||
var renders = 0;
|
||||
var cancels = 0;
|
||||
await PushActions.finishReply(
|
||||
chatToken: token,
|
||||
sent: true,
|
||||
cleanupChat: (t) async {
|
||||
expect(t, token);
|
||||
cleanups++;
|
||||
},
|
||||
loadThread: (_) async => thread,
|
||||
renderSilent: (_, _) async => renders++,
|
||||
cancelNotification: (_) async => cancels++,
|
||||
);
|
||||
expect(cleanups, 1);
|
||||
expect(renders, 0);
|
||||
expect(cancels, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('failed reply re-renders the unchanged thread exactly once', () async {
|
||||
var cleanups = 0;
|
||||
var renders = 0;
|
||||
List<ThreadMessage>? rendered;
|
||||
await PushActions.finishReply(
|
||||
chatToken: token,
|
||||
sent: false,
|
||||
cleanupChat: (_) async => cleanups++,
|
||||
loadThread: (_) async => thread,
|
||||
renderSilent: (t, messages) async {
|
||||
expect(t, token);
|
||||
renders++;
|
||||
rendered = messages;
|
||||
},
|
||||
cancelNotification: (_) async => fail('must not cancel'),
|
||||
);
|
||||
expect(cleanups, 0);
|
||||
expect(renders, 1);
|
||||
// History unchanged — same messages, no self entry appended.
|
||||
expect(rendered!.map((m) => m.nid), [1, 2]);
|
||||
});
|
||||
|
||||
test(
|
||||
'failed reply with empty history cancels to stop the spinner',
|
||||
() async {
|
||||
var cancels = 0;
|
||||
await PushActions.finishReply(
|
||||
chatToken: token,
|
||||
sent: false,
|
||||
cleanupChat: (_) async => fail('must not cleanup'),
|
||||
loadThread: (_) async => const [],
|
||||
renderSilent: (_, _) async => fail('nothing to render'),
|
||||
cancelNotification: (_) async => cancels++,
|
||||
);
|
||||
expect(cancels, 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/push/push_status.dart';
|
||||
|
||||
const _liveProxy = 'https://connect.marianum-fulda.de/push-proxy/';
|
||||
const _betaProxy = 'https://connect-beta.marianum-fulda.de/push-proxy/';
|
||||
|
||||
PushTypeStatus _typeStatus({
|
||||
bool nextcloudRegistered = true,
|
||||
String? registeredNcBaseUrl = 'https://cloud.marianum-fulda.de',
|
||||
String? registeredProxyServer = _liveProxy,
|
||||
DateTime? lastRegistrationAt,
|
||||
String? lastRegistrationError,
|
||||
}) => PushTypeStatus(
|
||||
nextcloudRegistered: nextcloudRegistered,
|
||||
registeredNcBaseUrl: registeredNcBaseUrl,
|
||||
registeredProxyServer: registeredProxyServer,
|
||||
lastRegistrationAt: lastRegistrationAt,
|
||||
lastRegistrationError: lastRegistrationError,
|
||||
);
|
||||
|
||||
PushStatusReport _report({
|
||||
bool settingEnabled = true,
|
||||
PushCheck osPermission = PushCheck.ok,
|
||||
PushCheck serverCapability = PushCheck.ok,
|
||||
bool appPasswordPresent = true,
|
||||
bool talkAppPasswordPresent = true,
|
||||
bool keypairPresent = true,
|
||||
PushTypeStatus? general,
|
||||
PushTypeStatus? talk,
|
||||
String? currentProxyServer = _liveProxy,
|
||||
}) => PushStatusReport(
|
||||
settingEnabled: settingEnabled,
|
||||
osPermission: osPermission,
|
||||
serverCapability: serverCapability,
|
||||
appPasswordPresent: appPasswordPresent,
|
||||
talkAppPasswordPresent: talkAppPasswordPresent,
|
||||
keypairPresent: keypairPresent,
|
||||
general: general ?? _typeStatus(),
|
||||
talk: talk ?? _typeStatus(),
|
||||
currentProxyServer: currentProxyServer,
|
||||
);
|
||||
|
||||
PushStatusRow _row(List<PushStatusRow> rows, String label) =>
|
||||
rows.singleWhere((r) => r.label == label);
|
||||
|
||||
void main() {
|
||||
group('buildPushStatusRows', () {
|
||||
test('healthy chain shows nine ok rows in chain order', () {
|
||||
final rows = buildPushStatusRows(_report());
|
||||
expect(rows, hasLength(9));
|
||||
expect(rows.map((r) => r.label), [
|
||||
'Push-Benachrichtigungen aktiviert',
|
||||
'Benachrichtigungsberechtigung',
|
||||
'Server-Unterstützung',
|
||||
'App-Passwörter',
|
||||
'Geräteschlüssel',
|
||||
'Nextcloud-Registrierung (Allgemein)',
|
||||
'Nextcloud-Registrierung (Talk)',
|
||||
'Connect-Registrierung (Allgemein)',
|
||||
'Connect-Registrierung (Talk)',
|
||||
]);
|
||||
expect(rows.every((r) => r.state == PushCheck.ok), isTrue);
|
||||
});
|
||||
|
||||
test('disabled setting fails the first row with a hint', () {
|
||||
final rows = buildPushStatusRows(_report(settingEnabled: false));
|
||||
final row = _row(rows, 'Push-Benachrichtigungen aktiviert');
|
||||
expect(row.state, PushCheck.fail);
|
||||
expect(row.detail, contains('deaktiviert'));
|
||||
});
|
||||
|
||||
test('unloaded capabilities show as unknown, not as failure', () {
|
||||
final rows = buildPushStatusRows(
|
||||
_report(serverCapability: PushCheck.unknown),
|
||||
);
|
||||
final row = _row(rows, 'Server-Unterstützung');
|
||||
expect(row.state, PushCheck.unknown);
|
||||
expect(row.detail, contains('noch nicht geladen'));
|
||||
});
|
||||
|
||||
test('missing talk app password fails the password row and names it', () {
|
||||
final rows = buildPushStatusRows(_report(talkAppPasswordPresent: false));
|
||||
final row = _row(rows, 'App-Passwörter');
|
||||
expect(row.state, PushCheck.fail);
|
||||
expect(row.detail, contains('Talk-App-Passwort fehlt'));
|
||||
});
|
||||
|
||||
test('registered rows carry their URL as detail', () {
|
||||
final rows = buildPushStatusRows(_report());
|
||||
expect(
|
||||
_row(rows, 'Nextcloud-Registrierung (Allgemein)').detail,
|
||||
'https://cloud.marianum-fulda.de',
|
||||
);
|
||||
expect(_row(rows, 'Connect-Registrierung (Talk)').detail, _liveProxy);
|
||||
});
|
||||
|
||||
test('a broken talk registration fails only the talk rows', () {
|
||||
final rows = buildPushStatusRows(
|
||||
_report(
|
||||
talk: _typeStatus(
|
||||
nextcloudRegistered: false,
|
||||
registeredProxyServer: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
_row(rows, 'Nextcloud-Registrierung (Allgemein)').state,
|
||||
PushCheck.ok,
|
||||
);
|
||||
expect(
|
||||
_row(rows, 'Connect-Registrierung (Allgemein)').state,
|
||||
PushCheck.ok,
|
||||
);
|
||||
expect(
|
||||
_row(rows, 'Nextcloud-Registrierung (Talk)').state,
|
||||
PushCheck.fail,
|
||||
);
|
||||
final talkConnect = _row(rows, 'Connect-Registrierung (Talk)');
|
||||
expect(talkConnect.state, PushCheck.fail);
|
||||
expect(talkConnect.detail, contains('ausstehend'));
|
||||
});
|
||||
|
||||
test('proxy endpoint mismatch fails the affected row naming both URLs', () {
|
||||
final report = _report(
|
||||
talk: _typeStatus(registeredProxyServer: _liveProxy),
|
||||
currentProxyServer: _betaProxy,
|
||||
general: _typeStatus(registeredProxyServer: _betaProxy),
|
||||
);
|
||||
expect(report.proxyEndpointMismatch(report.talk), isTrue);
|
||||
expect(report.proxyEndpointMismatch(report.general), isFalse);
|
||||
final row = _row(
|
||||
buildPushStatusRows(report),
|
||||
'Connect-Registrierung (Talk)',
|
||||
);
|
||||
expect(row.state, PushCheck.fail);
|
||||
expect(row.detail, contains('connect.marianum-fulda.de'));
|
||||
expect(row.detail, contains('connect-beta.marianum-fulda.de'));
|
||||
});
|
||||
|
||||
test('per-type registration errors fail only their own connect row', () {
|
||||
final rows = buildPushStatusRows(
|
||||
_report(talk: _typeStatus(lastRegistrationError: 'HTTP 404')),
|
||||
);
|
||||
expect(
|
||||
_row(rows, 'Connect-Registrierung (Allgemein)').state,
|
||||
PushCheck.ok,
|
||||
);
|
||||
final talkRow = _row(rows, 'Connect-Registrierung (Talk)');
|
||||
expect(talkRow.state, PushCheck.fail);
|
||||
expect(talkRow.detail, contains('fehlgeschlagen'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PushStatusReport.readyForTestNotification', () {
|
||||
test('test push only depends on the general chain', () {
|
||||
// Test pushes are Connect direct pushes via the general registration —
|
||||
// a broken talk registration must not disable the test action.
|
||||
final report = _report(
|
||||
talk: _typeStatus(
|
||||
nextcloudRegistered: false,
|
||||
registeredProxyServer: null,
|
||||
lastRegistrationError: 'HTTP 404',
|
||||
),
|
||||
);
|
||||
expect(report.readyForTestNotification, isTrue);
|
||||
});
|
||||
|
||||
test('denied OS permission wins over a complete registration', () {
|
||||
expect(
|
||||
_report(osPermission: PushCheck.fail).readyForTestNotification,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('undetermined OS permission stays permissive', () {
|
||||
expect(
|
||||
_report(osPermission: PushCheck.unknown).readyForTestNotification,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing general registration is not ready', () {
|
||||
expect(
|
||||
_report(
|
||||
general: _typeStatus(nextcloudRegistered: false),
|
||||
).readyForTestNotification,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
_report(
|
||||
general: _typeStatus(registeredProxyServer: null),
|
||||
).readyForTestNotification,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('general endpoint mismatch or failed attempt is not ready', () {
|
||||
expect(
|
||||
_report(
|
||||
general: _typeStatus(registeredProxyServer: _liveProxy),
|
||||
talk: _typeStatus(registeredProxyServer: _betaProxy),
|
||||
currentProxyServer: _betaProxy,
|
||||
).readyForTestNotification,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
_report(
|
||||
general: _typeStatus(lastRegistrationError: 'HTTP 404'),
|
||||
).readyForTestNotification,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/push/push_subject.dart';
|
||||
|
||||
void main() {
|
||||
group('parseTalkSubject', () {
|
||||
test('1:1 chat: "{user}\\n{message}"', () {
|
||||
final parsed = parseTalkSubject('Max Mustermann\nHallo zusammen');
|
||||
expect(parsed.sender, 'Max Mustermann');
|
||||
expect(parsed.roomName, isNull);
|
||||
expect(parsed.text, 'Hallo zusammen');
|
||||
});
|
||||
|
||||
test('group chat: "{user} in {call}\\n{message}" (de identical)', () {
|
||||
final parsed = parseTalkSubject('Max in Projektraum\nHallo');
|
||||
expect(parsed.sender, 'Max');
|
||||
expect(parsed.roomName, 'Projektraum');
|
||||
expect(parsed.text, 'Hallo');
|
||||
});
|
||||
|
||||
test('sender containing " in " splits at the LAST separator', () {
|
||||
final parsed = parseTalkSubject('Max in the Middle in Raum X\nHi');
|
||||
expect(parsed.sender, 'Max in the Middle');
|
||||
expect(parsed.roomName, 'Raum X');
|
||||
expect(parsed.text, 'Hi');
|
||||
});
|
||||
|
||||
test('legacy ": " separator still works as fallback', () {
|
||||
final parsed = parseTalkSubject('Max: Hallo');
|
||||
expect(parsed.sender, 'Max');
|
||||
expect(parsed.roomName, isNull);
|
||||
expect(parsed.text, 'Hallo');
|
||||
});
|
||||
|
||||
test('legacy group form "Sender in Raum: msg"', () {
|
||||
final parsed = parseTalkSubject('Max in Raum: Hallo');
|
||||
expect(parsed.sender, 'Max');
|
||||
expect(parsed.roomName, 'Raum');
|
||||
expect(parsed.text, 'Hallo');
|
||||
});
|
||||
|
||||
test('no separator falls back to a generic sender', () {
|
||||
final parsed = parseTalkSubject(
|
||||
'Max hat eine Nachricht in der Unterhaltung Raum gesendet',
|
||||
);
|
||||
expect(parsed.sender, 'Talk');
|
||||
expect(parsed.roomName, isNull);
|
||||
expect(
|
||||
parsed.text,
|
||||
'Max hat eine Nachricht in der Unterhaltung Raum gesendet',
|
||||
);
|
||||
});
|
||||
|
||||
test('multiline message keeps newlines after the first', () {
|
||||
final parsed = parseTalkSubject('Max\nZeile 1\nZeile 2');
|
||||
expect(parsed.sender, 'Max');
|
||||
expect(parsed.text, 'Zeile 1\nZeile 2');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user