mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
A plain `cd mobile && flutter test` failed 11 push and notification tests on a clean checkout of main, while CI passed. Agents running the full suite kept reporting them as "pre-existing, also fails on main", which hides real regressions in the same files. ### Root cause Push is gated on the compile-time `BUZZ_PUSH_GATEWAY_URL` define (`Env.pushGatewayConfigured`, since [#7261 configurable gateway](https://github.com/block/buzz/pull/7261) made the gateway optional). Without it the app is a push-less build: `readBuzzPushEndpointGrants()` returns no grants and `buzzPushLifecycleEnabled()` is false. CI's mobile shards, `just mobile-test` and the lefthook pre-push lane (which runs `just mobile-test`) all pass `--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`; a bare `flutter test` passes nothing. Each of the 11 was checked against that: - `notification_toggle_test` (5 cases) and `push_lease_revocation_outbox_test` "hosted relay assertion": no endpoint grants without a gateway (`Bad state: No element` at the grant lookup, `No endpoint grant exists for push lease tombstone`). - `push_bridge_test` "reads and exposes persisted endpoint grants" and "debug enrollment carries the configured relay and gateway URLs": the bridge returns `[]` before calling native (`Expected: an object with length of <1>, Actual: []`). - `push_bootstrap_test` "default push requires relay capability" and "new community starts registration": the lifecycle is disabled, so registration never starts (`Expected <1>, Actual <0>`). - `push_bridge_test` "development push gateway matches the compiled configuration": a stale test. It expected a `https://push.buzz.xyz` default that [#7261 configurable gateway](https://github.com/block/buzz/pull/7261) removed from the app; under CI's define both sides equal `push.example`, so it could never fail there. The same files pass on the same Mac with the define, so nothing is environmental. The `Unexpected native call takePendingNotificationResponse` log is unrelated noise: the toggle test's native mock didn't answer that call, which logs (and is caught) in CI too. ### Fix Tests get the gateway from a test hook instead of the define. `Env.pushGatewayUrl` becomes a getter over the compiled value with a `@visibleForTesting` `debugPushGatewayUrlOverride` (the same pattern as `debugUriStreamOverride`), and `test/flutter_test_config.dart` sets it for every test file. `push_unconfigured_build_test` sets it to `''`, so it runs in the main suite instead of being skipped there and run separately. CI, the Justfile, `scripts/flake-check.mjs` and the AGENTS/README check commands drop the define, so CI, `just mobile-test`, pre-push and a bare `flutter test` run the same thing. I chose this over documenting a canonical command because the define can't be made automatic: `flutter test` has no config file for defines, so any doc fix still leaves the obvious command red. A test hook makes every invocation agree. The stale test is replaced by one in the push-less file that binds the production read: with the override cleared, `Env.pushGatewayUrl` must equal the compiled `BUZZ_PUSH_GATEWAY_URL`. Now that CI compiles without the define, it fails if the app regains a built-in gateway default, which the old test never could. The toggle test's mock answers `takePendingNotificationResponse` with no pending response. What gets better: one test mode everywhere, and the push-less tests run in every run. Nothing gets worse: production reads the same compiled value (the override is never set outside tests, and an `aot-snapshot` probe shows AOT still folds the getter to the compiled constant and drops push-only code when the define is absent), and every test still runs in the push-capable mode CI used before. ### Validation - On a fresh origin/main worktree with the Hermit toolchain, a bare `flutter test` failed exactly these 11; the same files passed on the same machine with the define. - After the fix, the pre-push `just mobile-check` and `just mobile-test` (now a bare `flutter test` plus the recipe and secp256k1 checks) pass, with the push-less tests running instead of skipped. `scripts/flake-check.test.mjs` passes. - Falsifiability: re-adding a `defaultValue` to the compiled gateway fails the new no-default test, and removing `flutter_test_config.dart` brings back the other 10 failures. - Unrelated: an earlier full run also failed `community_provider_test` "discard orphan snapshots and image caches", which waits 500 ms for a background directory delete. The Mac was running other full suites at the time; the file passed 8 of 8 runs alone, with and without the define. It is not touched here. Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
364 lines
14 KiB
Dart
364 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:buzz/features/settings/settings_page.dart';
|
|
import 'package:buzz/shared/auth/auth_provider.dart';
|
|
import 'package:buzz/shared/community/community.dart';
|
|
import 'package:buzz/shared/community/community_provider.dart';
|
|
import 'package:buzz/shared/community/community_storage.dart';
|
|
import 'package:buzz/shared/push/dev_push_lease.dart';
|
|
import 'package:buzz/shared/push/push_bootstrap.dart';
|
|
import 'package:buzz/shared/push/push_lease_revocation_outbox.dart';
|
|
import 'package:buzz/shared/crypto/nip44.dart';
|
|
import 'package:buzz/shared/relay/nostr_models.dart';
|
|
import 'package:buzz/shared/relay/signed_event_relay.dart';
|
|
import 'package:buzz/shared/push/push_bridge.dart';
|
|
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
|
|
import 'package:buzz/shared/push/push_subscription.dart';
|
|
import 'package:buzz/shared/relay/app_lifecycle_provider.dart';
|
|
import 'package:buzz/shared/relay/relay_provider.dart';
|
|
import 'package:buzz/shared/relay/relay_session.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../shared/community/community_storage_test.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
setUpAll(() async {
|
|
final fonts = FontLoader('Inter')
|
|
..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'));
|
|
await fonts.load();
|
|
});
|
|
for (final outcome in [
|
|
'accepted',
|
|
'failed',
|
|
'pending',
|
|
'scaled',
|
|
'unset preference',
|
|
]) {
|
|
testWidgets('off then on remains usable with $outcome cleanup', (
|
|
tester,
|
|
) async {
|
|
final semantics = tester.ensureSemantics();
|
|
// iPhone 12 mini width; use a wider screen for the 2x single-line case.
|
|
tester.view.physicalSize = Size(outcome == 'scaled' ? 430 : 375, 1800);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
if (outcome == 'scaled') {
|
|
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
|
}
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
PackageInfo.setMockInitialValues(
|
|
appName: 'Buzz',
|
|
packageName: 'buzz',
|
|
version: '1.0.0',
|
|
buildNumber: '1',
|
|
buildSignature: '',
|
|
);
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final secure = FakeSecureStorage();
|
|
final storage = CommunityStorage(secure: secure);
|
|
final subscriptions = [
|
|
BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
|
|
notificationClass: 'default',
|
|
),
|
|
];
|
|
var discoveries = 0;
|
|
final session = _Connected();
|
|
apnsDeviceToken.value = 'test-apns-token';
|
|
addTearDown(() => apnsDeviceToken.value = null);
|
|
final grant = <String, Object>{
|
|
'relayOrigin': 'wss://relay.example',
|
|
'relayPubkey': _descriptor.executorPubkey,
|
|
'installationId': 'b' * 32,
|
|
'endpointGrant': 'test-endpoint-grant',
|
|
'endpointHash': 'c' * 64,
|
|
'endpointEpoch': 1,
|
|
'generation': 1,
|
|
'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
|
|
};
|
|
const nativePush = MethodChannel('buzz/push');
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(
|
|
nativePush,
|
|
(call) async => switch (call.method) {
|
|
'enrollPush' => grant,
|
|
'endpointGrants' => [grant],
|
|
'startRegistration' ||
|
|
'syncPushSnapshot' ||
|
|
'takePendingNotificationResponse' => null,
|
|
_ => throw StateError('Unexpected native call ${call.method}'),
|
|
},
|
|
);
|
|
addTearDown(() => messenger.setMockMethodCallHandler(nativePush, null));
|
|
final community =
|
|
Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'https://relay.example',
|
|
).copyWith(
|
|
nsec: nostr.Nip19.encode(
|
|
prefix: nostr.Nip19Prefix.nsec,
|
|
data: '1' * 64,
|
|
),
|
|
pushNotificationsEnabled: true,
|
|
pushLeaseInstallationId: 'd' * 32,
|
|
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
|
|
desired: subscriptions,
|
|
).withAccepted(subscriptions: subscriptions, generation: 7),
|
|
);
|
|
if (outcome == 'unset preference') {
|
|
final saved = community.toJson()..remove('pushNotificationsEnabledV2');
|
|
saved['pushNotificationsEnabled'] = false;
|
|
secure['buzz_communities'] = jsonEncode([saved]);
|
|
} else {
|
|
await storage.save(community);
|
|
}
|
|
await storage.saveActiveId(community.id);
|
|
|
|
final cleanup = Completer<void>();
|
|
final showingSettings = ValueNotifier(true);
|
|
addTearDown(showingSettings.dispose);
|
|
final snapshots = <List<Community>>[];
|
|
final tombstones = <int?>[];
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(prefs),
|
|
authProvider.overrideWith(_Auth.new),
|
|
communityStorageProvider.overrideWithValue(storage),
|
|
communitySnapshotWriterProvider.overrideWithValue((
|
|
communities,
|
|
) async {
|
|
snapshots.add(List.of(communities));
|
|
}),
|
|
communityPushLeaseDeactivatorProvider.overrideWithValue((
|
|
community, {
|
|
generation,
|
|
}) async {
|
|
tombstones.add(generation);
|
|
if (outcome == 'failed') throw StateError('relay unavailable');
|
|
if (outcome == 'pending') await cleanup.future;
|
|
await publishBuzzPushLeaseTombstone(
|
|
descriptor: _descriptor,
|
|
installationId: community.pushLeaseInstallationId!,
|
|
generation: generation!,
|
|
nsec: community.nsec!,
|
|
memberPubkey: pubkeyFromNsec(community.nsec)!,
|
|
submit: SignedEventRelay(
|
|
session: session,
|
|
nsec: community.nsec,
|
|
).submit,
|
|
);
|
|
}),
|
|
buzzPushLeaseRevocationStorageProvider.overrideWithValue(
|
|
BuzzPushLeaseRevocationStorage(secure: FakeSecureStorage()),
|
|
),
|
|
relaySessionProvider.overrideWith(() => session),
|
|
myPubkeyProvider.overrideWithValue(nostr.Keys('1' * 64).public),
|
|
buzzPushDescriptorFetcherProvider.overrideWithValue((_) async {
|
|
discoveries++;
|
|
return _descriptor;
|
|
}),
|
|
appLifecycleProvider.overrideWith(_Lifecycle.new),
|
|
],
|
|
child: BuzzPushBootstrap(
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: ValueListenableBuilder(
|
|
valueListenable: showingSettings,
|
|
builder: (_, showing, _) => showing
|
|
? SettingsPage(
|
|
profileHeader: const SizedBox.shrink(),
|
|
identityRecoveryPageBuilder: (_) =>
|
|
const SizedBox.shrink(),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final container = ProviderScope.containerOf(
|
|
tester.element(find.byType(SettingsPage)),
|
|
);
|
|
expect(
|
|
container.read(activeCommunityProvider).value?.pushNotificationsEnabled,
|
|
isTrue,
|
|
reason:
|
|
'${container.read(activeCommunityProvider)} / ${container.read(communityListProvider)}',
|
|
);
|
|
expect(session.leases.single['active'], isTrue);
|
|
// Capability discovery plus the active publication.
|
|
expect(discoveries, 2);
|
|
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
|
|
expect(
|
|
tester.getSemantics(
|
|
find.byKey(const ValueKey('push-notifications-setting')),
|
|
),
|
|
isSemantics(
|
|
label: 'Notifications',
|
|
hasToggledState: true,
|
|
isToggled: true,
|
|
),
|
|
);
|
|
expect(
|
|
tester.getSize(find.byType(Switch)).height,
|
|
greaterThanOrEqualTo(48),
|
|
);
|
|
// Compare rendered row bounds, not just their padding parameters.
|
|
final notificationHeight = tester
|
|
.getSize(find.byKey(const ValueKey('push-notifications-enabled')))
|
|
.height;
|
|
final statusHeight = tester
|
|
.getSize(find.byKey(const ValueKey('settings-set-status')))
|
|
.height;
|
|
expect(
|
|
notificationHeight,
|
|
statusHeight,
|
|
reason: 'single-line settings rows share a height',
|
|
);
|
|
await tester.tap(find.byType(Switch));
|
|
await tester.pumpAndSettle();
|
|
final stored = (await storage.loadAll()).single;
|
|
expect(stored.pushNotificationsEnabled, isFalse);
|
|
expect(
|
|
stored.pushSubscriptionState.pendingTombstoneGeneration,
|
|
outcome == 'failed' || outcome == 'pending'
|
|
? greaterThanOrEqualTo(9)
|
|
: isNull,
|
|
);
|
|
expect(tombstones.first, 9);
|
|
expect(
|
|
tombstones.length,
|
|
lessThanOrEqualTo(2),
|
|
reason: 'failed cleanup must not defeat the retry backoff',
|
|
);
|
|
expect(discoveries, 2);
|
|
session.setStatus(SessionStatus.disconnected);
|
|
await tester.pumpAndSettle();
|
|
session.setStatus(SessionStatus.connected);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
session.leases.where((lease) => lease['active'] == true),
|
|
hasLength(1),
|
|
);
|
|
expect(discoveries, 3);
|
|
expect(snapshots.last.single.pushNotificationsEnabled, isFalse);
|
|
final offSwitch = tester.widget<Switch>(find.byType(Switch));
|
|
expect(offSwitch.value, isFalse);
|
|
expect(offSwitch.onChanged, isNotNull);
|
|
// Closing and reopening Settings must not strand the off control either.
|
|
showingSettings.value = false;
|
|
await tester.pumpAndSettle();
|
|
showingSettings.value = true;
|
|
await tester.pumpAndSettle();
|
|
expect(tester.widget<Switch>(find.byType(Switch)).onChanged, isNotNull);
|
|
await tester.tap(find.text('Notifications'));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
|
|
expect((await storage.loadAll()).single.pushNotificationsEnabled, isTrue);
|
|
expect(
|
|
session.leases.where((lease) => lease['active'] == true).length,
|
|
2,
|
|
reason:
|
|
're-enabling must publish an active lease before the renewal timer',
|
|
);
|
|
expect(session.leases.last['generation'], greaterThan(tombstones.last!));
|
|
if (outcome == 'pending') {
|
|
cleanup.complete();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
(await storage.loadAll()).single.pushNotificationsEnabled,
|
|
isTrue,
|
|
);
|
|
}
|
|
final latestLease = session.leases.reduce(
|
|
(a, b) => (a['generation'] as int) > (b['generation'] as int) ? a : b,
|
|
);
|
|
expect(
|
|
latestLease['active'],
|
|
isTrue,
|
|
reason: 'a delayed old tombstone cannot supersede the new active lease',
|
|
);
|
|
expect(discoveries, 4);
|
|
// A real reconnect must still discover capability again.
|
|
(container.read(relaySessionProvider.notifier) as _Connected).setStatus(
|
|
SessionStatus.disconnected,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(currentRelayPushDescriptorProvider).value, isNull);
|
|
(container.read(relaySessionProvider.notifier) as _Connected).setStatus(
|
|
SessionStatus.connected,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(discoveries, 5);
|
|
expect(tester.widget<Switch>(find.byType(Switch)).onChanged, isNotNull);
|
|
expect(tester.takeException(), isNull);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pumpAndSettle();
|
|
semantics.dispose();
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
class _Lifecycle extends AppLifecycleNotifier {
|
|
@override
|
|
AppLifecycleState build() => AppLifecycleState.resumed;
|
|
}
|
|
|
|
class _Auth extends AuthNotifier {
|
|
@override
|
|
Future<AuthState> build() async =>
|
|
const AuthState(status: AuthStatus.unauthenticated);
|
|
}
|
|
|
|
class _Connected extends RelaySessionNotifier {
|
|
final leases = <Map<String, dynamic>>[];
|
|
|
|
@override
|
|
Future<NostrEvent> publish(
|
|
NostrEvent event, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
expect(event.kind, buzzPushLeaseKind);
|
|
final plaintext = nip44Decrypt(
|
|
getConversationKey('2' * 64, event.pubkey),
|
|
event.content,
|
|
);
|
|
leases.add(jsonDecode(plaintext) as Map<String, dynamic>);
|
|
return event;
|
|
}
|
|
|
|
void setStatus(SessionStatus status) => state = SessionState(status: status);
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
}
|
|
|
|
final _descriptor = BuzzPushLeaseDescriptor(
|
|
origin: 'wss://relay.example',
|
|
executorKeyId: 'relay-v1',
|
|
executorPubkey: nostr.Keys('2' * 64).public,
|
|
transport: 'apns',
|
|
maxLeaseTtlSeconds: 3600,
|
|
maxContentLength: 4096,
|
|
maxPlaintextLength: 4096,
|
|
maxEndpointLength: 2048,
|
|
maxStringLength: 512,
|
|
);
|