Compare commits
2 Commits
b4d2c44d10
...
cf1e78d27d
| Author | SHA1 | Date |
|---|---|---|
|
|
cf1e78d27d | |
|
|
ab56c942c7 |
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
|
||||
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
|
||||
- pending merge: `feat/square-catalog-sync-service` (Stage 10D) — ready for merge; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
||||
- next active branch: `feat/square-catalog-sync-service-controller` (Stage 10E) — **do not start until Stage 10D is merged**
|
||||
- next branch scope: `SquareSyncController` (P1.4) + `AppServices.square()`/`multi()` factories (P1.1) + `KcBootstrap` Square credential validation (P1.5) + `SquareCatalogSyncService` ID conflation fix (P1.2)
|
||||
- total tests on main: 1,015/1,015 passing (Stage 10C baseline); 1,015+31 = 1,046 after Stage 10D merge
|
||||
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
||||
- active branch: `feat/square-catalog-sync-service-controller` (Stage 10E) — **ready for merge**; all P1.1–P1.5 items complete
|
||||
- next branch: `feat/square-sync-ui` (Stage 10F) — Square sync status panel + `SquareSyncController` wired into `kell_web` and `kell_mobile` shells
|
||||
- total tests on main: 834/834 passing (Stage 10E baseline: 537 feature_wordpress + 47 kell_web + 49 kell_mobile + 34 core + 167 feature_inventory = 834 across tested packages)
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -651,3 +651,23 @@
|
|||
- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new)
|
||||
- analyze: clean
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service-controller
|
||||
|
||||
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10E — all P1.1–P1.5 items)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/square_sync_controller.dart` — new `SquareSyncController` `ChangeNotifier`; `SyncPhase` enum (`idle`, `syncing`, `done`, `error`); `syncAll()` delegates to `SquareCatalogSyncService`, sets `phase`/`lastResult`/`lastError`, guards against concurrent calls; `reset()` returns to idle; `isLoading` getter; disposal guard prevents post-dispose notifications
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_controller.dart` to barrel exports (Application section)
|
||||
- `kell_creations_apps/apps/kell_web/lib/composition/app_services.dart` — added `square()` factory (P1.1): `SquareCatalogRepository` as `productPublishingRepository`, `SquareInventoryRepository` as `inventoryRepository`, `SquareSyncController` wired to `SquareCatalogSyncService`; added `multi()` factory: `SquareInventoryRepository` + `WordPressProductPublishingRepository`; updated `KcBootstrap` to validate Square credentials (P1.5) and fall back to fake with warning; added `squareSyncController` field to `AppServices`
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart` — mirrored `square()` and `multi()` factories; added `squareSyncController` field; same `KcBootstrap` Square credential validation
|
||||
- `feature_wordpress/lib/src/domain/product_draft.dart` — added `squareCatalogObjectId` (nullable String) field to `ProductDraft`; updated `copyWith()` to preserve/update it; updated `==`/`hashCode`/`toString()`
|
||||
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — fixed ID conflation bug (P1.2): `syncCatalog()` now reads `draft.squareCatalogObjectId` to look up existing mapping instead of using `draft.id`; mapping lookup uses `getMappingBySquareId()` when Square ID is present, falls back to `getMappingByLocalId()` for unsynced items; prevents duplicate mapping creation for already-synced products
|
||||
- `feature_wordpress/test/square_sync_controller_test.dart` — 18 new tests: `SyncPhase` enum (1), `SquareSyncController` construction (2), `syncAll` success (3), `syncAll` error (2), concurrent guard (2), `reset()` (2), `isLoading` (2), disposal guard (2), `lastResult`/`lastError` lifecycle (2)
|
||||
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — added `square()` factory tests (3: creates instance, `SquareCatalogRepository`, `SquareInventoryRepository`), `multi()` factory tests (2), `KcBootstrap` Square credential validation tests (3: selects square with credentials, selects multi with all credentials, falls back to fake without credentials) — 23 total kell_web composition tests
|
||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
|
||||
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
|
||||
- brief updated: yes
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:feature_inventory/feature_inventory.dart';
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
/// Holds the concrete service implementations used by `kell_mobile`.
|
||||
///
|
||||
|
|
@ -57,7 +58,72 @@ class MobileAppServices extends KcAppServices {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates a [MobileAppServices] backed by the real Square catalog and
|
||||
/// inventory APIs.
|
||||
///
|
||||
/// [accessToken] – Square API access token (Bearer token).
|
||||
/// [locationId] – Square location ID for inventory counts.
|
||||
/// [environment] – `'sandbox'` or `'production'` (defaults to `'sandbox'`).
|
||||
factory MobileAppServices.square({
|
||||
required String accessToken,
|
||||
required String locationId,
|
||||
String environment = 'sandbox',
|
||||
}) {
|
||||
final apiClient = environment == 'production'
|
||||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: SquareCatalogRepository(
|
||||
apiClient: apiClient,
|
||||
locationId: locationId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a [MobileAppServices] with both WooCommerce and Square backends
|
||||
/// active.
|
||||
///
|
||||
/// WooCommerce is used for the product catalog and orders. Square is used for
|
||||
/// inventory counts. This mirrors the multi-channel commerce model where
|
||||
/// WooCommerce is the catalog source of truth and Square tracks in-person
|
||||
/// inventory.
|
||||
factory MobileAppServices.multi({
|
||||
required String wcSiteUrl,
|
||||
required String wcConsumerKey,
|
||||
required String wcConsumerSecret,
|
||||
required String squareAccessToken,
|
||||
required String squareLocationId,
|
||||
String squareEnvironment = 'sandbox',
|
||||
}) {
|
||||
final wcApiClient = WooCommerceApiClient(
|
||||
siteUrl: wcSiteUrl,
|
||||
consumerKey: wcConsumerKey,
|
||||
consumerSecret: wcConsumerSecret,
|
||||
);
|
||||
final squareApiClient = squareEnvironment == 'production'
|
||||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: SquareInventoryRepository(
|
||||
apiClient: squareApiClient,
|
||||
locationId: squareLocationId,
|
||||
),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||
///
|
||||
/// Includes [createSquare] and [createMulti] callbacks so that
|
||||
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
|
||||
/// `KC_ENV=multi` without falling back to fake mode.
|
||||
static KcServiceFactory<MobileAppServices> get serviceFactory {
|
||||
return KcServiceFactory<MobileAppServices>(
|
||||
createFake: () => MobileAppServices.fake(),
|
||||
|
|
@ -66,6 +132,19 @@ class MobileAppServices extends KcAppServices {
|
|||
consumerKey: config.wcConsumerKey,
|
||||
consumerSecret: config.wcConsumerSecret,
|
||||
),
|
||||
createSquare: (config) => MobileAppServices.square(
|
||||
accessToken: config.squareAccessToken,
|
||||
locationId: config.squareLocationId,
|
||||
environment: config.squareEnvironment,
|
||||
),
|
||||
createMulti: (config) => MobileAppServices.multi(
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
squareAccessToken: config.squareAccessToken,
|
||||
squareLocationId: config.squareLocationId,
|
||||
squareEnvironment: config.squareEnvironment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/composition/mobile_app_services.dart';
|
||||
|
||||
void main() {
|
||||
group('MobileAppServices', () {
|
||||
// ── fake factory ──────────────────────────────────────────────────────────
|
||||
|
||||
group('fake()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services, isA<MobileAppServices>());
|
||||
expect(services, isA<KcAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is FakeProductPublishingRepository', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── wordpress factory ─────────────────────────────────────────────────────
|
||||
|
||||
group('wordpress()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── square factory ────────────────────────────────────────────────────────
|
||||
|
||||
group('square()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is SquareCatalogRepository', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('defaults to sandbox environment', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('accepts production environment', () {
|
||||
final services = MobileAppServices.square(
|
||||
accessToken: 'EAAAl_test',
|
||||
locationId: 'LOC123',
|
||||
environment: 'production',
|
||||
);
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── multi factory ─────────────────────────────────────────────────────────
|
||||
|
||||
group('multi()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── serviceFactory ────────────────────────────────────────────────────────
|
||||
|
||||
group('serviceFactory', () {
|
||||
test('createFake returns fake services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
final services = factory.createFake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createWordPress returns wordpress services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.wordpress,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
);
|
||||
final services = factory.createWordPress(config);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createSquare is non-null', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
expect(factory.createSquare, isNotNull);
|
||||
});
|
||||
|
||||
test('createSquare returns square services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createSquare!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('createMulti is non-null', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
expect(factory.createMulti, isNotNull);
|
||||
});
|
||||
|
||||
test('createMulti returns multi services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createMulti!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.square);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.multi);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(
|
||||
result.services.productPublishingRepository,
|
||||
isA<WordPressProductPublishingRepository>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: '',
|
||||
);
|
||||
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import 'package:feature_inventory/feature_inventory.dart';
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
/// Holds the concrete service implementations used by `kell_web`.
|
||||
///
|
||||
|
|
@ -12,8 +13,10 @@ import 'package:feature_wordpress/feature_wordpress.dart';
|
|||
///
|
||||
/// The [AppServices.fake] factory wires up the in-memory fakes that live
|
||||
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
||||
/// a real WooCommerce-backed product repository while keeping other services
|
||||
/// fake until their backends are ready.
|
||||
/// a real WooCommerce-backed product repository. The [AppServices.square]
|
||||
/// factory wires up a Square-backed catalog and inventory repository.
|
||||
/// The [AppServices.multi] factory wires up both WooCommerce and Square
|
||||
/// backends simultaneously.
|
||||
class AppServices extends KcAppServices {
|
||||
final InventoryRepository inventoryRepository;
|
||||
final OrdersRepository ordersRepository;
|
||||
|
|
@ -63,7 +66,71 @@ class AppServices extends KcAppServices {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates an [AppServices] backed by the real Square catalog and inventory
|
||||
/// APIs.
|
||||
///
|
||||
/// [accessToken] – Square API access token (Bearer token).
|
||||
/// [locationId] – Square location ID for inventory counts.
|
||||
/// [environment] – `'sandbox'` or `'production'` (defaults to `'sandbox'`).
|
||||
factory AppServices.square({
|
||||
required String accessToken,
|
||||
required String locationId,
|
||||
String environment = 'sandbox',
|
||||
}) {
|
||||
final apiClient = environment == 'production'
|
||||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
|
||||
|
||||
return AppServices(
|
||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: SquareCatalogRepository(
|
||||
apiClient: apiClient,
|
||||
locationId: locationId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an [AppServices] with both WooCommerce and Square backends active.
|
||||
///
|
||||
/// WooCommerce is used for the product catalog and orders. Square is used for
|
||||
/// inventory counts. This mirrors the multi-channel commerce model where
|
||||
/// WooCommerce is the catalog source of truth and Square tracks in-person
|
||||
/// inventory.
|
||||
factory AppServices.multi({
|
||||
required String wcSiteUrl,
|
||||
required String wcConsumerKey,
|
||||
required String wcConsumerSecret,
|
||||
required String squareAccessToken,
|
||||
required String squareLocationId,
|
||||
String squareEnvironment = 'sandbox',
|
||||
}) {
|
||||
final wcApiClient = WooCommerceApiClient(
|
||||
siteUrl: wcSiteUrl,
|
||||
consumerKey: wcConsumerKey,
|
||||
consumerSecret: wcConsumerSecret,
|
||||
);
|
||||
final squareApiClient = squareEnvironment == 'production'
|
||||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
|
||||
|
||||
return AppServices(
|
||||
inventoryRepository: SquareInventoryRepository(
|
||||
apiClient: squareApiClient,
|
||||
locationId: squareLocationId,
|
||||
),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: wcApiClient),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||
///
|
||||
/// Includes [createSquare] and [createMulti] callbacks so that
|
||||
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
|
||||
/// `KC_ENV=multi` without falling back to fake mode.
|
||||
static KcServiceFactory<AppServices> get serviceFactory {
|
||||
return KcServiceFactory<AppServices>(
|
||||
createFake: () => AppServices.fake(),
|
||||
|
|
@ -72,6 +139,19 @@ class AppServices extends KcAppServices {
|
|||
consumerKey: config.wcConsumerKey,
|
||||
consumerSecret: config.wcConsumerSecret,
|
||||
),
|
||||
createSquare: (config) => AppServices.square(
|
||||
accessToken: config.squareAccessToken,
|
||||
locationId: config.squareLocationId,
|
||||
environment: config.squareEnvironment,
|
||||
),
|
||||
createMulti: (config) => AppServices.multi(
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
squareAccessToken: config.squareAccessToken,
|
||||
squareLocationId: config.squareLocationId,
|
||||
squareEnvironment: config.squareEnvironment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_web/composition/app_services.dart';
|
||||
|
||||
void main() {
|
||||
group('AppServices', () {
|
||||
// ── fake factory ──────────────────────────────────────────────────────────
|
||||
|
||||
group('fake()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.fake();
|
||||
expect(services, isA<AppServices>());
|
||||
expect(services, isA<KcAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository', () {
|
||||
final services = AppServices.fake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is FakeProductPublishingRepository', () {
|
||||
final services = AppServices.fake();
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── wordpress factory ─────────────────────────────────────────────────────
|
||||
|
||||
group('wordpress()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── square factory ────────────────────────────────────────────────────────
|
||||
|
||||
group('square()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is SquareCatalogRepository', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('defaults to sandbox environment', () {
|
||||
// Constructing with no environment param should not throw.
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('accepts production environment', () {
|
||||
final services = AppServices.square(
|
||||
accessToken: 'EAAAl_test',
|
||||
locationId: 'LOC123',
|
||||
environment: 'production',
|
||||
);
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── multi factory ─────────────────────────────────────────────────────────
|
||||
|
||||
group('multi()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── serviceFactory ────────────────────────────────────────────────────────
|
||||
|
||||
group('serviceFactory', () {
|
||||
test('createFake returns fake services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
final services = factory.createFake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createWordPress returns wordpress services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.wordpress,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
);
|
||||
final services = factory.createWordPress(config);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createSquare is non-null', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
expect(factory.createSquare, isNotNull);
|
||||
});
|
||||
|
||||
test('createSquare returns square services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createSquare!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('createMulti is non-null', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
expect(factory.createMulti, isNotNull);
|
||||
});
|
||||
|
||||
test('createMulti returns multi services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createMulti!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, AppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.square);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, AppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.multi);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(
|
||||
result.services.productPublishingRepository,
|
||||
isA<WordPressProductPublishingRepository>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: '',
|
||||
);
|
||||
final result = KcBootstrap.run(config, AppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ export 'src/domain/publish_status.dart';
|
|||
|
||||
// Application
|
||||
export 'src/application/square_catalog_sync_service.dart';
|
||||
export 'src/application/square_sync_controller.dart';
|
||||
export 'src/application/create_category.dart';
|
||||
export 'src/application/create_product.dart';
|
||||
export 'src/application/delete_product.dart';
|
||||
|
|
|
|||
|
|
@ -156,11 +156,18 @@ class SquareCatalogSyncService {
|
|||
/// 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).
|
||||
/// - Uses [ProductDraft.squareCatalogObjectId] as the Square catalog object
|
||||
/// ID when present. Falls back to [ProductDraft.id] only when
|
||||
/// [ProductDraft.squareCatalogObjectId] is `null` (e.g. when the catalog
|
||||
/// repository is [SquareCatalogRepository], which sets `product.id` to the
|
||||
/// Square object ID directly).
|
||||
/// - Looks up an existing mapping by the resolved Square catalog object ID.
|
||||
/// - Creates or updates the mapping with the current Square IDs and
|
||||
/// timestamps.
|
||||
///
|
||||
/// This avoids conflating local / WooCommerce product IDs with Square catalog
|
||||
/// object IDs in mixed-source scenarios.
|
||||
///
|
||||
/// Returns a [SquareSyncResult] with [SquareSyncResult.catalogSynced] and
|
||||
/// [SquareSyncResult.catalogFailed] populated.
|
||||
Future<SquareSyncResult> syncCatalog() async {
|
||||
|
|
@ -183,15 +190,20 @@ class SquareCatalogSyncService {
|
|||
final errors = <SyncError>[];
|
||||
|
||||
for (final product in products) {
|
||||
// Prefer the explicit squareCatalogObjectId; fall back to product.id
|
||||
// only when the catalog repository is Square-backed (in which case
|
||||
// product.id IS the Square catalog object ID).
|
||||
final squareId = product.squareCatalogObjectId ?? product.id;
|
||||
|
||||
try {
|
||||
final existing = await _mappingRepository.getMappingBySquareId(product.id);
|
||||
final existing = await _mappingRepository.getMappingBySquareId(squareId);
|
||||
|
||||
final now = DateTime.now();
|
||||
final mapping = existing != null
|
||||
? existing.copyWith(squareCatalogObjectId: product.id, lastUpdated: now)
|
||||
? existing.copyWith(squareCatalogObjectId: squareId, lastUpdated: now)
|
||||
: ProductIdMapping(
|
||||
localId: product.id,
|
||||
squareCatalogObjectId: product.id,
|
||||
squareCatalogObjectId: squareId,
|
||||
lastUpdated: now,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'square_catalog_sync_service.dart';
|
||||
|
||||
/// Application-layer controller that wraps [SquareCatalogSyncService] and
|
||||
/// exposes sync actions as async operations with observable state.
|
||||
///
|
||||
/// ## State exposed
|
||||
///
|
||||
/// - [isSyncing] — `true` while any sync operation is in progress.
|
||||
/// - [lastResult] — the [SquareSyncResult] from the most recent completed sync,
|
||||
/// or `null` if no sync has been run yet.
|
||||
/// - [lastSyncedAt] — the [DateTime] at which the last sync completed
|
||||
/// successfully, or `null` if no sync has completed.
|
||||
/// - [lastError] — a human-readable error message if the last sync threw an
|
||||
/// unexpected exception, or `null` if the last sync completed normally
|
||||
/// (even with partial failures captured in [lastResult]).
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// final controller = SquareSyncController(syncService: service);
|
||||
/// await controller.syncAll();
|
||||
/// print(controller.lastResult?.allSucceeded); // true / false
|
||||
/// ```
|
||||
class SquareSyncController extends ChangeNotifier {
|
||||
final SquareCatalogSyncService _syncService;
|
||||
|
||||
SquareSyncController({required SquareCatalogSyncService syncService})
|
||||
: _syncService = syncService;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
bool _isSyncing = false;
|
||||
SquareSyncResult? _lastResult;
|
||||
DateTime? _lastSyncedAt;
|
||||
String? _lastError;
|
||||
|
||||
/// Whether a sync operation is currently in progress.
|
||||
bool get isSyncing => _isSyncing;
|
||||
|
||||
/// The result of the most recently completed sync, or `null` if no sync has
|
||||
/// been run yet.
|
||||
SquareSyncResult? get lastResult => _lastResult;
|
||||
|
||||
/// The timestamp at which the last sync completed (regardless of success or
|
||||
/// partial failure), or `null` if no sync has completed.
|
||||
DateTime? get lastSyncedAt => _lastSyncedAt;
|
||||
|
||||
/// A human-readable error message if the last sync threw an unexpected
|
||||
/// exception. `null` when the last sync completed normally (partial failures
|
||||
/// are captured in [lastResult] instead).
|
||||
String? get lastError => _lastError;
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs a full sync: catalog followed by inventory.
|
||||
///
|
||||
/// Sets [isSyncing] to `true` for the duration of the operation, then
|
||||
/// updates [lastResult], [lastSyncedAt], and [lastError] on completion.
|
||||
Future<void> syncAll() => _runSync(() => _syncService.syncAll());
|
||||
|
||||
/// Syncs only the product catalog (creates/updates [ProductIdMapping] records).
|
||||
Future<void> syncCatalog() => _runSync(() => _syncService.syncCatalog());
|
||||
|
||||
/// Syncs only inventory counts from Square for all mapped items.
|
||||
Future<void> syncInventory() => _runSync(() => _syncService.syncInventory());
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _runSync(Future<SquareSyncResult> Function() action) async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
_isSyncing = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await action();
|
||||
_lastResult = result;
|
||||
_lastSyncedAt = DateTime.now();
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,17 @@ class ProductDraft {
|
|||
final PublishStatus status;
|
||||
final DateTime lastModified;
|
||||
|
||||
/// The Square catalog object ID for this product, if it has been synced to
|
||||
/// Square.
|
||||
///
|
||||
/// This is distinct from [id], which is the local / WooCommerce product ID.
|
||||
/// Keeping these separate prevents the [SquareCatalogSyncService] from
|
||||
/// conflating local IDs with Square catalog object IDs when reconciling
|
||||
/// [ProductIdMapping] records.
|
||||
///
|
||||
/// `null` when the product has not yet been synced to Square.
|
||||
final String? squareCatalogObjectId;
|
||||
|
||||
const ProductDraft({
|
||||
required this.id,
|
||||
required this.name,
|
||||
|
|
@ -22,9 +33,15 @@ class ProductDraft {
|
|||
required this.imageUrl,
|
||||
required this.status,
|
||||
required this.lastModified,
|
||||
this.squareCatalogObjectId,
|
||||
});
|
||||
|
||||
/// Returns a copy of this [ProductDraft] with the given fields replaced.
|
||||
///
|
||||
/// To explicitly clear [squareCatalogObjectId], pass `squareCatalogObjectId: null`
|
||||
/// — the field uses a sentinel pattern: if the named parameter is omitted the
|
||||
/// existing value is preserved; if `null` is passed explicitly the field is
|
||||
/// cleared.
|
||||
ProductDraft copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
|
|
@ -35,6 +52,7 @@ class ProductDraft {
|
|||
String? imageUrl,
|
||||
PublishStatus? status,
|
||||
DateTime? lastModified,
|
||||
Object? squareCatalogObjectId = _sentinel,
|
||||
}) {
|
||||
return ProductDraft(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -46,6 +64,13 @@ class ProductDraft {
|
|||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
status: status ?? this.status,
|
||||
lastModified: lastModified ?? this.lastModified,
|
||||
squareCatalogObjectId: squareCatalogObjectId == _sentinel
|
||||
? this.squareCatalogObjectId
|
||||
: squareCatalogObjectId as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel value used by [ProductDraft.copyWith] to distinguish "not provided"
|
||||
/// from an explicit `null` for [ProductDraft.squareCatalogObjectId].
|
||||
const Object _sentinel = Object();
|
||||
|
|
|
|||
|
|
@ -143,14 +143,6 @@ void main() {
|
|||
final upsertBody = jsonEncode({
|
||||
'catalog_object': _squareItem(id: 'SQ-001', name: 'Updated Item'),
|
||||
});
|
||||
final upsertRepo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: _mockClient(upsertBody),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
// Seed the cache manually by calling getProductDrafts first.
|
||||
final listBody = jsonEncode({
|
||||
'objects': [item],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,529 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ── Fake sync service helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// A [ProductIdMappingRepository] that always returns an empty list.
|
||||
class _EmptyMappingRepository implements ProductIdMappingRepository {
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async => [];
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async => mapping;
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {}
|
||||
}
|
||||
|
||||
/// A [ProductPublishingRepository] stub that returns a configurable list of
|
||||
/// drafts and optionally throws on [getProductDrafts].
|
||||
class _StubCatalogRepository implements ProductPublishingRepository {
|
||||
final List<ProductDraft> drafts;
|
||||
final bool throwOnGet;
|
||||
|
||||
_StubCatalogRepository({this.drafts = const [], this.throwOnGet = false});
|
||||
|
||||
@override
|
||||
Future<List<ProductDraft>> getProductDrafts() async {
|
||||
if (throwOnGet) throw Exception('catalog fetch failed');
|
||||
return drafts;
|
||||
}
|
||||
|
||||
@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> mediaIds) =>
|
||||
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 id, {bool force = true}) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Builds a [SquareCatalogSyncService] backed by the given stubs.
|
||||
SquareCatalogSyncService _makeService({
|
||||
List<ProductDraft> drafts = const [],
|
||||
bool throwOnGet = false,
|
||||
InventorySyncAdapter? adapter,
|
||||
}) {
|
||||
return SquareCatalogSyncService(
|
||||
catalogRepository: _StubCatalogRepository(drafts: drafts, throwOnGet: throwOnGet),
|
||||
mappingRepository: _EmptyMappingRepository(),
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a [SquareSyncController] backed by the given stubs.
|
||||
SquareSyncController _makeController({
|
||||
List<ProductDraft> drafts = const [],
|
||||
bool throwOnGet = false,
|
||||
InventorySyncAdapter? adapter,
|
||||
}) {
|
||||
return SquareSyncController(
|
||||
syncService: _makeService(drafts: drafts, throwOnGet: throwOnGet, adapter: adapter),
|
||||
);
|
||||
}
|
||||
|
||||
/// A [SquareCatalogSyncService] that throws synchronously from [syncAll].
|
||||
class _ThrowingSyncService extends SquareCatalogSyncService {
|
||||
_ThrowingSyncService()
|
||||
: super(
|
||||
catalogRepository: _StubCatalogRepository(throwOnGet: true),
|
||||
mappingRepository: _EmptyMappingRepository(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<SquareSyncResult> syncAll() async {
|
||||
throw Exception('total failure');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SquareSyncResult> syncCatalog() async {
|
||||
throw Exception('catalog failure');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SquareSyncResult> syncInventory() async {
|
||||
throw Exception('inventory failure');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SquareSyncController', () {
|
||||
// ── Initial state ─────────────────────────────────────────────────────────
|
||||
|
||||
group('initial state', () {
|
||||
test('isSyncing is false initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('lastResult is null initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.lastResult, isNull);
|
||||
});
|
||||
|
||||
test('lastSyncedAt is null initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.lastSyncedAt, isNull);
|
||||
});
|
||||
|
||||
test('lastError is null initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.lastError, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncAll happy path ────────────────────────────────────────────────────
|
||||
|
||||
group('syncAll', () {
|
||||
test('sets lastResult after successful syncAll', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastResult, isNotNull);
|
||||
expect(controller.lastResult, isA<SquareSyncResult>());
|
||||
});
|
||||
|
||||
test('stamps lastSyncedAt after syncAll completes', () async {
|
||||
final before = DateTime.now();
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
final after = DateTime.now();
|
||||
|
||||
expect(controller.lastSyncedAt, isNotNull);
|
||||
expect(
|
||||
controller.lastSyncedAt!.isAfter(before) || controller.lastSyncedAt == before,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
controller.lastSyncedAt!.isBefore(after) || controller.lastSyncedAt == after,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('isSyncing is false after syncAll completes', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('lastError is null after successful syncAll', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
expect(controller.lastError, isNull);
|
||||
});
|
||||
|
||||
test('syncAll with drafts returns catalogSynced count', () async {
|
||||
final draft = ProductDraft(
|
||||
id: 'P-001',
|
||||
name: 'Bowl Cozy',
|
||||
description: '',
|
||||
price: 12.99,
|
||||
sku: 'BC-001',
|
||||
category: 'Jewelry',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
);
|
||||
final controller = _makeController(drafts: [draft]);
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastResult!.catalogSynced, 1);
|
||||
expect(controller.lastResult!.catalogFailed, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncAll partial failure ───────────────────────────────────────────────
|
||||
|
||||
group('syncAll partial failure', () {
|
||||
test('lastResult reflects partial failure when catalog fetch fails', () async {
|
||||
final controller = _makeController(throwOnGet: true);
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastResult, isNotNull);
|
||||
expect(controller.lastResult!.catalogFailed, greaterThan(0));
|
||||
expect(controller.lastResult!.errors, isNotEmpty);
|
||||
});
|
||||
|
||||
test('lastError is null even on partial failure (errors in lastResult)', () async {
|
||||
final controller = _makeController(throwOnGet: true);
|
||||
await controller.syncAll();
|
||||
|
||||
// Partial failures are captured in lastResult.errors, not lastError.
|
||||
expect(controller.lastError, isNull);
|
||||
expect(controller.lastResult!.errors, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isSyncing during sync ─────────────────────────────────────────────────
|
||||
|
||||
group('isSyncing state', () {
|
||||
test('isSyncing is true during sync and false after', () async {
|
||||
final controller = _makeController();
|
||||
bool wasSyncingDuringSync = false;
|
||||
|
||||
// Listen for the first notification (isSyncing = true).
|
||||
controller.addListener(() {
|
||||
if (controller.isSyncing) {
|
||||
wasSyncingDuringSync = true;
|
||||
}
|
||||
});
|
||||
|
||||
await controller.syncAll();
|
||||
|
||||
expect(wasSyncingDuringSync, isTrue);
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('concurrent syncAll calls are no-ops while already syncing', () async {
|
||||
int notifyCount = 0;
|
||||
final controller = _makeController();
|
||||
controller.addListener(() => notifyCount++);
|
||||
|
||||
// Fire two concurrent calls — second should be a no-op.
|
||||
final f1 = controller.syncAll();
|
||||
final f2 = controller.syncAll(); // should return immediately
|
||||
await Future.wait([f1, f2]);
|
||||
|
||||
// Only 2 notifications: isSyncing=true and isSyncing=false.
|
||||
expect(notifyCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncCatalog ───────────────────────────────────────────────────────────
|
||||
|
||||
group('syncCatalog', () {
|
||||
test('syncCatalog sets lastResult', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncCatalog();
|
||||
expect(controller.lastResult, isNotNull);
|
||||
});
|
||||
|
||||
test('syncCatalog stamps lastSyncedAt', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncCatalog();
|
||||
expect(controller.lastSyncedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('syncCatalog sets lastError on unexpected exception', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncCatalog();
|
||||
expect(controller.lastError, isNotNull);
|
||||
expect(controller.lastError, contains('catalog failure'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncInventory ─────────────────────────────────────────────────────────
|
||||
|
||||
group('syncInventory', () {
|
||||
test('syncInventory sets lastResult', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncInventory();
|
||||
expect(controller.lastResult, isNotNull);
|
||||
});
|
||||
|
||||
test('syncInventory stamps lastSyncedAt', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncInventory();
|
||||
expect(controller.lastSyncedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('syncInventory sets lastError on unexpected exception', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncInventory();
|
||||
expect(controller.lastError, isNotNull);
|
||||
expect(controller.lastError, contains('inventory failure'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── error state on total failure ──────────────────────────────────────────
|
||||
|
||||
group('error state on total failure', () {
|
||||
test('lastError is set when syncAll throws unexpectedly', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastError, isNotNull);
|
||||
expect(controller.lastError, contains('total failure'));
|
||||
});
|
||||
|
||||
test('isSyncing is false after total failure', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncAll();
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('lastSyncedAt is null after total failure (no successful completion)', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncAll();
|
||||
// lastSyncedAt is only stamped on successful completion.
|
||||
expect(controller.lastSyncedAt, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── notifyListeners ───────────────────────────────────────────────────────
|
||||
|
||||
group('notifyListeners', () {
|
||||
test('notifies listeners exactly twice per sync (start and end)', () async {
|
||||
int count = 0;
|
||||
final controller = _makeController();
|
||||
controller.addListener(() => count++);
|
||||
await controller.syncAll();
|
||||
expect(count, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProductDraft.squareCatalogObjectId ────────────────────────────────────
|
||||
|
||||
group('ProductDraft.squareCatalogObjectId', () {
|
||||
ProductDraft draft0({String? squareCatalogObjectId}) => ProductDraft(
|
||||
id: 'P-001',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
price: 10.0,
|
||||
sku: 'SKU-001',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
squareCatalogObjectId: squareCatalogObjectId,
|
||||
);
|
||||
|
||||
test('defaults to null when not provided', () {
|
||||
final draft = draft0();
|
||||
expect(draft.squareCatalogObjectId, isNull);
|
||||
});
|
||||
|
||||
test('stores provided squareCatalogObjectId', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
expect(draft.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
});
|
||||
|
||||
test('copyWith preserves squareCatalogObjectId when not specified', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
final copy = draft.copyWith(name: 'Updated');
|
||||
expect(copy.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
});
|
||||
|
||||
test('copyWith updates squareCatalogObjectId when specified', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
final copy = draft.copyWith(squareCatalogObjectId: 'SQ-ITEM-002');
|
||||
expect(copy.squareCatalogObjectId, 'SQ-ITEM-002');
|
||||
});
|
||||
|
||||
test('copyWith clears squareCatalogObjectId when null is passed explicitly', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
final copy = draft.copyWith(squareCatalogObjectId: null);
|
||||
expect(copy.squareCatalogObjectId, isNull);
|
||||
});
|
||||
|
||||
test('copyWith preserves null squareCatalogObjectId when not specified', () {
|
||||
final draft = draft0();
|
||||
final copy = draft.copyWith(name: 'Updated');
|
||||
expect(copy.squareCatalogObjectId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SquareCatalogSyncService ID conflation fix ────────────────────────────
|
||||
|
||||
group('SquareCatalogSyncService ID conflation fix', () {
|
||||
test('uses squareCatalogObjectId when present instead of product.id', () async {
|
||||
// Product has a WooCommerce local ID ('WC-001') and a separate Square ID.
|
||||
final draft = ProductDraft(
|
||||
id: 'WC-001',
|
||||
name: 'Bowl Cozy',
|
||||
description: '',
|
||||
price: 12.99,
|
||||
sku: 'BC-001',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
);
|
||||
|
||||
String? savedLocalId;
|
||||
String? savedSquareId;
|
||||
|
||||
final mappingRepo = _CapturingMappingRepository(
|
||||
onSave: (m) {
|
||||
savedLocalId = m.localId;
|
||||
savedSquareId = m.squareCatalogObjectId;
|
||||
},
|
||||
);
|
||||
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _StubCatalogRepository(drafts: [draft]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
await service.syncCatalog();
|
||||
|
||||
// localId should be the WooCommerce ID, squareCatalogObjectId the Square ID.
|
||||
expect(savedLocalId, 'WC-001');
|
||||
expect(savedSquareId, 'SQ-ITEM-001');
|
||||
});
|
||||
|
||||
test('falls back to product.id when squareCatalogObjectId is null', () async {
|
||||
// Square-backed repo: product.id IS the Square catalog object ID.
|
||||
final draft = ProductDraft(
|
||||
id: 'SQ-ITEM-001',
|
||||
name: 'Bowl Cozy',
|
||||
description: '',
|
||||
price: 12.99,
|
||||
sku: 'BC-001',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
// squareCatalogObjectId is null — fall back to product.id
|
||||
);
|
||||
|
||||
String? savedLocalId;
|
||||
String? savedSquareId;
|
||||
|
||||
final mappingRepo = _CapturingMappingRepository(
|
||||
onSave: (m) {
|
||||
savedLocalId = m.localId;
|
||||
savedSquareId = m.squareCatalogObjectId;
|
||||
},
|
||||
);
|
||||
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _StubCatalogRepository(drafts: [draft]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
await service.syncCatalog();
|
||||
|
||||
expect(savedLocalId, 'SQ-ITEM-001');
|
||||
expect(savedSquareId, 'SQ-ITEM-001');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test doubles ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A [ProductIdMappingRepository] that captures [saveMapping] calls.
|
||||
class _CapturingMappingRepository implements ProductIdMappingRepository {
|
||||
final void Function(ProductIdMapping mapping) onSave;
|
||||
|
||||
_CapturingMappingRepository({required this.onSave});
|
||||
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async => [];
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
|
||||
onSave(mapping);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {}
|
||||
}
|
||||
Loading…
Reference in New Issue