Compare commits

..

3 Commits

Author SHA1 Message Date
Mike Kell 4b1168dbfa chore: update tracker — feat/square-api-client merged to main
Publish Docs / publish-docs (push) Successful in 40s Details
2026-07-19 19:48:44 -04:00
Mike Kell 3ea1d66477 Merge feat/square-api-client into main (Stage 10A complete) 2026-07-19 19:48:13 -04:00
Mike Kell 9b2dd15ee2 feat(integrations): Stage 10A — Square API client and authentication
Validate Docs / validate-docs (push) Successful in 1m14s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details
2026-07-19 19:26:00 -04:00
12 changed files with 1711 additions and 49 deletions

View File

@ -3,8 +3,8 @@
## Current status ## Current status
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete) - main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
- next branch: TBD (Stage 10 — real WooCommerce API integration wiring) - next branch: feat/square-catalog-sync (Stage 10B — Square catalog and inventory sync)
- 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 - 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 ## Slice tracker
@ -553,3 +553,22 @@
- tests: passed (424/424 feature_wordpress — all passing) - tests: passed (424/424 feature_wordpress — all passing)
- analyze: passed (dart analyze --fatal-infos — no issues found) - analyze: passed (dart analyze --fatal-infos — no issues found)
- brief updated: yes - brief updated: yes
### feat/square-api-client
- status: merged 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<T>()` 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

View File

@ -649,24 +649,14 @@ Square integration must follow the same architectural pattern as WooCommerce: AP
- `feat/square-catalog-sync` - `feat/square-catalog-sync`
- `feat/square-payments` - `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<T>()`); 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 `feat/square-api-client` — merged to `main`
- 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
#### Stage 10B — Square catalog and inventory sync #### Stage 10B — Square catalog and inventory sync

View File

@ -229,6 +229,9 @@ class _EnvironmentBadge extends StatelessWidget {
case KcAppEnvironment.square: case KcAppEnvironment.square:
backgroundColor = Colors.blue.shade100; backgroundColor = Colors.blue.shade100;
foregroundColor = Colors.blue.shade900; foregroundColor = Colors.blue.shade900;
case KcAppEnvironment.multi:
backgroundColor = Colors.purple.shade100;
foregroundColor = Colors.purple.shade900;
} }
return Center( return Center(

View File

@ -168,6 +168,9 @@ class _EnvironmentBadge extends StatelessWidget {
case AppEnvironment.square: case AppEnvironment.square:
backgroundColor = Colors.blue.shade100; backgroundColor = Colors.blue.shade100;
foregroundColor = Colors.blue.shade900; foregroundColor = Colors.blue.shade900;
case AppEnvironment.multi:
backgroundColor = Colors.purple.shade100;
foregroundColor = Colors.purple.shade900;
} }
return Center( return Center(

View File

@ -5,12 +5,15 @@
/// ///
/// The app reads the following compile-time constants: /// The app reads the following compile-time constants:
/// ///
/// | Key | Description | Default | /// | Key | Description | Default |
/// |------------------------|----------------------------------------------|----------| /// |--------------------------|----------------------------------------------|------------|
/// | `KC_ENV` | `fake`, `wordpress`, or `square` | `fake` | /// | `KC_ENV` | `fake`, `wordpress`, `square`, or `multi` | `fake` |
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) | /// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | /// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (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: /// Usage:
/// ```sh /// ```sh
@ -20,11 +23,21 @@
/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \ /// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_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 { class KcAppConfig {
/// The environment mode: `fake`, `wordpress`, or `square`. /// The environment mode: `fake`, `wordpress`, `square`, or `multi`.
final KcAppEnvironment environment; 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; final String wcSiteUrl;
/// WooCommerce REST API consumer key. /// WooCommerce REST API consumer key.
@ -33,11 +46,29 @@ class KcAppConfig {
/// WooCommerce REST API consumer secret. /// WooCommerce REST API consumer secret.
final String wcConsumerSecret; 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({ const KcAppConfig({
required this.environment, required this.environment,
required this.wcSiteUrl, required this.wcSiteUrl,
required this.wcConsumerKey, required this.wcConsumerKey,
required this.wcConsumerSecret, required this.wcConsumerSecret,
this.squareAccessToken = '',
this.squareLocationId = '',
this.squareEnvironment = 'sandbox',
}); });
/// Reads configuration from compile-time `--dart-define` constants. /// Reads configuration from compile-time `--dart-define` constants.
@ -46,6 +77,12 @@ class KcAppConfig {
const siteUrl = String.fromEnvironment('KC_WC_SITE_URL'); const siteUrl = String.fromEnvironment('KC_WC_SITE_URL');
const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY'); const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY');
const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET'); 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); final environment = KcAppEnvironment.fromString(envString);
@ -54,6 +91,9 @@ class KcAppConfig {
wcSiteUrl: siteUrl, wcSiteUrl: siteUrl,
wcConsumerKey: consumerKey, wcConsumerKey: consumerKey,
wcConsumerSecret: consumerSecret, wcConsumerSecret: consumerSecret,
squareAccessToken: squareAccessToken,
squareLocationId: squareLocationId,
squareEnvironment: squareEnvironment,
); );
} }
@ -61,6 +101,12 @@ class KcAppConfig {
bool get hasWordPressConfig => bool get hasWordPressConfig =>
wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty; 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). /// A human-readable label for the current environment (e.g. for badges).
String get environmentLabel => environment.label; String get environmentLabel => environment.label;
@ -71,15 +117,27 @@ class KcAppConfig {
environment == other.environment && environment == other.environment &&
wcSiteUrl == other.wcSiteUrl && wcSiteUrl == other.wcSiteUrl &&
wcConsumerKey == other.wcConsumerKey && wcConsumerKey == other.wcConsumerKey &&
wcConsumerSecret == other.wcConsumerSecret; wcConsumerSecret == other.wcConsumerSecret &&
squareAccessToken == other.squareAccessToken &&
squareLocationId == other.squareLocationId &&
squareEnvironment == other.squareEnvironment;
@override @override
int get hashCode => Object.hash(environment, wcSiteUrl, wcConsumerKey, wcConsumerSecret); int get hashCode => Object.hash(
environment,
wcSiteUrl,
wcConsumerKey,
wcConsumerSecret,
squareAccessToken,
squareLocationId,
squareEnvironment,
);
@override @override
String toString() => String toString() =>
'KcAppConfig(environment: ${environment.key}, ' 'KcAppConfig(environment: ${environment.key}, '
'hasWordPressConfig: $hasWordPressConfig)'; 'hasWordPressConfig: $hasWordPressConfig, '
'hasSquareConfig: $hasSquareConfig)';
} }
/// The supported runtime environments. /// The supported runtime environments.
@ -92,9 +150,15 @@ enum KcAppEnvironment {
/// Real Square backend (catalog, inventory, payments). /// Real Square backend (catalog, inventory, payments).
/// ///
/// Planned for Stage 10A. Selecting this mode before the Square integration /// Activated in Stage 10A. Selecting this mode wires the Square API client
/// is implemented falls back to [fake] mode at runtime. /// for catalog, inventory, and payment operations.
square('square', 'SQ'); 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 key;
final String label; final String label;

View File

@ -41,8 +41,9 @@ abstract class KcAppServices {
/// A factory that constructs the appropriate [KcAppServices] subclass /// A factory that constructs the appropriate [KcAppServices] subclass
/// from a [KcAppConfig]. /// from a [KcAppConfig].
/// ///
/// Each app target provides two factories one for fake mode and one for /// Each app target provides factories for each supported environment mode.
/// the real backend via [KcServiceFactory]. /// [createSquare] and [createMulti] are optional if not provided,
/// [KcBootstrap] falls back to [createFake] for those modes.
/// ///
/// ```dart /// ```dart
/// final factory = KcServiceFactory<WebAppServices>( /// final factory = KcServiceFactory<WebAppServices>(
@ -52,6 +53,10 @@ abstract class KcAppServices {
/// consumerKey: config.wcConsumerKey, /// consumerKey: config.wcConsumerKey,
/// consumerSecret: config.wcConsumerSecret, /// consumerSecret: config.wcConsumerSecret,
/// ), /// ),
/// createSquare: (config) => WebAppServices.square(
/// accessToken: config.squareAccessToken,
/// locationId: config.squareLocationId,
/// ),
/// ); /// );
/// ``` /// ```
class KcServiceFactory<T extends KcAppServices> { class KcServiceFactory<T extends KcAppServices> {
@ -63,5 +68,28 @@ class KcServiceFactory<T extends KcAppServices> {
/// Receives the full [KcAppConfig] so the factory can extract credentials. /// Receives the full [KcAppConfig] so the factory can extract credentials.
final T Function(KcAppConfig config) createWordPress; 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,
});
} }

View File

@ -66,22 +66,78 @@ class KcBootstrap {
return (services: factory.createWordPress(config), config: config); return (services: factory.createWordPress(config), config: config);
case KcAppEnvironment.square: case KcAppEnvironment.square:
// Square integration is planned for Stage 10A. Until then, fall back if (!config.hasSquareConfig) {
// to fake mode so the app remains functional when KC_ENV=square is set. if (kDebugMode) {
if (kDebugMode) { // ignore: avoid_print
// ignore: avoid_print debugPrint(
debugPrint( '⚠️ KC_ENV=square but Square credentials are missing.\n'
'⚠️ KC_ENV=square is not yet implemented.\n' ' Falling back to fake mode.\n'
' Falling back to fake mode until Stage 10A is complete.', ' 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( if (factory.createSquare == null) {
environment: KcAppEnvironment.fake, if (kDebugMode) {
wcSiteUrl: config.wcSiteUrl, // ignore: avoid_print
wcConsumerKey: config.wcConsumerKey, debugPrint(
wcConsumerSecret: config.wcConsumerSecret, '⚠️ KC_ENV=square but no createSquare factory provided.\n'
); ' Falling back to fake mode.',
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);
}
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);
} }
} }
} }

View File

@ -77,6 +77,117 @@ void main() {
expect(config.toString(), contains('fake')); expect(config.toString(), contains('fake'));
expect(config.toString(), contains('hasWordPressConfig: false')); 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', () { group('KcAppEnvironment', () {
@ -84,12 +195,14 @@ void main() {
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake); expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress); expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square); expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square);
expect(KcAppEnvironment.fromString('multi'), KcAppEnvironment.multi);
}); });
test('fromString is case-insensitive', () { test('fromString is case-insensitive', () {
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake); expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress); expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square); expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square);
expect(KcAppEnvironment.fromString('MULTI'), KcAppEnvironment.multi);
}); });
test('fromString defaults to fake for unknown values', () { test('fromString defaults to fake for unknown values', () {
@ -101,12 +214,14 @@ void main() {
expect(KcAppEnvironment.fake.label, 'FAKE'); expect(KcAppEnvironment.fake.label, 'FAKE');
expect(KcAppEnvironment.wordpress.label, 'WP'); expect(KcAppEnvironment.wordpress.label, 'WP');
expect(KcAppEnvironment.square.label, 'SQ'); expect(KcAppEnvironment.square.label, 'SQ');
expect(KcAppEnvironment.multi.label, 'MULTI');
}); });
test('key returns expected raw string', () { test('key returns expected raw string', () {
expect(KcAppEnvironment.fake.key, 'fake'); expect(KcAppEnvironment.fake.key, 'fake');
expect(KcAppEnvironment.wordpress.key, 'wordpress'); expect(KcAppEnvironment.wordpress.key, 'wordpress');
expect(KcAppEnvironment.square.key, 'square'); expect(KcAppEnvironment.square.key, 'square');
expect(KcAppEnvironment.multi.key, 'multi');
}); });
}); });
@ -212,12 +327,14 @@ void main() {
expect(result.config.wcSiteUrl, 'https://example.com'); 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( const config = KcAppConfig(
environment: KcAppEnvironment.square, environment: KcAppEnvironment.square,
wcSiteUrl: '', wcSiteUrl: '',
wcConsumerKey: '', wcConsumerKey: '',
wcConsumerSecret: '', wcConsumerSecret: '',
squareAccessToken: '',
squareLocationId: '',
); );
final result = KcBootstrap.run(config, factory); final result = KcBootstrap.run(config, factory);
@ -225,6 +342,103 @@ void main() {
expect(result.services.mode, 'fake'); expect(result.services.mode, 'fake');
expect(result.config.environment, KcAppEnvironment.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', () { group('KcAppScope', () {

View File

@ -11,4 +11,5 @@ export 'src/channel_adapter.dart';
export 'src/integration_health_check.dart'; export 'src/integration_health_check.dart';
export 'src/rate_limiter.dart'; export 'src/rate_limiter.dart';
export 'src/retry_policy.dart'; export 'src/retry_policy.dart';
export 'src/square_api_client.dart';
export 'src/webhook_handler.dart'; export 'src/webhook_handler.dart';

View File

@ -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<void> initialize() async {
// Bearer token auth requires no async setup; mark ready immediately.
_ready = true;
}
@override
bool get isReady => _ready;
@override
Future<T> execute<T>(Future<T> 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<String, String> 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<List<Map<String, dynamic>>> listCatalogItems({
List<String> types = const ['ITEM'],
}) async {
final allObjects = <Map<String, dynamic>>[];
String? cursor;
do {
final queryParams = <String, String>{
'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<String, dynamic>) {
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<Map<String, dynamic>>());
}
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<Map<String, dynamic>> 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<String, dynamic>) {
throw SquareApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
final obj = decoded['object'];
if (obj is! Map<String, dynamic>) {
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<Map<String, dynamic>> upsertCatalogItem(
Map<String, dynamic> 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<String, dynamic>) {
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<String, dynamic>) {
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<List<String>> 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<String, dynamic>) {
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<String>();
}
// 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<Map<String, dynamic>?> 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<String, dynamic>) {
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<String, dynamic>;
}
/// 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<List<Map<String, dynamic>>> batchChangeInventory(
List<Map<String, dynamic>> 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<String, dynamic>) {
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<Map<String, dynamic>>();
}
// 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<Map<String, dynamic>> createOrder(
List<Map<String, dynamic>> lineItems, {
String? referenceId,
Map<String, dynamic>? customer,
}) async {
final uri = Uri.parse('$baseUrl/v2/orders');
final orderBody = <String, dynamic>{
'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<String, dynamic>) {
throw SquareApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
final order = decoded['order'];
if (order is! Map<String, dynamic>) {
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<List<Map<String, dynamic>>> listOrders({
List<String>? states,
int limit = 100,
}) async {
final uri = Uri.parse('$baseUrl/v2/orders/search');
final filter = <String, dynamic>{
'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<String, dynamic>) {
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<Map<String, dynamic>>();
}
/// Fetches a single order by its Square [orderId].
///
/// Returns the raw order map.
Future<Map<String, dynamic>> 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<String, dynamic>) {
throw SquareApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
final order = decoded['order'];
if (order is! Map<String, dynamic>) {
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<Map<String, dynamic>> createPayment({
required Map<String, dynamic> amountMoney,
required String sourceId,
String? orderId,
String? customerId,
String? note,
}) async {
final uri = Uri.parse('$baseUrl/v2/payments');
final body = <String, dynamic>{
'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<String, dynamic>) {
throw SquareApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
final payment = decoded['payment'];
if (payment is! Map<String, dynamic>) {
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<Map<String, dynamic>> 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<String, dynamic>) {
throw SquareApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
final payment = decoded['payment'];
if (payment is! Map<String, dynamic>) {
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';
}

View File

@ -10,6 +10,7 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
http: ^1.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -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<String, dynamic> 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<ApiException>());
});
test('is an Exception', () {
const ex = SquareApiException(statusCode: 400, message: 'Bad request');
expect(ex, isA<Exception>());
});
});
// 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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()),
);
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()));
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<SquareApiException>()),
);
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<SquareApiException>()),
);
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<SquareApiException>()));
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<SquareApiException>()));
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();
});
});
}