diff --git a/lib/cloud/sync/sync_engine.dart b/lib/cloud/sync/sync_engine.dart index dc32ff724..d243d908f 100644 --- a/lib/cloud/sync/sync_engine.dart +++ b/lib/cloud/sync/sync_engine.dart @@ -38,6 +38,7 @@ part 'sync_engine_status.dart'; part 'sync_engine_realtime.dart'; part 'sync_engine_profile.dart'; part 'sync_engine_apply.dart'; +part 'sync_engine_category_guard.dart'; part 'sync_engine_serialization.dart'; part 'sync_engine_pull.dart'; @@ -1180,6 +1181,14 @@ class SyncEngine implements app.SyncService { logger.info('SyncEngine', 'pull #$pageIndex: applied ${outcome.applied}/${result.changes.length} (apply ${applyMs}ms, page total ${DateTime.now().difference(pageStart).inMilliseconds}ms)'); totalApplied += outcome.applied; + if (outcome.deferred) { + // Replay may start behind the saved cursor. Persist the retry point, + // not the old cursor, so the next ordinary pull can retry this page. + await appCursor.commit(nextSince ?? 0); + logger.info('SyncEngine', + 'pull deferred at cursor=$nextSince until local category changes are pushed'); + break; + } if (outcome.blocked) { logger.warning( 'SyncEngine', 'pull 被错误阻塞 cursor 停在 $nextSince — UI 应显示同步异常'); @@ -1213,6 +1222,7 @@ class SyncEngine implements app.SyncService { } /// 单页 apply。整页事务 try/catch: + /// - 待上传分类变更 → 整页暂缓,保留 cursor,不记录同步错误 /// - 不可恢复异常 → rollback + 错误入 [pullErrors] + return blocked /// - SQLite busy/locked → 单条 retry 2 次 Future<_PullPageOutcome> _applyPullPage( @@ -1223,6 +1233,7 @@ class SyncEngine implements app.SyncService { try { await db.transaction(() async { + await _ensureNoPendingCategoryChanges(changes); for (final ch in changes) { failingChange = ch; final ok = await _applyOneWithBusyRetry(ch); @@ -1237,6 +1248,10 @@ class SyncEngine implements app.SyncService { logger.info('SyncEngine', 'pull: 应用 $applied / 跳过 $skipped (本页)'); } return _PullPageOutcome(applied: applied, blocked: false); + } on _PendingCategoryChange catch (e) { + logger.info( + 'SyncEngine', 'pull page deferred for unpushed category ${e.syncId}'); + return const _PullPageOutcome(applied: 0, blocked: false, deferred: true); } catch (e, st) { // 整页 rollback 已自动完成(Drift transaction 抛错回滚) logger.error( @@ -1473,11 +1488,18 @@ class SyncHealthReport { /// pull 单页处理结果。详见 [SyncEngine._applyPullPage]。 class _PullPageOutcome { - const _PullPageOutcome({required this.applied, required this.blocked}); + const _PullPageOutcome({ + required this.applied, + required this.blocked, + this.deferred = false, + }); /// 本页成功 apply 的条数。整页 rollback 时为 0。 final int applied; /// 是否被错误阻塞(整页 rollback,cursor 不推进)。 final bool blocked; + + /// The page waits for local category changes to leave the outbox. + final bool deferred; } diff --git a/lib/cloud/sync/sync_engine_category_guard.dart b/lib/cloud/sync/sync_engine_category_guard.dart new file mode 100644 index 000000000..ef793eebc --- /dev/null +++ b/lib/cloud/sync/sync_engine_category_guard.dart @@ -0,0 +1,55 @@ +part of 'sync_engine.dart'; + +class _PendingCategoryChange implements Exception { + const _PendingCategoryChange(this.syncId); + + final String syncId; +} + +extension SyncEngineCategoryGuardExt on SyncEngine { + /// Check inside the page transaction, before any writes or icon cleanup. + /// A deferred page must be retried after push, not skipped past its cursor. + Future _ensureNoPendingCategoryChanges( + List changes) async { + final categoryChanges = changes + .where((c) => + c.entityType == 'category' && + (c.action == 'delete' || c.payload != null)) + .toList(); + if (categoryChanges.isEmpty) return; + + final deviceId = await _getDeviceId(); + final applicable = + categoryChanges.where((c) => c.updatedByDeviceId != deviceId).toList(); + if (applicable.isEmpty) return; + + final affectedSyncIds = applicable.map((c) => c.entitySyncId).toSet(); + final deletedSyncIds = applicable + .where((c) => c.action == 'delete') + .map((c) => c.entitySyncId) + .toSet(); + if (deletedSyncIds.isNotEmpty) { + final parents = await (db.select(db.categories) + ..where((c) => c.syncId.isIn(deletedSyncIds))) + .get(); + if (parents.isNotEmpty) { + // Category deletion also removes direct children. Their unpushed + // edits must be protected even without a child event in this page. + final children = await (db.select(db.categories) + ..where((c) => c.parentId.isIn(parents.map((p) => p.id)))) + .get(); + affectedSyncIds + .addAll(children.map((c) => c.syncId).whereType()); + } + } + + final pending = await (db.select(db.localChanges) + ..where((c) => + c.entityType.equals('category') & + c.entitySyncId.isIn(affectedSyncIds) & + c.pushedAt.isNull()) + ..limit(1)) + .getSingleOrNull(); + if (pending != null) throw _PendingCategoryChange(pending.entitySyncId); + } +} diff --git a/lib/data/repositories/local/local_repository.dart b/lib/data/repositories/local/local_repository.dart index ab00e756f..e66099bef 100644 --- a/lib/data/repositories/local/local_repository.dart +++ b/lib/data/repositories/local/local_repository.dart @@ -1269,27 +1269,29 @@ class LocalRepository extends BaseRepository { int? parentId, String? syncId, }) async { - final id = await _categoryRepo.createCategory( - name: name, - kind: kind, - icon: icon, - sortOrder: sortOrder, - level: level, - parentId: parentId, - syncId: syncId, - ); - if (changeTracker != null) { - final cat = await _categoryRepo.getCategoryById(id); - if (cat?.syncId != null) { - await changeTracker!.recordUserGlobalChange( - entityType: 'category', - entityId: id, - entitySyncId: cat!.syncId!, - action: 'create', - ); + return db.transaction(() async { + final id = await _categoryRepo.createCategory( + name: name, + kind: kind, + icon: icon, + sortOrder: sortOrder, + level: level, + parentId: parentId, + syncId: syncId, + ); + if (changeTracker != null) { + final cat = await _categoryRepo.getCategoryById(id); + if (cat?.syncId != null) { + await changeTracker!.recordUserGlobalChange( + entityType: 'category', + entityId: id, + entitySyncId: cat!.syncId!, + action: 'create', + ); + } } - } - return id; + return id; + }); } @override @@ -1301,43 +1303,48 @@ class LocalRepository extends BaseRepository { int? sortOrder, String? syncId, }) async { - final id = await _categoryRepo.createSubCategory( - parentId: parentId, - name: name, - kind: kind, - icon: icon, - sortOrder: sortOrder, - syncId: syncId, - ); - if (changeTracker != null) { - final cat = await _categoryRepo.getCategoryById(id); + return db.transaction(() async { + final id = await _categoryRepo.createSubCategory( + parentId: parentId, + name: name, + kind: kind, + icon: icon, + sortOrder: sortOrder, + syncId: syncId, + ); + if (changeTracker != null) { + final cat = await _categoryRepo.getCategoryById(id); + if (cat?.syncId != null) { + await changeTracker!.recordUserGlobalChange( + entityType: 'category', + entityId: id, + entitySyncId: cat!.syncId!, + action: 'create', + ); + } + } + return id; + }); + } + + @override + Future updateCategory(int id, + {String? name, String? icon, int? parentId, int? level}) async { + await db.transaction(() async { + final cat = changeTracker != null + ? await _categoryRepo.getCategoryById(id) + : null; + await _categoryRepo.updateCategory(id, + name: name, icon: icon, parentId: parentId, level: level); if (cat?.syncId != null) { await changeTracker!.recordUserGlobalChange( entityType: 'category', entityId: id, entitySyncId: cat!.syncId!, - action: 'create', + action: 'update', ); } - } - return id; - } - - @override - Future updateCategory(int id, - {String? name, String? icon, int? parentId, int? level}) async { - final cat = - changeTracker != null ? await _categoryRepo.getCategoryById(id) : null; - await _categoryRepo.updateCategory(id, - name: name, icon: icon, parentId: parentId, level: level); - if (cat?.syncId != null) { - await changeTracker!.recordUserGlobalChange( - entityType: 'category', - entityId: id, - entitySyncId: cat!.syncId!, - action: 'update', - ); - } + }); } @override @@ -1653,42 +1660,49 @@ class LocalRepository extends BaseRepository { String? customIconPath, String? communityIconId, }) async { - await _categoryRepo.updateCategoryIcon( - id, - iconType: iconType, - icon: icon, - customIconPath: customIconPath, - communityIconId: communityIconId, - ); - // 图标改动也需要 push 到服务端 —— 否则 mobile 改了图标 web 永远看不到。 - // ledgerId=0:分类是 user-scoped,和 name 改动走同一条 push 通道。 - if (changeTracker != null) { - final cat = await _categoryRepo.getCategoryById(id); - if (cat?.syncId != null) { - await changeTracker!.recordUserGlobalChange( - entityType: 'category', - entityId: id, - entitySyncId: cat!.syncId!, - action: 'update', - ); + // Commit the edit and outbox marker together, so pull cannot observe an + // edited category without the pending change that protects it. + await db.transaction(() async { + await _categoryRepo.updateCategoryIcon( + id, + iconType: iconType, + icon: icon, + customIconPath: customIconPath, + communityIconId: communityIconId, + ); + // 图标改动也需要 push 到服务端 —— 否则 mobile 改了图标 web 永远看不到。 + // ledgerId=0:分类是 user-scoped,和 name 改动走同一条 push 通道。 + if (changeTracker != null) { + final cat = await _categoryRepo.getCategoryById(id); + if (cat?.syncId != null) { + await changeTracker!.recordUserGlobalChange( + entityType: 'category', + entityId: id, + entitySyncId: cat!.syncId!, + action: 'update', + ); + } } - } + }); } @override Future clearCategoryCustomIcon(int id, {String? materialIcon}) async { - await _categoryRepo.clearCategoryCustomIcon(id, materialIcon: materialIcon); - if (changeTracker != null) { - final cat = await _categoryRepo.getCategoryById(id); - if (cat?.syncId != null) { - await changeTracker!.recordUserGlobalChange( - entityType: 'category', - entityId: id, - entitySyncId: cat!.syncId!, - action: 'update', - ); + await db.transaction(() async { + await _categoryRepo.clearCategoryCustomIcon(id, + materialIcon: materialIcon); + if (changeTracker != null) { + final cat = await _categoryRepo.getCategoryById(id); + if (cat?.syncId != null) { + await changeTracker!.recordUserGlobalChange( + entityType: 'category', + entityId: id, + entitySyncId: cat!.syncId!, + action: 'update', + ); + } } - } + }); } @override @@ -1922,7 +1936,8 @@ class LocalRepository extends BaseRepository { throw StateError(resolveAppLocalizations(null).ledgerMissing); } if (ledger.isShared && ledger.myRole != 'owner') { - throw StateError(resolveAppLocalizations(null).sharedLedgerBalanceRestricted); + throw StateError( + resolveAppLocalizations(null).sharedLedgerBalanceRestricted); } final account = await getAccount(accountId); diff --git a/test/cloud/sync/_fakes/fake_beecount_cloud_provider.dart b/test/cloud/sync/_fakes/fake_beecount_cloud_provider.dart index 2c69ac8f9..4bc17f81c 100644 --- a/test/cloud/sync/_fakes/fake_beecount_cloud_provider.dart +++ b/test/cloud/sync/_fakes/fake_beecount_cloud_provider.dart @@ -43,11 +43,13 @@ class FakeBeeCountCloudAuthService extends BeeCountCloudAuthService { // CloudAuthService 抽象接口实现 — fake 不真做认证 @override - Future get currentUser async => - _userId == null ? null : CloudUser(id: _userId!); + Future get currentUser async => _userId == null + ? null + : CloudUser(id: _userId!, metadata: {'deviceId': _deviceId}); /// 测试入口:模拟用户登录 / 登出 - void setLoggedIn({String? userId = 'test-user-id', String? deviceId = 'test-device-id'}) { + void setLoggedIn( + {String? userId = 'test-user-id', String? deviceId = 'test-device-id'}) { _userId = userId; _deviceId = deviceId; } @@ -190,8 +192,7 @@ class FakeBeeCountCloudProvider extends BeeCountCloudProvider { final slice = unread.take(limit).toList(); return BeeCountCloudPullResult( changes: slice, - serverCursor: - slice.isEmpty ? from : slice.last.changeId, + serverCursor: slice.isEmpty ? from : slice.last.changeId, hasMore: unread.length > slice.length, ); } @@ -227,7 +228,7 @@ class FakeBeeCountCloudProvider extends BeeCountCloudProvider { String ledgerId = '', String action = 'upsert', Map? payload, - String updatedByDeviceId = 'remote-device', + String? updatedByDeviceId = 'remote-device', }) { final change = BeeCountCloudSyncChange( changeId: _serverChanges.length + 1, @@ -301,8 +302,10 @@ class FakeBeeCountCloudProvider extends BeeCountCloudProvider { /// in-memory 附件 store:fileId → bytes final Map uploadedAttachments = {}; + /// 测试可塞预定义内容供 download final Map downloadableAttachments = {}; + /// 记录每次 uploadAttachment 调用(并发场景断言用) final List uploadAttachmentCalls = []; @@ -329,10 +332,10 @@ class FakeBeeCountCloudProvider extends BeeCountCloudProvider { @override Future downloadAttachment({required String fileId}) async { - final bytes = downloadableAttachments[fileId] ?? uploadedAttachments[fileId]; + final bytes = + downloadableAttachments[fileId] ?? uploadedAttachments[fileId]; if (bytes == null) { - throw CloudStorageException( - 'Fake: attachment not found: $fileId'); + throw CloudStorageException('Fake: attachment not found: $fileId'); } return bytes; } diff --git a/test/cloud/sync/category_pending_pull_test.dart b/test/cloud/sync/category_pending_pull_test.dart new file mode 100644 index 000000000..096e30808 --- /dev/null +++ b/test/cloud/sync/category_pending_pull_test.dart @@ -0,0 +1,369 @@ +import 'dart:async'; + +import 'package:beecount/cloud/sync/change_tracker.dart'; +import 'package:beecount/cloud/sync/sync_engine.dart'; +import 'package:beecount/data/db.dart'; +import 'package:beecount/data/repositories/local/local_repository.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '_fakes/fake_beecount_cloud_provider.dart'; + +class _ControlledProvider extends FakeBeeCountCloudProvider { + Future Function()? beforePullReturns; + int? pageSize; + + @override + Future pullChanges({ + int? since, + int limit = 1000, + bool persistCursor = true, + }) async { + final result = await super.pullChanges( + since: since, + limit: pageSize ?? limit, + persistCursor: persistCursor, + ); + final hook = beforePullReturns; + beforePullReturns = null; + if (hook != null) await hook(); + return result; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late BeeDatabase db; + late ChangeTracker tracker; + late LocalRepository repo; + late _ControlledProvider provider; + late SyncEngine engine; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + db = BeeDatabase.forTesting(NativeDatabase.memory()); + tracker = ChangeTracker(db); + repo = LocalRepository(db, changeTracker: tracker); + provider = _ControlledProvider(); + engine = SyncEngine( + db: db, + provider: provider, + changeTracker: tracker, + repo: repo, + ); + }); + + tearDown(() async { + await db.close(); + }); + + Future seedCategory({ + String syncId = 'category-1', + int? parentId, + }) { + return db.into(db.categories).insert(CategoriesCompanion.insert( + name: syncId, + kind: 'expense', + icon: const Value('train'), + syncId: Value(syncId), + level: Value(parentId == null ? 1 : 2), + parentId: Value(parentId), + )); + } + + Future category([String syncId = 'category-1']) { + return (db.select(db.categories)..where((c) => c.syncId.equals(syncId))) + .getSingleOrNull(); + } + + void remoteCategory({ + String syncId = 'category-1', + String icon = 'train', + String action = 'upsert', + String? deviceId, + }) { + provider.pushFakeChange( + entityType: 'category', + entitySyncId: syncId, + ledgerId: '__user_global__', + action: action, + updatedByDeviceId: deviceId, + payload: { + 'syncId': syncId, + 'name': syncId, + 'kind': 'expense', + 'iconType': 'material', + 'icon': icon, + }, + ); + } + + Future editIcon(int id) { + return repo.updateCategoryIcon( + id, + iconType: 'material', + icon: 'vaccines', + ); + } + + Future expectDeferred({int cursor = 0}) async { + expect(await engine.appCursor.read(), cursor); + expect(await engine.pullErrors.watchUnresolved().first, isEmpty); + expect(await tracker.getUnpushedChanges(), isNotEmpty); + } + + Future failOutboxWrites() { + return db.customStatement( + "CREATE TRIGGER fail_category_outbox BEFORE INSERT ON local_changes " + "BEGIN SELECT RAISE(ABORT, 'outbox write failed'); END", + ); + } + + for (final mutation in ['name', 'icon', 'clear custom icon']) { + test('$mutation and its pending marker are committed atomically', () async { + final id = await seedCategory(); + await failOutboxWrites(); + final editing = switch (mutation) { + 'name' => repo.updateCategory(id, name: 'Edited'), + 'icon' => editIcon(id), + _ => repo.clearCategoryCustomIcon(id, materialIcon: 'vaccines'), + }; + + await expectLater(editing, throwsException); + expect((await category())!.name, 'category-1'); + expect((await category())!.icon, 'train'); + expect(await db.select(db.localChanges).get(), isEmpty); + }); + } + + for (final isChild in [false, true]) { + test('${isChild ? 'child' : 'root'} creation and its marker are atomic', + () async { + final parentId = isChild ? await seedCategory(syncId: 'parent') : null; + await failOutboxWrites(); + final creating = isChild + ? repo.createSubCategory( + parentId: parentId!, + name: 'New child', + kind: 'expense', + syncId: 'category-1', + ) + : repo.createCategory( + name: 'New category', + kind: 'expense', + syncId: 'category-1', + ); + + await expectLater(creating, throwsException); + expect(await category(), isNull); + if (isChild) expect(await category('parent'), isNotNull); + expect(await db.select(db.localChanges).get(), isEmpty); + }); + } + + test('a server reconciliation cannot overwrite an edit made during HTTP', + () async { + final id = await seedCategory(); + await tracker.recordPulledFromServer( + entityType: 'category', + entityId: id, + entitySyncId: 'category-1', + ledgerId: 0, + ); + remoteCategory(); + final responseReady = Completer(); + final releaseResponse = Completer(); + provider.beforePullReturns = () { + responseReady.complete(); + return releaseResponse.future; + }; + + final pulling = engine.pull(''); + await responseReady.future; + await editIcon(id); + releaseResponse.complete(); + + expect(await pulling, 0); + expect((await category())!.icon, 'vaccines'); + await expectDeferred(); + + await engine.pushUserGlobalEntities(); + expect(provider.pushedBatches.single.single['payload'], + containsPair('icon', 'vaccines')); + expect(await tracker.getUnpushedChanges(), isEmpty); + remoteCategory(icon: 'vaccines'); + + expect(await engine.pull(''), 2); + expect((await category())!.icon, 'vaccines'); + expect(await engine.appCursor.read(), 2); + }); + + test('remote category updates also wait for local edits', () async { + final id = await seedCategory(); + await editIcon(id); + remoteCategory(deviceId: 'another-phone'); + + expect(await engine.pull(''), 0); + expect((await category())!.icon, 'vaccines'); + await expectDeferred(); + }); + + test('remote deletion does not discard an unpushed category edit', () async { + final id = await seedCategory(); + await editIcon(id); + remoteCategory(action: 'delete'); + + expect(await engine.pull(''), 0); + expect((await category())!.icon, 'vaccines'); + await expectDeferred(); + }); + + test('historical upsert does not resurrect a locally deleted category', + () async { + final id = await seedCategory(); + await repo.deleteCategory(id); + remoteCategory(); + + expect(await engine.pull(''), 0); + expect(await category(), isNull); + await expectDeferred(); + expect((await tracker.getUnpushedChanges()).single.action, 'delete'); + + await engine.pushUserGlobalEntities(); + remoteCategory(action: 'delete'); + expect(await engine.pull(''), 2); + expect(await category(), isNull); + expect(await engine.appCursor.read(), 2); + }); + + test('parent deletion waits for unpushed edits to its children', () async { + final parentId = await seedCategory(syncId: 'parent'); + final childId = await seedCategory(syncId: 'child', parentId: parentId); + await editIcon(childId); + remoteCategory(syncId: 'parent', action: 'delete'); + + expect(await engine.pull(''), 0); + expect(await category('parent'), isNotNull); + expect((await category('child'))!.icon, 'vaccines'); + await expectDeferred(); + }); + + test('a deferred category keeps the whole page unapplied', () async { + final id = await seedCategory(); + await editIcon(id); + provider.pushFakeChange( + entityType: 'account', + entitySyncId: 'account-1', + payload: {'name': 'Remote account', 'type': 'cash', 'currency': 'CNY'}, + ); + remoteCategory(); + + expect(await engine.pull(''), 0); + expect(await db.select(db.accounts).get(), isEmpty); + expect(await db.select(db.localChanges).get(), hasLength(1)); + expect(engine.pendingCustomIconJobs, isEmpty); + await expectDeferred(); + }); + + test('only the page containing the pending category is deferred', () async { + final id = await seedCategory(); + await editIcon(id); + provider.pageSize = 1; + remoteCategory(syncId: 'unrelated', icon: 'flight'); + remoteCategory(); + remoteCategory(syncId: 'not-fetched'); + + expect(await engine.pull(''), 1); + expect((await category('unrelated'))!.icon, 'flight'); + expect((await category())!.icon, 'vaccines'); + expect(await category('not-fetched'), isNull); + expect(provider.pullCalls, hasLength(2)); + await expectDeferred(cursor: 1); + + await engine.pushUserGlobalEntities(); + remoteCategory(icon: 'vaccines'); + expect(await engine.pull(''), 3); + expect((await category())!.icon, 'vaccines'); + expect(await category('not-fetched'), isNotNull); + expect(await engine.appCursor.read(), 4); + }); + + test('deferred replay persists its retry cursor instead of losing history', + () async { + final id = await seedCategory(); + await editIcon(id); + await engine.appCursor.commit(100); + remoteCategory(); + + expect(await engine.replayAllChanges(), 0); + await expectDeferred(); + expect((await category())!.icon, 'vaccines'); + + await engine.pushUserGlobalEntities(); + remoteCategory(icon: 'vaccines'); + expect(await engine.pull(''), 2); + expect(provider.pullCalls.last.since, 0); + expect((await category())!.icon, 'vaccines'); + expect(await engine.appCursor.read(), 2); + }); + + test('pushed changes do not block historical category reconciliation', + () async { + final id = await seedCategory(); + await editIcon(id); + final changes = await tracker.getUnpushedChanges(); + await tracker.markPushed(changes.map((c) => c.id).toList()); + remoteCategory(icon: 'local_hospital'); + + expect(await engine.pull(''), 1); + expect((await category())!.icon, 'local_hospital'); + expect(await engine.appCursor.read(), 1); + }); + + test('own-device events retain the existing skip behavior', () async { + final id = await seedCategory(); + await editIcon(id); + remoteCategory(deviceId: 'test-device-id'); + + expect(await engine.pull(''), 0); + expect((await category())!.icon, 'vaccines'); + expect(await engine.appCursor.read(), 1); + expect(await tracker.getUnpushedChanges(), hasLength(1)); + expect(await engine.pullErrors.watchUnresolved().first, isEmpty); + }); + + test('a skipped category without payload does not defer the page', () async { + final id = await seedCategory(); + await editIcon(id); + provider.pushFakeChange( + entityType: 'category', + entitySyncId: 'category-1', + ); + remoteCategory(syncId: 'unrelated'); + + expect(await engine.pull(''), 1); + expect((await category())!.icon, 'vaccines'); + expect(await category('unrelated'), isNotNull); + expect(await engine.appCursor.read(), 2); + }); + + test('an unpushed different entity with the same sync id does not block', + () async { + await seedCategory(); + await tracker.recordUserGlobalChange( + entityType: 'account', + entityId: 1, + entitySyncId: 'category-1', + action: 'update', + ); + remoteCategory(icon: 'local_hospital'); + + expect(await engine.pull(''), 1); + expect((await category())!.icon, 'local_hospital'); + expect(await engine.appCursor.read(), 1); + }); +} diff --git a/test/sync/category_parent_apply_test.dart b/test/sync/category_parent_apply_test.dart index bb11b80d8..923fdcd35 100644 --- a/test/sync/category_parent_apply_test.dart +++ b/test/sync/category_parent_apply_test.dart @@ -86,6 +86,7 @@ void main() { level: 2, parentId: parent, syncId: 'child'); + await engine.pushUserGlobalEntities(); provider.pushFakeChange( entityType: 'category', entitySyncId: 'child',