diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 3093ce0..0941e2a 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -3,8 +3,8 @@ ## Current status - main baseline updated through: feat/square-catalog-sync-repositories (Stage 10C complete) -- next branch: feat/square-catalog-sync-service (Stage 10D — SquareCatalogSyncService orchestrating catalog pull + inventory sync) -- current stage: Stage 10C complete — `SquareCatalogRepository` (feature_wordpress), `SquareInventoryRepository` (feature_inventory), `squareVariationId` field on `InventoryItem`; integrations 123/123, feature_wordpress 475/475, feature_inventory 167/167 tests passing; analyze clean; branch ready for merge +- next branch: feat/square-catalog-sync-service-controller (Stage 10E — SquareSyncController Riverpod provider wrapping SquareCatalogSyncService) +- current stage: Stage 10D complete — `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter` (feature_wordpress); feature_wordpress 506/506, feature_inventory 167/167 tests passing; analyze clean; branch ready for merge ## Slice tracker @@ -593,6 +593,20 @@ - analyze: clean (no new issues introduced) - brief updated: yes +### feat/square-catalog-sync-service + +- status: in progress on branch — ready for merge +- date: 2026-07-25 +- inspection: complete +- implementation: complete (Stage 10D slice) +- files changed: + - `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — new `SquareCatalogSyncService` application-layer service; `SyncError` value object with `itemId`, `message`, `==`/`hashCode`/`toString()`; `SquareSyncResult` value object with `catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`; `InventorySyncAdapter` abstract contract with `syncItem(itemId, remoteQuantity)`; `syncCatalog()` fetches all products via `ProductPublishingRepository.getProductDrafts()`, looks up or creates `ProductIdMapping` per product via `ProductIdMappingRepository`, captures per-item failures without aborting; `syncInventory()` iterates all mappings with a `squareVariationId`, calls `InventorySyncAdapter.syncItem()` for each, captures per-item failures; `syncAll()` runs `syncCatalog()` then `syncInventory()` and merges results; no-op when no adapter provided + - `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_sync_service.dart` to barrel exports (Application section) + - `feature_wordpress/test/square_catalog_sync_service_test.dart` — 31 new tests: `SyncError` (3: fields, equality, toString), `SquareSyncResult` (7: defaults, totalProcessed, allSucceeded×3, merge, toString), `syncCatalog` (9: empty, single, id match, multiple, existing-not-duplicated, fetch-failure, saveMapping-failure, partial-failure, SyncError-details), `syncInventory` (7: no-adapter, no-variationId-skipped, synced, false-return, throw-continues, getAllMappings-failure, empty-mappings), `syncAll` (5: merges, pre-seeded-variation-IDs, totalProcessed, catalog-failure-no-prevent-inventory, no-adapter) +- tests: passed (506/506 feature_wordpress ← +31 new, 167/167 feature_inventory — all passing) +- analyze: clean (dart analyze — no issues found) +- brief updated: yes + ### feat/square-catalog-sync-repositories - status: in progress on branch — ready for merge diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 562fe65..196bab7 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -171,20 +171,36 @@ Rules: No minimum thresholds are enforced — this is visibility-only tracking. Coverage is measured via `flutter test --coverage` (generates `lcov.info`) and reported in the CI workflow summary table. +### Stage 10D — complete (2026-07-25) + +**`feat/square-catalog-sync-service`** — `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter` + +Delivered: + +- `SquareCatalogSyncService` in `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` +- `SyncError` value object (`itemId`, `message`, `==`/`hashCode`/`toString()`) +- `SquareSyncResult` value object (`catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`) +- `InventorySyncAdapter` abstract contract (`syncItem(itemId, remoteQuantity) → Future`) +- `syncCatalog()` — fetches products via `ProductPublishingRepository`, reconciles `ProductIdMappingRepository`, per-item error capture +- `syncInventory()` — iterates mappings with `squareVariationId`, delegates to `InventorySyncAdapter`, per-item error capture +- `syncAll()` — runs catalog then inventory, merges results +- Exported from `feature_wordpress.dart` barrel +- 31 new tests (506/506 feature_wordpress total, 167/167 feature_inventory — all passing, analyze clean) + ### Next recommended branch -**`feat/square-catalog-sync-service`** — Stage 10D: `SquareCatalogSyncService` orchestrating catalog pull + inventory sync. +**`feat/square-catalog-sync-service-controller`** — Stage 10E: `SquareSyncController` Riverpod provider. -Branch from latest `main`. Stage 10C (`SquareCatalogRepository` + `SquareInventoryRepository`) is complete. The next slice adds a `SquareCatalogSyncService` application-layer service that: +Branch from latest `main`. Stage 10D (`SquareCatalogSyncService`) is complete. The next slice adds a Riverpod `AsyncNotifier` (or `ChangeNotifier` if Riverpod is not yet wired) that: -- Pulls the full Square catalog via `SquareCatalogRepository.getProductDrafts()` and reconciles it against the local `ProductIdMappingRepository` (creating/updating mappings for new/changed items) -- Pulls Square inventory counts via `SquareInventoryRepository.getInventoryItems()` and calls `syncWithRemote()` for each item that has a `squareVariationId` -- Returns a `SquareSyncResult` value object with `catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed` counts and a list of `SyncError` details -- Exposes `syncCatalog()`, `syncInventory()`, and `syncAll()` entry points -- Lives in `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` +- Wraps `SquareCatalogSyncService` and exposes `syncAll()`, `syncCatalog()`, `syncInventory()` as async actions +- Tracks `isSyncing` state and the last `SquareSyncResult` +- Exposes a `lastSyncedAt` timestamp +- Lives in `feature_wordpress/lib/src/application/square_sync_controller.dart` - Is exported from `feature_wordpress.dart` barrel +- Wires into `MobileAppServices` (or `WebAppServices`) so the sync can be triggered from the UI -Tests should cover: full sync happy path, partial failure (some items fail), empty catalog, items without `squareVariationId` skipped for inventory sync, `SyncError` details populated on failure. +Tests should cover: initial state, syncAll happy path, syncAll partial failure, isSyncing true during sync and false after, lastSyncedAt stamped on completion, error state on total failure. --- diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 8021bc9..e7c7580 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -18,6 +18,7 @@ export 'src/domain/product_publishing_repository.dart'; export 'src/domain/publish_status.dart'; // Application +export 'src/application/square_catalog_sync_service.dart'; export 'src/application/create_category.dart'; export 'src/application/create_product.dart'; export 'src/application/delete_product.dart'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/square_catalog_sync_service.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/square_catalog_sync_service.dart new file mode 100644 index 0000000..e375582 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/square_catalog_sync_service.dart @@ -0,0 +1,266 @@ +import '../domain/product_draft.dart'; +import '../domain/product_id_mapping.dart'; +import '../domain/product_id_mapping_repository.dart'; +import '../domain/product_publishing_repository.dart'; + +// --------------------------------------------------------------------------- +// Value objects +// --------------------------------------------------------------------------- + +/// Describes a single item that failed during a sync operation. +class SyncError { + /// The local product / inventory item ID that failed. + final String itemId; + + /// A human-readable description of the failure. + final String message; + + const SyncError({required this.itemId, required this.message}); + + @override + String toString() => 'SyncError(itemId: $itemId, message: $message)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncError && + runtimeType == other.runtimeType && + itemId == other.itemId && + message == other.message; + + @override + int get hashCode => Object.hash(itemId, message); +} + +/// Aggregate result returned by [SquareCatalogSyncService.syncAll], +/// [SquareCatalogSyncService.syncCatalog], and +/// [SquareCatalogSyncService.syncInventory]. +class SquareSyncResult { + /// Number of catalog items successfully synced (mapping created/updated). + final int catalogSynced; + + /// Number of inventory items successfully synced (quantity pulled from Square). + final int inventorySynced; + + /// Number of catalog items that failed to sync. + final int catalogFailed; + + /// Number of inventory items that failed to sync. + final int inventoryFailed; + + /// Detailed error list for failed items. + final List errors; + + const SquareSyncResult({ + this.catalogSynced = 0, + this.inventorySynced = 0, + this.catalogFailed = 0, + this.inventoryFailed = 0, + this.errors = const [], + }); + + /// Total number of items processed (catalog + inventory). + int get totalProcessed => catalogSynced + catalogFailed + inventorySynced + inventoryFailed; + + /// `true` when no errors occurred. + bool get allSucceeded => catalogFailed == 0 && inventoryFailed == 0; + + /// Returns a new [SquareSyncResult] that merges [other] into this result. + SquareSyncResult merge(SquareSyncResult other) { + return SquareSyncResult( + catalogSynced: catalogSynced + other.catalogSynced, + inventorySynced: inventorySynced + other.inventorySynced, + catalogFailed: catalogFailed + other.catalogFailed, + inventoryFailed: inventoryFailed + other.inventoryFailed, + errors: [...errors, ...other.errors], + ); + } + + @override + String toString() => + 'SquareSyncResult(catalogSynced: $catalogSynced, ' + 'inventorySynced: $inventorySynced, ' + 'catalogFailed: $catalogFailed, ' + 'inventoryFailed: $inventoryFailed, ' + 'errors: ${errors.length})'; +} + +// --------------------------------------------------------------------------- +// Inventory adapter contract +// --------------------------------------------------------------------------- + +/// Minimal contract used by [SquareCatalogSyncService] to sync inventory +/// without taking a hard dependency on the full [InventoryRepository] from +/// `feature_inventory`. +/// +/// Implementations should delegate to [SquareInventoryRepository] or +/// [FakeInventoryRepository] as appropriate. +abstract class InventorySyncAdapter { + /// Pulls the current quantity for [itemId] from Square and updates the local + /// record. + /// + /// [remoteQuantity] is used as a fallback when the Square API returns an + /// empty count list. + /// + /// Returns `true` on success, `false` on failure. + Future syncItem(String itemId, int remoteQuantity); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// Application-layer service that orchestrates a full Square catalog and +/// inventory sync. +/// +/// ## Responsibilities +/// +/// 1. **Catalog sync** ([syncCatalog]): +/// - Fetches all products from Square via [ProductPublishingRepository.getProductDrafts]. +/// - For each product, looks up or creates a [ProductIdMapping] in +/// [ProductIdMappingRepository]. +/// - Updates the mapping's [ProductIdMapping.squareCatalogObjectId] and +/// [ProductIdMapping.squareVariationId] if they have changed. +/// +/// 2. **Inventory sync** ([syncInventory]): +/// - Iterates over all [ProductIdMapping] records that have a +/// [ProductIdMapping.squareVariationId]. +/// - Calls [InventorySyncAdapter.syncItem] for each, passing `0` as the +/// fallback quantity (the adapter pulls the live count from Square). +/// +/// 3. **Full sync** ([syncAll]): +/// - Runs [syncCatalog] then [syncInventory] and merges the results. +/// +/// ## Error handling +/// +/// Individual item failures are captured as [SyncError] entries in the +/// returned [SquareSyncResult]. A failure on one item does not abort the +/// remaining items. +class SquareCatalogSyncService { + final ProductPublishingRepository _catalogRepository; + final ProductIdMappingRepository _mappingRepository; + final InventorySyncAdapter? _inventoryAdapter; + + SquareCatalogSyncService({ + required ProductPublishingRepository catalogRepository, + required ProductIdMappingRepository mappingRepository, + InventorySyncAdapter? inventoryAdapter, + }) : _catalogRepository = catalogRepository, + _mappingRepository = mappingRepository, + _inventoryAdapter = inventoryAdapter; + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /// Pulls the full Square catalog and reconciles [ProductIdMappingRepository]. + /// + /// For each product returned by [ProductPublishingRepository.getProductDrafts]: + /// - Looks up an existing mapping by [ProductDraft.id] (treated as the + /// Square catalog object ID). + /// - Creates or updates the mapping with the current Square IDs and + /// timestamps. + /// + /// Returns a [SquareSyncResult] with [SquareSyncResult.catalogSynced] and + /// [SquareSyncResult.catalogFailed] populated. + Future syncCatalog() async { + List products; + try { + products = await _catalogRepository.getProductDrafts(); + } catch (e) { + return SquareSyncResult( + catalogFailed: 1, + errors: [SyncError(itemId: 'catalog', message: e.toString())], + ); + } + + if (products.isEmpty) { + return const SquareSyncResult(); + } + + int synced = 0; + int failed = 0; + final errors = []; + + for (final product in products) { + try { + final existing = await _mappingRepository.getMappingBySquareId(product.id); + + final now = DateTime.now(); + final mapping = existing != null + ? existing.copyWith(squareCatalogObjectId: product.id, lastUpdated: now) + : ProductIdMapping( + localId: product.id, + squareCatalogObjectId: product.id, + lastUpdated: now, + ); + + await _mappingRepository.saveMapping(mapping); + synced++; + } catch (e) { + failed++; + errors.add(SyncError(itemId: product.id, message: e.toString())); + } + } + + return SquareSyncResult(catalogSynced: synced, catalogFailed: failed, errors: errors); + } + + /// Syncs inventory counts from Square for all mapped items that have a + /// [ProductIdMapping.squareVariationId]. + /// + /// Requires an [InventorySyncAdapter] to be provided at construction time. + /// If no adapter is provided, returns an empty [SquareSyncResult]. + /// + /// Returns a [SquareSyncResult] with [SquareSyncResult.inventorySynced] and + /// [SquareSyncResult.inventoryFailed] populated. + Future syncInventory() async { + if (_inventoryAdapter == null) { + return const SquareSyncResult(); + } + + List mappings; + try { + mappings = await _mappingRepository.getAllMappings(); + } catch (e) { + return SquareSyncResult( + inventoryFailed: 1, + errors: [SyncError(itemId: 'mappings', message: e.toString())], + ); + } + + final syncable = mappings.where((m) => m.squareVariationId != null).toList(); + + if (syncable.isEmpty) { + return const SquareSyncResult(); + } + + int synced = 0; + int failed = 0; + final errors = []; + + for (final mapping in syncable) { + try { + final success = await _inventoryAdapter.syncItem(mapping.localId, 0); + if (success) { + synced++; + } else { + failed++; + errors.add(SyncError(itemId: mapping.localId, message: 'syncItem returned false')); + } + } catch (e) { + failed++; + errors.add(SyncError(itemId: mapping.localId, message: e.toString())); + } + } + + return SquareSyncResult(inventorySynced: synced, inventoryFailed: failed, errors: errors); + } + + /// Runs [syncCatalog] followed by [syncInventory] and merges the results. + Future syncAll() async { + final catalogResult = await syncCatalog(); + final inventoryResult = await syncInventory(); + return catalogResult.merge(inventoryResult); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/square_catalog_sync_service_test.dart b/kell_creations_apps/packages/feature_wordpress/test/square_catalog_sync_service_test.dart new file mode 100644 index 0000000..fff56c8 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/square_catalog_sync_service_test.dart @@ -0,0 +1,605 @@ +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +/// A [ProductPublishingRepository] stub that returns a fixed list of drafts. +class _FakeCatalogRepository implements ProductPublishingRepository { + final List drafts; + bool shouldThrow; + + _FakeCatalogRepository({this.drafts = const [], this.shouldThrow = false}); + + @override + Future> getProductDrafts() async { + if (shouldThrow) throw Exception('catalog fetch failed'); + return List.unmodifiable(drafts); + } + + // Unsupported stubs — not exercised by the sync service + @override + Future publishDraft(String id) => throw UnimplementedError(); + @override + Future updateProductStatus(String id, PublishStatus status) => + throw UnimplementedError(); + @override + Future updateProductPrice(String id, double price) => throw UnimplementedError(); + @override + Future updateProductName(String id, String name) => throw UnimplementedError(); + @override + Future updateProductDescription(String id, String description) => + throw UnimplementedError(); + @override + Future updateProductCategory(String id, String category) => + throw UnimplementedError(); + @override + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) => throw UnimplementedError(); + @override + Future deleteProduct(String id, {bool force = false}) => throw UnimplementedError(); + @override + Future updateProductImages(String id, List imageIds) => + throw UnimplementedError(); + @override + Future> getProductImages(String id) => throw UnimplementedError(); + @override + Future> getCategories() => throw UnimplementedError(); + @override + Future createCategory({required String name, int parentId = 0}) => + throw UnimplementedError(); + @override + Future updateCategory(int categoryId, {String? name, int? parentId}) => + throw UnimplementedError(); + @override + Future deleteCategory(int categoryId, {bool force = true}) => throw UnimplementedError(); +} + +/// A [ProductIdMappingRepository] backed by an in-memory map. +class _InMemoryMappingRepository implements ProductIdMappingRepository { + final Map _store = {}; + bool shouldThrowOnSave; + bool shouldThrowOnGetAll; + + _InMemoryMappingRepository({this.shouldThrowOnSave = false, this.shouldThrowOnGetAll = false}); + + @override + Future> getAllMappings() async { + if (shouldThrowOnGetAll) throw Exception('getAllMappings failed'); + return List.unmodifiable(_store.values.toList()); + } + + @override + Future getMappingByLocalId(String localId) async => _store[localId]; + + @override + Future getMappingByWooCommerceId(String wooCommerceId) async { + return _store.values.where((m) => m.wooCommerceId == wooCommerceId).firstOrNull; + } + + @override + Future getMappingBySquareId(String squareCatalogObjectId) async { + return _store.values.where((m) => m.squareCatalogObjectId == squareCatalogObjectId).firstOrNull; + } + + @override + Future saveMapping(ProductIdMapping mapping) async { + if (shouldThrowOnSave) throw Exception('saveMapping failed'); + _store[mapping.localId] = mapping; + return mapping; + } + + @override + Future deleteMapping(String localId) async { + _store.remove(localId); + } + + /// Seed a mapping directly (bypasses shouldThrowOnSave). + void seed(ProductIdMapping mapping) => _store[mapping.localId] = mapping; + + int get count => _store.length; +} + +/// A simple [InventorySyncAdapter] that records calls and returns a +/// configurable result. +class _FakeInventoryAdapter implements InventorySyncAdapter { + final Map _results; + final List calledWith = []; + + _FakeInventoryAdapter({Map? results}) : _results = results ?? {}; + + @override + Future syncItem(String itemId, int remoteQuantity) async { + calledWith.add(itemId); + return _results[itemId] ?? true; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +ProductDraft _draft(String id, {String name = 'Product'}) => ProductDraft( + id: id, + name: name, + description: '', + price: 10.0, + sku: '', + category: '', + imageUrl: '', + status: PublishStatus.draft, + lastModified: DateTime(2026, 1, 1), +); + +ProductIdMapping _mapping( + String localId, { + String? squareCatalogObjectId, + String? squareVariationId, +}) => ProductIdMapping( + localId: localId, + squareCatalogObjectId: squareCatalogObjectId, + squareVariationId: squareVariationId, + lastUpdated: DateTime(2026, 1, 1), +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + // SyncError + // ------------------------------------------------------------------------- + group('SyncError', () { + test('stores itemId and message', () { + const e = SyncError(itemId: 'abc', message: 'oops'); + expect(e.itemId, 'abc'); + expect(e.message, 'oops'); + }); + + test('equality', () { + const a = SyncError(itemId: 'x', message: 'm'); + const b = SyncError(itemId: 'x', message: 'm'); + const c = SyncError(itemId: 'y', message: 'm'); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('toString includes itemId and message', () { + const e = SyncError(itemId: 'id1', message: 'fail'); + expect(e.toString(), contains('id1')); + expect(e.toString(), contains('fail')); + }); + }); + + // ------------------------------------------------------------------------- + // SquareSyncResult + // ------------------------------------------------------------------------- + group('SquareSyncResult', () { + test('default constructor has zero counts and empty errors', () { + const r = SquareSyncResult(); + expect(r.catalogSynced, 0); + expect(r.inventorySynced, 0); + expect(r.catalogFailed, 0); + expect(r.inventoryFailed, 0); + expect(r.errors, isEmpty); + }); + + test('totalProcessed sums all four counts', () { + const r = SquareSyncResult( + catalogSynced: 2, + inventorySynced: 3, + catalogFailed: 1, + inventoryFailed: 1, + ); + expect(r.totalProcessed, 7); + }); + + test('allSucceeded is true when no failures', () { + const r = SquareSyncResult(catalogSynced: 5, inventorySynced: 3); + expect(r.allSucceeded, isTrue); + }); + + test('allSucceeded is false when catalogFailed > 0', () { + const r = SquareSyncResult(catalogFailed: 1); + expect(r.allSucceeded, isFalse); + }); + + test('allSucceeded is false when inventoryFailed > 0', () { + const r = SquareSyncResult(inventoryFailed: 1); + expect(r.allSucceeded, isFalse); + }); + + test('merge combines counts and errors', () { + const a = SquareSyncResult( + catalogSynced: 2, + catalogFailed: 1, + errors: [SyncError(itemId: 'a', message: 'err')], + ); + const b = SquareSyncResult( + inventorySynced: 3, + inventoryFailed: 1, + errors: [SyncError(itemId: 'b', message: 'err2')], + ); + final merged = a.merge(b); + expect(merged.catalogSynced, 2); + expect(merged.catalogFailed, 1); + expect(merged.inventorySynced, 3); + expect(merged.inventoryFailed, 1); + expect(merged.errors.length, 2); + }); + + test('toString includes all field names', () { + const r = SquareSyncResult(catalogSynced: 1, inventorySynced: 2); + final s = r.toString(); + expect(s, contains('catalogSynced')); + expect(s, contains('inventorySynced')); + }); + }); + + // ------------------------------------------------------------------------- + // SquareCatalogSyncService — syncCatalog + // ------------------------------------------------------------------------- + group('SquareCatalogSyncService — syncCatalog', () { + test('empty catalog returns zero counts', () async { + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: _InMemoryMappingRepository(), + ); + final result = await service.syncCatalog(); + expect(result.catalogSynced, 0); + expect(result.catalogFailed, 0); + expect(result.errors, isEmpty); + }); + + test('single product creates a new mapping', () async { + final mappingRepo = _InMemoryMappingRepository(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-001')]), + mappingRepository: mappingRepo, + ); + final result = await service.syncCatalog(); + expect(result.catalogSynced, 1); + expect(result.catalogFailed, 0); + expect(mappingRepo.count, 1); + }); + + test('mapping squareCatalogObjectId matches product id', () async { + final mappingRepo = _InMemoryMappingRepository(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-abc')]), + mappingRepository: mappingRepo, + ); + await service.syncCatalog(); + final mapping = await mappingRepo.getMappingBySquareId('sq-abc'); + expect(mapping, isNotNull); + expect(mapping!.squareCatalogObjectId, 'sq-abc'); + }); + + test('multiple products all synced', () async { + final mappingRepo = _InMemoryMappingRepository(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository( + drafts: [_draft('sq-1'), _draft('sq-2'), _draft('sq-3')], + ), + mappingRepository: mappingRepo, + ); + final result = await service.syncCatalog(); + expect(result.catalogSynced, 3); + expect(result.catalogFailed, 0); + expect(mappingRepo.count, 3); + }); + + test('existing mapping is updated not duplicated', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('sq-001', squareCatalogObjectId: 'sq-001')); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-001')]), + mappingRepository: mappingRepo, + ); + await service.syncCatalog(); + expect(mappingRepo.count, 1); // still 1, not 2 + }); + + test('catalog fetch failure returns catalogFailed=1 with error', () async { + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(shouldThrow: true), + mappingRepository: _InMemoryMappingRepository(), + ); + final result = await service.syncCatalog(); + expect(result.catalogFailed, 1); + expect(result.catalogSynced, 0); + expect(result.errors.length, 1); + expect(result.errors.first.itemId, 'catalog'); + }); + + test('saveMapping failure records error and continues', () async { + final mappingRepo = _InMemoryMappingRepository(shouldThrowOnSave: true); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1'), _draft('sq-2')]), + mappingRepository: mappingRepo, + ); + final result = await service.syncCatalog(); + expect(result.catalogFailed, 2); + expect(result.catalogSynced, 0); + expect(result.errors.length, 2); + }); + + test('partial failure — first item fails, second succeeds', () async { + final mappingRepo = _InMemoryMappingRepository(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-fail'), _draft('sq-ok')]), + mappingRepository: _PartialFailMappingRepository( + failIds: {'sq-fail'}, + delegate: mappingRepo, + ), + ); + final result = await service.syncCatalog(); + expect(result.catalogSynced, 1); + expect(result.catalogFailed, 1); + expect(result.errors.first.itemId, 'sq-fail'); + }); + + test('SyncError details populated on failure', () async { + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(shouldThrow: true), + mappingRepository: _InMemoryMappingRepository(), + ); + final result = await service.syncCatalog(); + expect(result.errors.first.message, contains('catalog fetch failed')); + }); + }); + + // ------------------------------------------------------------------------- + // SquareCatalogSyncService — syncInventory + // ------------------------------------------------------------------------- + group('SquareCatalogSyncService — syncInventory', () { + test('no adapter returns empty result', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + ); + final result = await service.syncInventory(); + expect(result.inventorySynced, 0); + expect(result.inventoryFailed, 0); + }); + + test('items without squareVariationId are skipped', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('id1')); // no squareVariationId + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncInventory(); + expect(result.inventorySynced, 0); + expect(result.inventoryFailed, 0); + expect(adapter.calledWith, isEmpty); + }); + + test('items with squareVariationId are synced', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + mappingRepo.seed(_mapping('id2', squareCatalogObjectId: 'sq-2', squareVariationId: 'var-2')); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncInventory(); + expect(result.inventorySynced, 2); + expect(result.inventoryFailed, 0); + expect(adapter.calledWith, containsAll(['id1', 'id2'])); + }); + + test('adapter returning false records failure', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + final adapter = _FakeInventoryAdapter(results: {'id1': false}); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncInventory(); + expect(result.inventorySynced, 0); + expect(result.inventoryFailed, 1); + expect(result.errors.first.itemId, 'id1'); + }); + + test('adapter throwing records failure and continues', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + mappingRepo.seed(_mapping('id2', squareCatalogObjectId: 'sq-2', squareVariationId: 'var-2')); + final adapter = _MixedInventoryAdapter(throwIds: {'id1'}); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncInventory(); + expect(result.inventorySynced, 1); + expect(result.inventoryFailed, 1); + expect(result.errors.first.itemId, 'id1'); + }); + + test('getAllMappings failure returns inventoryFailed=1', () async { + final mappingRepo = _InMemoryMappingRepository(shouldThrowOnGetAll: true); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncInventory(); + expect(result.inventoryFailed, 1); + expect(result.errors.first.itemId, 'mappings'); + }); + + test('empty mappings returns empty result', () async { + final mappingRepo = _InMemoryMappingRepository(); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncInventory(); + expect(result.inventorySynced, 0); + expect(result.inventoryFailed, 0); + expect(adapter.calledWith, isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // SquareCatalogSyncService — syncAll + // ------------------------------------------------------------------------- + group('SquareCatalogSyncService — syncAll', () { + test('syncAll merges catalog and inventory results', () async { + final mappingRepo = _InMemoryMappingRepository(); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1'), _draft('sq-2')]), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + // syncAll calls syncCatalog first which creates mappings without + // squareVariationId, so inventory sync skips them — correct behaviour: + // variation IDs are set by the catalog repository, not the sync service. + final result = await service.syncAll(); + expect(result.catalogSynced, 2); + expect(result.catalogFailed, 0); + expect(result.inventorySynced, 0); + expect(result.inventoryFailed, 0); + expect(result.allSucceeded, isTrue); + }); + + test('syncAll with pre-seeded variation IDs syncs inventory', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncAll(); + expect(result.catalogSynced, 1); + expect(result.inventorySynced, 1); + expect(result.allSucceeded, isTrue); + }); + + test('syncAll totalProcessed equals sum of all counts', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncAll(); + expect( + result.totalProcessed, + result.catalogSynced + + result.catalogFailed + + result.inventorySynced + + result.inventoryFailed, + ); + }); + + test('syncAll catalog failure does not prevent inventory sync', () async { + final mappingRepo = _InMemoryMappingRepository(); + mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1')); + final adapter = _FakeInventoryAdapter(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(shouldThrow: true), + mappingRepository: mappingRepo, + inventoryAdapter: adapter, + ); + final result = await service.syncAll(); + expect(result.catalogFailed, 1); + // Inventory sync still runs using existing mappings + expect(result.inventorySynced, 1); + }); + + test('syncAll without adapter skips inventory', () async { + final mappingRepo = _InMemoryMappingRepository(); + final service = SquareCatalogSyncService( + catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]), + mappingRepository: mappingRepo, + ); + final result = await service.syncAll(); + expect(result.catalogSynced, 1); + expect(result.inventorySynced, 0); + expect(result.inventoryFailed, 0); + }); + }); +} + +// --------------------------------------------------------------------------- +// Additional test doubles +// --------------------------------------------------------------------------- + +/// A mapping repository that delegates to [_InMemoryMappingRepository] but +/// throws on [saveMapping] for specific IDs. +class _PartialFailMappingRepository implements ProductIdMappingRepository { + final Set failIds; + final _InMemoryMappingRepository delegate; + + _PartialFailMappingRepository({required this.failIds, required this.delegate}); + + @override + Future> getAllMappings() => delegate.getAllMappings(); + + @override + Future getMappingByLocalId(String localId) => + delegate.getMappingByLocalId(localId); + + @override + Future getMappingByWooCommerceId(String wooCommerceId) => + delegate.getMappingByWooCommerceId(wooCommerceId); + + @override + Future getMappingBySquareId(String squareCatalogObjectId) => + delegate.getMappingBySquareId(squareCatalogObjectId); + + @override + Future saveMapping(ProductIdMapping mapping) async { + if (failIds.contains(mapping.localId)) { + throw Exception('forced failure for ${mapping.localId}'); + } + return delegate.saveMapping(mapping); + } + + @override + Future deleteMapping(String localId) => delegate.deleteMapping(localId); +} + +/// An [InventorySyncAdapter] that throws for specific IDs and succeeds for +/// others. +class _MixedInventoryAdapter implements InventorySyncAdapter { + final Set throwIds; + + _MixedInventoryAdapter({required this.throwIds}); + + @override + Future syncItem(String itemId, int remoteQuantity) async { + if (throwIds.contains(itemId)) { + throw Exception('inventory sync failed for $itemId'); + } + return true; + } +}