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:
parent
530bf96358
commit
c0cd299408
3 changed files with 341 additions and 1 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue