Compare commits

..

No commits in common. "f4949d8c9b034126829bf72223c926c797970e12" and "079bf8c0f0c8d8ae20ba111ebaa4b77c9d66bd45" have entirely different histories.

11 changed files with 11 additions and 1528 deletions

View File

@ -1,6 +0,0 @@
# Execution & Workflow Rules
- Always run tests after making code changes.
- Do not modify configuration files (.env, docker-compose.yml, deployment scripts) without explicit user instructions.
- Keep terminal commands scoped to the current project directory.
- Work in modular, step-by-step implementations rather than massive multi-file overhauls.

View File

@ -2,9 +2,9 @@
## Current status
- main baseline updated through: feat/square-catalog-sync-repositories (Stage 10C complete)
- next branch: feat/square-catalog-sync-service (Stage 10D — SquareCatalogSyncService orchestrating catalog pull + inventory sync)
- current stage: Stage 10C complete — `SquareCatalogRepository` (feature_wordpress), `SquareInventoryRepository` (feature_inventory), `squareVariationId` field on `InventoryItem`; integrations 123/123, feature_wordpress 475/475, feature_inventory 167/167 tests passing; analyze clean; branch ready for merge
- 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`
## Slice tracker
@ -592,22 +592,3 @@
- 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

View File

@ -127,7 +127,6 @@ 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`
@ -148,16 +147,17 @@ 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`: `167/167 passed` ← +35 new (Stage 10C)
- latest reported count for `feature_inventory`: `75/75 passed`
- latest reported count for `feature_orders`: `198/198 passed`
- latest reported count for `feature_wordpress`: `475/475 passed` ← +51 new (Stage 10C)
- latest reported count for `feature_wordpress`: `319/319 passed`
- latest reported count for `kell_web`: `24/24 passed`
- latest reported count for `integrations`: `123/123 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 `data`: `63/63 passed`
- latest reported count for `auth`: `42/42 passed`
- latest reported count for `kell_mobile`: `26/26 passed`
- 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)
- 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)
#### Baseline test coverage (established 2026-05-22)
@ -173,18 +173,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch
**`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.
**`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.
---

View File

@ -7,7 +7,6 @@ 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';

View File

@ -1,236 +0,0 @@
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;
}
}

View File

@ -36,15 +36,6 @@ 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,
@ -62,7 +53,6 @@ class InventoryItem {
this.wooCommerceProductId,
this.lastSyncedAt,
this.syncStatus = InventorySyncStatus.notSynced,
this.squareVariationId,
});
/// Returns a copy of this [InventoryItem] with the given fields replaced.
@ -83,7 +73,6 @@ class InventoryItem {
int? wooCommerceProductId,
DateTime? lastSyncedAt,
InventorySyncStatus? syncStatus,
String? squareVariationId,
}) {
return InventoryItem(
id: id ?? this.id,
@ -102,7 +91,6 @@ class InventoryItem {
wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
syncStatus: syncStatus ?? this.syncStatus,
squareVariationId: squareVariationId ?? this.squareVariationId,
);
}
}

View File

@ -15,8 +15,6 @@ dependencies:
path: ../design_system
feature_wordpress:
path: ../feature_wordpress
integrations:
path: ../integrations
dev_dependencies:
flutter_test:

View File

@ -1,386 +0,0 @@
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');
});
});
});
}

View File

@ -3,7 +3,6 @@ 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';

View File

@ -1,322 +0,0 @@
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',
};
}
}

View File

@ -1,522 +0,0 @@
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>()));
});
});
});
}