Files
buzz/mobile/test/features/settings/notification_toggle_test.dart
Tom Brow 8f3f4e266a test(mobile): run push tests without a gateway dart-define (#9423)
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>
2026-11-12 16:19:10 -08:00

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,
);