import 'dart:convert'; import 'package:buzz/shared/push/push_subscription.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { final me = 'a' * 64; const channelA = '123e4567-e89b-42d3-a456-426614174000'; const channelB = '123e4567-e89b-42d3-a456-426614174001'; test('subscribes to and mutes UUIDv5 starter channels', () { const starter = 'fee88e92-da9f-5958-a910-48db3264ad38'; final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: [starter, channelA], ); expect(subscriptions.last.filter.hTags, [channelA, starter]); final muted = buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: [starter, channelA], mutedChannelIds: [starter], ); expect(muted.last.filter.hTags, [channelA]); expect(muted.first.ignore.last.hTags, [starter]); expect( BuzzPushSubscription.fromJson(muted.first.toJson()).toJson(), muted.first.toJson(), ); }); test('channel filters preserve exact canonical UUID text', () { for (final channel in [ channelA, 'fee88e92-da9f-5958-a910-48db3264ad38', '01890abc-def0-7890-abcd-0123456789ab', '00000000-0000-0000-0000-000000000000', 'ffffffff-ffff-ffff-ffff-ffffffffffff', ]) { final filter = BuzzPushFilter(kinds: [9], hTags: [channel]); expect(BuzzPushFilter.fromJson(filter.toJson()).hTags, [channel]); } for (final channel in [ '', channelA.toUpperCase(), channelA.replaceAll('-', ''), '{$channelA}', 'urn:uuid:$channelA', '$channelA ', '$channelA\n', 'g23e4567-e89b-42d3-a456-426614174000', 'a' * 129, ]) { expect( () => BuzzPushFilter(kinds: [9], hTags: [channel]), throwsFormatException, reason: channel, ); } }); test( 'desired subscription state round-trips with an accepted-state seam', () { final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: [channelB, channelA], ); final state = BuzzPushLeaseSubscriptionState.desired( desired: subscriptions, ); final decoded = BuzzPushLeaseSubscriptionState.fromJson( jsonDecode(jsonEncode(state.toJson())) as Map, ); expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired); expect(decoded.accepted, isNull); expect(decoded.toJson(), state.toJson()); expect(decoded.authoritative, hasLength(2)); expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]); }, ); test('accepted authority requires observed accepted subscriptions', () { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; expect( () => BuzzPushLeaseSubscriptionState.fromJson({ 'authority': 'accepted', 'desired': [subscription.toJson()], }), throwsFormatException, ); }); test('persists accepted and reserved relay lease generations', () { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; final state = BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) .withAccepted(subscriptions: [subscription], generation: 9) .withReservedGeneration(10); final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); expect(decoded.acceptedGeneration, 9); expect(decoded.generationCursor, 10); expect(decoded.toJson(), state.toJson()); }); test('a retry reserves beyond a committed but unrecorded generation', () { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; final accepted = BuzzPushLeaseSubscriptionState.desired( desired: [subscription], ).withAccepted(subscriptions: [subscription], generation: 4); final committed = accepted.withReservedGeneration(5); final recovered = BuzzPushLeaseSubscriptionState.fromJson( committed.toJson(), ).withReservedGeneration(6); expect(recovered.acceptedGeneration, 4); expect(recovered.generationCursor, 6); }); test('builds aligned self and unmuted channel subscriptions', () { final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me.toUpperCase(), channelIds: [channelB, channelA], mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'], ); expect(subscriptions, hasLength(2)); expect(subscriptions.first.filter.kinds, buzzPushSelfDirectedKinds); expect(subscriptions.first.filter.kinds, isNot(contains(7))); expect(subscriptions.first.filter.pTags, [me]); expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); expect(subscriptions.last.filter.hTags, [channelA]); expect(subscriptions.first.ignore, hasLength(2)); expect(subscriptions.first.ignore.first.kinds, buzzPushRenderableKinds); expect(subscriptions.first.ignore.last.hTags, [channelB]); expect( subscriptions.first.suppress?.pTagsMax, buzzPushHellthreadParticipantLimit, ); }); test('ignores mutes outside the push channel grammar', () { // Starter channels use UUIDv5 IDs; muting one must not break push setup. const starterChannel = 'fee88e92-da9f-5958-a910-48db3264ad38'; final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: [channelA], mutedChannelIds: [starterChannel, 'not-a-channel'], ); expect(subscriptions, hasLength(2)); expect(subscriptions.first.ignore, hasLength(1)); expect(subscriptions.last.filter.hTags, [channelA]); }); test('chunks channel subscriptions to relay limits deterministically', () { final channels = [ for (var i = 0; i < 51; i++) '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', ]..shuffle(); final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: channels, ); expect(subscriptions, hasLength(3)); expect(subscriptions[1].filter.hTags, hasLength(50)); expect(subscriptions[2].filter.hTags, hasLength(1)); expect( subscriptions[1].filter.hTags, orderedEquals([...subscriptions[1].filter.hTags!]..sort()), ); }); test('rejects malformed and unsupported subscription fields', () { final valid = buildDesiredBuzzPushSubscriptions( myPubkey: me, ).single.toJson(); expect( () => buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: const ['not-a-channel'], ), throwsFormatException, ); expect( () => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}), throwsFormatException, ); expect( () => BuzzPushSubscription.fromJson({ 'filter': { 'kinds': [7], '#p': [me], }, 'class': 'default', }), throwsFormatException, ); expect( () => BuzzPushSubscription.fromJson({ 'filter': { 'kinds': [9], '#p': ['not-a-pubkey'], }, 'class': 'default', }), throwsFormatException, ); }); }