Compare commits

..

No commits in common. "70607d36f4f025016a06f5b2f35ddf327513392d" and "f4949d8c9b034126829bf72223c926c797970e12" have entirely different histories.

5 changed files with 10 additions and 912 deletions

View File

@ -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

View File

@ -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.
---

View File

@ -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';

View File

@ -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);
}
}

View File

@ -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;
}
}