Compare commits
No commits in common. "cf1e78d27d11e618f9f842fa8b2e8c0b5dfee259" and "b4d2c44d10252ed908811f73cd5920d456502af9" have entirely different histories.
cf1e78d27d
...
b4d2c44d10
|
|
@ -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
|
||||||
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
- pending merge: `feat/square-catalog-sync-service` (Stage 10D) — ready for merge; 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 active branch: `feat/square-catalog-sync-service-controller` (Stage 10E) — **do not start until Stage 10D is merged**
|
||||||
- next branch: `feat/square-sync-ui` (Stage 10F) — Square sync status panel + `SquareSyncController` wired into `kell_web` and `kell_mobile` shells
|
- 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: 834/834 passing (Stage 10E baseline: 537 feature_wordpress + 47 kell_web + 49 kell_mobile + 34 core + 167 feature_inventory = 834 across tested packages)
|
- total tests on main: 1,015/1,015 passing (Stage 10C baseline); 1,015+31 = 1,046 after Stage 10D merge
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -651,23 +651,3 @@
|
||||||
- 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,7 +3,6 @@ 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`.
|
||||||
///
|
///
|
||||||
|
|
@ -58,72 +57,7 @@ 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(),
|
||||||
|
|
@ -132,19 +66,6 @@ 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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
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,7 +3,6 @@ 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`.
|
||||||
///
|
///
|
||||||
|
|
@ -13,10 +12,8 @@ import 'package:integrations/integrations.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. The [AppServices.square]
|
/// a real WooCommerce-backed product repository while keeping other services
|
||||||
/// factory wires up a Square-backed catalog and inventory repository.
|
/// fake until their backends are ready.
|
||||||
/// 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;
|
||||||
|
|
@ -66,71 +63,7 @@ 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(),
|
||||||
|
|
@ -139,19 +72,6 @@ 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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
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,7 +19,6 @@ 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,18 +156,11 @@ 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]:
|
||||||
/// - Uses [ProductDraft.squareCatalogObjectId] as the Square catalog object
|
/// - Looks up an existing mapping by [ProductDraft.id] (treated as the
|
||||||
/// ID when present. Falls back to [ProductDraft.id] only when
|
/// Square catalog object ID).
|
||||||
/// [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 {
|
||||||
|
|
@ -190,20 +183,15 @@ 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(squareId);
|
final existing = await _mappingRepository.getMappingBySquareId(product.id);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final mapping = existing != null
|
final mapping = existing != null
|
||||||
? existing.copyWith(squareCatalogObjectId: squareId, lastUpdated: now)
|
? existing.copyWith(squareCatalogObjectId: product.id, lastUpdated: now)
|
||||||
: ProductIdMapping(
|
: ProductIdMapping(
|
||||||
localId: product.id,
|
localId: product.id,
|
||||||
squareCatalogObjectId: squareId,
|
squareCatalogObjectId: product.id,
|
||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
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,17 +12,6 @@ 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,
|
||||||
|
|
@ -33,15 +22,9 @@ 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,
|
||||||
|
|
@ -52,7 +35,6 @@ 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,
|
||||||
|
|
@ -64,13 +46,6 @@ 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,6 +143,14 @@ 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],
|
||||||
|
|
|
||||||
|
|
@ -1,529 +0,0 @@
|
||||||
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