tane/apps/app_seeds/test/services/notification_service_test.dart
vjrj d026376993 feat(messages): unread badges + OS notification for private messages
Private messages (NIP-17) arrived in the foreground with no signal: no
unread count, no badge, no OS notification. Hook both into the single
InboxService.ingest choke point.

- UnreadService: per-peer "last read" in the keystore (timestamps+pubkey
  only, no message text), live count via a changes stream, active-peer
  suppression so the open chat never badges or notifies.
- UnreadBadge: reusable, RTL-aware (Flutter Badge) — on the home hamburger,
  the drawer "Chat" item, and per-conversation in the messages list.
- Mark-read on chat open (ChatScreen).
- NotificationService over flutter_local_notifications: generic
  "New message from <name>" (no message text, for privacy), payload = pubkey,
  tap opens /chat/:pubkey. No-op on web/Windows; Android POST_NOTIFICATIONS.
- i18n notifications.newMessageFrom (en/es/pt/ast).

Background/push stays out of scope (foreground-only by design).

Tests: UnreadService, NotificationService (mock plugin), InboxService hooks
(new peer notifies/counts; duplicate, own, and open-chat do not), and a
UnreadBadge widget test. dart analyze clean; commons_core green.
2026-07-10 21:12:00 +02:00

78 lines
2.8 KiB
Dart

import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:tane/services/notification_service.dart';
class _MockPlugin extends Mock implements FlutterLocalNotificationsPlugin {}
void main() {
late _MockPlugin plugin;
setUpAll(() {
registerFallbackValue(
const NotificationDetails(
android: AndroidNotificationDetails('x', 'x'),
),
);
registerFallbackValue(const InitializationSettings());
});
setUp(() {
plugin = _MockPlugin();
when(() => plugin.show(any(), any(), any(), any(),
payload: any(named: 'payload'))).thenAnswer((_) async {});
// Generic platform resolution returns null, so the permission requests in
// initialize() are skipped in tests.
when(() => plugin.resolvePlatformSpecificImplementation())
.thenReturn(null);
});
test('showMessage passes the title and pubkey payload, no body', () async {
final service = NotificationService(plugin: plugin, supported: true);
await service.showMessage(
peerPubkey: 'abc123', title: 'New message from Alice');
final captured = verify(() => plugin.show(
captureAny(),
captureAny(),
captureAny(),
captureAny(),
payload: captureAny(named: 'payload'),
)).captured;
expect(captured[1], 'New message from Alice'); // title
expect(captured[2], isNull); // no body (privacy: no message text)
expect(captured[4], 'abc123'); // payload = peer pubkey
});
test('is a no-op on unsupported platforms (web/windows)', () async {
final service = NotificationService(plugin: plugin, supported: false);
await service.initialize();
await service.showMessage(peerPubkey: 'abc123', title: 'hi');
verifyNever(() => plugin.show(any(), any(), any(), any(),
payload: any(named: 'payload')));
});
test('a tap with a payload invokes onTapChat with the pubkey', () async {
// Capture the response handler initialize() registers, then simulate a tap.
void Function(NotificationResponse)? handler;
when(() => plugin.initialize(
any(),
onDidReceiveNotificationResponse:
any(named: 'onDidReceiveNotificationResponse'),
)).thenAnswer((inv) async {
handler = inv.namedArguments[#onDidReceiveNotificationResponse]
as void Function(NotificationResponse)?;
return true;
});
final service = NotificationService(plugin: plugin, supported: true);
String? tapped;
service.onTapChat = (pk) => tapped = pk;
await service.initialize();
handler!(const NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: 'abc123',
));
expect(tapped, 'abc123');
});
}