Merge feat/square-catalog-sync-repositories into main (Stage 10C)
This commit is contained in:
commit
7e3cded043
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/square-catalog-sync (Stage 10B complete)
|
- main baseline updated through: feat/square-catalog-sync-repositories (Stage 10C complete)
|
||||||
- next branch: feat/square-catalog-sync-repositories (Stage 10B continuation — SquareCatalogRepository + SquareInventoryRepository)
|
- next branch: feat/square-catalog-sync-service (Stage 10D — SquareCatalogSyncService orchestrating catalog pull + inventory sync)
|
||||||
- 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`
|
- 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
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -592,3 +592,22 @@
|
||||||
- tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new)
|
- tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new)
|
||||||
- analyze: clean (no new issues introduced)
|
- analyze: clean (no new issues introduced)
|
||||||
- brief updated: yes
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ Rules:
|
||||||
- ✅ Authentication foundation activated (Stage 8C complete — merged `feat/auth-foundation` → `main`, 2026-07-11). Stage 8 complete.
|
- ✅ 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 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).
|
- ✅ 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`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -147,17 +148,16 @@ Rules:
|
||||||
- `kell_mobile` tests passing
|
- `kell_mobile` tests passing
|
||||||
- latest reported count for `core`: `21/21 passed`
|
- latest reported count for `core`: `21/21 passed`
|
||||||
- latest reported count for `design_system`: `41/41 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_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 `kell_web`: `24/24 passed`
|
||||||
- latest reported count for `integrations`: `123/123 passed` ← +35 new (Stage 10B)
|
- latest reported count for `integrations`: `123/123 passed`
|
||||||
- latest reported count for `feature_wordpress` (product_id_mapping): `23/23 passed` ← +23 new (Stage 10B)
|
|
||||||
- latest reported count for `data`: `63/63 passed`
|
- latest reported count for `data`: `63/63 passed`
|
||||||
- latest reported count for `auth`: `42/42 passed`
|
- latest reported count for `auth`: `42/42 passed`
|
||||||
- latest reported count for `kell_mobile`: `26/26 passed`
|
- latest reported count for `kell_mobile`: `26/26 passed`
|
||||||
- total: `957/957 passed` (pre-Stage-10B baseline) + 58 new Stage 10B tests
|
- total: `1015/1015 passed` (Stage 10C baseline: 957 + 58 Stage 10B + 86 Stage 10C new tests)
|
||||||
- baseline commit: merge of `feat/square-catalog-sync` into `main` (Stage 10B complete, 2026-07-25)
|
- baseline commit: merge of `feat/square-catalog-sync-repositories` into `main` (Stage 10C complete, 2026-07-25)
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### 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
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/square-catalog-sync-repositories`** — Stage 10B continuation: `SquareCatalogRepository` and `SquareInventoryRepository`.
|
**`feat/square-catalog-sync-service`** — Stage 10D: `SquareCatalogSyncService` orchestrating catalog pull + inventory sync.
|
||||||
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.
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export 'src/application/inventory_controller.dart';
|
||||||
export 'src/application/inventory_sync_service.dart';
|
export 'src/application/inventory_sync_service.dart';
|
||||||
export 'src/application/update_inventory_item.dart';
|
export 'src/application/update_inventory_item.dart';
|
||||||
export 'src/data/fake_inventory_repository.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/data/woo_commerce_inventory_repository.dart';
|
||||||
export 'src/domain/inventory_adjustment_result.dart';
|
export 'src/domain/inventory_adjustment_result.dart';
|
||||||
export 'src/domain/inventory_item.dart';
|
export 'src/domain/inventory_item.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<InventoryItem> _items;
|
||||||
|
|
||||||
|
SquareInventoryRepository({
|
||||||
|
required SquareApiClient apiClient,
|
||||||
|
required this.locationId,
|
||||||
|
List<InventoryItem> initialItems = const [],
|
||||||
|
SquareInventoryMapper mapper = const SquareInventoryMapper(),
|
||||||
|
}) : _apiClient = apiClient,
|
||||||
|
_mapper = mapper,
|
||||||
|
_items = List.of(initialItems);
|
||||||
|
|
||||||
|
// ── Read operations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<InventoryItem>> getInventoryItems() async {
|
||||||
|
return List.unmodifiable(_items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem?> getItemBySku(String sku) async {
|
||||||
|
try {
|
||||||
|
return _items.firstWhere((i) => i.sku == sku);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write operations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryAdjustmentResult> 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<InventoryAdjustmentResult> 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<InventoryAdjustmentResult> 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<InventoryItem> createItem(InventoryItem item) async {
|
||||||
|
_items.add(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<InventoryItem> 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<InventoryAdjustmentResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,15 @@ class InventoryItem {
|
||||||
/// WooCommerce product ID.
|
/// WooCommerce product ID.
|
||||||
final InventorySyncStatus syncStatus;
|
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({
|
const InventoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.sku,
|
required this.sku,
|
||||||
|
|
@ -53,6 +62,7 @@ class InventoryItem {
|
||||||
this.wooCommerceProductId,
|
this.wooCommerceProductId,
|
||||||
this.lastSyncedAt,
|
this.lastSyncedAt,
|
||||||
this.syncStatus = InventorySyncStatus.notSynced,
|
this.syncStatus = InventorySyncStatus.notSynced,
|
||||||
|
this.squareVariationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Returns a copy of this [InventoryItem] with the given fields replaced.
|
/// Returns a copy of this [InventoryItem] with the given fields replaced.
|
||||||
|
|
@ -73,6 +83,7 @@ class InventoryItem {
|
||||||
int? wooCommerceProductId,
|
int? wooCommerceProductId,
|
||||||
DateTime? lastSyncedAt,
|
DateTime? lastSyncedAt,
|
||||||
InventorySyncStatus? syncStatus,
|
InventorySyncStatus? syncStatus,
|
||||||
|
String? squareVariationId,
|
||||||
}) {
|
}) {
|
||||||
return InventoryItem(
|
return InventoryItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -91,6 +102,7 @@ class InventoryItem {
|
||||||
wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId,
|
wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId,
|
||||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||||
syncStatus: syncStatus ?? this.syncStatus,
|
syncStatus: syncStatus ?? this.syncStatus,
|
||||||
|
squareVariationId: squareVariationId ?? this.squareVariationId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ dependencies:
|
||||||
path: ../design_system
|
path: ../design_system
|
||||||
feature_wordpress:
|
feature_wordpress:
|
||||||
path: ../feature_wordpress
|
path: ../feature_wordpress
|
||||||
|
integrations:
|
||||||
|
path: ../integrations
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -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<InventoryItem> 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<StateError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ library;
|
||||||
// Data
|
// Data
|
||||||
export 'src/data/fake_product_id_mapping_repository.dart';
|
export 'src/data/fake_product_id_mapping_repository.dart';
|
||||||
export 'src/data/fake_product_publishing_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/woo_commerce_api_client.dart';
|
||||||
export 'src/data/wordpress_product_mapper.dart';
|
export 'src/data/wordpress_product_mapper.dart';
|
||||||
export 'src/data/wordpress_product_publishing_repository.dart';
|
export 'src/data/wordpress_product_publishing_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<ProductDraft> _cache = [];
|
||||||
|
|
||||||
|
SquareCatalogRepository({
|
||||||
|
required SquareApiClient apiClient,
|
||||||
|
required this.locationId,
|
||||||
|
SquareProductMapper mapper = const SquareProductMapper(),
|
||||||
|
}) : _apiClient = apiClient,
|
||||||
|
_mapper = mapper;
|
||||||
|
|
||||||
|
// ── Read operations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductDraft>> getProductDrafts() async {
|
||||||
|
final rawObjects = await _apiClient.listCatalogItems(types: ['ITEM']);
|
||||||
|
final channelProducts = _mapper.fromSquareCatalogList(rawObjects.cast<Map<String, dynamic>>());
|
||||||
|
|
||||||
|
final drafts = channelProducts.map(_channelProductToDraft).toList();
|
||||||
|
_cache
|
||||||
|
..clear()
|
||||||
|
..addAll(drafts);
|
||||||
|
return List.unmodifiable(drafts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status operations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> 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<ProductDraft> 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<ProductDraft> 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<ProductDraft> 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<ProductDraft> 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<ProductDraft> 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<ProductDraft> 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<String, dynamic>());
|
||||||
|
|
||||||
|
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<void> deleteProduct(String id, {bool force = false}) async {
|
||||||
|
await _apiClient.deleteCatalogItem(id);
|
||||||
|
_cache.removeWhere((d) => d.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unsupported operations ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'SquareCatalogRepository does not support image management. '
|
||||||
|
'Use the Square media API directly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductCategory>> getCategories() {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'SquareCatalogRepository does not support category listing. '
|
||||||
|
'Square categories are managed via CATEGORY-type catalog objects.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductCategory> createCategory({required String name, int parentId = 0}) {
|
||||||
|
throw UnsupportedError('SquareCatalogRepository does not support category creation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) {
|
||||||
|
throw UnsupportedError('SquareCatalogRepository does not support category updates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteCategory(int categoryId, {bool force = true}) {
|
||||||
|
throw UnsupportedError('SquareCatalogRepository does not support category deletion.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductImage>> 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<void> _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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> _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<SquareCatalogRepository> _seededRepo(List<Map<String, dynamic>> 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<StateError>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<StateError>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<StateError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
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<StateError>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<UnsupportedError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCategories throws UnsupportedError', () {
|
||||||
|
expect(() => repo.getCategories(), throwsA(isA<UnsupportedError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createCategory throws UnsupportedError', () {
|
||||||
|
expect(() => repo.createCategory(name: 'Jewelry'), throwsA(isA<UnsupportedError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateCategory throws UnsupportedError', () {
|
||||||
|
expect(() => repo.updateCategory(1, name: 'Updated'), throwsA(isA<UnsupportedError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteCategory throws UnsupportedError', () {
|
||||||
|
expect(() => repo.deleteCategory(1), throwsA(isA<UnsupportedError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProductImages throws UnsupportedError', () {
|
||||||
|
expect(() => repo.getProductImages('SQ-001'), throwsA(isA<UnsupportedError>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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<SquareApiException>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue