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