Compare commits
No commits in common. "70607d36f4f025016a06f5b2f35ddf327513392d" and "f4949d8c9b034126829bf72223c926c797970e12" have entirely different histories.
70607d36f4
...
f4949d8c9b
|
|
@ -3,8 +3,8 @@
|
|||
## Current status
|
||||
|
||||
- main baseline updated through: feat/square-catalog-sync-repositories (Stage 10C complete)
|
||||
- next branch: feat/square-catalog-sync-service-controller (Stage 10E — SquareSyncController Riverpod provider wrapping SquareCatalogSyncService)
|
||||
- current stage: Stage 10D complete — `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter` (feature_wordpress); feature_wordpress 506/506, feature_inventory 167/167 tests passing; analyze clean; branch ready for merge
|
||||
- next branch: feat/square-catalog-sync-service (Stage 10D — SquareCatalogSyncService orchestrating catalog pull + inventory sync)
|
||||
- current stage: Stage 10C complete — `SquareCatalogRepository` (feature_wordpress), `SquareInventoryRepository` (feature_inventory), `squareVariationId` field on `InventoryItem`; integrations 123/123, feature_wordpress 475/475, feature_inventory 167/167 tests passing; analyze clean; branch ready for merge
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -593,20 +593,6 @@
|
|||
- analyze: clean (no new issues introduced)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service
|
||||
|
||||
- status: in progress on branch — ready for merge
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10D slice)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — new `SquareCatalogSyncService` application-layer service; `SyncError` value object with `itemId`, `message`, `==`/`hashCode`/`toString()`; `SquareSyncResult` value object with `catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`; `InventorySyncAdapter` abstract contract with `syncItem(itemId, remoteQuantity)`; `syncCatalog()` fetches all products via `ProductPublishingRepository.getProductDrafts()`, looks up or creates `ProductIdMapping` per product via `ProductIdMappingRepository`, captures per-item failures without aborting; `syncInventory()` iterates all mappings with a `squareVariationId`, calls `InventorySyncAdapter.syncItem()` for each, captures per-item failures; `syncAll()` runs `syncCatalog()` then `syncInventory()` and merges results; no-op when no adapter provided
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_sync_service.dart` to barrel exports (Application section)
|
||||
- `feature_wordpress/test/square_catalog_sync_service_test.dart` — 31 new tests: `SyncError` (3: fields, equality, toString), `SquareSyncResult` (7: defaults, totalProcessed, allSucceeded×3, merge, toString), `syncCatalog` (9: empty, single, id match, multiple, existing-not-duplicated, fetch-failure, saveMapping-failure, partial-failure, SyncError-details), `syncInventory` (7: no-adapter, no-variationId-skipped, synced, false-return, throw-continues, getAllMappings-failure, empty-mappings), `syncAll` (5: merges, pre-seeded-variation-IDs, totalProcessed, catalog-failure-no-prevent-inventory, no-adapter)
|
||||
- tests: passed (506/506 feature_wordpress ← +31 new, 167/167 feature_inventory — all passing)
|
||||
- analyze: clean (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-repositories
|
||||
|
||||
- status: in progress on branch — ready for merge
|
||||
|
|
|
|||
|
|
@ -171,36 +171,20 @@ Rules:
|
|||
|
||||
No minimum thresholds are enforced — this is visibility-only tracking. Coverage is measured via `flutter test --coverage` (generates `lcov.info`) and reported in the CI workflow summary table.
|
||||
|
||||
### Stage 10D — complete (2026-07-25)
|
||||
|
||||
**`feat/square-catalog-sync-service`** — `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter`
|
||||
|
||||
Delivered:
|
||||
|
||||
- `SquareCatalogSyncService` in `feature_wordpress/lib/src/application/square_catalog_sync_service.dart`
|
||||
- `SyncError` value object (`itemId`, `message`, `==`/`hashCode`/`toString()`)
|
||||
- `SquareSyncResult` value object (`catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`)
|
||||
- `InventorySyncAdapter` abstract contract (`syncItem(itemId, remoteQuantity) → Future<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
|
||||
|
||||
**`feat/square-catalog-sync-service-controller`** — Stage 10E: `SquareSyncController` Riverpod provider.
|
||||
**`feat/square-catalog-sync-service`** — Stage 10D: `SquareCatalogSyncService` orchestrating catalog pull + inventory sync.
|
||||
|
||||
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:
|
||||
Branch from latest `main`. Stage 10C (`SquareCatalogRepository` + `SquareInventoryRepository`) is complete. The next slice adds a `SquareCatalogSyncService` application-layer service that:
|
||||
|
||||
- Wraps `SquareCatalogSyncService` and exposes `syncAll()`, `syncCatalog()`, `syncInventory()` as async actions
|
||||
- Tracks `isSyncing` state and the last `SquareSyncResult`
|
||||
- Exposes a `lastSyncedAt` timestamp
|
||||
- Lives in `feature_wordpress/lib/src/application/square_sync_controller.dart`
|
||||
- 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
|
||||
- Wires into `MobileAppServices` (or `WebAppServices`) so the sync can be triggered from the UI
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export 'src/domain/product_publishing_repository.dart';
|
|||
export 'src/domain/publish_status.dart';
|
||||
|
||||
// Application
|
||||
export 'src/application/square_catalog_sync_service.dart';
|
||||
export 'src/application/create_category.dart';
|
||||
export 'src/application/create_product.dart';
|
||||
export 'src/application/delete_product.dart';
|
||||
|
|
|
|||
|
|
@ -1,266 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,605 +0,0 @@
|
|||
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