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:
2026-07-05 22:48:04 +02:00
parent 35e144799e
commit 483fea62ba
31 changed files with 2807 additions and 320 deletions
+178
View File
@@ -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'));
});
});
}