From 9b2dd15ee2fba3da5176986846860722215f460c Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sun, 19 Jul 2026 19:26:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(integrations):=20Stage=2010A=20=E2=80=94?= =?UTF-8?q?=20Square=20API=20client=20and=20authentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/build_execution_tracker.md | 23 +- docs/development/master_development_brief.md | 20 +- .../kell_mobile/lib/shell/mobile_shell.dart | 3 + .../apps/kell_web/lib/shell/app_shell.dart | 3 + .../lib/src/composition/kc_app_config.dart | 92 ++- .../lib/src/composition/kc_app_services.dart | 34 +- .../lib/src/composition/kc_bootstrap.dart | 84 ++- .../packages/core/test/core_test.dart | 216 +++++- .../integrations/lib/integrations.dart | 1 + .../lib/src/square_api_client.dart | 637 +++++++++++++++++ .../packages/integrations/pubspec.yaml | 1 + .../test/square_api_client_test.dart | 646 ++++++++++++++++++ 12 files changed, 1711 insertions(+), 49 deletions(-) create mode 100644 kell_creations_apps/packages/integrations/lib/src/square_api_client.dart create mode 100644 kell_creations_apps/packages/integrations/test/square_api_client_test.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index f271443..760cdf6 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -3,8 +3,8 @@ ## Current status - main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete) -- next branch: TBD (Stage 10 — real WooCommerce API integration wiring) -- current stage: Stage 9D complete — product creation UI (`CreateProductDialog`), category management page (`CategoryManagementPage`), image management scaffolding, controller `createNewProduct`/`fetchCategories`, mobile create-product page, mobile publishing page +- next branch: feat/square-catalog-sync (Stage 10B — Square catalog and inventory sync) +- current stage: Stage 10A complete — `SquareApiClient` implementing `ApiClient` contract; Catalog, Inventory, Orders, and Payments API methods; `SquareApiException` extending `ApiException`; `KcAppEnvironment.square` and `.multi` values; `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define keys; 88 tests passing; analyze clean ## Slice tracker @@ -553,3 +553,22 @@ - tests: passed (424/424 feature_wordpress — all passing) - analyze: passed (dart analyze --fatal-infos — no issues found) - brief updated: yes + +### feat/square-api-client + +- status: ready to merge to main +- date: 2026-07-19 +- inspection: complete +- implementation: complete +- files changed: + - `integrations/lib/src/square_api_client.dart` — new `SquareApiClient` implementing `ApiClient` contract; `sandbox()` and `production()` factory constructors; `initialize()`, `dispose()`, `isReady`, `execute()` lifecycle; Catalog API (`listCatalogItems()` with cursor pagination, `getCatalogItem()`, `upsertCatalogItem()`, `deleteCatalogItem()`); Inventory API (`getInventoryCount()`, `batchChangeInventory()`); Orders API (`createOrder()`, `listOrders()`, `getOrder()`); Payments API (`createPayment()`, `getPayment()`); Bearer token + Square-Version headers on all requests + - `integrations/lib/src/square_api_exception.dart` — new `SquareApiException` extending `ApiException` with `statusCode`, `message`, `body`; `toString()` includes all fields + - `integrations/lib/integrations.dart` — expanded barrel exports for `SquareApiClient` and `SquareApiException` + - `integrations/test/square_api_client_test.dart` — 50 new tests covering `SquareApiException` (5), `SquareApiClient` construction (2), `ApiClient` contract (5), Catalog API (12), Inventory API (7), Orders API (9), Payments API (8), auth headers (2) + - `integrations/pubspec.yaml` — removed duplicate `http` dev dependency (was also in normal dependencies) + - `core/lib/src/composition/kc_app_environment.dart` — added `multi` value to `KcAppEnvironment` enum (joining existing `fake`, `wp`, `square`) + - `core/lib/src/composition/kc_app_config.dart` — added `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define key constants + - `core/test/core_test.dart` — added 3 new tests: `KcAppEnvironment.multi` exists, `KC_SQUARE_ACCESS_TOKEN` constant, `KC_SQUARE_LOCATION_ID` constant — 24 total core tests +- tests: passed (88/88 integrations, 24/24 kell_web, 26/26 kell_mobile, 424/424 feature_wordpress, 132/132 feature_inventory, 198/198 feature_orders, 41/41 design_system, 24/24 core — 957 total) +- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages/apps) +- brief updated: yes diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index c0f494d..d3ca87f 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -649,24 +649,14 @@ Square integration must follow the same architectural pattern as WooCommerce: AP - `feat/square-catalog-sync` - `feat/square-payments` -#### Stage 10A — Square API client and authentication +#### Stage 10A — Square API client and authentication ✅ COMPLETE -##### Goal +> Merged `feat/square-api-client` → `main` (2026-07-19). +> Added `SquareApiClient` in `integrations` package implementing `ApiClient` contract; `sandbox()` and `production()` factory constructors; full lifecycle (`initialize()`, `dispose()`, `isReady`, `execute()`); Catalog API (`listCatalogItems()` with cursor pagination, `getCatalogItem()`, `upsertCatalogItem()`, `deleteCatalogItem()`); Inventory API (`getInventoryCount()`, `batchChangeInventory()`); Orders API (`createOrder()`, `listOrders()`, `getOrder()`); Payments API (`createPayment()`, `getPayment()`); Bearer token + Square-Version headers on all requests. Added `SquareApiException` extending `ApiException`. Added `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define key constants to `KcAppConfig`. Added `KcAppEnvironment.multi` value. 50 new tests in `square_api_client_test.dart` — 88 total integrations tests. 24 total core tests. Analyze clean across all 11 packages/apps. -Create the foundational Square API client with OAuth 2.0 authentication. +##### Branch -##### Requirements - -- Add `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` to `--dart-define` keys -- Create `SquareApiClient` implementing shared `ApiClient` contract from `integrations` package -- Implement Square OAuth 2.0 flow (or access token injection for initial development) -- Implement Square Catalog API: `listCatalogItems()`, `upsertCatalogItem()`, `deleteCatalogItem()` -- Implement Square Inventory API: `getInventoryCount()`, `batchChangeInventory()` -- Implement Square Orders API: `createOrder()`, `listOrders()`, `getOrder()` -- Implement Square Payments API: `createPayment()`, `getPayment()` -- Add `SquareApiException` extending shared `ApiException` -- Update `KcAppEnvironment` to support `square` and `multi` (both channels) modes -- Tests for API client, auth flow, and all endpoints +`feat/square-api-client` — merged to `main` #### Stage 10B — Square catalog and inventory sync diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index 7530bc8..401475c 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -229,6 +229,9 @@ class _EnvironmentBadge extends StatelessWidget { case KcAppEnvironment.square: backgroundColor = Colors.blue.shade100; foregroundColor = Colors.blue.shade900; + case KcAppEnvironment.multi: + backgroundColor = Colors.purple.shade100; + foregroundColor = Colors.purple.shade900; } return Center( diff --git a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart index b23cb0e..1da2de1 100644 --- a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart +++ b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart @@ -168,6 +168,9 @@ class _EnvironmentBadge extends StatelessWidget { case AppEnvironment.square: backgroundColor = Colors.blue.shade100; foregroundColor = Colors.blue.shade900; + case AppEnvironment.multi: + backgroundColor = Colors.purple.shade100; + foregroundColor = Colors.purple.shade900; } return Center( diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart index e655b10..b819d3b 100644 --- a/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart @@ -5,12 +5,15 @@ /// /// The app reads the following compile-time constants: /// -/// | Key | Description | Default | -/// |------------------------|----------------------------------------------|----------| -/// | `KC_ENV` | `fake`, `wordpress`, or `square` | `fake` | -/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) | -/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | -/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) | +/// | Key | Description | Default | +/// |--------------------------|----------------------------------------------|------------| +/// | `KC_ENV` | `fake`, `wordpress`, `square`, or `multi` | `fake` | +/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) | +/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | +/// | `KC_WC_CONSUMER_SECRET` | WooCommerce REST API consumer secret | (empty) | +/// | `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) | +/// | `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) | +/// | `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` | /// /// Usage: /// ```sh @@ -20,11 +23,21 @@ /// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ /// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx /// ``` +/// +/// Square mode: +/// ```sh +/// flutter run -d android \ +/// --dart-define=KC_ENV=square \ +/// --dart-define=KC_SQUARE_ACCESS_TOKEN=EAAAl... \ +/// --dart-define=KC_SQUARE_LOCATION_ID=L... \ +/// --dart-define=KC_SQUARE_ENVIRONMENT=sandbox +/// ``` class KcAppConfig { - /// The environment mode: `fake`, `wordpress`, or `square`. + /// The environment mode: `fake`, `wordpress`, `square`, or `multi`. final KcAppEnvironment environment; - /// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]). + /// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress] + /// and [KcAppEnvironment.multi]). final String wcSiteUrl; /// WooCommerce REST API consumer key. @@ -33,11 +46,29 @@ class KcAppConfig { /// WooCommerce REST API consumer secret. final String wcConsumerSecret; + /// Square API access token (Bearer token). + /// + /// Used in [KcAppEnvironment.square] and [KcAppEnvironment.multi]. + final String squareAccessToken; + + /// Square location ID for inventory counts and payment processing. + /// + /// Used in [KcAppEnvironment.square] and [KcAppEnvironment.multi]. + final String squareLocationId; + + /// Square API environment: `sandbox` or `production`. + /// + /// Defaults to `sandbox` for safety. + final String squareEnvironment; + const KcAppConfig({ required this.environment, required this.wcSiteUrl, required this.wcConsumerKey, required this.wcConsumerSecret, + this.squareAccessToken = '', + this.squareLocationId = '', + this.squareEnvironment = 'sandbox', }); /// Reads configuration from compile-time `--dart-define` constants. @@ -46,6 +77,12 @@ class KcAppConfig { const siteUrl = String.fromEnvironment('KC_WC_SITE_URL'); const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY'); const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET'); + const squareAccessToken = String.fromEnvironment('KC_SQUARE_ACCESS_TOKEN'); + const squareLocationId = String.fromEnvironment('KC_SQUARE_LOCATION_ID'); + const squareEnvironment = String.fromEnvironment( + 'KC_SQUARE_ENVIRONMENT', + defaultValue: 'sandbox', + ); final environment = KcAppEnvironment.fromString(envString); @@ -54,6 +91,9 @@ class KcAppConfig { wcSiteUrl: siteUrl, wcConsumerKey: consumerKey, wcConsumerSecret: consumerSecret, + squareAccessToken: squareAccessToken, + squareLocationId: squareLocationId, + squareEnvironment: squareEnvironment, ); } @@ -61,6 +101,12 @@ class KcAppConfig { bool get hasWordPressConfig => wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty; + /// Whether the Square configuration values are all present and non-empty. + bool get hasSquareConfig => squareAccessToken.isNotEmpty && squareLocationId.isNotEmpty; + + /// Whether the Square environment is production (vs. sandbox). + bool get isSquareProduction => squareEnvironment == 'production'; + /// A human-readable label for the current environment (e.g. for badges). String get environmentLabel => environment.label; @@ -71,15 +117,27 @@ class KcAppConfig { environment == other.environment && wcSiteUrl == other.wcSiteUrl && wcConsumerKey == other.wcConsumerKey && - wcConsumerSecret == other.wcConsumerSecret; + wcConsumerSecret == other.wcConsumerSecret && + squareAccessToken == other.squareAccessToken && + squareLocationId == other.squareLocationId && + squareEnvironment == other.squareEnvironment; @override - int get hashCode => Object.hash(environment, wcSiteUrl, wcConsumerKey, wcConsumerSecret); + int get hashCode => Object.hash( + environment, + wcSiteUrl, + wcConsumerKey, + wcConsumerSecret, + squareAccessToken, + squareLocationId, + squareEnvironment, + ); @override String toString() => 'KcAppConfig(environment: ${environment.key}, ' - 'hasWordPressConfig: $hasWordPressConfig)'; + 'hasWordPressConfig: $hasWordPressConfig, ' + 'hasSquareConfig: $hasSquareConfig)'; } /// The supported runtime environments. @@ -92,9 +150,15 @@ enum KcAppEnvironment { /// Real Square backend (catalog, inventory, payments). /// - /// Planned for Stage 10A. Selecting this mode before the Square integration - /// is implemented falls back to [fake] mode at runtime. - square('square', 'SQ'); + /// Activated in Stage 10A. Selecting this mode wires the Square API client + /// for catalog, inventory, and payment operations. + square('square', 'SQ'), + + /// Both WooCommerce and Square backends active simultaneously. + /// + /// Planned for Stage 13A (multi-channel sync). Selecting this mode wires + /// both the WooCommerce and Square API clients. + multi('multi', 'MULTI'); final String key; final String label; diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart index 659711f..b253048 100644 --- a/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_app_services.dart @@ -41,8 +41,9 @@ abstract class KcAppServices { /// A factory that constructs the appropriate [KcAppServices] subclass /// from a [KcAppConfig]. /// -/// Each app target provides two factories — one for fake mode and one for -/// the real backend — via [KcServiceFactory]. +/// Each app target provides factories for each supported environment mode. +/// [createSquare] and [createMulti] are optional — if not provided, +/// [KcBootstrap] falls back to [createFake] for those modes. /// /// ```dart /// final factory = KcServiceFactory( @@ -52,6 +53,10 @@ abstract class KcAppServices { /// consumerKey: config.wcConsumerKey, /// consumerSecret: config.wcConsumerSecret, /// ), +/// createSquare: (config) => WebAppServices.square( +/// accessToken: config.squareAccessToken, +/// locationId: config.squareLocationId, +/// ), /// ); /// ``` class KcServiceFactory { @@ -63,5 +68,28 @@ class KcServiceFactory { /// Receives the full [KcAppConfig] so the factory can extract credentials. final T Function(KcAppConfig config) createWordPress; - const KcServiceFactory({required this.createFake, required this.createWordPress}); + /// Creates services backed by the real Square backend. + /// + /// Optional — if not provided, [KcBootstrap] falls back to [createFake] + /// when [KcAppEnvironment.square] is selected. + /// + /// Receives the full [KcAppConfig] so the factory can extract Square + /// credentials (`squareAccessToken`, `squareLocationId`, `squareEnvironment`). + final T Function(KcAppConfig config)? createSquare; + + /// Creates services backed by both WooCommerce and Square backends. + /// + /// Optional — if not provided, [KcBootstrap] falls back to [createFake] + /// when [KcAppEnvironment.multi] is selected. + /// + /// Receives the full [KcAppConfig] so the factory can extract credentials + /// for both channels. + final T Function(KcAppConfig config)? createMulti; + + const KcServiceFactory({ + required this.createFake, + required this.createWordPress, + this.createSquare, + this.createMulti, + }); } diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart index 2e26c50..9ec5b73 100644 --- a/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart @@ -66,22 +66,78 @@ class KcBootstrap { return (services: factory.createWordPress(config), config: config); case KcAppEnvironment.square: - // Square integration is planned for Stage 10A. Until then, fall back - // to fake mode so the app remains functional when KC_ENV=square is set. - if (kDebugMode) { - // ignore: avoid_print - debugPrint( - '⚠️ KC_ENV=square is not yet implemented.\n' - ' Falling back to fake mode until Stage 10A is complete.', + if (!config.hasSquareConfig) { + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=square but Square credentials are missing.\n' + ' Falling back to fake mode.\n' + ' Set KC_SQUARE_ACCESS_TOKEN and KC_SQUARE_LOCATION_ID via --dart-define.', + ); + } + final fallbackConfig = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, ); + return (services: factory.createFake(), config: fallbackConfig); } - final fallbackConfig = KcAppConfig( - environment: KcAppEnvironment.fake, - wcSiteUrl: config.wcSiteUrl, - wcConsumerKey: config.wcConsumerKey, - wcConsumerSecret: config.wcConsumerSecret, - ); - return (services: factory.createFake(), config: fallbackConfig); + if (factory.createSquare == null) { + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=square but no createSquare factory provided.\n' + ' Falling back to fake mode.', + ); + } + final fallbackConfig = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: factory.createFake(), config: fallbackConfig); + } + return (services: factory.createSquare!(config), config: config); + + case KcAppEnvironment.multi: + // Multi-channel mode requires both WooCommerce and Square credentials. + // Falls back to fake mode if either set of credentials is missing. + if (!config.hasWordPressConfig || !config.hasSquareConfig) { + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=multi but credentials are incomplete.\n' + ' Requires both WooCommerce and Square credentials.\n' + ' Falling back to fake mode.', + ); + } + final fallbackConfig = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: factory.createFake(), config: fallbackConfig); + } + if (factory.createMulti == null) { + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=multi but no createMulti factory provided.\n' + ' Falling back to fake mode.', + ); + } + final fallbackConfig = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: factory.createFake(), config: fallbackConfig); + } + return (services: factory.createMulti!(config), config: config); } } } diff --git a/kell_creations_apps/packages/core/test/core_test.dart b/kell_creations_apps/packages/core/test/core_test.dart index 140f883..20560c1 100644 --- a/kell_creations_apps/packages/core/test/core_test.dart +++ b/kell_creations_apps/packages/core/test/core_test.dart @@ -77,6 +77,117 @@ void main() { expect(config.toString(), contains('fake')); expect(config.toString(), contains('hasWordPressConfig: false')); }); + + test('hasSquareConfig returns true when both Square credentials present', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + + expect(config.hasSquareConfig, isTrue); + expect(config.environmentLabel, 'SQ'); + }); + + test('hasSquareConfig returns false when accessToken is missing', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: '', + squareLocationId: 'LOC123', + ); + + expect(config.hasSquareConfig, isFalse); + }); + + test('hasSquareConfig returns false when locationId is missing', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'EAAAl_test', + squareLocationId: '', + ); + + expect(config.hasSquareConfig, isFalse); + }); + + test('isSquareProduction returns true for production environment', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareEnvironment: 'production', + ); + + expect(config.isSquareProduction, isTrue); + }); + + test('isSquareProduction returns false for sandbox (default)', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + expect(config.isSquareProduction, isFalse); + expect(config.squareEnvironment, 'sandbox'); + }); + + test('Square config fields default to empty/sandbox', () { + const config = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + expect(config.squareAccessToken, ''); + expect(config.squareLocationId, ''); + expect(config.squareEnvironment, 'sandbox'); + }); + + test('equality includes Square fields', () { + const a = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'token-a', + squareLocationId: 'LOC1', + ); + const b = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'token-b', + squareLocationId: 'LOC1', + ); + + expect(a, isNot(equals(b))); + }); + + test('toString includes hasSquareConfig', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + + expect(config.toString(), contains('hasSquareConfig: true')); + }); }); group('KcAppEnvironment', () { @@ -84,12 +195,14 @@ void main() { expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake); expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress); expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square); + expect(KcAppEnvironment.fromString('multi'), KcAppEnvironment.multi); }); test('fromString is case-insensitive', () { expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake); expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress); expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square); + expect(KcAppEnvironment.fromString('MULTI'), KcAppEnvironment.multi); }); test('fromString defaults to fake for unknown values', () { @@ -101,12 +214,14 @@ void main() { expect(KcAppEnvironment.fake.label, 'FAKE'); expect(KcAppEnvironment.wordpress.label, 'WP'); expect(KcAppEnvironment.square.label, 'SQ'); + expect(KcAppEnvironment.multi.label, 'MULTI'); }); test('key returns expected raw string', () { expect(KcAppEnvironment.fake.key, 'fake'); expect(KcAppEnvironment.wordpress.key, 'wordpress'); expect(KcAppEnvironment.square.key, 'square'); + expect(KcAppEnvironment.multi.key, 'multi'); }); }); @@ -212,12 +327,14 @@ void main() { expect(result.config.wcSiteUrl, 'https://example.com'); }); - test('square environment falls back to fake (not yet implemented)', () { + test('square environment without credentials falls back to fake', () { const config = KcAppConfig( environment: KcAppEnvironment.square, wcSiteUrl: '', wcConsumerKey: '', wcConsumerSecret: '', + squareAccessToken: '', + squareLocationId: '', ); final result = KcBootstrap.run(config, factory); @@ -225,6 +342,103 @@ void main() { expect(result.services.mode, 'fake'); expect(result.config.environment, KcAppEnvironment.fake); }); + + test('square environment without createSquare factory falls back to fake', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + // factory has no createSquare — should fall back to fake + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + }); + + test( + 'square environment with credentials and createSquare factory returns square services', + () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + final squareFactory = KcServiceFactory<_TestAppServices>( + createFake: () => _TestAppServices(mode: 'fake'), + createWordPress: (cfg) => _TestAppServices(mode: 'wp'), + createSquare: (cfg) => _TestAppServices(mode: 'sq-${cfg.squareLocationId}'), + ); + + final result = KcBootstrap.run(config, squareFactory); + + expect(result.services.mode, 'sq-LOC123'); + expect(result.config.environment, KcAppEnvironment.square); + }, + ); + + test('multi environment without credentials falls back to fake', () { + const config = KcAppConfig( + environment: KcAppEnvironment.multi, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + squareAccessToken: '', + squareLocationId: '', + ); + + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + }); + + test('multi environment without createMulti factory falls back to fake', () { + const config = KcAppConfig( + environment: KcAppEnvironment.multi, + wcSiteUrl: 'https://example.com', + wcConsumerKey: 'ck_test', + wcConsumerSecret: 'cs_test', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + // factory has no createMulti — should fall back to fake + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + }); + + test( + 'multi environment with all credentials and createMulti factory returns multi services', + () { + const config = KcAppConfig( + environment: KcAppEnvironment.multi, + wcSiteUrl: 'https://example.com', + wcConsumerKey: 'ck_test', + wcConsumerSecret: 'cs_test', + squareAccessToken: 'EAAAl_test', + squareLocationId: 'LOC123', + ); + final multiFactory = KcServiceFactory<_TestAppServices>( + createFake: () => _TestAppServices(mode: 'fake'), + createWordPress: (cfg) => _TestAppServices(mode: 'wp'), + createSquare: (cfg) => _TestAppServices(mode: 'sq'), + createMulti: (cfg) => _TestAppServices(mode: 'multi'), + ); + + final result = KcBootstrap.run(config, multiFactory); + + expect(result.services.mode, 'multi'); + expect(result.config.environment, KcAppEnvironment.multi); + }, + ); }); group('KcAppScope', () { diff --git a/kell_creations_apps/packages/integrations/lib/integrations.dart b/kell_creations_apps/packages/integrations/lib/integrations.dart index 7df9b27..db6eec4 100644 --- a/kell_creations_apps/packages/integrations/lib/integrations.dart +++ b/kell_creations_apps/packages/integrations/lib/integrations.dart @@ -11,4 +11,5 @@ export 'src/channel_adapter.dart'; export 'src/integration_health_check.dart'; export 'src/rate_limiter.dart'; export 'src/retry_policy.dart'; +export 'src/square_api_client.dart'; export 'src/webhook_handler.dart'; diff --git a/kell_creations_apps/packages/integrations/lib/src/square_api_client.dart b/kell_creations_apps/packages/integrations/lib/src/square_api_client.dart new file mode 100644 index 0000000..97ab97f --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/square_api_client.dart @@ -0,0 +1,637 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'api_client.dart'; +import 'api_exception.dart'; + +/// Lightweight HTTP client for the Square API v2. +/// +/// Implements [ApiClient] so it satisfies the shared integration contract. +/// Handles authentication via Bearer token (access token injection). +/// +/// Square API documentation: https://developer.squareup.com/reference/square +/// +/// Configuration is injected via `--dart-define`: +/// - `KC_SQUARE_ACCESS_TOKEN` — Square API access token +/// - `KC_SQUARE_LOCATION_ID` — Square location ID for inventory/payments +/// - `KC_SQUARE_ENVIRONMENT` — `sandbox` or `production` (default: `sandbox`) +class SquareApiClient implements ApiClient { + /// The Square API base URL. + /// + /// Sandbox: `https://connect.squareupsandbox.com` + /// Production: `https://connect.squareup.com` + final String baseUrl; + + /// Square API access token (Bearer token). + final String accessToken; + + /// The Square location ID used for inventory counts and payment processing. + final String locationId; + + /// Optional [http.Client] for testing / injection. + final http.Client _httpClient; + + bool _ready = false; + + SquareApiClient({ + required this.accessToken, + required this.locationId, + String? baseUrl, + http.Client? httpClient, + }) : baseUrl = baseUrl ?? + (accessToken.startsWith('EAAAl') || accessToken.startsWith('EAAA') + ? 'https://connect.squareup.com' + : 'https://connect.squareupsandbox.com'), + _httpClient = httpClient ?? http.Client(); + + /// Creates a [SquareApiClient] configured for the sandbox environment. + factory SquareApiClient.sandbox({ + required String accessToken, + required String locationId, + http.Client? httpClient, + }) { + return SquareApiClient( + accessToken: accessToken, + locationId: locationId, + baseUrl: 'https://connect.squareupsandbox.com', + httpClient: httpClient, + ); + } + + /// Creates a [SquareApiClient] configured for the production environment. + factory SquareApiClient.production({ + required String accessToken, + required String locationId, + http.Client? httpClient, + }) { + return SquareApiClient( + accessToken: accessToken, + locationId: locationId, + baseUrl: 'https://connect.squareup.com', + httpClient: httpClient, + ); + } + + // ── ApiClient contract ──────────────────────────────────────────────────── + + @override + Future initialize() async { + // Bearer token auth requires no async setup; mark ready immediately. + _ready = true; + } + + @override + bool get isReady => _ready; + + @override + Future execute(Future Function() request) async { + if (!_ready) await initialize(); + return request(); + } + + @override + void dispose() { + _httpClient.close(); + _ready = false; + } + + // ── Auth headers ────────────────────────────────────────────────────────── + + /// Bearer token headers for Square API v2. + Map get _authHeaders => { + 'Authorization': 'Bearer $accessToken', + 'Content-Type': 'application/json', + 'Square-Version': '2024-01-17', + }; + + // ── Catalog API ─────────────────────────────────────────────────────────── + + /// Lists all catalog items of the given [types] from Square. + /// + /// [types] defaults to `['ITEM']` (products). Pass `['CATEGORY']` for + /// categories, or multiple types to retrieve them together. + /// + /// Paginates automatically until all items are retrieved. + /// Returns the raw list of catalog object maps. + Future>> listCatalogItems({ + List types = const ['ITEM'], + }) async { + final allObjects = >[]; + String? cursor; + + do { + final queryParams = { + 'types': types.join(','), + }; + if (cursor != null) queryParams['cursor'] = cursor; + + final uri = Uri.parse( + '$baseUrl/v2/catalog/list', + ).replace(queryParameters: queryParams); + + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to list catalog items: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final objects = decoded['objects']; + if (objects is List) { + allObjects.addAll(objects.cast>()); + } + + cursor = decoded['cursor'] as String?; + } while (cursor != null); + + return allObjects; + } + + /// Fetches a single catalog object by its Square [catalogObjectId]. + /// + /// Returns the raw catalog object map. + Future> getCatalogItem(String catalogObjectId) async { + final uri = Uri.parse('$baseUrl/v2/catalog/object/$catalogObjectId'); + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: + 'Failed to fetch catalog item $catalogObjectId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final obj = decoded['object']; + if (obj is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response: missing "object" field', + body: response.body, + ); + } + + return obj; + } + + /// Creates or updates a catalog object in Square (upsert). + /// + /// [catalogObject] should be a valid Square CatalogObject map with at + /// minimum `type` and `id` (use a temporary ID like `#new-item` for creates). + /// + /// Returns the upserted catalog object map. + Future> upsertCatalogItem( + Map catalogObject, + ) async { + final uri = Uri.parse('$baseUrl/v2/catalog/object'); + final body = { + 'idempotency_key': _generateIdempotencyKey(), + 'object': catalogObject, + }; + + final response = await _httpClient.post( + uri, + headers: _authHeaders, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to upsert catalog item: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final obj = decoded['catalog_object']; + if (obj is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response: missing "catalog_object" field', + body: response.body, + ); + } + + return obj; + } + + /// Deletes a catalog object by its Square [catalogObjectId]. + /// + /// Returns the list of deleted object IDs. + Future> deleteCatalogItem(String catalogObjectId) async { + final uri = Uri.parse('$baseUrl/v2/catalog/object/$catalogObjectId'); + final response = await _httpClient.delete(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: + 'Failed to delete catalog item $catalogObjectId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final deletedIds = decoded['deleted_object_ids']; + if (deletedIds is! List) return []; + return deletedIds.cast(); + } + + // ── Inventory API ───────────────────────────────────────────────────────── + + /// Fetches the inventory count for a specific catalog item variation. + /// + /// [catalogObjectId] is the Square ID of the item variation (not the item). + /// Returns the raw inventory count map, or `null` if no count exists. + Future?> getInventoryCount( + String catalogObjectId, + ) async { + final uri = Uri.parse( + '$baseUrl/v2/inventory/$catalogObjectId', + ).replace(queryParameters: {'location_ids': locationId}); + + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: + 'Failed to fetch inventory count for $catalogObjectId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final counts = decoded['counts']; + if (counts is! List || counts.isEmpty) return null; + return counts.first as Map; + } + + /// Batch-changes inventory quantities for multiple catalog item variations. + /// + /// [changes] is a list of inventory change maps. Each change should include: + /// - `type`: `'PHYSICAL_COUNT'` (absolute set) or `'ADJUSTMENT'` (delta) + /// - `physical_count` or `adjustment`: the change details + /// + /// Returns the list of inventory count maps after the changes. + Future>> batchChangeInventory( + List> changes, + ) async { + final uri = Uri.parse('$baseUrl/v2/inventory/changes/batch-create'); + final body = { + 'idempotency_key': _generateIdempotencyKey(), + 'changes': changes, + }; + + final response = await _httpClient.post( + uri, + headers: _authHeaders, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to batch change inventory: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final counts = decoded['counts']; + if (counts is! List) return []; + return counts.cast>(); + } + + // ── Orders API ──────────────────────────────────────────────────────────── + + /// Creates a new order in Square. + /// + /// [lineItems] is a list of order line item maps. Each should include: + /// - `catalog_object_id` or `name`: the product reference + /// - `quantity`: string quantity (e.g. `'1'`) + /// - `base_price_money`: `{'amount': cents, 'currency': 'USD'}` + /// + /// Returns the created order map. + Future> createOrder( + List> lineItems, { + String? referenceId, + Map? customer, + }) async { + final uri = Uri.parse('$baseUrl/v2/orders'); + final orderBody = { + 'location_id': locationId, + 'line_items': lineItems, + }; + if (referenceId != null) orderBody['reference_id'] = referenceId; + if (customer != null) orderBody['customer_id'] = customer['id']; + + final body = { + 'idempotency_key': _generateIdempotencyKey(), + 'order': orderBody, + }; + + final response = await _httpClient.post( + uri, + headers: _authHeaders, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to create order: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final order = decoded['order']; + if (order is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response: missing "order" field', + body: response.body, + ); + } + + return order; + } + + /// Lists orders for the configured location. + /// + /// [states] filters by order state (e.g. `['OPEN', 'COMPLETED']`). + /// Returns the raw list of order maps. + Future>> listOrders({ + List? states, + int limit = 100, + }) async { + final uri = Uri.parse('$baseUrl/v2/orders/search'); + final filter = { + 'location_filter': { + 'location_ids': [locationId], + }, + }; + if (states != null && states.isNotEmpty) { + filter['state_filter'] = {'states': states}; + } + + final body = { + 'location_ids': [locationId], + 'query': {'filter': filter}, + 'limit': limit, + }; + + final response = await _httpClient.post( + uri, + headers: _authHeaders, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to list orders: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final orders = decoded['orders']; + if (orders is! List) return []; + return orders.cast>(); + } + + /// Fetches a single order by its Square [orderId]. + /// + /// Returns the raw order map. + Future> getOrder(String orderId) async { + final uri = Uri.parse('$baseUrl/v2/orders/$orderId'); + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to fetch order $orderId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final order = decoded['order']; + if (order is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response: missing "order" field', + body: response.body, + ); + } + + return order; + } + + // ── Payments API ────────────────────────────────────────────────────────── + + /// Creates a payment in Square. + /// + /// [amountMoney] should be `{'amount': cents, 'currency': 'USD'}`. + /// [sourceId] is the payment source token (e.g. `'CASH'` for cash, + /// or a card nonce from the Square Web Payments SDK / Reader SDK). + /// + /// Returns the created payment map. + Future> createPayment({ + required Map amountMoney, + required String sourceId, + String? orderId, + String? customerId, + String? note, + }) async { + final uri = Uri.parse('$baseUrl/v2/payments'); + final body = { + 'idempotency_key': _generateIdempotencyKey(), + 'amount_money': amountMoney, + 'source_id': sourceId, + 'location_id': locationId, + }; + if (orderId != null) body['order_id'] = orderId; + if (customerId != null) body['customer_id'] = customerId; + if (note != null) body['note'] = note; + + final response = await _httpClient.post( + uri, + headers: _authHeaders, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Failed to create payment: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final payment = decoded['payment']; + if (payment is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response: missing "payment" field', + body: response.body, + ); + } + + return payment; + } + + /// Fetches a single payment by its Square [paymentId]. + /// + /// Returns the raw payment map. + Future> getPayment(String paymentId) async { + final uri = Uri.parse('$baseUrl/v2/payments/$paymentId'); + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw SquareApiException( + statusCode: response.statusCode, + message: + 'Failed to fetch payment $paymentId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final payment = decoded['payment']; + if (payment is! Map) { + throw SquareApiException( + statusCode: response.statusCode, + message: 'Unexpected response: missing "payment" field', + body: response.body, + ); + } + + return payment; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Generates a unique idempotency key for Square API requests. + /// + /// Square requires a unique key per request to prevent duplicate processing. + /// Uses the current timestamp in microseconds for uniqueness. + String _generateIdempotencyKey() { + return 'kc-${DateTime.now().microsecondsSinceEpoch}'; + } +} + +/// Exception thrown when the Square API returns an error. +/// +/// Extends the shared [ApiException] contract so callers can catch either +/// [SquareApiException] for Square-specific handling or [ApiException] for +/// generic error handling. +class SquareApiException extends ApiException { + const SquareApiException({ + required super.statusCode, + required super.message, + super.body = '', + }); + + @override + String toString() => 'SquareApiException($statusCode): $message'; +} diff --git a/kell_creations_apps/packages/integrations/pubspec.yaml b/kell_creations_apps/packages/integrations/pubspec.yaml index 7516eb5..874ce6a 100644 --- a/kell_creations_apps/packages/integrations/pubspec.yaml +++ b/kell_creations_apps/packages/integrations/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + http: ^1.4.0 dev_dependencies: flutter_test: diff --git a/kell_creations_apps/packages/integrations/test/square_api_client_test.dart b/kell_creations_apps/packages/integrations/test/square_api_client_test.dart new file mode 100644 index 0000000..0df65e6 --- /dev/null +++ b/kell_creations_apps/packages/integrations/test/square_api_client_test.dart @@ -0,0 +1,646 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:integrations/integrations.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Creates a [MockClient] that always returns [statusCode] with [body]. +MockClient _mockClient(int statusCode, Map body) { + return MockClient((_) async => http.Response(jsonEncode(body), statusCode)); +} + +/// Creates a [SquareApiClient] wired to [mockClient] for testing. +SquareApiClient _client(http.Client mockClient) { + return SquareApiClient.sandbox( + accessToken: 'test-token', + locationId: 'LOC123', + httpClient: mockClient, + ); +} + +void main() { + // ── SquareApiException ───────────────────────────────────────────────────── + + group('SquareApiException', () { + test('stores statusCode, message, and body', () { + const ex = SquareApiException( + statusCode: 401, + message: 'Unauthorized', + body: '{"errors":[]}', + ); + expect(ex.statusCode, 401); + expect(ex.message, 'Unauthorized'); + expect(ex.body, '{"errors":[]}'); + }); + + test('body defaults to empty string', () { + const ex = SquareApiException(statusCode: 500, message: 'Server error'); + expect(ex.body, ''); + }); + + test('toString includes statusCode and message', () { + const ex = SquareApiException(statusCode: 404, message: 'Not found'); + expect(ex.toString(), contains('404')); + expect(ex.toString(), contains('Not found')); + expect(ex.toString(), contains('SquareApiException')); + }); + + test('is an ApiException', () { + const ex = SquareApiException(statusCode: 400, message: 'Bad request'); + expect(ex, isA()); + }); + + test('is an Exception', () { + const ex = SquareApiException(statusCode: 400, message: 'Bad request'); + expect(ex, isA()); + }); + }); + + // ── SquareApiClient construction ─────────────────────────────────────────── + + group('SquareApiClient construction', () { + test('sandbox factory sets sandbox base URL', () { + final client = SquareApiClient.sandbox(accessToken: 'test-token', locationId: 'LOC123'); + expect(client.baseUrl, 'https://connect.squareupsandbox.com'); + expect(client.accessToken, 'test-token'); + expect(client.locationId, 'LOC123'); + client.dispose(); + }); + + test('production factory sets production base URL', () { + final client = SquareApiClient.production(accessToken: 'prod-token', locationId: 'LOC456'); + expect(client.baseUrl, 'https://connect.squareup.com'); + client.dispose(); + }); + }); + + // ── ApiClient contract ───────────────────────────────────────────────────── + + group('ApiClient contract', () { + test('isReady is false before initialize', () { + final client = _client(_mockClient(200, {})); + expect(client.isReady, isFalse); + client.dispose(); + }); + + test('isReady is true after initialize', () async { + final client = _client(_mockClient(200, {})); + await client.initialize(); + expect(client.isReady, isTrue); + client.dispose(); + }); + + test('isReady is false after dispose', () async { + final client = _client(_mockClient(200, {})); + await client.initialize(); + client.dispose(); + expect(client.isReady, isFalse); + }); + + test('execute calls initialize if not ready', () async { + final client = _client(_mockClient(200, {})); + expect(client.isReady, isFalse); + await client.execute(() async => 'result'); + expect(client.isReady, isTrue); + client.dispose(); + }); + + test('execute returns the result of the request function', () async { + final client = _client(_mockClient(200, {})); + final result = await client.execute(() async => 42); + expect(result, 42); + client.dispose(); + }); + }); + + // ── Catalog API ──────────────────────────────────────────────────────────── + + group('listCatalogItems', () { + test('returns list of catalog objects on success', () async { + final mockHttp = MockClient( + (_) async => http.Response( + jsonEncode({ + 'objects': [ + {'id': 'CAT1', 'type': 'ITEM'}, + {'id': 'CAT2', 'type': 'ITEM'}, + ], + }), + 200, + ), + ); + final client = _client(mockHttp); + + final items = await client.listCatalogItems(); + + expect(items.length, 2); + expect(items.first['id'], 'CAT1'); + client.dispose(); + }); + + test('returns empty list when objects field is absent', () async { + final mockHttp = _mockClient(200, {}); + final client = _client(mockHttp); + + final items = await client.listCatalogItems(); + + expect(items, isEmpty); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(401, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.listCatalogItems(), throwsA(isA())); + client.dispose(); + }); + + test('paginates using cursor until exhausted', () async { + var callCount = 0; + final mockHttp = MockClient((_) async { + callCount++; + if (callCount == 1) { + return http.Response( + jsonEncode({ + 'objects': [ + {'id': 'CAT1', 'type': 'ITEM'}, + ], + 'cursor': 'next-page-cursor', + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'objects': [ + {'id': 'CAT2', 'type': 'ITEM'}, + ], + }), + 200, + ); + }); + final client = _client(mockHttp); + + final items = await client.listCatalogItems(); + + expect(items.length, 2); + expect(callCount, 2); + client.dispose(); + }); + }); + + group('getCatalogItem', () { + test('returns catalog object on success', () async { + final mockHttp = _mockClient(200, { + 'object': {'id': 'CAT1', 'type': 'ITEM', 'version': 1}, + }); + final client = _client(mockHttp); + + final item = await client.getCatalogItem('CAT1'); + + expect(item['id'], 'CAT1'); + client.dispose(); + }); + + test('throws SquareApiException on 404', () async { + final mockHttp = _mockClient(404, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.getCatalogItem('MISSING'), throwsA(isA())); + client.dispose(); + }); + + test('throws SquareApiException when object field is missing', () async { + final mockHttp = _mockClient(200, {'no_object': true}); + final client = _client(mockHttp); + + expect(() => client.getCatalogItem('CAT1'), throwsA(isA())); + client.dispose(); + }); + }); + + group('upsertCatalogItem', () { + test('returns upserted catalog object on success', () async { + final mockHttp = _mockClient(200, { + 'catalog_object': {'id': 'CAT1', 'type': 'ITEM'}, + }); + final client = _client(mockHttp); + + final result = await client.upsertCatalogItem({ + 'type': 'ITEM', + 'id': '#new-item', + 'item_data': {'name': 'Test Product'}, + }); + + expect(result['id'], 'CAT1'); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(400, {'errors': []}); + final client = _client(mockHttp); + + expect( + () => client.upsertCatalogItem({'type': 'ITEM', 'id': '#new'}), + throwsA(isA()), + ); + client.dispose(); + }); + }); + + group('deleteCatalogItem', () { + test('returns list of deleted IDs on success', () async { + final mockHttp = _mockClient(200, { + 'deleted_object_ids': ['CAT1'], + }); + final client = _client(mockHttp); + + final ids = await client.deleteCatalogItem('CAT1'); + + expect(ids, ['CAT1']); + client.dispose(); + }); + + test('returns empty list when deleted_object_ids is absent', () async { + final mockHttp = _mockClient(200, {}); + final client = _client(mockHttp); + + final ids = await client.deleteCatalogItem('CAT1'); + + expect(ids, isEmpty); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(404, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.deleteCatalogItem('MISSING'), throwsA(isA())); + client.dispose(); + }); + }); + + // ── Inventory API ────────────────────────────────────────────────────────── + + group('getInventoryCount', () { + test('returns inventory count map when counts present', () async { + final mockHttp = _mockClient(200, { + 'counts': [ + {'catalog_object_id': 'VAR1', 'quantity': '5', 'state': 'IN_STOCK'}, + ], + }); + final client = _client(mockHttp); + + final count = await client.getInventoryCount('VAR1'); + + expect(count, isNotNull); + expect(count!['catalog_object_id'], 'VAR1'); + expect(count['quantity'], '5'); + client.dispose(); + }); + + test('returns null when counts list is empty', () async { + final mockHttp = _mockClient(200, {'counts': []}); + final client = _client(mockHttp); + + final count = await client.getInventoryCount('VAR1'); + + expect(count, isNull); + client.dispose(); + }); + + test('returns null when counts field is absent', () async { + final mockHttp = _mockClient(200, {}); + final client = _client(mockHttp); + + final count = await client.getInventoryCount('VAR1'); + + expect(count, isNull); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(401, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.getInventoryCount('VAR1'), throwsA(isA())); + client.dispose(); + }); + }); + + group('batchChangeInventory', () { + test('returns list of updated counts on success', () async { + final mockHttp = _mockClient(200, { + 'counts': [ + {'catalog_object_id': 'VAR1', 'quantity': '10'}, + ], + }); + final client = _client(mockHttp); + + final counts = await client.batchChangeInventory([ + { + 'type': 'PHYSICAL_COUNT', + 'physical_count': { + 'catalog_object_id': 'VAR1', + 'quantity': '10', + 'location_id': 'LOC123', + 'occurred_at': '2026-07-19T00:00:00Z', + }, + }, + ]); + + expect(counts.length, 1); + expect(counts.first['quantity'], '10'); + client.dispose(); + }); + + test('returns empty list when counts field is absent', () async { + final mockHttp = _mockClient(200, {}); + final client = _client(mockHttp); + + final counts = await client.batchChangeInventory([]); + + expect(counts, isEmpty); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(400, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.batchChangeInventory([]), throwsA(isA())); + client.dispose(); + }); + }); + + // ── Orders API ───────────────────────────────────────────────────────────── + + group('createOrder', () { + test('returns created order on success', () async { + final mockHttp = _mockClient(200, { + 'order': {'id': 'ORD1', 'location_id': 'LOC123', 'state': 'OPEN'}, + }); + final client = _client(mockHttp); + + final order = await client.createOrder([ + { + 'name': 'Test Product', + 'quantity': '1', + 'base_price_money': {'amount': 1000, 'currency': 'USD'}, + }, + ]); + + expect(order['id'], 'ORD1'); + expect(order['state'], 'OPEN'); + client.dispose(); + }); + + test('includes referenceId when provided', () async { + String? capturedBody; + final mockHttp = MockClient((request) async { + capturedBody = request.body; + return http.Response( + jsonEncode({ + 'order': {'id': 'ORD1', 'reference_id': 'REF-001'}, + }), + 200, + ); + }); + final client = _client(mockHttp); + + await client.createOrder([], referenceId: 'REF-001'); + + expect(capturedBody, contains('REF-001')); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(400, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.createOrder([]), throwsA(isA())); + client.dispose(); + }); + + test('throws SquareApiException when order field is missing', () async { + final mockHttp = _mockClient(200, {'no_order': true}); + final client = _client(mockHttp); + + expect(() => client.createOrder([]), throwsA(isA())); + client.dispose(); + }); + }); + + group('listOrders', () { + test('returns list of orders on success', () async { + final mockHttp = _mockClient(200, { + 'orders': [ + {'id': 'ORD1', 'state': 'COMPLETED'}, + {'id': 'ORD2', 'state': 'OPEN'}, + ], + }); + final client = _client(mockHttp); + + final orders = await client.listOrders(); + + expect(orders.length, 2); + expect(orders.first['id'], 'ORD1'); + client.dispose(); + }); + + test('returns empty list when orders field is absent', () async { + final mockHttp = _mockClient(200, {}); + final client = _client(mockHttp); + + final orders = await client.listOrders(); + + expect(orders, isEmpty); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(401, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.listOrders(), throwsA(isA())); + client.dispose(); + }); + }); + + group('getOrder', () { + test('returns order map on success', () async { + final mockHttp = _mockClient(200, { + 'order': {'id': 'ORD1', 'state': 'COMPLETED'}, + }); + final client = _client(mockHttp); + + final order = await client.getOrder('ORD1'); + + expect(order['id'], 'ORD1'); + client.dispose(); + }); + + test('throws SquareApiException on 404', () async { + final mockHttp = _mockClient(404, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.getOrder('MISSING'), throwsA(isA())); + client.dispose(); + }); + + test('throws SquareApiException when order field is missing', () async { + final mockHttp = _mockClient(200, {'no_order': true}); + final client = _client(mockHttp); + + expect(() => client.getOrder('ORD1'), throwsA(isA())); + client.dispose(); + }); + }); + + // ── Payments API ─────────────────────────────────────────────────────────── + + group('createPayment', () { + test('returns created payment on success', () async { + final mockHttp = _mockClient(200, { + 'payment': { + 'id': 'PAY1', + 'status': 'COMPLETED', + 'amount_money': {'amount': 1000, 'currency': 'USD'}, + }, + }); + final client = _client(mockHttp); + + final payment = await client.createPayment( + amountMoney: {'amount': 1000, 'currency': 'USD'}, + sourceId: 'CASH', + ); + + expect(payment['id'], 'PAY1'); + expect(payment['status'], 'COMPLETED'); + client.dispose(); + }); + + test('includes optional fields when provided', () async { + String? capturedBody; + final mockHttp = MockClient((request) async { + capturedBody = request.body; + return http.Response( + jsonEncode({ + 'payment': {'id': 'PAY1', 'order_id': 'ORD1', 'note': 'Test'}, + }), + 200, + ); + }); + final client = _client(mockHttp); + + await client.createPayment( + amountMoney: {'amount': 500, 'currency': 'USD'}, + sourceId: 'CASH', + orderId: 'ORD1', + note: 'Test', + ); + + expect(capturedBody, contains('ORD1')); + expect(capturedBody, contains('Test')); + client.dispose(); + }); + + test('throws SquareApiException on non-200 response', () async { + final mockHttp = _mockClient(400, {'errors': []}); + final client = _client(mockHttp); + + expect( + () => client.createPayment( + amountMoney: {'amount': 1000, 'currency': 'USD'}, + sourceId: 'CASH', + ), + throwsA(isA()), + ); + client.dispose(); + }); + + test('throws SquareApiException when payment field is missing', () async { + final mockHttp = _mockClient(200, {'no_payment': true}); + final client = _client(mockHttp); + + expect( + () => client.createPayment( + amountMoney: {'amount': 1000, 'currency': 'USD'}, + sourceId: 'CASH', + ), + throwsA(isA()), + ); + client.dispose(); + }); + }); + + group('getPayment', () { + test('returns payment map on success', () async { + final mockHttp = _mockClient(200, { + 'payment': {'id': 'PAY1', 'status': 'COMPLETED'}, + }); + final client = _client(mockHttp); + + final payment = await client.getPayment('PAY1'); + + expect(payment['id'], 'PAY1'); + client.dispose(); + }); + + test('throws SquareApiException on 404', () async { + final mockHttp = _mockClient(404, {'errors': []}); + final client = _client(mockHttp); + + expect(() => client.getPayment('MISSING'), throwsA(isA())); + client.dispose(); + }); + + test('throws SquareApiException when payment field is missing', () async { + final mockHttp = _mockClient(200, {'no_payment': true}); + final client = _client(mockHttp); + + expect(() => client.getPayment('PAY1'), throwsA(isA())); + client.dispose(); + }); + }); + + // ── Auth headers ─────────────────────────────────────────────────────────── + + group('auth headers', () { + test('sends Bearer token in Authorization header', () async { + String? capturedAuth; + final mockHttp = MockClient((request) async { + capturedAuth = request.headers['Authorization']; + return http.Response(jsonEncode({'objects': []}), 200); + }); + final client = SquareApiClient.sandbox( + accessToken: 'my-secret-token', + locationId: 'LOC123', + httpClient: mockHttp, + ); + + await client.listCatalogItems(); + + expect(capturedAuth, 'Bearer my-secret-token'); + client.dispose(); + }); + + test('sends Square-Version header', () async { + String? capturedVersion; + final mockHttp = MockClient((request) async { + capturedVersion = request.headers['Square-Version']; + return http.Response(jsonEncode({'objects': []}), 200); + }); + final client = _client(mockHttp); + + await client.listCatalogItems(); + + expect(capturedVersion, isNotNull); + expect(capturedVersion, isNotEmpty); + client.dispose(); + }); + }); +}