From 336a586387266c88aa1a5833ae4333e07ad9d145 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 20:07:43 -0400 Subject: [PATCH] Fix cloud conflict adoption and durable media recovery --- convex/imageAssetLifecycle.test.ts | 31 +++ lib/collab/convex_strategy_repository.dart | 4 + lib/collab/durable_cloud_media_outbox.dart | 9 +- .../active_page_live_sync_provider.dart | 15 +- .../cloud_media_upload_queue_provider.dart | 208 ++++++++++----- .../collab/strategy_op_queue_provider.dart | 12 +- .../strategy_page_session_provider.dart | 64 ++++- lib/services/unsaved_strategy_guard.dart | 4 + lib/strategy/strategy_page_apply.dart | 8 +- lib/strategy/strategy_page_source.dart | 20 +- ...loud_media_upload_queue_provider_test.dart | 245 ++++++++++++++++++ ...rable_cloud_media_outbox_version_test.dart | 58 +++++ test/strategy_page_session_provider_test.dart | 198 +++++++++++++- test/unsaved_strategy_guard_test.dart | 61 +++++ 14 files changed, 837 insertions(+), 100 deletions(-) create mode 100644 test/durable_cloud_media_outbox_version_test.dart diff --git a/convex/imageAssetLifecycle.test.ts b/convex/imageAssetLifecycle.test.ts index 38b64be8..900600b7 100644 --- a/convex/imageAssetLifecycle.test.ts +++ b/convex/imageAssetLifecycle.test.ts @@ -136,6 +136,37 @@ afterEach(() => { }); describe("image asset lifecycle", () => { + test("stale pending uploads require a fresh intent even when referenced", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy, pages } = await getStrategyAndPages(t); + const staleAt = Date.now() - 48 * 60 * 60 * 1000; + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "offline-image", strategyId: strategy._id, provider: "r2", + objectKey: "offline/image.png", uploadAttemptPublicId: "offline-attempt", + uploadStatus: "pending", fileExtension: ".png", mimeType: "image/png", + createdAt: staleAt, updatedAt: staleAt, + }); + await ctx.db.insert("elements", { + publicId: "offline-image", strategyId: strategy._id, pageId: pages[0]!._id, + elementType: "image", payloadKind: "image", payloadVersion: 1, payload: imagePayload("offline-image"), + sortIndex: 0, revision: 1, deleted: false, createdAt: staleAt, updatedAt: staleAt, + }); + }); + await t.mutation(markStaleImageUploadsDeleted, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalled(); + await expect(owner.action(completeUpload, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, assetPublicId: "offline-image", provider: "r2", + uploadId: "offline-attempt", objectKey: "offline/image.png", + })).rejects.toThrow(/Upload intent not found/); + }); + test("page deletion removes only assets unreferenced by remaining Pages and Lineups", async () => { vi.useFakeTimers(); const fetchMock = mockR2Deletes(); diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index baf62564..d0da913a 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -494,6 +494,10 @@ bool isTypedConvexUnauthenticatedError(Object error) { error.rawCode == ConvexErrorCode.unauthenticated.wireName); } +bool isMissingImageUploadIntentError(Object error) => + error is ConvexFunctionException && + error.code == ConvexErrorCode.uploadIntentNotFound; + CloudFolderEntry _folderEntry(FoldersListTreeResultItem folder) { return ( folder: Folder( diff --git a/lib/collab/durable_cloud_media_outbox.dart b/lib/collab/durable_cloud_media_outbox.dart index 8c373335..369c471c 100644 --- a/lib/collab/durable_cloud_media_outbox.dart +++ b/lib/collab/durable_cloud_media_outbox.dart @@ -10,16 +10,19 @@ const durableCloudMediaOutboxVersionKey = '__media_outbox_record_version__'; Future prepareDurableCloudMediaOutbox() async { final box = Hive.box(HiveBoxNames.cloudMediaOutboxBox); - if (box.get(durableCloudMediaOutboxVersionKey) == - durableCloudMediaOutboxRecordVersion) { + final version = box.get(durableCloudMediaOutboxVersionKey); + if (version == durableCloudMediaOutboxRecordVersion) { return; } - if (box.isNotEmpty) { + if (version != null && version != 1) { throw StateError( 'The media outbox has an unsupported record version. ' 'Refusing to discard pending media work.', ); } + // Prerelease v1 records had no owning account. Keep them byte-for-byte + // for recovery, and let load() report them as unreadable saved work. + // Never assign them to whichever account happens to sign in next. await box.put( durableCloudMediaOutboxVersionKey, durableCloudMediaOutboxRecordVersion, diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 67fe97c6..dac37870 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -168,7 +168,7 @@ class ActivePageLiveSyncNotifier extends Notifier { for (final intent in intents) { final revision = intent.ack.appliedRevision; final key = intent.entityKey; - if (revision == null || key.pageId != state.hydratedPageId) { + if (revision == null) { continue; } final accepted = _normalizedAcceptedEntity( @@ -205,7 +205,13 @@ class ActivePageLiveSyncNotifier extends Notifier { PagePatchOp(:final payload) => _NormalizedEntity( key: key, overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, - payload: payload, + // The canvas tracks side only. Rename acks must not replace that + // baseline, or introduce fields the canvas never compares. + payload: { + if (previous != null) ..._decodeObject(previous.payload), + if (payload.containsKey('isAttack')) + 'isAttack': payload['isAttack'], + }, sortIndex: null, revision: revision, deleted: false, @@ -515,6 +521,7 @@ class ActivePageLiveSyncNotifier extends Notifier { ActivePageProjectedState? projectPageState({ required String strategyPublicId, required String pageId, + Set excludedOverlays = const {}, }) { setContext(strategyPublicId: strategyPublicId, activePageId: pageId); final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; @@ -557,7 +564,9 @@ class ActivePageLiveSyncNotifier extends Notifier { var projectedIsAttack = page.isAttack; final pageOverlays = state.overlayByEntityKey.entries.where( - (entry) => entry.key.pageId == page.publicId, + (entry) => + entry.key.pageId == page.publicId && + !excludedOverlays.contains(entry.key), ); for (final entry in pageOverlays) { final overlay = entry.value; diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart index b909cbf7..313c760d 100644 --- a/lib/providers/collab/cloud_media_upload_queue_provider.dart +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -107,6 +107,8 @@ final cloudMediaAccountIdProvider = Provider( (ref) => ref.watch(authProvider.select((state) => state.user?.id)), ); +enum _MediaOutboxMutation { write, remove } + class CloudMediaUploadQueueNotifier extends Notifier { static const Duration _blockedRetryDelay = Duration(seconds: 30); @@ -121,6 +123,8 @@ class CloudMediaUploadQueueNotifier final Set _uploadCompletedJobs = {}; final Map _jobsByStorageKey = {}; final Set _restoredStagedJobIds = {}; + final Map _unverifiedByStorageKey = {}; + Future _writeTail = Future.value(); bool _disposed = false; late DurableCloudMediaOutboxStore _store; @@ -189,9 +193,6 @@ class CloudMediaUploadQueueNotifier isProcessing: false, loadIssues: loaded.issues, durableLoaded: true, - durabilityError: loaded.issues.isEmpty - ? null - : 'The media outbox contains unreadable saved work.', ); } @@ -687,7 +688,8 @@ class CloudMediaUploadQueueNotifier return false; } - await _deleteJob(job); + final deleted = await _deleteJob(job, onlyIfUnreferenced: true); + if (!deleted) return false; _refreshState(); _logMedia( 'missing_source.removed_unreferenced job=${job.jobId} ' @@ -775,8 +777,18 @@ class CloudMediaUploadQueueNotifier 'strategy=${job.strategyPublicId}'); } catch (error) { _logMedia('attach.failed error=$error ${_describeJob(job)}'); + final missingIntent = isMissingImageUploadIntentError(error); await _markJobFailed( - job, + missingIntent + ? job.copyWith( + provider: null, + uploadId: null, + objectKey: null, + storageId: null, + etag: null, + uploadUrlExpiresAt: null, + ) + : job, '$error', showToast: job.attempts == 0, ); @@ -892,17 +904,14 @@ class CloudMediaUploadQueueNotifier _opReferencesAsset(pending.op, job.assetPublicId), ), ) - .map( - (job) => job.copyWith( - referenceDurable: true, - updatedAt: DateTime.now(), - ), - ) .toList(growable: false); - if (readyFromDurableOps.isNotEmpty) { + for (final job in readyFromDurableOps) { if (ref.read(cloudMediaAccountIdProvider) != accountId) return; - await _putJobsAtomically(readyFromDurableOps); - _refreshState(); + final promoted = job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ); + await _writeJobs([promoted], () => _store.put(promoted), replacing: job); } if (durableOps.issues.isNotEmpty) return; @@ -933,30 +942,28 @@ class CloudMediaUploadQueueNotifier if (ref.read(cloudMediaAccountIdProvider) != accountId) return; - final referenced = entry.value - .where( - (job) => _snapshotReferencesAsset(snapshot, job.assetPublicId), - ) - .map( - (job) => job.copyWith( - referenceDurable: true, - updatedAt: DateTime.now(), - ), - ) - .toList(growable: false); - await _putJobsAtomically(referenced); - if (ref.read(cloudMediaAccountIdProvider) != accountId) return; - final referencedIds = referenced.map((job) => job.jobId).toSet(); - for (final orphan in entry.value) { - if (!referencedIds.contains(orphan.jobId) && + for (final job in entry.value) { + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + // Both jobs and op references may change while the server is read. + // Never promote or delete using a stale copy of a media job. + if (!identical(_getJob(job.jobId), job)) continue; + final localReference = _hasLocalReference(job); + if (_snapshotReferencesAsset(snapshot, job.assetPublicId) || + localReference == true) { + final promoted = job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ); + await _writeJobs( + [promoted], + () => _store.put(promoted), + replacing: job, + ); + } else if (localReference == false && _restoredStagedJobIds.contains( - durableCloudMediaOutboxStorageKey(orphan), + durableCloudMediaOutboxStorageKey(job), )) { - await _deleteJob(orphan); - _logMedia( - 'reference_reconcile.removed_orphan job=${orphan.jobId} ' - 'strategy=${orphan.strategyPublicId}', - ); + await _deleteJob(job, onlyIfUnreferenced: true); } } _refreshState(); @@ -1111,13 +1118,7 @@ class CloudMediaUploadQueueNotifier } Future _putJob(CloudMediaUploadJob job) async { - try { - await _store.put(job); - _jobsByStorageKey[durableCloudMediaOutboxStorageKey(job)] = job; - } catch (error, stackTrace) { - _recordDurabilityFailure(error, stackTrace); - rethrow; - } + await _writeJobs([job], () => _store.put(job)); } Future _putJobsAtomically( @@ -1125,31 +1126,91 @@ class CloudMediaUploadQueueNotifier ) async { final jobList = jobs.toList(growable: false); if (jobList.isEmpty) return; - try { - await _store.putAll(jobList); - for (final job in jobList) { - final storageKey = durableCloudMediaOutboxStorageKey(job); - _jobsByStorageKey[storageKey] = job; - if (job.referenceDurable) { - _restoredStagedJobIds.remove(storageKey); - } - } - } catch (error, stackTrace) { - _recordDurabilityFailure(error, stackTrace); - rethrow; - } + await _writeJobs(jobList, () => _store.putAll(jobList)); } - Future _deleteJob(CloudMediaUploadJob job) async { - try { - await _store.remove(job); - final storageKey = durableCloudMediaOutboxStorageKey(job); - _jobsByStorageKey.remove(storageKey); - _restoredStagedJobIds.remove(storageKey); - } catch (error, stackTrace) { - _recordDurabilityFailure(error, stackTrace); - rethrow; - } + // Null means unreadable records or unverified writes prevent proving that + // this asset is unreferenced. Include in-memory ops still being persisted. + bool? _hasLocalReference(CloudMediaUploadJob job) { + final durable = ref.read(durableStrategyOutboxStoreProvider).load(); + final queue = ref.read(strategyOpQueueProvider); + if (durable.issues.isNotEmpty || + !queue.durableLoaded || + queue.hasDurabilityFailure) return null; + final durableReference = durable.records.any((record) => + record.accountId == job.accountId && + record.strategyPublicId == job.strategyPublicId && + (_opReferencesAsset(record.pending.op, job.assetPublicId) || + (record.successorPending != null && + _opReferencesAsset( + record.successorPending!.op, job.assetPublicId)))); + if (durableReference) return true; + final pendingReference = queue.accountId == job.accountId && + queue.strategyPublicId == job.strategyPublicId && + queue.pending.any( + (pending) => _opReferencesAsset(pending.op, job.assetPublicId)); + return pendingReference ? null : false; + } + + Future _writeJobs( + List jobs, + Future Function() persist, { + CloudMediaUploadJob? replacing, + }) => + _serializeWrite(() async { + if (replacing != null && + !identical(_getJob(replacing.jobId), replacing)) return; + final keys = jobs.map(durableCloudMediaOutboxStorageKey).toSet(); + try { + await persist(); + for (final job in jobs) { + final key = durableCloudMediaOutboxStorageKey(job); + _jobsByStorageKey[key] = job; + if (job.referenceDurable) _restoredStagedJobIds.remove(key); + } + for (final key in keys) { + _unverifiedByStorageKey.remove(key); + } + _refreshState(); + } catch (error, stackTrace) { + for (final key in keys) { + _unverifiedByStorageKey[key] = _MediaOutboxMutation.write; + } + _recordDurabilityFailure(error, stackTrace); + rethrow; + } + }); + + Future _deleteJob( + CloudMediaUploadJob job, { + bool onlyIfUnreferenced = false, + }) => + _serializeWrite(() async { + final key = durableCloudMediaOutboxStorageKey(job); + if (onlyIfUnreferenced && + (!_belongsToActiveAccount(job) || + !identical(_jobsByStorageKey[key], job) || + _unverifiedByStorageKey[key] == _MediaOutboxMutation.write || + _hasLocalReference(job) != false)) return false; + try { + await _store.remove(job); + _jobsByStorageKey.remove(key); + _restoredStagedJobIds.remove(key); + _unverifiedByStorageKey.remove(key); + _refreshState(); + return true; + } catch (error, stackTrace) { + _unverifiedByStorageKey[key] = _MediaOutboxMutation.remove; + _recordDurabilityFailure(error, stackTrace); + rethrow; + } + }); + + Future _serializeWrite(Future Function() action) { + final result = _writeTail.then((_) => action()); + _writeTail = + result.then((_) {}, onError: (Object _, StackTrace __) {}); + return result; } void _recordDurabilityFailure(Object error, StackTrace stackTrace) { @@ -1159,16 +1220,23 @@ class CloudMediaUploadQueueNotifier stackTrace: stackTrace, source: 'cloud_media.upload_queue', ); - state = state.copyWith( - durabilityError: 'Media work could not be saved on this device.', - isProcessing: false, - ); + _refreshState(isProcessing: false); } void _refreshState({bool? isProcessing}) { + if (_disposed) return; + final accountId = ref.read(cloudMediaAccountIdProvider); + final prefix = + accountId == null ? null : '${Uri.encodeComponent(accountId)}|'; + final hasUnverifiedWrites = prefix != null && + _unverifiedByStorageKey.keys.any((key) => key.startsWith(prefix)); state = state.copyWith( jobs: _readJobs(), isProcessing: isProcessing ?? state.isProcessing, + durabilityError: hasUnverifiedWrites + ? 'Media work could not be saved on this device.' + : null, + clearDurabilityError: !hasUnverifiedWrites, ); _syncUploadProgressToast(); } diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 95f6f09d..03c4eba6 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -115,6 +115,9 @@ class StrategyOpQueueState { final Map attentionByEntityKey; final List loadIssues; final bool durableLoaded; + + /// A write in this process has not been verified. Persisted, unreadable + /// records are reported separately by [loadIssues]. final bool hasDurabilityFailure; final bool isFlushing; final String? lastError; @@ -254,7 +257,7 @@ class StrategyOpQueueNotifier extends Notifier { clientId: const Uuid().v4(), loadIssues: loaded.issues, durableLoaded: true, - hasDurabilityFailure: loaded.issues.isNotEmpty, + hasDurabilityFailure: false, lastError: loaded.issues.isEmpty ? null : 'The cloud outbox contains unreadable saved work.', @@ -362,8 +365,7 @@ class StrategyOpQueueNotifier extends Notifier { } final clientId = matching.firstOrNull?.pending.clientId ?? const Uuid().v4(); - final hasDurabilityFailure = state.loadIssues.isNotEmpty || - _hasDurabilityFailureForAccount(accountId); + final hasDurabilityFailure = _hasDurabilityFailureForAccount(accountId); state = StrategyOpQueueState( accountId: accountId, strategyPublicId: strategyPublicId, @@ -1620,8 +1622,7 @@ class StrategyOpQueueNotifier extends Notifier { pausedByEntityKey: paused, attentionByEntityKey: attention, accountOutbox: _accountSummary(accountId), - hasDurabilityFailure: state.loadIssues.isNotEmpty || - _hasDurabilityFailureForAccount(accountId), + hasDurabilityFailure: _hasDurabilityFailureForAccount(accountId), isFlushing: isFlushing, lastError: effectiveError, clearError: effectiveError == null, @@ -1822,7 +1823,6 @@ class StrategyOpQueueNotifier extends Notifier { } bool get _hasDurabilityFailureForCurrentAccount => - state.loadIssues.isNotEmpty || _hasDurabilityFailureForAccount(state.accountId); void _recordPersistenceFailure(Object error, StackTrace stackTrace) { diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 8bc1e13d..7cca0823 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/page_transition/agent_path.dart'; import 'package:icarus/page_transition/transition_planner.dart'; @@ -459,13 +460,34 @@ class StrategyPageSessionNotifier extends Notifier { } final targetPageId = _resolveHydrationTargetPage(snapshot); + final hasPendingMetadata = ref.read(strategyOpQueueProvider).pending.any( + (pending) { + final op = pending.op; + return op is StrategyPatchOp && + op.payload.keys.any((key) => + key == 'mapData' || + key == 'themeProfileId' || + key == 'clearThemeProfileId' || + key == 'themeOverridePalette' || + key == 'clearThemeOverridePalette'); + }, + ); + final localMetadata = hasPendingMetadata + ? ( + map: ref.read(mapProvider).currentMap, + theme: ref.read(strategyThemeProvider), + ) + : null; if (targetPageId != null) { final pageSource = CloudStrategyPageSource( ref, strategyId: strategyId, activePageId: () => state.activePageId, ); - final pageData = await pageSource.loadAuthoritativePage(targetPageId); + final pageData = await pageSource.loadAuthoritativePage( + targetPageId, + discardedEntities: rejected.keys.toSet(), + ); await _applyLoadedPageData( pageData, strategyId: strategyId, @@ -473,12 +495,39 @@ class StrategyPageSessionNotifier extends Notifier { hydrationKey: _buildRemotePageHydrationKey(snapshot, targetPageId), preserveTextDrafts: true, loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + preservedMetadata: + rejected.containsKey(const EntitySyncKey.strategy()) + ? null + : localMetadata, ); } final discarded = await ref .read(strategyOpQueueProvider.notifier) .discardRejected(rejected.keys.toSet()); + // A failed durable delete keeps its overlay and conflict. Restore those + // entities if only part of the requested adoption could be saved. + if (discarded.length != rejected.length && targetPageId != null) { + final pageSource = CloudStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + final pageData = await pageSource.loadAuthoritativePage( + targetPageId, + discardedEntities: discarded, + ); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: StrategySource.cloud, + preserveTextDrafts: true, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + preservedMetadata: discarded.contains(const EntitySyncKey.strategy()) + ? null + : localMetadata, + ); + } if (discarded.isEmpty) return false; ref.read(activePageLiveSyncProvider.notifier).adoptRemoteForEntities( @@ -501,7 +550,7 @@ class StrategyPageSessionNotifier extends Notifier { .read(strategyOpQueueProvider.notifier) .completeRemoteAdoption(discarded); _pendingRemoteReapply = false; - return true; + return discarded.length == rejected.length; } finally { _isResolvingConflicts = false; } @@ -613,13 +662,17 @@ class StrategyPageSessionNotifier extends Notifier { _RemotePageHydrationKey? hydrationKey, bool preserveTextDrafts = false, RemoteEditorSnapshot? loadedRemoteSnapshot, + ({MapValue map, StrategyThemeState theme})? preservedMetadata, }) async { final preserveHistory = source == StrategySource.cloud && _lastHydratedRemotePageKey?.strategyPublicId == strategyId && _lastHydratedRemotePageKey?.pageId == pageData.pageId; - final themeProfileId = _resolveThemeProfileId(source, strategyId); - final themeOverridePalette = - _resolveThemeOverridePalette(source, strategyId); + final themeProfileId = preservedMetadata != null + ? preservedMetadata.theme.profileId + : _resolveThemeProfileId(source, strategyId); + final themeOverridePalette = preservedMetadata != null + ? preservedMetadata.theme.overridePalette + : _resolveThemeOverridePalette(source, strategyId); state = state.copyWith( isApplyingPage: true, @@ -638,6 +691,7 @@ class StrategyPageSessionNotifier extends Notifier { themeProfileId: themeProfileId, themeOverridePalette: themeOverridePalette, preserveHistory: preserveHistory, + mapOverride: preservedMetadata?.map, ); for (final entry in retainedTextDrafts.entries) { ref.read(textDraftProvider.notifier).setDraft(entry.key, entry.value); diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 708d890a..ea9c7ee6 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -201,6 +201,10 @@ Future _guardCloudStrategyExit({ final hasUnreadableSavedWork = queueState.loadIssues.isNotEmpty || mediaQueueState.loadIssues.isNotEmpty; final canLeaveWithDurableWork = !hasUnstagedWork && + queueState.durableLoaded && + mediaQueueState.durableLoaded && + !queueState.hasDurabilityFailure && + mediaQueueState.durabilityError == null && ((hasDurablePendingWork && queueState.outboxIsReliable && mediaQueueState.outboxIsReliable) || diff --git a/lib/strategy/strategy_page_apply.dart b/lib/strategy/strategy_page_apply.dart index f79871b4..66b7ab1f 100644 --- a/lib/strategy/strategy_page_apply.dart +++ b/lib/strategy/strategy_page_apply.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; @@ -17,9 +18,10 @@ import 'package:icarus/strategy/strategy_page_models.dart'; Future applyStrategyEditorPageData( Ref ref, StrategyEditorPageData data, { - required String themeProfileId, + required String? themeProfileId, required MapThemePalette? themeOverridePalette, bool preserveHistory = false, + MapValue? mapOverride, }) async { ref.read(agentProvider.notifier).clearAll(); ref.read(abilityProvider.notifier).clearAll(); @@ -38,7 +40,9 @@ Future applyStrategyEditorPageData( ref.read(placedImageProvider.notifier).fromHive(data.images); ref.read(utilityProvider.notifier).fromHive(data.utilities); ref.read(lineUpProvider.notifier).fromHive(data.lineUpGroups); - ref.read(mapProvider.notifier).fromHive(data.map, data.isAttack); + ref + .read(mapProvider.notifier) + .fromHive(mapOverride ?? data.map, data.isAttack); ref.read(strategySettingsProvider.notifier).fromHive(data.settings); ref.read(strategyThemeProvider.notifier).fromStrategy( profileId: themeProfileId, diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 93bf3e86..571dee5d 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -175,15 +175,17 @@ class CloudStrategyPageSource implements StrategyPageSource { } @override - Future loadPage(String pageId) => - _loadPage(pageId, projectLocalWork: true); + Future loadPage(String pageId) => _loadPage(pageId); - Future loadAuthoritativePage(String pageId) => - _loadPage(pageId, projectLocalWork: false); + Future loadAuthoritativePage( + String pageId, { + required Set discardedEntities, + }) => + _loadPage(pageId, excludedOverlays: discardedEntities); Future _loadPage( String pageId, { - required bool projectLocalWork, + Set excludedOverlays = const {}, }) async { if (ref .read(remoteEditorSnapshotProvider) @@ -205,12 +207,12 @@ class CloudStrategyPageSource implements StrategyPageSource { orElse: () => pages.first, ); - final projected = projectLocalWork - ? ref.read(activePageLiveSyncProvider.notifier).projectPageState( + final projected = + ref.read(activePageLiveSyncProvider.notifier).projectPageState( strategyPublicId: strategyId, pageId: page.publicId, - ) - : null; + excludedOverlays: excludedOverlays, + ); if (projected != null && (page.publicId == activePageId() || ref diff --git a/test/cloud_media_upload_queue_provider_test.dart b/test/cloud_media_upload_queue_provider_test.dart index 335afa10..5e9d6f0d 100644 --- a/test/cloud_media_upload_queue_provider_test.dart +++ b/test/cloud_media_upload_queue_provider_test.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/durable_cloud_media_outbox.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/generated/convex_error_codes.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; @@ -100,8 +104,44 @@ class _FixedOpQueue extends StrategyOpQueueNotifier { StrategyOpQueueState build() => initialState; } +class _GoneUploadRepository implements ConvexStrategyRepository { + final List attemptedUploadIds = []; + @override + Future completeImageUpload({ + required String strategyPublicId, + required String assetPublicId, + String? provider, + String? uploadId, + String? objectKey, + String? storageId, + String? etag, + String? mimeType, + String? fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + attemptedUploadIds.add(uploadId); + throw const ConvexFunctionException( + code: ConvexErrorCode.uploadIntentNotFound, + rawCode: 'UPLOAD_INTENT_NOT_FOUND', + message: 'Upload intent not found.', + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _FailingBatchStore extends MemoryDurableCloudMediaOutboxStore { bool failBatch = false; + bool failRemove = false; + + @override + Future remove(CloudMediaUploadJob job) async { + if (failRemove) throw StateError('delete failed'); + await super.remove(job); + } @override Future putAll(Iterable jobs) async { @@ -121,6 +161,7 @@ ProviderContainer _container( bool cloudEnabled = false, String? accountId = 'account-a', CloudMediaReferenceSnapshotLoader? referenceSnapshotLoader, + ConvexStrategyRepository? repository, }) { final resolvedOpQueueState = opQueueState ?? (strategyOpen @@ -134,6 +175,8 @@ ProviderContainer _container( return ProviderContainer( overrides: [ durableCloudMediaOutboxStoreProvider.overrideWithValue(store), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), durableStrategyOutboxStoreProvider.overrideWithValue( strategyStore ?? MemoryDurableStrategyOutboxStore(), ), @@ -201,6 +244,208 @@ RemoteFullStrategySnapshot _fullSnapshot({ void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('media durability recovers after a successful retry', () async { + final store = _FailingBatchStore()..failBatch = true; + final container = _container(store); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + final images = [SimpleImageData(id: 'retry-image', fileExtension: '.png')]; + await expectLater( + queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: images, + ), + throwsStateError); + store.failBatch = false; + await queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', images: images); + expect(store.load().issues, isEmpty); + expect(store.load().jobs, hasLength(1)); + await queue.clearJobsForStrategy('strategy-a'); + expect(store.load().jobs, isEmpty); + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect( + container.read(cloudMediaUploadQueueProvider).outboxIsReliable, isTrue); + }); + + test('removed upload intent returns to durable upload recovery', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + await store.put(CloudMediaUploadJob( + jobId: 'image-a', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'image-a', + fileExtension: '.png', + mimeType: 'image/png', + provider: 'r2', + uploadId: 'swept-upload', + objectKey: 'old/image.png', + state: CloudMediaJobState.pendingAttach, + attempts: 1, + updatedAt: DateTime.now().subtract(const Duration(days: 2)), + )); + final repository = _GoneUploadRepository(); + final container = _container(store, + cloudReady: true, cloudEnabled: true, repository: repository); + addTearDown(container.dispose); + container.read(cloudMediaUploadQueueProvider.notifier); + await Future.delayed(const Duration(milliseconds: 100)); + expect(repository.attemptedUploadIds, ['swept-upload']); + final restored = store.load().jobs.single; + expect(restored.uploadId, isNull); + expect(restored.objectKey, isNull); + expect(restored.hasUploadedRemoteObject, isFalse); + expect(restored.referenceDurable, isTrue); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .single + .hasUploadedRemoteObject, + isFalse, + reason: + 'A permanently deleted server intent must not be retried as if the object still exists.'); + }); + + test('a successful write cannot clear another media key failure', () async { + final store = _FailingBatchStore()..failBatch = true; + final container = _container(store); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + final failed = [SimpleImageData(id: 'failed', fileExtension: '.png')]; + await expectLater( + queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: failed, + ), + throwsStateError); + store.failBatch = false; + await queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: [SimpleImageData(id: 'other', fileExtension: '.png')], + ); + expect(container.read(cloudMediaUploadQueueProvider).outboxIsReliable, + isFalse); + await queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: failed, + ); + expect( + container.read(cloudMediaUploadQueueProvider).outboxIsReliable, isTrue); + }); + + for (final restage in [false, true]) { + test('server reads preserve new references and restaged=$restage media', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final ops = MemoryDurableStrategyOutboxStore(); + final job = CloudMediaUploadJob( + jobId: 'racing-image', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'racing-image', + fileExtension: '.png', + mimeType: 'image/png', + referenceDurable: false, + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 4), + ); + await store.put(job); + final snapshot = Completer(); + final started = Completer(); + final container = _container( + store, + strategyStore: ops, + cloudReady: true, + referenceSnapshotLoader: (_) { + if (!started.isCompleted) started.complete(); + return snapshot.future; + }, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await started.future; + if (restage) { + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'racing-image', + fileExtension: '.jpg', + width: 42, + ); + } + await ops.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: const EntitySyncKey.element('page-a', 'racing-image'), + pending: const PendingOp( + clientId: 'client-a', + op: ElementAddOp( + opId: 'new-reference', + elementPublicId: 'racing-image', + pagePublicId: 'page-a', + sortIndex: 0, + payload: {'id': 'racing-image'}, + ), + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + )); + snapshot.complete(_fullSnapshot()); + await queue.retryNow(); + expect(store.load().jobs.single.referenceDurable, isTrue); + expect(store.load().jobs.single.width, restage ? 42 : null); + expect(store.load().jobs.single.fileExtension, restage ? '.jpg' : '.png'); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .single + .assetPublicId, + 'racing-image'); + }); + } + + test('an orphan removal can retry and clear its durability error', () async { + final store = _FailingBatchStore(); + await store.put(CloudMediaUploadJob( + jobId: 'orphan', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'orphan', + fileExtension: '.png', + mimeType: 'image/png', + referenceDurable: false, + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 4), + )); + var snapshotAvailable = false; + final container = _container( + store, + cloudReady: true, + referenceSnapshotLoader: (_) async { + if (!snapshotAvailable) throw StateError('offline'); + return _fullSnapshot(); + }, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await Future.delayed(Duration.zero); + snapshotAvailable = true; + store.failRemove = true; + await expectLater(queue.retryNow(), throwsStateError); + expect(container.read(cloudMediaUploadQueueProvider).outboxIsReliable, + isFalse); + + store.failRemove = false; + await queue.retryNow(); + expect(store.load().jobs, isEmpty); + expect( + container.read(cloudMediaUploadQueueProvider).outboxIsReliable, isTrue); + }); + test('restart restores unfinished lineup media from the durable outbox', () async { final store = MemoryDurableCloudMediaOutboxStore(); diff --git a/test/durable_cloud_media_outbox_version_test.dart b/test/durable_cloud_media_outbox_version_test.dart new file mode 100644 index 00000000..6685ab00 --- /dev/null +++ b/test/durable_cloud_media_outbox_version_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/const/hive_boxes.dart'; + +void main() { + for (final marker in [null, 1]) { + test('legacy media marker $marker opens without deleting saved work', + () async { + final directory = + await Directory.systemTemp.createTemp('icarus-media-v1-'); + try { + Hive.init(directory.path); + final box = + await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + if (marker != null) + await box.put(durableCloudMediaOutboxVersionKey, marker); + final legacy = { + 'outboxVersion': 1, + 'jobId': 'old-image', + 'assetPublicId': 'old-image', + 'uploadId': 'pending-upload' + }; + await box.put('old-image', legacy); + + await prepareDurableCloudMediaOutbox(); + await prepareDurableCloudMediaOutbox(); + + expect(box.get(durableCloudMediaOutboxVersionKey), 2); + expect(jsonEncode(box.get('old-image')), jsonEncode(legacy)); + final loaded = HiveDurableCloudMediaOutboxStore().load(); + expect(loaded.jobs, isEmpty); + expect(loaded.issues.single.storageKey, 'old-image'); + } finally { + await Hive.close(); + await directory.delete(recursive: true); + } + }); + } + + test('unknown future media versions are not restamped', () async { + final directory = + await Directory.systemTemp.createTemp('icarus-media-future-'); + try { + Hive.init(directory.path); + final box = await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + await box.put(durableCloudMediaOutboxVersionKey, 999); + await expectLater(prepareDurableCloudMediaOutbox(), throwsStateError); + expect(box.get(durableCloudMediaOutboxVersionKey), 999); + } finally { + await Hive.close(); + await directory.delete(recursive: true); + } + }); +} diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 8f951f03..c026a315 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -89,6 +89,7 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { _FakeStrategyOpQueueNotifier({this.blockFlush = false}); final bool blockFlush; + bool failDiscard = false; int flushNowCount = 0; @override @@ -183,6 +184,7 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { Future> discardRejected( Set entityKeys, ) async { + if (failDiscard) return {}; final attention = Map.from( state.attentionByEntityKey, ); @@ -248,14 +250,15 @@ Future _settle() async { await Future.delayed(Duration.zero); } -RemotePage _page(String id, int index, {int revision = 1, String? name}) { +RemotePage _page(String id, int index, + {int revision = 1, String? name, bool isAttack = true}) { final now = DateTime.utc(2026); return RemotePage( publicId: id, strategyPublicId: 'cloud-strategy', name: name ?? 'Page ${index + 1}', sortIndex: index, - isAttack: true, + isAttack: isAttack, revision: revision, createdAt: now, updatedAt: now, @@ -957,6 +960,88 @@ void main() { ); }); + for (final failDiscard in [false, true]) { + test('use cloud preserves unrelated edits when discard fails=$failDiscard', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [ + _textElement(page.publicId, 'conflicted', 'server-before', + worldSized: true), + _textElement(page.publicId, 'unrelated', 'server-original', + worldSized: true), + ]), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + queue.failDiscard = failDiscard; + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container + .read(textProvider.notifier) + .commitText('conflicted', 'local-loser'); + await _settle(); + final rejected = container + .read(strategyOpQueueProvider) + .pending + .map((p) => p.op) + .firstWhere((op) => op.entityPublicId == 'conflicted'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, contentRevision: 2, elements: [ + _textElement(page.publicId, 'conflicted', 'server-winner', + worldSized: true), + _textElement(page.publicId, 'unrelated', 'server-original', + worldSized: true), + ]), + )); + queue.reject(rejected); + await _settle(); + container + .read(textProvider.notifier) + .commitText('unrelated', 'keep-my-edit'); + container.read(mapProvider.notifier).fromHive(MapValue.haven, true); + container + .read(strategyThemeProvider.notifier) + .fromStrategy(profileId: 'local-theme'); + await _settle(); + expect(container.read(strategyOpQueueProvider).queuedByEntityKey, + contains(const EntitySyncKey.element('page-1', 'unrelated'))); + expect(await session.useCloudVersionsForRejected(), !failDiscard); + await _settle(); + final canvas = { + for (final text in container.read(textProvider)) text.id: text.text + }; + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )!; + await queue.syncDesiredOpsForPage( + pageId: 'page-1', desiredOpsByEntityKey: desired); + expect(canvas['unrelated'], 'keep-my-edit'); + expect( + canvas['conflicted'], failDiscard ? 'local-loser' : 'server-winner'); + expect(container.read(mapProvider).currentMap, MapValue.haven); + expect(container.read(strategyThemeProvider).profileId, 'local-theme'); + final queued = container.read(strategyOpQueueProvider).queuedByEntityKey; + expect( + queued[const EntitySyncKey.element('page-1', 'unrelated')]! + .pending + .op + .payload + .toString(), + contains('keep-my-edit')); + expect(queued, + isNot(contains(const EntitySyncKey.element('page-1', 'conflicted')))); + }); + } + test('using cloud with no remaining attention is a silent no-op', () async { final page = _page('page-1', 0); final container = await _cloudContainer( @@ -1269,6 +1354,115 @@ void main() { expect(afterAck.expectedElementRevision, 2); }); + test('an ack after leaving a page advances its retained overlay revision', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [ + _textElement('page-1', 'text-a', 'before', worldSized: true), + ]), + )); + final container = await _syncContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + snapshot: remote.initialSnapshot); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-a', position: const Offset(10, 20)) + ..text = 'my edit' + ..markSizeAsWorld(), + ]); + const key = EntitySyncKey.element('page-1', 'text-a'); + final op = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )![key]!; + sync.setContext(strategyPublicId: 'cloud-strategy', activePageId: 'page-2'); + sync.recordAckBatch([ + AckedEntityIntent( + entityKey: key, op: op, ack: AppliedOpAck(opId: op.opId, revision: 2)) + ]); + expect( + container + .read(activePageLiveSyncProvider) + .overlayByEntityKey[key]! + .baseRevision, + 2); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [ + _textElement('page-1', 'text-a', 'my edit', + revision: 2, worldSized: true), + ]))); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + snapshot: remote.initialSnapshot); + container.read(textProvider.notifier).commitText('text-a', 'next edit'); + final next = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )![key] as ElementPatchOp; + expect(next.expectedElementRevision, 2); + expect(next.payload.toString(), contains('next edit')); + }); + + test('rename acks preserve the side baseline during a remote side change', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )); + final container = await _syncContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + container.read(mapProvider.notifier).fromHive(MapValue.ascent, true); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + snapshot: remote.initialSnapshot); + const key = EntitySyncKey.pageDescriptor('page-1'); + sync.recordAckBatch([ + const AckedEntityIntent( + entityKey: key, + op: PagePatchOp( + opId: 'rename', + pagePublicId: 'page-1', + payload: {'name': 'Renamed'}, + expectedPageRevision: 1), + ack: AppliedOpAck(opId: 'rename', revision: 2), + ) + ]); + final changed = + _page('page-1', 0, revision: 3, name: 'Renamed', isAttack: false); + remote.setSnapshot(_editorSnapshot( + pages: [changed], + activePage: _pageSnapshot(changed), + )); + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )!; + expect(desired, isNot(contains(key))); + expect( + sync + .projectPageState( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )! + .isAttack, + isFalse); + }); + test('a final delete is emitted behind an in-flight local add', () async { final page = _page('page-1', 0); const textId = 'new-local-text'; diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index b59f9114..b4f1608d 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -731,6 +731,67 @@ void main() { expect(await guardFuture, isFalse); }); + for (final failedStore in ['ops', 'media']) { + testWidgets('unreadable records cannot hide a $failedStore write failure', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + hasDurabilityFailure: failedStore == 'ops', + ), + )), + cloudMediaUploadQueueProvider.overrideWith(() => _GuardMediaQueue( + CloudMediaUploadQueueState( + jobs: const [], + isProcessing: false, + durabilityError: + failedStore == 'media' ? 'Disk write failed.' : null, + loadIssues: const [ + DurableCloudMediaOutboxLoadIssue( + storageKey: 'old-unreadable', + error: 'bad record', + ) + ], + ), + )), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ]); + addTearDown(container.dispose); + await pumpHarness(tester); + var continued = false; + final guarded = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'mixed-durability-test', + onContinue: () async { + continued = true; + }, + ); + await tester.pumpAndSettle(); + expect(find.text('Leave Anyway'), findsNothing); + await tester.tap(find.text('Stay Here')); + await tester.pumpAndSettle(); + expect(await guarded, isFalse); + expect(continued, isFalse); + }); + } + testWidgets('an unreliable media outbox can leave without deleting work', (tester) async { notifier = _FakeGuardStrategyProvider(