import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_storage.dart'; import 'package:buzz/shared/push/push_subscription.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real /// in-memory logic. class FakeSecureStorage extends Fake implements FlutterSecureStorage { final Map _data = {}; @override Future read({ required String key, AppleOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, }) async => _data[key]; @override Future write({ required String key, required String? value, AppleOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, }) async { if (value != null) { _data[key] = value; } else { _data.remove(key); } } @override Future delete({ required String key, AppleOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, }) async => _data.remove(key); @override Future> readAll({ AppleOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, }) async => Map.from(_data); @override Future deleteAll({ AppleOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, }) async => _data.clear(); @override Future containsKey({ required String key, AppleOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, AppleOptions? mOptions, WindowsOptions? wOptions, }) async => _data.containsKey(key); // Convenience for setting up test data. String? operator [](String key) => _data[key]; void operator []=(String key, String value) => _data[key] = value; } void main() { late FakeSecureStorage fakeSecure; late CommunityStorage storage; setUp(() { fakeSecure = FakeSecureStorage(); storage = CommunityStorage(secure: fakeSecure); }); group('CommunityStorage', () { test('loadAll returns empty list when no data', () async { final result = await storage.loadAll(); expect(result, isEmpty); }); test('save and loadAll round-trips a community', () async { final ws = Community.create( name: 'Test', relayUrl: 'https://relay.example.com', pubkey: 'abc123', ); await storage.save(ws); final loaded = await storage.loadAll(); expect(loaded, hasLength(1)); expect(loaded.first.id, ws.id); expect(loaded.first.name, 'Test'); expect(loaded.first.relayUrl, 'https://relay.example.com'); expect(loaded.first.pubkey, 'abc123'); expect( loaded.first.pushSubscriptionState.authority, BuzzPushLeaseSubscriptionAuthority.desired, ); }); test('round-trips desired push subscription state', () async { final pubkey = 'a' * 64; final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: pubkey, channelIds: const ['123e4567-e89b-42d3-a456-426614174000'], ); final community = Community.create( name: 'Push', relayUrl: 'https://relay.example.com', pubkey: pubkey, ).copyWith( pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( desired: subscriptions, ), ); await storage.save(community); final loaded = (await storage.loadAll()).single; expect( loaded.pushSubscriptionState.toJson(), community.pushSubscriptionState.toJson(), ); }); test('round-trips a pending push tombstone journal', () async { final subscription = BuzzPushSubscription( filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), notificationClass: 'default', ); final state = BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) .withAccepted(subscriptions: [subscription], generation: 4) .withPendingTombstone(5); final community = Community.create( name: 'Push', relayUrl: 'https://relay.example.com', ).copyWith(pushSubscriptionState: state); await storage.save(community); final loaded = (await storage.loadAll()).single; expect(loaded.pushSubscriptionState.toJson(), state.toJson()); expect(loaded.pushSubscriptionState.pendingTombstoneGeneration, 5); }); test( 'migrates a disabled reserved generation into a tombstone journal', () { final community = Community.create( name: 'Push', relayUrl: 'https://relay.example.com', ).copyWith( pushNotificationsEnabled: false, pushSubscriptionState: const BuzzPushLeaseSubscriptionState.desired( acceptedGeneration: 4, generationCursor: 5, ), ); final json = community.toJson(); (json['pushSubscriptionState'] as Map).remove( 'pendingTombstoneGeneration', ); final migrated = Community.fromJson(json); expect(migrated.pushSubscriptionState.pendingTombstoneGeneration, 5); }, ); test('save updates existing community with same id', () async { final ws = Community.create( name: 'Original', relayUrl: 'https://relay.example.com', ); await storage.save(ws); await storage.save(ws.copyWith(name: 'Updated')); final loaded = await storage.loadAll(); expect(loaded, hasLength(1)); expect(loaded.first.name, 'Updated'); }); test('remove deletes a community', () async { final ws1 = Community.create( name: 'One', relayUrl: 'https://one.example.com', ); final ws2 = Community.create( name: 'Two', relayUrl: 'https://two.example.com', ); await storage.save(ws1); await storage.save(ws2); await storage.remove(ws1.id); final loaded = await storage.loadAll(); expect(loaded, hasLength(1)); expect(loaded.first.id, ws2.id); }); test('active community ID persists', () async { await storage.saveActiveId('ws-123'); final id = await storage.loadActiveId(); expect(id, 'ws-123'); }); test('clearActiveId removes active ID', () async { await storage.saveActiveId('ws-123'); await storage.clearActiveId(); final id = await storage.loadActiveId(); expect(id, isNull); }); group('migration', () { test('migrates saved workspaces to communities', () async { final legacy = Community.create( name: 'Legacy', relayUrl: 'https://legacy.example.com', ); fakeSecure['buzz_workspaces'] = jsonEncode([legacy.toJson()]); fakeSecure['buzz_active_workspace_id'] = legacy.id; final loaded = await storage.loadAll(); expect(loaded.single.id, legacy.id); expect(await storage.loadActiveId(), legacy.id); expect(fakeSecure['buzz_communities'], isNotNull); expect(fakeSecure['buzz_workspaces'], isNull); expect(fakeSecure['buzz_active_workspace_id'], isNull); }); test('migrates legacy keys to community on first load', () async { fakeSecure['buzz_relay_url'] = 'https://legacy.example.com'; fakeSecure['buzz_token'] = 'legacy_token'; fakeSecure['buzz_pubkey'] = 'legacy_pub'; fakeSecure['buzz_nsec'] = 'legacy_nsec'; final loaded = await storage.loadAll(); expect(loaded, hasLength(1)); expect(loaded.first.relayUrl, 'https://legacy.example.com'); expect(loaded.first.pubkey, 'legacy_pub'); expect(loaded.first.nsec, 'legacy_nsec'); expect(loaded.first.name, isNotEmpty); expect( loaded.first.sensitiveActionPolicy, SensitiveActionPolicy.disabledByUser, ); // Legacy keys should be deleted. expect(fakeSecure['buzz_relay_url'], isNull); expect(fakeSecure['buzz_token'], isNull); expect(fakeSecure['buzz_pubkey'], isNull); expect(fakeSecure['buzz_nsec'], isNull); // Active ID should be set. final activeId = await storage.loadActiveId(); expect(activeId, loaded.first.id); }); test('does not migrate when no legacy keys exist', () async { final loaded = await storage.loadAll(); expect(loaded, isEmpty); }); test('does not re-migrate after first load', () async { fakeSecure['buzz_relay_url'] = 'https://legacy.example.com'; fakeSecure['buzz_token'] = 'legacy_token'; final first = await storage.loadAll(); expect(first, hasLength(1)); final second = await storage.loadAll(); expect(second, hasLength(1)); expect(second.first.id, first.first.id); }); test('migration generates name from localhost URL', () async { fakeSecure['buzz_relay_url'] = 'http://localhost:3000'; fakeSecure['buzz_token'] = 'tok'; final loaded = await storage.loadAll(); expect(loaded.first.name, 'Local Dev'); }); }); }); }