diff --git a/lib/core/config.dart b/lib/core/config.dart index 071883eb..0f57883a 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -76,6 +76,13 @@ class Config { static const int cleanupIntervalMinutes = 30; static const int sessionExpirationHours = 720; + /// Upper bound for a pending range-order child session (persisted without an + /// orderId while waiting for mostrod's child new-order message). The handoff + /// resolves in seconds, so a record still unlinked after this window never + /// will be. Bounded independently of [sessionExpirationHours] so orphan + /// records cannot accumulate when session expiration is disabled (0). + static const int pendingChildSessionExpirationHours = 48; + // Notification configuration static String notificationChannelId = 'mostro_mobile'; static int notificationId = 38383; diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index 1f1b1f0e..40b8e5fa 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -45,6 +45,10 @@ class SessionStorage extends BaseStorage { return Session.fromJson(clone); } + /// Record-key prefix for pending range-order child sessions, which have no + /// orderId yet and are keyed by their trade public key instead. + static const String pendingChildKeyPrefix = 'pending-child:'; + Future putSession(Session session) async { if (session.orderId == null) { throw ArgumentError('Cannot store a session with an empty orderId'); @@ -52,6 +56,29 @@ class SessionStorage extends BaseStorage { await putItem(session.orderId!, session); } + /// Persists a pending range-order child session (no orderId yet), keyed by + /// its trade public key. Persisting it matters for two reasons: the session + /// must survive an app kill between release and the child new-order message, + /// and the background isolate loads sessions from this store to decrypt + /// events addressed to the child trade key. + Future putPendingChildSession(Session session) async { + if (session.orderId != null) { + throw ArgumentError( + 'Pending child session must not have an orderId; use putSession', + ); + } + if (session.parentOrderId == null) { + throw ArgumentError('Pending child session requires a parentOrderId'); + } + await putItem('$pendingChildKeyPrefix${session.tradeKey.public}', session); + } + + /// Removes the pending child session record for [tradeKeyPublic], if any. + /// Called once the child order id is known and the session is re-stored + /// under its orderId, or when an expired pending child is cleaned up. + Future deletePendingChildSession(String tradeKeyPublic) => + deleteItem('$pendingChildKeyPrefix$tradeKeyPublic'); + /// Shortcut to get a single session by its ID. Future getSession(String sessionId) => getItem(sessionId); diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 6754655c..65d5b65c 100644 --- a/lib/features/notifications/services/background_notification_service.dart +++ b/lib/features/notifications/services/background_notification_service.dart @@ -9,6 +9,7 @@ import 'package:mostro_mobile/services/logger_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mostro_mobile/core/app.dart'; +import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/order.dart'; @@ -342,6 +343,13 @@ Future _handleTradeKeyEvent(NostrEvent event, Session session) a final mostroMessage = MostroMessage.fromJson(result[0]); mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch; + // If this message belongs to a pending range-order child session, link it to + // the child order id right here. The foreground link + // (MostroService._maybeLinkChildOrder) never runs while the app is + // backgrounded or killed, and this and later events for the child order need + // the session stored under its orderId (e.g. role-gated notifications). + await _maybeLinkChildOrder(mostroMessage, session); + // If this event transitions the order to Active, learn the counterpart's // tradeKey from the Order payload and persist it on the session so the // background service can immediately subscribe to P2P chat events. @@ -353,6 +361,49 @@ Future _handleTradeKeyEvent(NostrEvent event, Session session) a return mostroMessage; } +/// Links a pending range-order child session to its concrete child order id +/// when a message for that child arrives while the app is backgrounded. +/// Mirrors MostroService._maybeLinkChildOrder for the background isolate: +/// stores the session under its orderId and drops the pending record. +/// +/// The trigger is any message carrying an order id, not just the child +/// new-order confirmation: the device may well be woken (FCM) by a later +/// event — the very take-order message this notification path exists for — +/// while the new-order message was never delivered to this isolate. The child +/// trade key is used by exactly one order, so any id other than the parent's +/// identifies it. +Future _maybeLinkChildOrder( + MostroMessage message, + Session session, +) async { + final childOrderId = message.id; + if (childOrderId == null || childOrderId.isEmpty) return; + if (session.orderId != null || session.parentOrderId == null) return; + if (childOrderId == session.parentOrderId) return; + + try { + session.orderId = childOrderId; + + final sessionStorage = await _openSessionStorage(); + await sessionStorage.putSession(session); + // Only after the session is durably stored under its orderId, otherwise a + // crash in between would lose it entirely. + await sessionStorage.deletePendingChildSession(session.tradeKey.public); + + logger.i( + 'Background linked child order $childOrderId to parent ${session.parentOrderId}', + ); + } catch (e, stackTrace) { + // Keep the in-memory session pending so the next attempt (background or + // foreground) retries the link instead of assuming a stored orderId. + session.orderId = null; + logger.e( + 'Failed to link child order in background: $e', + stackTrace: stackTrace, + ); + } +} + /// Persists the peer on the given session when [message] is an action that /// reveals the counterpart's tradeKey (buyer took order / hold invoice /// payment accepted), and triggers a live chat subscription in the @@ -399,17 +450,7 @@ Future _maybeUpdateSessionWithPeer( // Setting peer on the session computes the shared key via ECDH. session.peer = Peer(publicKey: peerPubkey); - final db = await openMostroDatabase('mostro.db'); - const secureStorage = FlutterSecureStorage(); - final sharedPrefs = SharedPreferencesAsync(); - final keyStorage = KeyStorage( - secureStorage: secureStorage, - sharedPrefs: sharedPrefs, - ); - final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); - final keyManager = KeyManager(keyStorage, keyDerivator); - await keyManager.init(); - final sessionStorage = SessionStorage(keyManager, db: db); + final sessionStorage = await _openSessionStorage(); await sessionStorage.putSession(session); logger.i('Background persisted peer for order ${session.orderId}'); @@ -499,17 +540,26 @@ Future _loadMostroPubkey() async { } } +/// Opens a SessionStorage bound to the background isolate's own database and +/// key manager handles. The isolate has no Riverpod container, so every +/// persistence helper here has to build its own. +Future _openSessionStorage() async { + final db = await openMostroDatabase('mostro.db'); + const secureStorage = FlutterSecureStorage(); + final sharedPrefs = SharedPreferencesAsync(); + final keyStorage = KeyStorage( + secureStorage: secureStorage, + sharedPrefs: sharedPrefs, + ); + final keyDerivator = KeyDerivator(Config.keyDerivationPath); + final keyManager = KeyManager(keyStorage, keyDerivator); + await keyManager.init(); + return SessionStorage(keyManager, db: db); +} + Future> _loadSessionsFromDatabase() async { try { - final db = await openMostroDatabase('mostro.db'); - const secureStorage = FlutterSecureStorage(); - final sharedPrefs = SharedPreferencesAsync(); - final keyStorage = KeyStorage(secureStorage: secureStorage, sharedPrefs: sharedPrefs); - final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); - final keyManager = KeyManager(keyStorage, keyDerivator); - - await keyManager.init(); - final sessionStorage = SessionStorage(keyManager, db: db); + final sessionStorage = await _openSessionStorage(); return await sessionStorage.getAll(); } catch (e) { logger.e('Session load error: $e'); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 7be83dbc..36f8f1f0 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -188,28 +188,34 @@ class MostroService { } } + /// Links a pending range-order child session to its concrete child order id. + /// + /// The trigger is any message carrying an order id, not just the child + /// new-order confirmation: after a restart (or when the confirmation was + /// only seen by the background isolate) the first message this notifier sees + /// for the child trade key can be a later one, and leaving the session + /// pending would keep the child order out of My Trades. The child trade key + /// is used by exactly one order, so any id other than the parent's + /// identifies it. Future _maybeLinkChildOrder( MostroMessage message, Session session, ) async { - if (message.action != Action.newOrder || message.id == null) { - return; - } - - if (session.orderId != null || session.parentOrderId == null) { - return; - } + final childOrderId = message.id; + if (childOrderId == null || childOrderId.isEmpty) return; + if (session.orderId != null || session.parentOrderId == null) return; + if (childOrderId == session.parentOrderId) return; final sessionNotifier = ref.read(sessionNotifierProvider.notifier); await sessionNotifier.linkChildSessionToOrderId( - message.id!, + childOrderId, session.tradeKey.public, ); - ref.read(orderNotifierProvider(message.id!).notifier).subscribe(); + ref.read(orderNotifierProvider(childOrderId).notifier).subscribe(); logger.i( - 'Linked child order ${message.id} to parent ${session.parentOrderId}', + 'Linked child order $childOrderId to parent ${session.parentOrderId}', ); } diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 9a693378..6ee78a2f 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -91,31 +91,84 @@ class SessionNotifier extends StateNotifier> { return order != null && !order.status.isTerminal; } + /// Cutoff past which a persisted pending child session is discarded. + /// + /// Pending children are a short-lived handoff: mostrod publishes the child + /// order right after the release, so a record still unlinked after + /// [Config.pendingChildSessionExpirationHours] never will be. The bound + /// applies even when session expiration is disabled, otherwise orphan + /// records would pile up on disk and keep widening the orders subscription + /// filter on every launch. When session expiration is enabled and stricter, + /// it wins. + DateTime get _pendingChildCutoff { + final now = DateTime.now(); + final pendingCutoff = now.subtract( + const Duration(hours: Config.pendingChildSessionExpirationHours), + ); + if (_isForever) return pendingCutoff; + final sessionCutoff = now.subtract(Duration(hours: _expirationHours)); + // The later cutoff is the stricter one. + return sessionCutoff.isAfter(pendingCutoff) ? sessionCutoff : pendingCutoff; + } + + /// Restores a persisted record that carries no orderId. Only pending + /// range-order children are stored that way (keyed by trade key) so they + /// survive an app kill between release and the child new-order message; any + /// other orderId-less record can never be linked and is dropped. + Future _restorePendingChildSession(Session session) async { + final isPendingChild = session.parentOrderId != null; + if (isPendingChild && session.startTime.isAfter(_pendingChildCutoff)) { + _pendingChildSessions[session.tradeKey.public] = session; + return; + } + if (!isPendingChild) { + logger.w( + 'Dropping stored session without orderId nor parentOrderId ' + '(trade key ${session.tradeKey.public})', + ); + } + await _dropPendingChildRecord(session.tradeKey.public); + } + + /// Drops the pending record of a child session that no longer needs it + /// (linked under its orderId, or expired). Failures are logged instead of + /// thrown: the session is already stored correctly and a leftover pending + /// record expires on its own, whereas an exception here would skip the + /// state emission and the push-token retry of the caller. + Future _dropPendingChildRecord(String tradeKeyPublic) async { + try { + await _storage.deletePendingChildSession(tradeKeyPublic); + } catch (e) { + logger.e( + 'Failed to delete pending child record for $tradeKeyPublic: $e', + ); + } + } + Future init() async { final allSessions = await _storage.getAllSessions(); - if (_isForever) { - for (final session in allSessions) { - _sessions[session.orderId!] = session; + final cutoff = + DateTime.now().subtract(Duration(hours: _expirationHours)); + for (final session in allSessions) { + if (session.orderId == null) { + await _restorePendingChildSession(session); + continue; } - } else { - final cutoff = DateTime.now() - .subtract(Duration(hours: _expirationHours)); - for (final session in allSessions) { - if (session.startTime.isAfter(cutoff)) { + + if (_isForever || session.startTime.isAfter(cutoff)) { + _sessions[session.orderId!] = session; + } else { + if (await _isActiveSession(session)) { + logger.i('Skipping cleanup for active session ${session.orderId}'); _sessions[session.orderId!] = session; - } else { - if (await _isActiveSession(session)) { - logger.i('Skipping cleanup for active session ${session.orderId}'); - _sessions[session.orderId!] = session; - continue; - } - await _storage.deleteSession(session.orderId!); - _sessions.remove(session.orderId!); - try { - await _cleanupSessionData(session); - } catch (e) { - logger.e('Failed to cleanup data for session ${session.orderId}: $e'); - } + continue; + } + await _storage.deleteSession(session.orderId!); + _sessions.remove(session.orderId!); + try { + await _cleanupSessionData(session); + } catch (e) { + logger.e('Failed to cleanup data for session ${session.orderId}: $e'); } } } @@ -140,13 +193,26 @@ class SessionNotifier extends StateNotifier> { } void _cleanup() async { - if (_isForever) return; - + // Pending children expire on their own bounded window, so this runs even + // when session expiration is disabled. + final pendingCutoff = _pendingChildCutoff; final cutoff = DateTime.now() .subtract(Duration(hours: _expirationHours)); - final expiredSessions = await _storage.getAllSessions(); + final storedSessions = await _storage.getAllSessions(); + + for (final session in storedSessions) { + // Pending child sessions (no orderId) are keyed by trade key and have + // no associated order data to clean up. + if (session.orderId == null) { + if (session.startTime.isBefore(pendingCutoff)) { + _pendingChildSessions.remove(session.tradeKey.public); + await _dropPendingChildRecord(session.tradeKey.public); + } + continue; + } + + if (_isForever) continue; - for (final session in expiredSessions) { if (session.startTime.isBefore(cutoff)) { if (await _isActiveSession(session)) { logger.i('Skipping cleanup for active session ${session.orderId}'); @@ -162,8 +228,11 @@ class SessionNotifier extends StateNotifier> { } } + // Also covers pending children whose persistence failed and therefore + // only live in memory. pendingCutoff is already the stricter of the two + // windows, so this subsumes the session expiration cutoff. _pendingChildSessions.removeWhere( - (_, session) => session.startTime.isBefore(cutoff), + (_, session) => session.startTime.isBefore(pendingCutoff), ); _emitState(); @@ -205,8 +274,16 @@ class SessionNotifier extends StateNotifier> { Future saveSession(Session session) async { _sessions[session.orderId!] = session; _requestIdToSession.removeWhere((_, value) => identical(value, session)); - _pendingChildSessions.remove(session.tradeKey.public); + final wasPendingChild = + _pendingChildSessions.remove(session.tradeKey.public) != null; await _storage.putSession(session); + // Drop the pending record only after the session is durably stored under + // its orderId, otherwise a crash in between would lose it entirely. The + // parentOrderId check also clears records left behind when the link + // already happened in the background isolate. + if (wasPendingChild || session.parentOrderId != null) { + await _dropPendingChildRecord(session.tradeKey.public); + } _emitState(); // Register push notification token for this trade @@ -348,6 +425,17 @@ class SessionNotifier extends StateNotifier> { _pendingChildSessions[tradeKey.public] = session; _emitState(); + // Persist immediately: the session must survive an app kill between + // release and the child new-order message, and the background isolate + // loads sessions from storage to decrypt events addressed to this trade + // key. Without this, child-order events received while the app is not in + // the foreground could never be decrypted (and never notified). + try { + await _storage.putPendingChildSession(session); + } catch (e) { + logger.e('Failed to persist pending child session: $e'); + } + // Register the child trade key with the push server right away: the child // order can be taken as soon as mostrod publishes it, and without this // mapping the push server cannot wake the device (FCM) for events @@ -378,6 +466,9 @@ class SessionNotifier extends StateNotifier> { session.orderId = childOrderId; _sessions[childOrderId] = session; await _storage.putSession(session); + // The session is now stored under its orderId; drop the pending record so + // it is not restored twice on the next init. + await _dropPendingChildRecord(tradeKeyPublic); _emitState(); // Retry the push registration on link in case the creation-time attempt diff --git a/test/notifiers/pending_child_session_persistence_test.dart b/test/notifiers/pending_child_session_persistence_test.dart new file mode 100644 index 00000000..01a040ff --- /dev/null +++ b/test/notifiers/pending_child_session_persistence_test.dart @@ -0,0 +1,334 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:sembast/sembast_memory.dart'; + +import '../mocks.mocks.dart'; + +void main() { + late MockRef mockRef; + late MockKeyManager mockKeyManager; + late SessionStorage storage; + + // Dummy private keys for testing purposes only + final masterKey = NostrKeyPairs( + private: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ); + final childTradeKey = NostrKeyPairs( + private: + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ); + const childKeyIndex = 5; + + Settings buildSettings({int? sessionExpirationHours}) => Settings( + relays: [], + fullPrivacyMode: false, + mostroPublicKey: 'test', + defaultFiatCode: 'USD', + selectedLanguage: null, + sessionExpirationHours: sessionExpirationHours, + ); + + SessionNotifier buildNotifier({int? sessionExpirationHours}) => + SessionNotifier( + mockRef, + storage, + buildSettings(sessionExpirationHours: sessionExpirationHours), + ); + + /// Rewrites the persisted pending child record with an older start time, + /// simulating a record that has been sitting on disk for [age]. + Future agePendingChildRecord(Duration age) async { + final key = '${SessionStorage.pendingChildKeyPrefix}${childTradeKey.public}'; + final stored = await storage.store.record(key).get(storage.db); + final aged = Map.from(stored!) + ..['start_time'] = + DateTime.now().subtract(age).toIso8601String(); + await storage.store.record(key).put(storage.db, aged); + } + + setUpAll(() { + provideDummy(MockKeyManager()); + }); + + setUp(() async { + mockRef = MockRef(); + mockKeyManager = MockKeyManager(); + + when(mockRef.read(keyManagerProvider)).thenReturn(mockKeyManager); + when(mockKeyManager.masterKeyPair).thenReturn(masterKey); + when(mockKeyManager.deriveTradeKeyPair(childKeyIndex)) + .thenReturn(childTradeKey); + + final db = await newDatabaseFactoryMemory().openDatabase('sessions-test'); + storage = SessionStorage(mockKeyManager, db: db); + }); + + group('createChildOrderSession persistence', () { + test('persists the pending child session to storage', () async { + // Arrange + final notifier = buildNotifier(); + + // Act + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + + // Assert: the record is on disk, so the background isolate (which + // loads sessions from storage) can decrypt events addressed to the + // child trade key. + final stored = await storage.getAllSessions(); + expect(stored, hasLength(1)); + expect(stored.first.orderId, isNull); + expect(stored.first.parentOrderId, 'parent-order-id'); + expect(stored.first.tradeKey.public, childTradeKey.public); + }); + + test('restores the pending child session after an app restart', () async { + // Arrange: a pending child exists, then the app is killed + final notifier = buildNotifier(); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + + // Act: a fresh notifier over the same storage simulates the restart + final restarted = buildNotifier(); + await restarted.init(); + + // Assert + final restored = restarted.state + .where((s) => s.tradeKey.public == childTradeKey.public) + .toList(); + expect(restored, hasLength(1)); + expect(restored.first.orderId, isNull); + expect(restored.first.parentOrderId, 'parent-order-id'); + }); + + test('drops an expired pending child session on init', () async { + // Arrange: persist a pending child, then age it beyond expiration + final notifier = buildNotifier(); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + await agePendingChildRecord(const Duration(hours: 3)); + + // Act: restart with a 1-hour expiration window + final restarted = buildNotifier(sessionExpirationHours: 1); + await restarted.init(); + + // Assert: not restored and removed from storage + expect(restarted.state, isEmpty); + expect(await storage.getAllSessions(), isEmpty); + }); + }); + + group('linkChildSessionToOrderId persistence', () { + test('re-stores the session under its orderId and drops the pending one', + () async { + // Arrange + final notifier = buildNotifier(); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + + // Act + await notifier.linkChildSessionToOrderId( + 'child-order-id', + childTradeKey.public, + ); + + // Assert: a single record remains, keyed by the child order id + final stored = await storage.getAllSessions(); + expect(stored, hasLength(1)); + expect(stored.first.orderId, 'child-order-id'); + expect(await storage.getSession('child-order-id'), isNotNull); + }); + + test('links a pending child restored after an app restart', () async { + // Arrange: pending child created, app killed, app restarted + final notifier = buildNotifier(); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + final restarted = buildNotifier(); + await restarted.init(); + + // Act: the child new-order message arrives after the restart + await restarted.linkChildSessionToOrderId( + 'child-order-id', + childTradeKey.public, + ); + + // Assert + expect( + restarted.getSessionByOrderId('child-order-id'), + isNotNull, + ); + final stored = await storage.getAllSessions(); + expect(stored, hasLength(1)); + expect(stored.first.orderId, 'child-order-id'); + }); + }); + + group('pending child bounded lifetime', () { + test('keeps a pending child when session expiration is disabled', + () async { + // Arrange: expiration disabled (0 == forever) and an old-ish record + final notifier = buildNotifier(sessionExpirationHours: 0); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + await agePendingChildRecord(const Duration(hours: 3)); + + // Act + final restarted = buildNotifier(sessionExpirationHours: 0); + await restarted.init(); + + // Assert + expect(restarted.state, hasLength(1)); + expect(await storage.getAllSessions(), hasLength(1)); + }); + + test('drops a pending child past its own TTL even with expiration disabled', + () async { + // Arrange: a pending child that was never linked. With expiration + // disabled it would otherwise stay on disk (and in the orders filter) + // forever, so the bounded TTL has to apply on its own. + final notifier = buildNotifier(sessionExpirationHours: 0); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + await agePendingChildRecord( + Duration(hours: Config.pendingChildSessionExpirationHours + 1), + ); + + // Act + final restarted = buildNotifier(sessionExpirationHours: 0); + await restarted.init(); + + // Assert + expect(restarted.state, isEmpty); + expect(await storage.getAllSessions(), isEmpty); + }); + + test('drops a stored record with neither orderId nor parentOrderId', + () async { + // Arrange: only pending children are stored without an orderId; any + // other such record can never be linked. + final notifier = buildNotifier(); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + final key = + '${SessionStorage.pendingChildKeyPrefix}${childTradeKey.public}'; + final stored = await storage.store.record(key).get(storage.db); + await storage.store.record(key).put( + storage.db, + Map.from(stored!)..['parent_order_id'] = null, + ); + + // Act + final restarted = buildNotifier(); + await restarted.init(); + + // Assert + expect(restarted.state, isEmpty); + expect(await storage.getAllSessions(), isEmpty); + }); + + test('saveSession drops the pending record once the order id is known', + () async { + // Arrange + final notifier = buildNotifier(); + final session = await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + + // Act: the child session is saved through the generic path instead of + // linkChildSessionToOrderId + session.orderId = 'child-order-id'; + await notifier.saveSession(session); + + // Assert + final stored = await storage.getAllSessions(); + expect(stored, hasLength(1)); + expect(stored.first.orderId, 'child-order-id'); + }); + }); + + group('SessionStorage pending child records', () { + test('putPendingChildSession rejects sessions that have an orderId', + () async { + final notifier = buildNotifier(); + final session = await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + session.orderId = 'some-order-id'; + + expect( + () => storage.putPendingChildSession(session), + throwsArgumentError, + ); + }); + + test('getAll includes pending child sessions for the background isolate', + () async { + // Arrange + final notifier = buildNotifier(); + await notifier.createChildOrderSession( + tradeKey: childTradeKey, + keyIndex: childKeyIndex, + parentOrderId: 'parent-order-id', + role: Role.seller, + ); + + // Act: getAll() is what _loadSessionsFromDatabase uses in the + // background isolate to match events by trade key. + final all = await storage.getAll(); + + // Assert + expect( + all.any((s) => s.tradeKey.public == childTradeKey.public), + isTrue, + ); + }); + }); +} diff --git a/test/notifiers/session_notifier_test.dart b/test/notifiers/session_notifier_test.dart index 2b0215d0..a9abb89e 100644 --- a/test/notifiers/session_notifier_test.dart +++ b/test/notifiers/session_notifier_test.dart @@ -40,6 +40,8 @@ void main() { when(mockKeyManager.masterKeyPair).thenReturn(masterKey); when(mockPushService.registerToken(any)).thenAnswer((_) async => true); when(mockStorage.putSession(any)).thenAnswer((_) async {}); + when(mockStorage.putPendingChildSession(any)).thenAnswer((_) async {}); + when(mockStorage.deletePendingChildSession(any)).thenAnswer((_) async {}); notifier = SessionNotifier( mockRef,