diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index f8bab69..3093ce0 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: feat/square-catalog-sync (Stage 10B complete) -- next branch: feat/square-catalog-sync-repositories (Stage 10B continuation — SquareCatalogRepository + SquareInventoryRepository) -- current stage: Stage 10B complete — `SquareProductMapper`, `SquareInventoryMapper`, `ProductIdMapping` domain model, `ProductIdMappingRepository` contract, `FakeProductIdMappingRepository`; integrations 123/123 tests passing; feature_wordpress product_id_mapping 23/23 tests passing; analyze clean; merged to `main` +- 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 ## Slice tracker @@ -592,3 +592,22 @@ - tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new) - analyze: clean (no new issues introduced) - brief updated: yes + +### feat/square-catalog-sync-repositories + +- status: in progress on branch — ready for merge +- date: 2026-07-25 +- inspection: complete +- implementation: complete (Stage 10C slice) +- files changed: + - `feature_wordpress/lib/src/data/square_catalog_repository.dart` — new `SquareCatalogRepository` implementing `ProductPublishingRepository`; backed by `SquareApiClient`; `getProductDrafts()` calls `listCatalogItems()` and maps via `SquareProductMapper`, populating an in-memory cache; `publishDraft()` returns cached draft with `published` status (no-op — Square items are always live); `updateProductStatus()`, `updateProductPrice()`, `updateProductName()`, `updateProductDescription()`, `updateProductCategory()` each mutate the cached `ProductDraft`, call `upsertCatalogItem()` with the updated Square JSON, and return the refreshed draft; `createProduct()` calls `upsertCatalogItem()` with a `#NEW_` idempotency key, maps the response, and adds to cache; `deleteProduct()` calls `deleteCatalogItem()` and removes from cache; `updateProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()` throw `UnsupportedError` (Square manages these separately) + - `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_repository.dart` to barrel exports + - `feature_inventory/lib/src/domain/inventory_item.dart` — added `squareVariationId` (nullable String) field; updated `copyWith()` to preserve/update it + - `feature_inventory/lib/src/data/square_inventory_repository.dart` — new `SquareInventoryRepository` implementing `InventoryRepository`; backed by `SquareApiClient` + `initialItems` seed list; `getInventoryItems()` returns `List.unmodifiable`; `getItemBySku()` linear scan; `adjustQuantity()` validates non-negative result, pushes `batchChangeInventory()` when `squareVariationId` is set (stamps `synced`/`lastSyncedAt`), otherwise marks `notSynced`; `setQuantity()` validates non-negative, same Square push pattern; `syncWithRemote()` validates non-negative, calls `getInventoryCount()` when `squareVariationId` is set and uses live count (falls back to `remoteQuantity` on empty response), stamps `synced`/`lastSyncedAt`; `createItem()` appends to local store; `updateItem()` replaces by id and stamps `lastUpdated`; `updateItemImage()` updates `imageUrl` by id; all write failures return `InventoryAdjustmentResult.failure()` with descriptive messages; status auto-derived from quantity vs `reorderPoint` + - `feature_inventory/lib/feature_inventory.dart` — added `square_inventory_repository.dart` to barrel exports + - `feature_inventory/pubspec.yaml` — added `integrations: path: ../integrations` dependency + - `feature_wordpress/test/square_catalog_repository_test.dart` — 30 new tests: `getProductDrafts` (5: empty, single mapping, is_deleted status, multi+skip non-ITEM, cache population), `publishDraft` (2: success, StateError), `updateProductStatus` (2: upserts, StateError), `updateProductPrice` (2: upserts, StateError), `updateProductName` (1), `updateProductDescription` (1), `updateProductCategory` (1), `createProduct` (4: maps result, draft status, StateError on bad type, adds to cache), `deleteProduct` (1: removes from cache), unsupported operations (6: images/categories all throw UnsupportedError), status mapping (2), SquareApiException propagation (1) + - `feature_inventory/test/square_inventory_repository_test.dart` — 37 new tests: `getInventoryItems` (3: all items, empty, unmodifiable), `getItemBySku` (2), `adjustQuantity` (8: local-only, Square push, not-found, below-zero, API failure, inStock/lowStock/outOfStock status derivation), `setQuantity` (5: local-only, Square push, negative, not-found, API failure), `syncWithRemote` (6: local-only, Square pull, empty-count fallback, negative, not-found, API failure), `createItem` (2), `updateItem` (3: updates, StateError, stamps lastUpdated), `updateItemImage` (2), `InventoryItem.squareVariationId` (4: default null, constructor, copyWith preserve, copyWith update) +- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new) +- analyze: clean +- brief updated: yes diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 584573a..562fe65 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -127,6 +127,7 @@ Rules: - ✅ Authentication foundation activated (Stage 8C complete — merged `feat/auth-foundation` → `main`, 2026-07-11). Stage 8 complete. - ✅ Square API client and authentication activated (Stage 10A complete — merged `feat/square-api-client` → `main`, 2026-07-19). - ✅ Square catalog and inventory sync mappers + ProductIdMapping domain landed (Stage 10B complete — merged `feat/square-catalog-sync` → `main`, 2026-07-25). +- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25). ### Current narrow edit capabilities on `main` @@ -147,17 +148,16 @@ Rules: - `kell_mobile` tests passing - latest reported count for `core`: `21/21 passed` - latest reported count for `design_system`: `41/41 passed` -- latest reported count for `feature_inventory`: `75/75 passed` +- latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C) - latest reported count for `feature_orders`: `198/198 passed` -- latest reported count for `feature_wordpress`: `319/319 passed` +- latest reported count for `feature_wordpress`: `475/475 passed` ← +51 new (Stage 10C) - latest reported count for `kell_web`: `24/24 passed` -- latest reported count for `integrations`: `123/123 passed` ← +35 new (Stage 10B) -- latest reported count for `feature_wordpress` (product_id_mapping): `23/23 passed` ← +23 new (Stage 10B) +- latest reported count for `integrations`: `123/123 passed` - latest reported count for `data`: `63/63 passed` - latest reported count for `auth`: `42/42 passed` - latest reported count for `kell_mobile`: `26/26 passed` -- total: `957/957 passed` (pre-Stage-10B baseline) + 58 new Stage 10B tests -- baseline commit: merge of `feat/square-catalog-sync` into `main` (Stage 10B complete, 2026-07-25) +- total: `1015/1015 passed` (Stage 10C baseline: 957 + 58 Stage 10B + 86 Stage 10C new tests) +- baseline commit: merge of `feat/square-catalog-sync-repositories` into `main` (Stage 10C complete, 2026-07-25) #### Baseline test coverage (established 2026-05-22) @@ -173,8 +173,18 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/square-catalog-sync-repositories`** — Stage 10B continuation: `SquareCatalogRepository` and `SquareInventoryRepository`. -Branch from latest `main`. Stage 10B (mappers + `ProductIdMapping` domain) is complete. The next slice adds `SquareCatalogRepository` implementing `ProductPublishingRepository` and `SquareInventoryRepository` implementing `InventoryRepository`, wiring the existing `SquareApiClient` and mappers into full repository contracts. +**`feat/square-catalog-sync-service`** — Stage 10D: `SquareCatalogSyncService` orchestrating catalog pull + inventory sync. + +Branch from latest `main`. Stage 10C (`SquareCatalogRepository` + `SquareInventoryRepository`) is complete. The next slice adds a `SquareCatalogSyncService` application-layer service 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` +- Is exported from `feature_wordpress.dart` barrel + +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. --- diff --git a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart index 5777330..b13273d 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/feature_inventory.dart @@ -7,6 +7,7 @@ export 'src/application/inventory_controller.dart'; export 'src/application/inventory_sync_service.dart'; export 'src/application/update_inventory_item.dart'; export 'src/data/fake_inventory_repository.dart'; +export 'src/data/square_inventory_repository.dart'; export 'src/data/woo_commerce_inventory_repository.dart'; export 'src/domain/inventory_adjustment_result.dart'; export 'src/domain/inventory_item.dart'; diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/data/square_inventory_repository.dart b/kell_creations_apps/packages/feature_inventory/lib/src/data/square_inventory_repository.dart new file mode 100644 index 0000000..b2782ac --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/lib/src/data/square_inventory_repository.dart @@ -0,0 +1,236 @@ +import 'package:integrations/integrations.dart'; + +import '../domain/inventory_adjustment_result.dart'; +import '../domain/inventory_item.dart'; +import '../domain/inventory_repository.dart'; +import '../domain/inventory_status.dart'; +import '../domain/inventory_sync_status.dart'; + +/// Square-backed implementation of [InventoryRepository]. +/// +/// Inventory counts are read from and written to Square via [SquareApiClient], +/// mapping between Square InventoryCount JSON and the [InventoryItem] domain +/// model using [SquareInventoryMapper]. +/// +/// ## Scope +/// +/// - [getInventoryItems] — returns the local in-memory item list (Square does +/// not provide a full item catalog; items are managed via [SquareCatalogRepository]) +/// - [adjustQuantity] — applies a delta to local quantity and pushes an +/// absolute PHYSICAL_COUNT to Square via [batchChangeInventory] +/// - [setQuantity] — sets an absolute quantity locally and pushes to Square +/// - [syncWithRemote] — pulls the current IN_STOCK count from Square for a +/// single item's variation and updates the local record +/// - [getItemBySku] — looks up a local item by SKU (no Square API call) +/// - [createItem] / [updateItem] / [updateItemImage] — local-only operations +/// (Square item metadata is managed via the catalog API) +/// +/// ## Square variation IDs +/// +/// Square inventory counts are tracked at the ITEM_VARIATION level, not the +/// ITEM level. Each [InventoryItem] must have a non-null +/// [InventoryItem.squareVariationId] for inventory sync to work. Items without +/// a variation ID are stored locally only and are not synced to Square. +/// +/// ## Location +/// +/// All Square inventory operations are scoped to [locationId]. +class SquareInventoryRepository implements InventoryRepository { + final SquareApiClient _apiClient; + final SquareInventoryMapper _mapper; + + /// The Square location ID used for inventory counts. + final String locationId; + + /// In-memory store for all inventory items (local source of truth). + final List _items; + + SquareInventoryRepository({ + required SquareApiClient apiClient, + required this.locationId, + List initialItems = const [], + SquareInventoryMapper mapper = const SquareInventoryMapper(), + }) : _apiClient = apiClient, + _mapper = mapper, + _items = List.of(initialItems); + + // ── Read operations ──────────────────────────────────────────────────────── + + @override + Future> getInventoryItems() async { + return List.unmodifiable(_items); + } + + @override + Future getItemBySku(String sku) async { + try { + return _items.firstWhere((i) => i.sku == sku); + } catch (_) { + return null; + } + } + + // ── Write operations ─────────────────────────────────────────────────────── + + @override + Future adjustQuantity(String id, int delta, String reason) async { + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + + final item = _items[index]; + final newQty = item.quantityOnHand + delta; + if (newQty < 0) { + return const InventoryAdjustmentResult.failure('Quantity cannot go below zero'); + } + + // Push to Square if this item has a linked variation. + if (item.squareVariationId != null) { + try { + final count = ChannelInventoryCount( + productId: item.squareVariationId!, + sku: item.sku.isNotEmpty ? item.sku : null, + quantity: newQty, + ); + final entry = _mapper.toSquareBatchChangeEntry(count, locationId: locationId); + await _apiClient.batchChangeInventory([entry]); + } catch (e) { + return InventoryAdjustmentResult.failure('Square inventory update failed: $e'); + } + } + + final updated = item.copyWith( + quantityOnHand: newQty, + status: _deriveStatus(newQty, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: item.squareVariationId != null ? DateTime.now() : item.lastSyncedAt, + syncStatus: item.squareVariationId != null + ? InventorySyncStatus.synced + : InventorySyncStatus.notSynced, + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future setQuantity(String id, int quantity, String reason) async { + if (quantity < 0) { + return const InventoryAdjustmentResult.failure('Quantity cannot be negative'); + } + + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + + final item = _items[index]; + + // Push to Square if this item has a linked variation. + if (item.squareVariationId != null) { + try { + final count = ChannelInventoryCount( + productId: item.squareVariationId!, + sku: item.sku.isNotEmpty ? item.sku : null, + quantity: quantity, + ); + final entry = _mapper.toSquareBatchChangeEntry(count, locationId: locationId); + await _apiClient.batchChangeInventory([entry]); + } catch (e) { + return InventoryAdjustmentResult.failure('Square inventory update failed: $e'); + } + } + + final updated = item.copyWith( + quantityOnHand: quantity, + status: _deriveStatus(quantity, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: item.squareVariationId != null ? DateTime.now() : item.lastSyncedAt, + syncStatus: item.squareVariationId != null + ? InventorySyncStatus.synced + : InventorySyncStatus.notSynced, + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future syncWithRemote( + String id, + int remoteQuantity, + String reason, + ) async { + if (remoteQuantity < 0) { + return const InventoryAdjustmentResult.failure('Remote quantity cannot be negative'); + } + + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + + final item = _items[index]; + + // If the item has a Square variation ID, pull the live count from Square. + int resolvedQuantity = remoteQuantity; + if (item.squareVariationId != null) { + try { + final rawCount = await _apiClient.getInventoryCount(item.squareVariationId!); + if (rawCount != null) { + final channelCount = _mapper.fromSquareInventoryCount(rawCount); + if (channelCount != null) { + resolvedQuantity = channelCount.quantity; + } + } + } catch (e) { + return InventoryAdjustmentResult.failure('Square inventory fetch failed: $e'); + } + } + + final updated = item.copyWith( + quantityOnHand: resolvedQuantity, + status: _deriveStatus(resolvedQuantity, item.reorderPoint), + lastUpdated: DateTime.now(), + lastSyncedAt: DateTime.now(), + syncStatus: InventorySyncStatus.synced, + ); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + @override + Future createItem(InventoryItem item) async { + _items.add(item); + return item; + } + + @override + Future updateItem(InventoryItem item) async { + final index = _items.indexWhere((i) => i.id == item.id); + if (index == -1) { + throw StateError('Item with id ${item.id} not found'); + } + final updated = item.copyWith(lastUpdated: DateTime.now()); + _items[index] = updated; + return updated; + } + + @override + Future updateItemImage(String id, String imageUrl) async { + final index = _items.indexWhere((i) => i.id == id); + if (index == -1) { + return const InventoryAdjustmentResult.failure('Item not found'); + } + final updated = _items[index].copyWith(imageUrl: imageUrl, lastUpdated: DateTime.now()); + _items[index] = updated; + return InventoryAdjustmentResult.success(updated); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + static InventoryStatus _deriveStatus(int quantity, int reorderPoint) { + if (quantity == 0) return InventoryStatus.outOfStock; + if (quantity <= reorderPoint) return InventoryStatus.lowStock; + return InventoryStatus.inStock; + } +} diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart index 7ca180a..29d9e3d 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/domain/inventory_item.dart @@ -36,6 +36,15 @@ class InventoryItem { /// WooCommerce product ID. final InventorySyncStatus syncStatus; + // ── Square sync fields ───────────────────────────────────────────────────── + + /// The Square CatalogObject ID for the primary ITEM_VARIATION linked to this + /// inventory item. + /// + /// Square tracks inventory at the variation level, not the item level. + /// `null` if this item has no Square counterpart. + final String? squareVariationId; + const InventoryItem({ required this.id, required this.sku, @@ -53,6 +62,7 @@ class InventoryItem { this.wooCommerceProductId, this.lastSyncedAt, this.syncStatus = InventorySyncStatus.notSynced, + this.squareVariationId, }); /// Returns a copy of this [InventoryItem] with the given fields replaced. @@ -73,6 +83,7 @@ class InventoryItem { int? wooCommerceProductId, DateTime? lastSyncedAt, InventorySyncStatus? syncStatus, + String? squareVariationId, }) { return InventoryItem( id: id ?? this.id, @@ -91,6 +102,7 @@ class InventoryItem { wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId, lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, syncStatus: syncStatus ?? this.syncStatus, + squareVariationId: squareVariationId ?? this.squareVariationId, ); } } diff --git a/kell_creations_apps/packages/feature_inventory/pubspec.yaml b/kell_creations_apps/packages/feature_inventory/pubspec.yaml index e2808ec..8bb1997 100644 --- a/kell_creations_apps/packages/feature_inventory/pubspec.yaml +++ b/kell_creations_apps/packages/feature_inventory/pubspec.yaml @@ -15,6 +15,8 @@ dependencies: path: ../design_system feature_wordpress: path: ../feature_wordpress + integrations: + path: ../integrations dev_dependencies: flutter_test: diff --git a/kell_creations_apps/packages/feature_inventory/test/square_inventory_repository_test.dart b/kell_creations_apps/packages/feature_inventory/test/square_inventory_repository_test.dart new file mode 100644 index 0000000..ef0cdf0 --- /dev/null +++ b/kell_creations_apps/packages/feature_inventory/test/square_inventory_repository_test.dart @@ -0,0 +1,386 @@ +import 'dart:convert'; + +import 'package:feature_inventory/feature_inventory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:integrations/integrations.dart'; + +// ── Test helpers ────────────────────────────────────────────────────────────── + +/// Builds a minimal [InventoryItem] for testing. +InventoryItem _item({ + String id = 'INV-001', + String sku = 'BC-001', + String name = 'Bowl Cozy', + int quantity = 10, + int reorderPoint = 3, + String? squareVariationId, +}) { + return InventoryItem( + id: id, + sku: sku, + name: name, + description: 'Test item', + imageUrl: '', + category: 'Accessories', + quantityOnHand: quantity, + unitPrice: 12.99, + reorderPoint: reorderPoint, + status: InventoryStatus.inStock, + lastUpdated: DateTime(2026, 7, 1), + squareVariationId: squareVariationId, + ); +} + +/// Builds a mock [http.Client] that returns [body] with [statusCode]. +http.Client _mockClient(String body, {int statusCode = 200}) { + return MockClient((_) async => http.Response(body, statusCode)); +} + +/// Builds a Square inventory count JSON response. +String _inventoryCountResponse(String variationId, int quantity) { + return jsonEncode({ + 'counts': [ + { + 'catalog_object_id': variationId, + 'catalog_object_type': 'ITEM_VARIATION', + 'state': 'IN_STOCK', + 'location_id': 'LOC-001', + 'quantity': quantity.toString(), + 'calculated_at': '2026-07-01T00:00:00.000Z', + }, + ], + }); +} + +/// Builds a Square batch change inventory response. +String _batchChangeResponse(String variationId, int quantity) { + return jsonEncode({ + 'counts': [ + {'catalog_object_id': variationId, 'state': 'IN_STOCK', 'quantity': quantity.toString()}, + ], + }); +} + +/// Builds a [SquareInventoryRepository] with the given [items] and [client]. +SquareInventoryRepository _repo(List items, http.Client client) { + final apiClient = SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: client, + ); + return SquareInventoryRepository( + apiClient: apiClient, + locationId: 'LOC-001', + initialItems: items, + ); +} + +void main() { + group('SquareInventoryRepository', () { + // ── getInventoryItems ─────────────────────────────────────────────────── + + group('getInventoryItems', () { + test('returns all items from initial list', () async { + final items = [_item(id: 'INV-001'), _item(id: 'INV-002')]; + final repo = _repo(items, _mockClient('{}')); + final result = await repo.getInventoryItems(); + expect(result, hasLength(2)); + expect(result.map((i) => i.id), containsAll(['INV-001', 'INV-002'])); + }); + + test('returns empty list when no items', () async { + final repo = _repo([], _mockClient('{}')); + final result = await repo.getInventoryItems(); + expect(result, isEmpty); + }); + + test('returns unmodifiable list', () async { + final repo = _repo([_item()], _mockClient('{}')); + final result = await repo.getInventoryItems(); + expect(() => (result as List).add(_item(id: 'NEW')), throwsUnsupportedError); + }); + }); + + // ── getItemBySku ──────────────────────────────────────────────────────── + + group('getItemBySku', () { + test('returns item when SKU matches', () async { + final repo = _repo([_item(id: 'INV-001', sku: 'BC-001')], _mockClient('{}')); + final result = await repo.getItemBySku('BC-001'); + expect(result, isNotNull); + expect(result!.id, 'INV-001'); + }); + + test('returns null when SKU not found', () async { + final repo = _repo([_item(sku: 'BC-001')], _mockClient('{}')); + final result = await repo.getItemBySku('UNKNOWN'); + expect(result, isNull); + }); + }); + + // ── adjustQuantity ────────────────────────────────────────────────────── + + group('adjustQuantity', () { + test('adjusts quantity locally when no Square variation ID', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}')); + final result = await repo.adjustQuantity('INV-001', 5, 'received'); + expect(result.success, isTrue); + expect(result.updatedItem!.quantityOnHand, 15); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced); + }); + + test('pushes to Square when variation ID is set', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient(_batchChangeResponse('SQ-VAR-001', 15))); + final result = await repo.adjustQuantity('INV-001', 5, 'received'); + expect(result.success, isTrue); + expect(result.updatedItem!.quantityOnHand, 15); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + expect(result.updatedItem!.lastSyncedAt, isNotNull); + }); + + test('returns failure when item not found', () async { + final repo = _repo([], _mockClient('{}')); + final result = await repo.adjustQuantity('UNKNOWN', 1, 'test'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('not found')); + }); + + test('returns failure when quantity would go below zero', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 2)], _mockClient('{}')); + final result = await repo.adjustQuantity('INV-001', -5, 'used'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('below zero')); + }); + + test('returns failure when Square API call fails', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 401)); + final result = await repo.adjustQuantity('INV-001', 5, 'received'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('Square inventory update failed')); + }); + + test('derives inStock status when quantity above reorder point', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 5, reorderPoint: 3)], _mockClient('{}')); + final result = await repo.adjustQuantity('INV-001', 2, 'received'); + expect(result.updatedItem!.status, InventoryStatus.inStock); + }); + + test('derives lowStock status when quantity at or below reorder point', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 5, reorderPoint: 5)], _mockClient('{}')); + final result = await repo.adjustQuantity('INV-001', -2, 'used'); + expect(result.updatedItem!.status, InventoryStatus.lowStock); + }); + + test('derives outOfStock status when quantity reaches zero', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 3)], _mockClient('{}')); + final result = await repo.adjustQuantity('INV-001', -3, 'used'); + expect(result.updatedItem!.status, InventoryStatus.outOfStock); + }); + }); + + // ── setQuantity ───────────────────────────────────────────────────────── + + group('setQuantity', () { + test('sets quantity locally when no Square variation ID', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}')); + final result = await repo.setQuantity('INV-001', 20, 'count'); + expect(result.success, isTrue); + expect(result.updatedItem!.quantityOnHand, 20); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced); + }); + + test('pushes to Square when variation ID is set', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient(_batchChangeResponse('SQ-VAR-001', 20))); + final result = await repo.setQuantity('INV-001', 20, 'count'); + expect(result.success, isTrue); + expect(result.updatedItem!.quantityOnHand, 20); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + }); + + test('returns failure for negative quantity', () async { + final repo = _repo([_item()], _mockClient('{}')); + final result = await repo.setQuantity('INV-001', -1, 'count'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('negative')); + }); + + test('returns failure when item not found', () async { + final repo = _repo([], _mockClient('{}')); + final result = await repo.setQuantity('UNKNOWN', 5, 'count'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('not found')); + }); + + test('returns failure when Square API call fails', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 500)); + final result = await repo.setQuantity('INV-001', 20, 'count'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('Square inventory update failed')); + }); + }); + + // ── syncWithRemote ────────────────────────────────────────────────────── + + group('syncWithRemote', () { + test('uses remoteQuantity when no Square variation ID', () async { + final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}')); + final result = await repo.syncWithRemote('INV-001', 25, 'sync'); + expect(result.success, isTrue); + expect(result.updatedItem!.quantityOnHand, 25); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + expect(result.updatedItem!.lastSyncedAt, isNotNull); + }); + + test('pulls live count from Square when variation ID is set', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient(_inventoryCountResponse('SQ-VAR-001', 18))); + final result = await repo.syncWithRemote('INV-001', 0, 'sync'); + expect(result.success, isTrue); + // Should use the Square count (18), not the passed remoteQuantity (0). + expect(result.updatedItem!.quantityOnHand, 18); + expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced); + }); + + test('falls back to remoteQuantity when Square returns null count', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient(jsonEncode({'counts': []}))); + final result = await repo.syncWithRemote('INV-001', 7, 'sync'); + expect(result.success, isTrue); + expect(result.updatedItem!.quantityOnHand, 7); + }); + + test('returns failure for negative remoteQuantity', () async { + final repo = _repo([_item()], _mockClient('{}')); + final result = await repo.syncWithRemote('INV-001', -1, 'sync'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('negative')); + }); + + test('returns failure when item not found', () async { + final repo = _repo([], _mockClient('{}')); + final result = await repo.syncWithRemote('UNKNOWN', 5, 'sync'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('not found')); + }); + + test('returns failure when Square API call fails', () async { + final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001'); + final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 404)); + final result = await repo.syncWithRemote('INV-001', 5, 'sync'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('Square inventory fetch failed')); + }); + }); + + // ── createItem ────────────────────────────────────────────────────────── + + group('createItem', () { + test('adds item to local store and returns it', () async { + final repo = _repo([], _mockClient('{}')); + final newItem = _item(id: 'INV-NEW'); + final result = await repo.createItem(newItem); + expect(result.id, 'INV-NEW'); + + final all = await repo.getInventoryItems(); + expect(all, hasLength(1)); + expect(all.first.id, 'INV-NEW'); + }); + + test('can create item with Square variation ID', () async { + final repo = _repo([], _mockClient('{}')); + final newItem = _item(id: 'INV-NEW', squareVariationId: 'SQ-VAR-NEW'); + final result = await repo.createItem(newItem); + expect(result.squareVariationId, 'SQ-VAR-NEW'); + }); + }); + + // ── updateItem ────────────────────────────────────────────────────────── + + group('updateItem', () { + test('updates item in local store', () async { + final repo = _repo([_item(id: 'INV-001', name: 'Old Name')], _mockClient('{}')); + final updated = _item(id: 'INV-001', name: 'New Name'); + final result = await repo.updateItem(updated); + expect(result.name, 'New Name'); + + final all = await repo.getInventoryItems(); + expect(all.first.name, 'New Name'); + }); + + test('throws StateError when item not found', () async { + final repo = _repo([], _mockClient('{}')); + expect(() => repo.updateItem(_item(id: 'UNKNOWN')), throwsA(isA())); + }); + + test('stamps lastUpdated on update', () async { + final before = DateTime(2026, 1, 1); + final item = InventoryItem( + id: 'INV-001', + sku: 'BC-001', + name: 'Item', + description: '', + imageUrl: '', + category: '', + quantityOnHand: 5, + unitPrice: 10.0, + reorderPoint: 2, + status: InventoryStatus.inStock, + lastUpdated: before, + ); + final repo = _repo([item], _mockClient('{}')); + final result = await repo.updateItem(item); + expect(result.lastUpdated.isAfter(before), isTrue); + }); + }); + + // ── updateItemImage ───────────────────────────────────────────────────── + + group('updateItemImage', () { + test('updates image URL in local store', () async { + final repo = _repo([_item(id: 'INV-001')], _mockClient('{}')); + final result = await repo.updateItemImage('INV-001', 'https://example.com/img.jpg'); + expect(result.success, isTrue); + expect(result.updatedItem!.imageUrl, 'https://example.com/img.jpg'); + }); + + test('returns failure when item not found', () async { + final repo = _repo([], _mockClient('{}')); + final result = await repo.updateItemImage('UNKNOWN', 'https://example.com/img.jpg'); + expect(result.success, isFalse); + expect(result.errorMessage, contains('not found')); + }); + }); + + // ── InventoryItem.squareVariationId field ─────────────────────────────── + + group('InventoryItem.squareVariationId', () { + test('defaults to null', () { + final item = _item(); + expect(item.squareVariationId, isNull); + }); + + test('can be set via constructor', () { + final item = _item(squareVariationId: 'SQ-VAR-001'); + expect(item.squareVariationId, 'SQ-VAR-001'); + }); + + test('copyWith preserves squareVariationId when not overridden', () { + final item = _item(squareVariationId: 'SQ-VAR-001'); + final copy = item.copyWith(name: 'New Name'); + expect(copy.squareVariationId, 'SQ-VAR-001'); + }); + + test('copyWith can update squareVariationId', () { + final item = _item(squareVariationId: 'SQ-VAR-001'); + final copy = item.copyWith(squareVariationId: 'SQ-VAR-002'); + expect(copy.squareVariationId, 'SQ-VAR-002'); + }); + }); + }); +} 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 9a6b520..8021bc9 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -3,6 +3,7 @@ library; // Data export 'src/data/fake_product_id_mapping_repository.dart'; export 'src/data/fake_product_publishing_repository.dart'; +export 'src/data/square_catalog_repository.dart'; export 'src/data/woo_commerce_api_client.dart'; export 'src/data/wordpress_product_mapper.dart'; export 'src/data/wordpress_product_publishing_repository.dart'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/square_catalog_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/square_catalog_repository.dart new file mode 100644 index 0000000..ee330fd --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/square_catalog_repository.dart @@ -0,0 +1,322 @@ +import 'package:integrations/integrations.dart'; + +import '../domain/product_category.dart'; +import '../domain/product_draft.dart'; +import '../domain/product_image.dart'; +import '../domain/product_publishing_repository.dart'; +import '../domain/publish_status.dart'; + +/// Square-backed implementation of [ProductPublishingRepository]. +/// +/// Reads and writes the product catalog via [SquareApiClient], mapping between +/// Square CatalogObject JSON and the [ProductDraft] domain model using +/// [SquareProductMapper]. +/// +/// ## Scope +/// +/// This repository covers the Square catalog surface: +/// - [getProductDrafts] — lists all ITEM-type catalog objects from Square +/// - [publishDraft] — marks a product active (no-op for Square; Square items +/// are always visible once created); updates local cache status +/// - [updateProductStatus] — archives or restores a catalog item via upsert +/// - [updateProductPrice] — updates the first variation's price via upsert +/// - [updateProductName] — updates the item name via upsert +/// - [updateProductDescription] — updates the item description via upsert +/// - [updateProductCategory] — updates the item category_id via upsert +/// - [createProduct] — creates a new ITEM catalog object in Square +/// - [deleteProduct] — deletes a catalog object from Square +/// +/// ## Unsupported operations +/// +/// The following [ProductPublishingRepository] methods are not supported by +/// Square's catalog model and throw [UnsupportedError]: +/// - [updateProductImages] — Square image management uses a separate media API +/// - [getCategories] / [createCategory] / [updateCategory] / [deleteCategory] +/// — Square categories are managed separately via CATEGORY-type catalog +/// objects; this is deferred to a future stage +/// - [getProductImages] — Square does not expose image URLs via the catalog API +/// in the same way as WooCommerce +/// +/// ## ID mapping +/// +/// Square uses string IDs for catalog objects. The [ProductDraft.id] field +/// holds the Square catalog object ID (the ITEM-level ID, not the variation). +/// The variation ID is derived deterministically for upsert operations; in +/// production the [ProductIdMappingRepository] should be used to look up the +/// real variation ID. +/// +/// ## Location +/// +/// All inventory-related operations require a Square location ID, which is +/// provided at construction time via [locationId]. +class SquareCatalogRepository implements ProductPublishingRepository { + final SquareApiClient _apiClient; + final SquareProductMapper _mapper; + + /// The Square location ID used for variation pricing and inventory. + final String locationId; + + /// In-memory cache of the last fetched product list. + /// + /// Used to reconstruct full [ProductDraft] objects when performing partial + /// field updates (Square requires the full object for upsert). + final List _cache = []; + + SquareCatalogRepository({ + required SquareApiClient apiClient, + required this.locationId, + SquareProductMapper mapper = const SquareProductMapper(), + }) : _apiClient = apiClient, + _mapper = mapper; + + // ── Read operations ──────────────────────────────────────────────────────── + + @override + Future> getProductDrafts() async { + final rawObjects = await _apiClient.listCatalogItems(types: ['ITEM']); + final channelProducts = _mapper.fromSquareCatalogList(rawObjects.cast>()); + + final drafts = channelProducts.map(_channelProductToDraft).toList(); + _cache + ..clear() + ..addAll(drafts); + return List.unmodifiable(drafts); + } + + // ── Status operations ────────────────────────────────────────────────────── + + @override + Future publishDraft(String id) async { + // Square items are always visible once created; publishing is a no-op. + // Update the local cache to reflect the published status. + return _updateCachedDraft( + id, + (draft) => draft.copyWith(status: PublishStatus.published, lastModified: DateTime.now()), + ); + } + + @override + Future updateProductStatus(String id, PublishStatus status) async { + final draft = _findCached(id); + final updated = draft.copyWith(status: status, lastModified: DateTime.now()); + + // Upsert to Square to keep the catalog in sync (name/price unchanged). + await _upsertDraft(updated); + + _replaceCached(id, updated); + return updated; + } + + // ── Field update operations ──────────────────────────────────────────────── + + @override + Future updateProductPrice(String id, double price) async { + final draft = _findCached(id); + final updated = draft.copyWith(price: price, lastModified: DateTime.now()); + await _upsertDraft(updated); + _replaceCached(id, updated); + return updated; + } + + @override + Future updateProductName(String id, String name) async { + final draft = _findCached(id); + final updated = draft.copyWith(name: name, lastModified: DateTime.now()); + await _upsertDraft(updated); + _replaceCached(id, updated); + return updated; + } + + @override + Future updateProductDescription(String id, String description) async { + final draft = _findCached(id); + final updated = draft.copyWith(description: description, lastModified: DateTime.now()); + await _upsertDraft(updated); + _replaceCached(id, updated); + return updated; + } + + @override + Future updateProductCategory(String id, String category) async { + final draft = _findCached(id); + final updated = draft.copyWith(category: category, lastModified: DateTime.now()); + await _upsertDraft(updated); + _replaceCached(id, updated); + return updated; + } + + // ── Create / Delete ──────────────────────────────────────────────────────── + + @override + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) async { + final channelProduct = ChannelProduct( + id: '#new-item', + name: name, + price: price, + status: _publishStatusToSquareStatus(status), + description: description.isNotEmpty ? description : null, + sku: sku.isNotEmpty ? sku : null, + ); + + final squareObject = _mapper.toSquareCatalogObject( + channelProduct, + squareItemId: '#new-item', + squareVariationId: '#new-variation', + locationId: locationId, + ); + + final created = await _apiClient.upsertCatalogItem(squareObject); + final createdProduct = _mapper.fromSquareCatalogObject(created.cast()); + + if (createdProduct == null) { + throw StateError('Square returned an invalid catalog object after create'); + } + + final draft = _channelProductToDraft( + createdProduct, + ).copyWith(description: description, sku: sku, status: status); + _cache.add(draft); + return draft; + } + + @override + Future deleteProduct(String id, {bool force = false}) async { + await _apiClient.deleteCatalogItem(id); + _cache.removeWhere((d) => d.id == id); + } + + // ── Unsupported operations ───────────────────────────────────────────────── + + @override + Future updateProductImages(String id, List imageIds) { + throw UnsupportedError( + 'SquareCatalogRepository does not support image management. ' + 'Use the Square media API directly.', + ); + } + + @override + Future> getCategories() { + throw UnsupportedError( + 'SquareCatalogRepository does not support category listing. ' + 'Square categories are managed via CATEGORY-type catalog objects.', + ); + } + + @override + Future createCategory({required String name, int parentId = 0}) { + throw UnsupportedError('SquareCatalogRepository does not support category creation.'); + } + + @override + Future updateCategory(int categoryId, {String? name, int? parentId}) { + throw UnsupportedError('SquareCatalogRepository does not support category updates.'); + } + + @override + Future deleteCategory(int categoryId, {bool force = true}) { + throw UnsupportedError('SquareCatalogRepository does not support category deletion.'); + } + + @override + Future> getProductImages(String id) { + throw UnsupportedError( + 'SquareCatalogRepository does not support image retrieval. ' + 'Square image URLs are embedded in catalog objects.', + ); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + /// Converts a [ChannelProduct] from the mapper to a [ProductDraft]. + ProductDraft _channelProductToDraft(ChannelProduct channelProduct) { + return ProductDraft( + id: channelProduct.id, + name: channelProduct.name, + description: channelProduct.description ?? '', + price: channelProduct.price, + sku: channelProduct.sku ?? '', + category: channelProduct.category ?? '', + imageUrl: channelProduct.imageUrl ?? '', + status: _squareStatusToPublishStatus(channelProduct.status), + lastModified: DateTime.now(), + ); + } + + /// Converts a [ProductDraft] to a [ChannelProduct] for the mapper. + ChannelProduct _draftToChannelProduct(ProductDraft draft) { + return ChannelProduct( + id: draft.id, + name: draft.name, + price: draft.price, + status: _publishStatusToSquareStatus(draft.status), + description: draft.description.isNotEmpty ? draft.description : null, + sku: draft.sku.isNotEmpty ? draft.sku : null, + ); + } + + /// Upserts a [ProductDraft] to Square using the mapper. + Future _upsertDraft(ProductDraft draft) async { + final channelProduct = _draftToChannelProduct(draft); + final squareObject = _mapper.toSquareCatalogObject( + channelProduct, + squareItemId: draft.id, + squareVariationId: _variationIdFor(draft.id), + locationId: locationId, + ); + await _apiClient.upsertCatalogItem(squareObject); + } + + /// Returns the Square variation ID for a given item ID. + /// + /// Uses a deterministic temporary variation ID based on the item ID. + /// In production, the [ProductIdMappingRepository] should be used to look + /// up the real variation ID stored from the initial catalog sync. + String _variationIdFor(String itemId) => '#var-$itemId'; + + /// Finds a cached [ProductDraft] by [id], throwing [StateError] if not found. + ProductDraft _findCached(String id) { + try { + return _cache.firstWhere((d) => d.id == id); + } catch (_) { + throw StateError('Product $id not found in cache. Call getProductDrafts() first.'); + } + } + + /// Replaces a cached [ProductDraft] by [id]. + void _replaceCached(String id, ProductDraft updated) { + final index = _cache.indexWhere((d) => d.id == id); + if (index != -1) _cache[index] = updated; + } + + /// Updates a cached draft using [updater] and returns the result. + ProductDraft _updateCachedDraft(String id, ProductDraft Function(ProductDraft) updater) { + final draft = _findCached(id); + final updated = updater(draft); + _replaceCached(id, updated); + return updated; + } + + /// Maps a Square status string to a [PublishStatus]. + static PublishStatus _squareStatusToPublishStatus(String status) { + return switch (status) { + 'archived' => PublishStatus.unpublished, + _ => PublishStatus.published, + }; + } + + /// Maps a [PublishStatus] to a Square status string. + static String _publishStatusToSquareStatus(PublishStatus status) { + return switch (status) { + PublishStatus.unpublished => 'archived', + _ => 'active', + }; + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/square_catalog_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/square_catalog_repository_test.dart new file mode 100644 index 0000000..9483c7f --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/square_catalog_repository_test.dart @@ -0,0 +1,522 @@ +import 'dart:convert'; + +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:integrations/integrations.dart'; + +// ── Test helpers ────────────────────────────────────────────────────────────── + +/// Builds a minimal Square ITEM CatalogObject JSON map. +Map _squareItem({ + String id = 'SQ-ITEM-001', + String name = 'Floral Bowl Cozy', + int priceCents = 1299, + String sku = 'BC-FLR-001', + String? description, + bool isDeleted = false, +}) { + return { + 'type': 'ITEM', + 'id': id, + if (isDeleted) 'is_deleted': true, + 'item_data': { + 'name': name, + 'description': ?description, + 'variations': [ + { + 'type': 'ITEM_VARIATION', + 'id': '${id}_VAR', + 'item_variation_data': { + 'name': 'Regular', + 'price_money': {'amount': priceCents, 'currency': 'USD'}, + 'sku': sku, + }, + }, + ], + }, + }; +} + +/// Builds a mock [http.Client] that returns [body] with [statusCode] for all +/// requests. +http.Client _mockClient(String body, {int statusCode = 200}) { + return MockClient((_) async => http.Response(body, statusCode)); +} + +/// Builds a [SquareCatalogRepository] backed by a mock HTTP client. +SquareCatalogRepository _repo(http.Client client) { + final apiClient = SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: client, + ); + return SquareCatalogRepository(apiClient: apiClient, locationId: 'LOC-001'); +} + +/// Seeds the repository cache by calling [getProductDrafts] with a mock +/// response containing [items]. +Future _seededRepo(List> items) async { + final listBody = jsonEncode({'objects': items}); + final repo = _repo(_mockClient(listBody)); + await repo.getProductDrafts(); + return repo; +} + +void main() { + group('SquareCatalogRepository', () { + // ── getProductDrafts ──────────────────────────────────────────────────── + + group('getProductDrafts', () { + test('returns empty list when Square returns no objects', () async { + final repo = _repo(_mockClient(jsonEncode({'objects': []}))); + final drafts = await repo.getProductDrafts(); + expect(drafts, isEmpty); + }); + + test('maps a single Square ITEM to a ProductDraft', () async { + final item = _squareItem( + id: 'SQ-001', + name: 'Bowl Cozy', + priceCents: 1299, + sku: 'BC-001', + description: 'A cozy for bowls', + ); + final repo = _repo( + _mockClient( + jsonEncode({ + 'objects': [item], + }), + ), + ); + final drafts = await repo.getProductDrafts(); + + expect(drafts, hasLength(1)); + expect(drafts.first.id, 'SQ-001'); + expect(drafts.first.name, 'Bowl Cozy'); + expect(drafts.first.price, closeTo(12.99, 0.001)); + expect(drafts.first.sku, 'BC-001'); + expect(drafts.first.description, 'A cozy for bowls'); + expect(drafts.first.status, PublishStatus.published); + }); + + test('maps is_deleted=true to PublishStatus.unpublished', () async { + final item = _squareItem(id: 'SQ-002', isDeleted: true); + final repo = _repo( + _mockClient( + jsonEncode({ + 'objects': [item], + }), + ), + ); + final drafts = await repo.getProductDrafts(); + + expect(drafts.first.status, PublishStatus.unpublished); + }); + + test('maps multiple items and skips non-ITEM types', () async { + final objects = [ + _squareItem(id: 'SQ-001', name: 'Item A'), + {'type': 'CATEGORY', 'id': 'CAT-001', 'category_data': {}}, + _squareItem(id: 'SQ-002', name: 'Item B'), + ]; + final repo = _repo(_mockClient(jsonEncode({'objects': objects}))); + final drafts = await repo.getProductDrafts(); + + expect(drafts, hasLength(2)); + expect(drafts.map((d) => d.id), containsAll(['SQ-001', 'SQ-002'])); + }); + + test('populates cache for subsequent field updates', () async { + final item = _squareItem(id: 'SQ-001', name: 'Cached Item'); + final repo = _repo( + _mockClient( + jsonEncode({ + 'objects': [item], + }), + ), + ); + await repo.getProductDrafts(); + + // If cache is populated, updateProductName should not throw StateError. + final upsertBody = jsonEncode({ + 'catalog_object': _squareItem(id: 'SQ-001', name: 'Updated Item'), + }); + final upsertRepo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: _mockClient(upsertBody), + ), + locationId: 'LOC-001', + ); + // Seed the cache manually by calling getProductDrafts first. + final listBody = jsonEncode({ + 'objects': [item], + }); + final seededRepo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response(listBody, 200); + } + return http.Response(upsertBody, 200); + }), + ), + locationId: 'LOC-001', + ); + await seededRepo.getProductDrafts(); + final updated = await seededRepo.updateProductName('SQ-001', 'Updated Item'); + expect(updated.name, 'Updated Item'); + }); + }); + + // ── publishDraft ──────────────────────────────────────────────────────── + + group('publishDraft', () { + test('returns draft with published status (no-op for Square)', () async { + final repo = await _seededRepo([_squareItem(id: 'SQ-001')]); + final result = await repo.publishDraft('SQ-001'); + expect(result.status, PublishStatus.published); + expect(result.id, 'SQ-001'); + }); + + test('throws StateError when product not in cache', () async { + final repo = _repo(_mockClient(jsonEncode({'objects': []}))); + expect(() => repo.publishDraft('UNKNOWN'), throwsA(isA())); + }); + }); + + // ── updateProductStatus ───────────────────────────────────────────────── + + group('updateProductStatus', () { + test('updates status to unpublished and upserts to Square', () async { + final upsertResponse = jsonEncode({ + 'catalog_object': _squareItem(id: 'SQ-001', isDeleted: true), + }); + final repo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response( + jsonEncode({ + 'objects': [_squareItem(id: 'SQ-001')], + }), + 200, + ); + } + return http.Response(upsertResponse, 200); + }), + ), + locationId: 'LOC-001', + ); + await repo.getProductDrafts(); + final result = await repo.updateProductStatus('SQ-001', PublishStatus.unpublished); + expect(result.status, PublishStatus.unpublished); + }); + + test('throws StateError when product not in cache', () async { + final repo = _repo(_mockClient(jsonEncode({'objects': []}))); + expect( + () => repo.updateProductStatus('UNKNOWN', PublishStatus.draft), + throwsA(isA()), + ); + }); + }); + + // ── updateProductPrice ────────────────────────────────────────────────── + + group('updateProductPrice', () { + test('updates price and upserts to Square', () async { + final upsertResponse = jsonEncode({ + 'catalog_object': _squareItem(id: 'SQ-001', priceCents: 1999), + }); + final repo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response( + jsonEncode({ + 'objects': [_squareItem(id: 'SQ-001')], + }), + 200, + ); + } + return http.Response(upsertResponse, 200); + }), + ), + locationId: 'LOC-001', + ); + await repo.getProductDrafts(); + final result = await repo.updateProductPrice('SQ-001', 19.99); + expect(result.price, closeTo(19.99, 0.001)); + }); + + test('throws StateError when product not in cache', () async { + final repo = _repo(_mockClient(jsonEncode({'objects': []}))); + expect(() => repo.updateProductPrice('UNKNOWN', 9.99), throwsA(isA())); + }); + }); + + // ── updateProductName ─────────────────────────────────────────────────── + + group('updateProductName', () { + test('updates name and upserts to Square', () async { + final upsertResponse = jsonEncode({ + 'catalog_object': _squareItem(id: 'SQ-001', name: 'New Name'), + }); + final repo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response( + jsonEncode({ + 'objects': [_squareItem(id: 'SQ-001')], + }), + 200, + ); + } + return http.Response(upsertResponse, 200); + }), + ), + locationId: 'LOC-001', + ); + await repo.getProductDrafts(); + final result = await repo.updateProductName('SQ-001', 'New Name'); + expect(result.name, 'New Name'); + }); + }); + + // ── updateProductDescription ──────────────────────────────────────────── + + group('updateProductDescription', () { + test('updates description and upserts to Square', () async { + final upsertResponse = jsonEncode({ + 'catalog_object': _squareItem(id: 'SQ-001', description: 'New desc'), + }); + final repo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response( + jsonEncode({ + 'objects': [_squareItem(id: 'SQ-001')], + }), + 200, + ); + } + return http.Response(upsertResponse, 200); + }), + ), + locationId: 'LOC-001', + ); + await repo.getProductDrafts(); + final result = await repo.updateProductDescription('SQ-001', 'New desc'); + expect(result.description, 'New desc'); + }); + }); + + // ── updateProductCategory ─────────────────────────────────────────────── + + group('updateProductCategory', () { + test('updates category and upserts to Square', () async { + final upsertResponse = jsonEncode({'catalog_object': _squareItem(id: 'SQ-001')}); + final repo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response( + jsonEncode({ + 'objects': [_squareItem(id: 'SQ-001')], + }), + 200, + ); + } + return http.Response(upsertResponse, 200); + }), + ), + locationId: 'LOC-001', + ); + await repo.getProductDrafts(); + final result = await repo.updateProductCategory('SQ-001', 'Jewelry'); + expect(result.category, 'Jewelry'); + }); + }); + + // ── createProduct ─────────────────────────────────────────────────────── + + group('createProduct', () { + test('creates a product and returns a ProductDraft', () async { + final createdItem = _squareItem( + id: 'SQ-NEW-001', + name: 'New Bowl', + priceCents: 2500, + sku: 'NB-001', + description: 'A new bowl', + ); + final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem}))); + final draft = await repo.createProduct( + name: 'New Bowl', + price: 25.00, + description: 'A new bowl', + sku: 'NB-001', + ); + + expect(draft.id, 'SQ-NEW-001'); + expect(draft.name, 'New Bowl'); + expect(draft.price, closeTo(25.00, 0.001)); + expect(draft.description, 'A new bowl'); + expect(draft.sku, 'NB-001'); + }); + + test('applies draft status from parameter', () async { + final createdItem = _squareItem(id: 'SQ-NEW-002', name: 'Draft Item'); + final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem}))); + final draft = await repo.createProduct( + name: 'Draft Item', + price: 10.00, + status: PublishStatus.draft, + ); + expect(draft.status, PublishStatus.draft); + }); + + test('throws StateError when Square returns invalid catalog object', () async { + // Return a non-ITEM type so mapper returns null. + final badResponse = jsonEncode({ + 'catalog_object': {'type': 'CATEGORY', 'id': 'CAT-001'}, + }); + final repo = _repo(_mockClient(badResponse)); + expect(() => repo.createProduct(name: 'Bad', price: 1.00), throwsA(isA())); + }); + + test('adds created product to cache', () async { + final createdItem = _squareItem(id: 'SQ-NEW-003', name: 'Cached New'); + final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem}))); + await repo.createProduct(name: 'Cached New', price: 5.00); + + // publishDraft should succeed (product is in cache). + final published = await repo.publishDraft('SQ-NEW-003'); + expect(published.id, 'SQ-NEW-003'); + }); + }); + + // ── deleteProduct ─────────────────────────────────────────────────────── + + group('deleteProduct', () { + test('deletes product from Square and removes from cache', () async { + final deleteResponse = jsonEncode({ + 'deleted_object_ids': ['SQ-001'], + }); + final repo = SquareCatalogRepository( + apiClient: SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC-001', + httpClient: MockClient((req) async { + if (req.url.path.contains('list')) { + return http.Response( + jsonEncode({ + 'objects': [_squareItem(id: 'SQ-001')], + }), + 200, + ); + } + return http.Response(deleteResponse, 200); + }), + ), + locationId: 'LOC-001', + ); + await repo.getProductDrafts(); + await repo.deleteProduct('SQ-001'); + + // After deletion, publishDraft should throw StateError (not in cache). + expect(() => repo.publishDraft('SQ-001'), throwsA(isA())); + }); + }); + + // ── Unsupported operations ────────────────────────────────────────────── + + group('unsupported operations', () { + late SquareCatalogRepository repo; + + setUp(() { + repo = _repo(_mockClient(jsonEncode({'objects': []}))); + }); + + test('updateProductImages throws UnsupportedError', () { + expect(() => repo.updateProductImages('SQ-001', [1, 2]), throwsA(isA())); + }); + + test('getCategories throws UnsupportedError', () { + expect(() => repo.getCategories(), throwsA(isA())); + }); + + test('createCategory throws UnsupportedError', () { + expect(() => repo.createCategory(name: 'Jewelry'), throwsA(isA())); + }); + + test('updateCategory throws UnsupportedError', () { + expect(() => repo.updateCategory(1, name: 'Updated'), throwsA(isA())); + }); + + test('deleteCategory throws UnsupportedError', () { + expect(() => repo.deleteCategory(1), throwsA(isA())); + }); + + test('getProductImages throws UnsupportedError', () { + expect(() => repo.getProductImages('SQ-001'), throwsA(isA())); + }); + }); + + // ── Status mapping ────────────────────────────────────────────────────── + + group('status mapping', () { + test('active Square status maps to PublishStatus.published', () async { + final item = _squareItem(id: 'SQ-001', isDeleted: false); + final repo = _repo( + _mockClient( + jsonEncode({ + 'objects': [item], + }), + ), + ); + final drafts = await repo.getProductDrafts(); + expect(drafts.first.status, PublishStatus.published); + }); + + test('is_deleted=true maps to PublishStatus.unpublished', () async { + final item = _squareItem(id: 'SQ-001', isDeleted: true); + final repo = _repo( + _mockClient( + jsonEncode({ + 'objects': [item], + }), + ), + ); + final drafts = await repo.getProductDrafts(); + expect(drafts.first.status, PublishStatus.unpublished); + }); + }); + + // ── SquareApiException propagation ────────────────────────────────────── + + group('SquareApiException propagation', () { + test('getProductDrafts propagates SquareApiException on 401', () async { + final repo = _repo(_mockClient('{"errors":[]}', statusCode: 401)); + expect(() => repo.getProductDrafts(), throwsA(isA())); + }); + }); + }); +}