diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index b798e51..ba581f6 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.3.10', + title: 'Выгрузка локального избранного в Pawchive и двусторонняя синхронизация', + bullets: [ + 'Экспорт локального избранного: добавлена возможность выгрузить всех ваших локальных авторов из приложения прямо в аккаунт Pawchive на сервере в 1 клик.', + 'Двусторонняя синхронизация: переключатель двусторонней синхронизации объединяет коллекции — скачивает новых авторов с сервера и отправляет локальных на сервер Pawchive.', + 'Кнопка выгрузки в карточке аккаунта: быстрая кнопка со стрелкой вверх и пункт контекстного меню «Выгрузить в Pawchive» для любого подключённого аккаунта.', + 'Синхронизация в реальном времени с любых провайдеров: при добавлении автора в избранное в лентах Kemono, Coomer или Pawchive изменение мгновенно передаётся на сервер Pawchive.', + ], + ), LunarisChange( version: '2.3.9', title: 'Исправление входа и синхронизации аккаунтов Pawchive', diff --git a/lib/backend/services/pawchive_sync_service.dart b/lib/backend/services/pawchive_sync_service.dart index 5d747c9..cc7c3dd 100644 --- a/lib/backend/services/pawchive_sync_service.dart +++ b/lib/backend/services/pawchive_sync_service.dart @@ -16,12 +16,32 @@ class PawchiveSyncResult { required this.updatedSettings, required this.newlyAddedToLocal, required this.totalSyncedCount, + this.pushedToRemoteCount = 0, this.errorMessage, }); final AppSettings updatedSettings; final int newlyAddedToLocal; final int totalSyncedCount; + final int pushedToRemoteCount; + final String? errorMessage; + + bool get isSuccess => errorMessage == null; +} + +class PawchivePushResult { + const PawchivePushResult({ + required this.pushedCount, + required this.totalRemoteCount, + required this.totalLocalCandidates, + this.updatedSettings, + this.errorMessage, + }); + + final int pushedCount; + final int totalRemoteCount; + final int totalLocalCandidates; + final AppSettings? updatedSettings; final String? errorMessage; bool get isSuccess => errorMessage == null; @@ -249,10 +269,12 @@ class PawchiveSyncService { } /// Synchronizes a specific Pawchive account with current local AppSettings. + /// If [pushLocalPawchiveArtists] is true (or [settings.pawchiveBidirectionalSync] is enabled), + /// local favorites are also pushed to the Pawchive server. Future syncAccountFavorites({ required PawchiveAccount account, required AppSettings settings, - bool pushLocalPawchiveArtists = false, + bool? pushLocalPawchiveArtists, }) async { try { final remoteArtists = await fetchRemoteFavoriteArtists( @@ -262,40 +284,57 @@ class PawchiveSyncService { final currentFavorites = List.from(settings.favoriteArtists); final existingKeys = {}; + final existingServiceId = {}; for (final raw in currentFavorites) { try { final parsed = FavoriteArtistItem.fromJson( jsonDecode(raw) as Map, ); existingKeys.add(parsed.key); + if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) { + existingServiceId.add('${parsed.service}:${parsed.id}'.toLowerCase()); + } } catch (_) {} } var addedCount = 0; for (final artist in remoteArtists) { - if (!existingKeys.contains(artist.key)) { + final pair = '${artist.service}:${artist.id}'.toLowerCase(); + if (!existingKeys.contains(artist.key) && !existingServiceId.contains(pair)) { currentFavorites.insert(0, jsonEncode(artist.toJson())); existingKeys.add(artist.key); + existingServiceId.add(pair); addedCount++; } } - // Optionally push local Pawchive artists to remote - if (pushLocalPawchiveArtists) { - final remoteKeys = remoteArtists.map((a) => a.key).toSet(); + var pushedCount = 0; + final shouldPush = + pushLocalPawchiveArtists ?? settings.pawchiveBidirectionalSync; + if (shouldPush) { + final remotePairSet = remoteArtists + .map((a) => '${a.service}:${a.id}'.toLowerCase()) + .toSet(); + for (final raw in currentFavorites) { try { final parsed = FavoriteArtistItem.fromJson( jsonDecode(raw) as Map, ); - if (parsed.providerId == 'pawchive' && - !remoteKeys.contains(parsed.key)) { - await toggleRemoteFavorite( - account: account, - service: parsed.service, - artistId: parsed.id, - isFavorite: true, - ); + if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) { + final pair = '${parsed.service}:${parsed.id}'.toLowerCase(); + if (!remotePairSet.contains(pair)) { + final ok = await toggleRemoteFavorite( + account: account, + service: parsed.service, + artistId: parsed.id, + isFavorite: true, + ); + if (ok) { + pushedCount++; + remotePairSet.add(pair); + } + } } } catch (_) {} } @@ -304,7 +343,7 @@ class PawchiveSyncService { // Update account sync stats in settings final updatedAccount = account.copyWith( lastSyncedAt: DateTime.now(), - syncedArtistsCount: remoteArtists.length, + syncedArtistsCount: remoteArtists.length + pushedCount, ); final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) { @@ -320,45 +359,125 @@ class PawchiveSyncService { return PawchiveSyncResult( updatedSettings: newSettings, newlyAddedToLocal: addedCount, - totalSyncedCount: remoteArtists.length, + pushedToRemoteCount: pushedCount, + totalSyncedCount: remoteArtists.length + pushedCount, ); } catch (e) { return PawchiveSyncResult( updatedSettings: settings, newlyAddedToLocal: 0, + pushedToRemoteCount: 0, totalSyncedCount: 0, errorMessage: e.toString(), ); } } + /// Explicitly pushes all local favorite artists into a specified Pawchive account. + Future pushLocalFavoritesToAccount({ + required PawchiveAccount account, + required AppSettings settings, + }) async { + try { + final remoteArtists = await fetchRemoteFavoriteArtists( + baseUrl: account.baseUrl, + sessionCookie: account.sessionCookie, + ); + + final remotePairSet = remoteArtists + .map((a) => '${a.service}:${a.id}'.toLowerCase()) + .toSet(); + + var pushedCount = 0; + var totalLocalCandidates = 0; + + for (final raw in settings.favoriteArtists) { + try { + final parsed = FavoriteArtistItem.fromJson( + jsonDecode(raw) as Map, + ); + if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) { + totalLocalCandidates++; + final pair = '${parsed.service}:${parsed.id}'.toLowerCase(); + if (!remotePairSet.contains(pair)) { + final ok = await toggleRemoteFavorite( + account: account, + service: parsed.service, + artistId: parsed.id, + isFavorite: true, + ); + if (ok) { + pushedCount++; + remotePairSet.add(pair); + } + } + } + } catch (_) {} + } + + final updatedAccount = account.copyWith( + lastSyncedAt: DateTime.now(), + syncedArtistsCount: remoteArtists.length + pushedCount, + ); + + final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) { + return a.id == updatedAccount.id ? updatedAccount : a; + }).toList(); + + final newSettings = settings.copyWith( + pawchiveAccounts: + updatedAccountsList.map((a) => jsonEncode(a.toJson())).toList(), + ); + + return PawchivePushResult( + pushedCount: pushedCount, + totalRemoteCount: remoteArtists.length + pushedCount, + totalLocalCandidates: totalLocalCandidates, + updatedSettings: newSettings, + ); + } catch (e) { + return PawchivePushResult( + pushedCount: 0, + totalRemoteCount: 0, + totalLocalCandidates: 0, + errorMessage: e.toString(), + ); + } + } + /// Synchronizes ALL connected Pawchive accounts with local AppSettings. Future syncAllAccounts({ required AppSettings settings, + bool? pushLocalArtists, }) async { final accounts = settings.parsedPawchiveAccounts; if (accounts.isEmpty) { return PawchiveSyncResult( updatedSettings: settings, newlyAddedToLocal: 0, + pushedToRemoteCount: 0, totalSyncedCount: 0, errorMessage: 'Нет добавленных аккаунтов Pawchive', ); } + final doPush = pushLocalArtists ?? settings.pawchiveBidirectionalSync; var runningSettings = settings; var totalAdded = 0; var totalSynced = 0; + var totalPushed = 0; String? firstError; for (final account in accounts) { final res = await syncAccountFavorites( account: account, settings: runningSettings, + pushLocalPawchiveArtists: doPush, ); if (res.isSuccess) { runningSettings = res.updatedSettings; totalAdded += res.newlyAddedToLocal; + totalPushed += res.pushedToRemoteCount; totalSynced += res.totalSyncedCount; } else { firstError ??= res.errorMessage; @@ -368,6 +487,7 @@ class PawchiveSyncService { return PawchiveSyncResult( updatedSettings: runningSettings, newlyAddedToLocal: totalAdded, + pushedToRemoteCount: totalPushed, totalSyncedCount: totalSynced, errorMessage: firstError, ); diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 0a2e2e8..7439728 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -51,6 +51,7 @@ class AppSettings { required this.lastFeedScrollOffset, this.favoriteArtists = const [], this.pawchiveAccounts = const [], + this.pawchiveBidirectionalSync = false, this.skippedUpdateVersion, this.lastUpdateCheckAt, this.defaultRatingFilter, @@ -66,6 +67,7 @@ class AppSettings { final List favoriteArtists; final List pawchiveAccounts; + final bool pawchiveBidirectionalSync; final List enabledProviderIds; final bool nsfwEnabled; final int cacheTtlHours; @@ -122,6 +124,7 @@ class AppSettings { providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2}, favoriteArtists: [], pawchiveAccounts: [], + pawchiveBidirectionalSync: false, themeMode: 'dark', languageCode: 'ru', appSeedColor: 0xFFE84D8A, @@ -205,6 +208,7 @@ class AppSettings { bool clearLastFeedRating = false, List? favoriteArtists, List? pawchiveAccounts, + bool? pawchiveBidirectionalSync, bool? amoledMode, bool? useDynamicColor, String? gridMode, @@ -216,6 +220,8 @@ class AppSettings { return AppSettings( favoriteArtists: favoriteArtists ?? this.favoriteArtists, pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts, + pawchiveBidirectionalSync: + pawchiveBidirectionalSync ?? this.pawchiveBidirectionalSync, enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds, nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled, cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours, @@ -276,6 +282,7 @@ class AppSettings { Map toJson() => { 'favoriteArtists': favoriteArtists, 'pawchiveAccounts': pawchiveAccounts, + 'pawchiveBidirectionalSync': pawchiveBidirectionalSync, 'enabledProviderIds': enabledProviderIds, 'nsfwEnabled': nsfwEnabled, 'cacheTtlHours': cacheTtlHours, @@ -421,6 +428,9 @@ class AppSettings { pawchiveAccounts: List.from( (json['pawchiveAccounts'] as List?) ?? defaults.pawchiveAccounts, ), + pawchiveBidirectionalSync: + (json['pawchiveBidirectionalSync'] as bool?) ?? + defaults.pawchiveBidirectionalSync, skippedUpdateVersion: json['skippedUpdateVersion'] as String?, lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?, diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 9b1b160..4dc9046 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -439,7 +439,7 @@ class _ArtistPostsScreenState extends ConsumerState { settings.copyWith(favoriteArtists: current), ); - if (widget.providerId == 'pawchive') { + if (widget.service.isNotEmpty && widget.artistId.isNotEmpty) { final activeAcc = settings.activePawchiveAccount; if (activeAcc != null) { unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index 1848c37..2038159 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -78,8 +78,8 @@ class _ArtistsScreenState extends ConsumerState { settings.copyWith(favoriteArtists: current), ); - // Sync with active Pawchive account if artist belongs to Pawchive - if (artist.providerId == 'pawchive') { + // Sync with active Pawchive account if available + if (artist.service.isNotEmpty && artist.id.isNotEmpty) { final activeAcc = settings.activePawchiveAccount; if (activeAcc != null) { unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite( diff --git a/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart b/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart index 2520f8b..290610c 100644 --- a/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart +++ b/lib/features/artists/presentation/widgets/pawchive_accounts_sheet.dart @@ -33,6 +33,7 @@ class _PawchiveAccountsSheetState extends ConsumerState bool _isAdding = false; bool _obscurePassword = true; String? _syncingAccountId; + String? _pushingAccountId; bool _syncingAll = false; String? _errorMessage; @@ -207,10 +208,13 @@ class _PawchiveAccountsSheetState extends ConsumerState .read(settingsControllerProvider.notifier) .saveSettings(result.updatedSettings); if (mounted) { + final pushedMsg = result.pushedToRemoteCount > 0 + ? ', выгружено в Pawchive: +${result.pushedToRemoteCount}' + : ''; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых)', + 'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых в приложении$pushedMsg)', ), ), ); @@ -232,6 +236,92 @@ class _PawchiveAccountsSheetState extends ConsumerState } } + Future _pushFavoritesToAccount(PawchiveAccount account) async { + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + final totalLocal = settings.favoriteArtists.length; + if (totalLocal == 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('В локальном избранном пока нет авторов')), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Выгрузить избранное в Pawchive?'), + content: Text( + 'Все локальные авторы из приложения ($totalLocal) будут добавлены в избранное аккаунта @${account.username} на сервере Pawchive.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Отмена'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.cloud_upload_rounded), + label: const Text('Выгрузить'), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _pushingAccountId = account.id); + try { + final syncService = ref.read(pawchiveSyncServiceProvider); + final result = await syncService.pushLocalFavoritesToAccount( + account: account, + settings: settings, + ); + + if (result.isSuccess) { + if (result.updatedSettings != null) { + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(result.updatedSettings!); + } + + if (mounted) { + if (result.pushedCount > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Выгружено ${result.pushedCount} авторов в @${account.username}! Всего в аккаунте: ${result.totalRemoteCount}.', + ), + backgroundColor: Colors.green.shade700, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Все авторы (${result.totalLocalCandidates}) уже есть в аккаунте @${account.username}.', + ), + ), + ); + } + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка выгрузки: ${result.errorMessage}'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + } finally { + if (mounted) { + setState(() => _pushingAccountId = null); + } + } + } + Future _syncAllAccounts() async { setState(() => _syncingAll = true); try { @@ -245,10 +335,13 @@ class _PawchiveAccountsSheetState extends ConsumerState .read(settingsControllerProvider.notifier) .saveSettings(result.updatedSettings); if (mounted) { + final pushedMsg = result.pushedToRemoteCount > 0 + ? ', выгружено в Pawchive: +${result.pushedToRemoteCount}' + : ''; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых)', + 'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых в приложении$pushedMsg)', ), backgroundColor: Colors.green.shade700, ), @@ -445,6 +538,78 @@ class _PawchiveAccountsSheetState extends ConsumerState ), ), ...accounts.map((acc) => _buildAccountCard(acc, scheme, theme)), + const SizedBox(height: 12), + + // Two-way sync & Export to Pawchive panel + Card( + elevation: 0, + color: scheme.surfaceContainerHigh.withValues(alpha: 0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.3), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.swap_vert_rounded, size: 20, color: scheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Двусторонняя синхронизация', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Switch( + value: settings.pawchiveBidirectionalSync, + onChanged: (val) async { + final newSettings = settings.copyWith( + pawchiveBidirectionalSync: val, + ); + await ref + .read(settingsControllerProvider.notifier) + .saveSettings(newSettings); + }, + ), + ], + ), + Text( + 'При обычной синхронизации также выгружать всех локальных авторов на сервер Pawchive.', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: (_syncingAll || _pushingAccountId != null) + ? null + : () { + final target = settings.activePawchiveAccount ?? accounts.first; + _pushFavoritesToAccount(target); + }, + icon: _pushingAccountId != null + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_upload_rounded, size: 18), + label: Text( + 'Выгрузить избранное (${settings.favoriteArtists.length}) в @${(settings.activePawchiveAccount ?? accounts.first).username}', + ), + ), + ], + ), + ), + ), const SizedBox(height: 16), ], @@ -622,6 +787,8 @@ class _PawchiveAccountsSheetState extends ConsumerState Widget _buildAccountCard(PawchiveAccount acc, ColorScheme scheme, ThemeData theme) { final isSyncing = _syncingAccountId == acc.id; + final isPushing = _pushingAccountId == acc.id; + final isBusy = isSyncing || isPushing; return Card( margin: const EdgeInsets.only(bottom: 8), @@ -687,6 +854,17 @@ class _PawchiveAccountsSheetState extends ConsumerState trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + IconButton( + icon: isPushing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_upload_outlined), + tooltip: 'Выгрузить локальное избранное в этот аккаунт', + onPressed: isBusy ? null : () => _pushFavoritesToAccount(acc), + ), IconButton( icon: isSyncing ? const SizedBox( @@ -696,13 +874,15 @@ class _PawchiveAccountsSheetState extends ConsumerState ) : const Icon(Icons.sync_rounded), tooltip: 'Синхронизировать этот аккаунт', - onPressed: isSyncing ? null : () => _syncSingleAccount(acc), + onPressed: isBusy ? null : () => _syncSingleAccount(acc), ), PopupMenuButton( icon: const Icon(Icons.more_vert_rounded), onSelected: (val) { if (val == 'active') { _setActiveAccount(acc); + } else if (val == 'push') { + _pushFavoritesToAccount(acc); } else if (val == 'delete') { _removeAccount(acc); } @@ -719,6 +899,16 @@ class _PawchiveAccountsSheetState extends ConsumerState ], ), ), + const PopupMenuItem( + value: 'push', + child: Row( + children: [ + Icon(Icons.cloud_upload_outlined, size: 20), + SizedBox(width: 8), + Text('Выгрузить в Pawchive'), + ], + ), + ), const PopupMenuItem( value: 'delete', child: Row( diff --git a/pubspec.yaml b/pubspec.yaml index 8cb4c8b..f840e4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.3.9+49 +version: 2.3.10+50 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/pawchive_sync_test.dart b/test/backend/pawchive_sync_test.dart index f9086c5..95bbaa7 100644 --- a/test/backend/pawchive_sync_test.dart +++ b/test/backend/pawchive_sync_test.dart @@ -265,5 +265,83 @@ void main() { expect(lastMethod, equals('DELETE')); expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345')); }); + + test('pushLocalFavoritesToAccount exports local artists to Pawchive', () async { + final postedPaths = []; + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter((options) async { + if (options.path.endsWith('/api/v1/account/favorites')) { + final payload = jsonEncode([ + { + 'id': 'already_remote_1', + 'service': 'fanbox', + 'name': 'AlreadyRemote', + }, + ]); + return ResponseBody.fromString( + payload, + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + if (options.method == 'POST' && + options.path.contains('/api/v1/favorites/creator/')) { + postedPaths.add(options.path); + return ResponseBody.fromString('ok', 204); + } + return ResponseBody.fromString('Not found', 404); + }); + + final service = PawchiveSyncService(dio: dio); + + const itemAlreadyRemote = FavoriteArtistItem( + id: 'already_remote_1', + service: 'fanbox', + providerId: 'pawchive', + name: 'AlreadyRemote', + ); + const itemToUpload1 = FavoriteArtistItem( + id: 'local_creator_99', + service: 'patreon', + providerId: 'kemono', + name: 'LocalPatreonCreator', + ); + const itemToUpload2 = FavoriteArtistItem( + id: 'local_creator_88', + service: 'subscribestar', + providerId: 'coomer', + name: 'LocalSubscribeStarCreator', + ); + + final settings = AppSettings.defaults.copyWith( + favoriteArtists: [ + jsonEncode(itemAlreadyRemote.toJson()), + jsonEncode(itemToUpload1.toJson()), + jsonEncode(itemToUpload2.toJson()), + ], + ); + + final account = PawchiveAccount( + id: 'myacc', + username: 'myacc', + sessionCookie: 'session=123', + createdAt: DateTime.now(), + ); + + final pushResult = await service.pushLocalFavoritesToAccount( + account: account, + settings: settings, + ); + + expect(pushResult.isSuccess, isTrue); + expect(pushResult.pushedCount, equals(2)); + expect(pushResult.totalLocalCandidates, equals(3)); + expect(pushResult.totalRemoteCount, equals(3)); // 1 existing + 2 pushed + expect(postedPaths.length, equals(2)); + expect(postedPaths[0], contains('/api/v1/favorites/creator/patreon/local_creator_99')); + expect(postedPaths[1], contains('/api/v1/favorites/creator/subscribestar/local_creator_88')); + }); }); }