feat(social): add SavedSearchAlertService + notification alerts channel

One discovery subscription over the shared connection for the user's
current zone; matches each incoming offer against saved searches and
fires a text-free OS alert on the new 'alerts' channel for fresh matches.
Skips own/blocked/hidden/non-active offers and dedups via seen keys. The
replay-on-connect doubles as the catch-up scan. Notification payloads are
now prefixed so taps route to chat or the saved search.
This commit is contained in:
vjrj 2026-07-17 12:42:41 +02:00
parent 530bf96358
commit c0cd299408
3 changed files with 341 additions and 1 deletions

View file

@ -0,0 +1,148 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/saved_search_alert_service.dart';
import 'package:tane/services/saved_searches_store.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import '../support/test_support.dart';
/// Records search-alert calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final searchIds = <String>[];
final titles = <String>[];
@override
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
searchIds.add(searchId);
titles.add(title);
}
}
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
const myPubkey = 'me';
// A connection that never actually opens ingest doesn't use it.
Future<SocialConnection> offlineConnection() async => SocialConnection(
social: await SocialService.fromRootSeedHex(seedHex),
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => throw StateError('unused in ingest tests'),
online: const Stream.empty(),
);
late SavedSearchesStore store;
late SocialSettings settings;
late _RecordingNotifications notifications;
late SavedSearchAlertService service;
setUp(() async {
store = SavedSearchesStore(InMemorySecretStore());
settings = SocialSettings(InMemorySecretStore());
notifications = _RecordingNotifications();
service = SavedSearchAlertService(
connection: await offlineConnection(),
selfPubkey: myPubkey,
store: store,
settings: settings,
notifications: notifications,
alertTitle: (s) => 'New match: ${s.label}',
);
});
tearDown(() async {
await service.stop();
await store.close();
});
Offer offer({
String id = 'o1',
String author = 'grower',
String summary = 'Heirloom tomato seeds',
OfferType type = OfferType.gift,
OfferLifecycle status = OfferLifecycle.active,
String? category = 'Solanaceae',
bool isOrganic = false,
}) =>
Offer(
id: id,
authorPubkeyHex: author,
summary: summary,
type: type,
status: status,
approxGeohash: 'u09',
category: category,
isOrganic: isOrganic,
);
SavedSearch search(String id, {String query = ''}) =>
SavedSearch(id: id, label: id, query: query);
test('a matching offer fires one alert and records the match', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, ['tom']);
expect(notifications.titles, ['New match: tom']);
expect(await store.newMatchCount('tom'), 1);
});
test('a non-matching offer is silent', () async {
await store.save(search('pep', query: 'pepper'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a replayed offer does not re-alert', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
await service.ingest(offer()); // relay replays the stored event
expect(notifications.searchIds, ['tom']);
});
test('one offer can match several searches', () async {
await store.save(search('all'));
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, containsAll(<String>['all', 'tom']));
expect(notifications.searchIds, hasLength(2));
});
test('my own offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(author: myPubkey));
expect(notifications.searchIds, isEmpty);
});
test('non-active offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(status: OfferLifecycle.closed));
expect(notifications.searchIds, isEmpty);
});
test('offers from a blocked author never alert', () async {
await settings.block('grower');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a hidden (reported) offer never alerts', () async {
await settings.hideOffer(authorPubkeyHex: 'grower', offerId: 'o1');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('with no saved searches nothing fires', () async {
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
}