Compare commits
3 Commits
b4d2c44d10
...
a74cf35bd1
| Author | SHA1 | Date |
|---|---|---|
|
|
a74cf35bd1 | |
|
|
cf1e78d27d | |
|
|
ab56c942c7 |
|
|
@ -4,10 +4,10 @@
|
||||||
|
|
||||||
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
|
- 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
|
- 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
|
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 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**
|
- active branch: `feat/square-catalog-sync-service-controller` (Stage 10E) — **ready for merge**; all P1.1–P1.5 items complete
|
||||||
- next branch scope: `SquareSyncController` (P1.4) + `AppServices.square()`/`multi()` factories (P1.1) + `KcBootstrap` Square credential validation (P1.5) + `SquareCatalogSyncService` ID conflation fix (P1.2)
|
- 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: 1,015/1,015 passing (Stage 10C baseline); 1,015+31 = 1,046 after Stage 10D merge
|
- 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
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -651,3 +651,23 @@
|
||||||
- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new)
|
- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new)
|
||||||
- analyze: clean
|
- analyze: clean
|
||||||
- brief updated: yes
|
- 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_orders/feature_orders.dart';
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
import 'package:integrations/integrations.dart';
|
||||||
|
|
||||||
/// Holds the concrete service implementations used by `kell_mobile`.
|
/// 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].
|
/// 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 {
|
static KcServiceFactory<MobileAppServices> get serviceFactory {
|
||||||
return KcServiceFactory<MobileAppServices>(
|
return KcServiceFactory<MobileAppServices>(
|
||||||
createFake: () => MobileAppServices.fake(),
|
createFake: () => MobileAppServices.fake(),
|
||||||
|
|
@ -66,6 +132,19 @@ class MobileAppServices extends KcAppServices {
|
||||||
consumerKey: config.wcConsumerKey,
|
consumerKey: config.wcConsumerKey,
|
||||||
consumerSecret: config.wcConsumerSecret,
|
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_orders/feature_orders.dart';
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
import 'package:integrations/integrations.dart';
|
||||||
|
|
||||||
/// Holds the concrete service implementations used by `kell_web`.
|
/// 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
|
/// The [AppServices.fake] factory wires up the in-memory fakes that live
|
||||||
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
||||||
/// a real WooCommerce-backed product repository while keeping other services
|
/// a real WooCommerce-backed product repository. The [AppServices.square]
|
||||||
/// fake until their backends are ready.
|
/// 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 {
|
class AppServices extends KcAppServices {
|
||||||
final InventoryRepository inventoryRepository;
|
final InventoryRepository inventoryRepository;
|
||||||
final OrdersRepository ordersRepository;
|
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].
|
/// 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 {
|
static KcServiceFactory<AppServices> get serviceFactory {
|
||||||
return KcServiceFactory<AppServices>(
|
return KcServiceFactory<AppServices>(
|
||||||
createFake: () => AppServices.fake(),
|
createFake: () => AppServices.fake(),
|
||||||
|
|
@ -72,6 +139,19 @@ class AppServices extends KcAppServices {
|
||||||
consumerKey: config.wcConsumerKey,
|
consumerKey: config.wcConsumerKey,
|
||||||
consumerSecret: config.wcConsumerSecret,
|
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
|
// Application
|
||||||
export 'src/application/square_catalog_sync_service.dart';
|
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_category.dart';
|
||||||
export 'src/application/create_product.dart';
|
export 'src/application/create_product.dart';
|
||||||
export 'src/application/delete_product.dart';
|
export 'src/application/delete_product.dart';
|
||||||
|
|
|
||||||
|
|
@ -156,11 +156,18 @@ class SquareCatalogSyncService {
|
||||||
/// Pulls the full Square catalog and reconciles [ProductIdMappingRepository].
|
/// Pulls the full Square catalog and reconciles [ProductIdMappingRepository].
|
||||||
///
|
///
|
||||||
/// For each product returned by [ProductPublishingRepository.getProductDrafts]:
|
/// For each product returned by [ProductPublishingRepository.getProductDrafts]:
|
||||||
/// - Looks up an existing mapping by [ProductDraft.id] (treated as the
|
/// - Uses [ProductDraft.squareCatalogObjectId] as the Square catalog object
|
||||||
/// Square catalog object ID).
|
/// 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
|
/// - Creates or updates the mapping with the current Square IDs and
|
||||||
/// timestamps.
|
/// timestamps.
|
||||||
///
|
///
|
||||||
|
/// This avoids conflating local / WooCommerce product IDs with Square catalog
|
||||||
|
/// object IDs in mixed-source scenarios.
|
||||||
|
///
|
||||||
/// Returns a [SquareSyncResult] with [SquareSyncResult.catalogSynced] and
|
/// Returns a [SquareSyncResult] with [SquareSyncResult.catalogSynced] and
|
||||||
/// [SquareSyncResult.catalogFailed] populated.
|
/// [SquareSyncResult.catalogFailed] populated.
|
||||||
Future<SquareSyncResult> syncCatalog() async {
|
Future<SquareSyncResult> syncCatalog() async {
|
||||||
|
|
@ -183,15 +190,20 @@ class SquareCatalogSyncService {
|
||||||
final errors = <SyncError>[];
|
final errors = <SyncError>[];
|
||||||
|
|
||||||
for (final product in products) {
|
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 {
|
try {
|
||||||
final existing = await _mappingRepository.getMappingBySquareId(product.id);
|
final existing = await _mappingRepository.getMappingBySquareId(squareId);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final mapping = existing != null
|
final mapping = existing != null
|
||||||
? existing.copyWith(squareCatalogObjectId: product.id, lastUpdated: now)
|
? existing.copyWith(squareCatalogObjectId: squareId, lastUpdated: now)
|
||||||
: ProductIdMapping(
|
: ProductIdMapping(
|
||||||
localId: product.id,
|
localId: product.id,
|
||||||
squareCatalogObjectId: product.id,
|
squareCatalogObjectId: squareId,
|
||||||
lastUpdated: now,
|
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 PublishStatus status;
|
||||||
final DateTime lastModified;
|
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({
|
const ProductDraft({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|
@ -22,9 +33,15 @@ class ProductDraft {
|
||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.lastModified,
|
required this.lastModified,
|
||||||
|
this.squareCatalogObjectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Returns a copy of this [ProductDraft] with the given fields replaced.
|
/// 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({
|
ProductDraft copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
|
|
@ -35,6 +52,7 @@ class ProductDraft {
|
||||||
String? imageUrl,
|
String? imageUrl,
|
||||||
PublishStatus? status,
|
PublishStatus? status,
|
||||||
DateTime? lastModified,
|
DateTime? lastModified,
|
||||||
|
Object? squareCatalogObjectId = _sentinel,
|
||||||
}) {
|
}) {
|
||||||
return ProductDraft(
|
return ProductDraft(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -46,6 +64,13 @@ class ProductDraft {
|
||||||
imageUrl: imageUrl ?? this.imageUrl,
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
lastModified: lastModified ?? this.lastModified,
|
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({
|
final upsertBody = jsonEncode({
|
||||||
'catalog_object': _squareItem(id: 'SQ-001', name: 'Updated Item'),
|
'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.
|
// Seed the cache manually by calling getProductDrafts first.
|
||||||
final listBody = jsonEncode({
|
final listBody = jsonEncode({
|
||||||
'objects': [item],
|
'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