mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary Muting a starter channel (#welcome-everyone or #general) could leave the iOS app on a blank screen after launch. Starter channels use UUIDv5 IDs. Push subscription setup checked every synced mute against the lease's UUIDv4 channel grammar and threw `FormatException: Push subscription channel ID is invalid.` from `pushSubscriptionSyncProvider` during build, so the first frame never rendered. Whether you saw a blank screen depended on whether channels and mutes finished loading before the first frame. Mutes only feed the lease as ignore filters for subscribed (DM) channels, and mutes sync across every community and channel type. So the builder now matches mutes against the subscribed channels before using them, instead of validating every mute. Subscribed channel IDs are still validated as before. ### Related issue None found. ### Testing - Captured the failure on a dogfood iPhone build from the device system log, with a stack trace through `buildDesiredBuzzPushSubscriptions` and the muted ID `fee88e92-da9f-5958-a910-48db3264ad38` (#welcome-everyone). Unmuting that channel from desktop fixed the device immediately, which confirms the cause. - New regression test `ignores mutes outside the push channel grammar` fails on `main` with the same exception and passes with this change. - The pre-push `mobile-checks` hook (`just mobile-check && just mobile-test`) passed on this head. - Screenshots: not applicable. This changes no UI. It only stops a startup failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
5.5 KiB
Dart
170 lines
5.5 KiB
Dart
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(
|
|
'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<String, dynamic>,
|
|
);
|
|
|
|
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,
|
|
);
|
|
});
|
|
}
|