Compare commits
No commits in common. "main" and "feat/square-api-client" have entirely different histories.
main
...
feat/squar
|
|
@ -1,6 +0,0 @@
|
|||
# Execution & Workflow Rules
|
||||
|
||||
- Always run tests after making code changes.
|
||||
- Do not modify configuration files (.env, docker-compose.yml, deployment scripts) without explicit user instructions.
|
||||
- Keep terminal commands scoped to the current project directory.
|
||||
- Work in modular, step-by-step implementations rather than massive multi-file overhauls.
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
# Workspace Development Rules: Kell Creations Platform
|
||||
|
||||
## 1. Core Operating Principles
|
||||
|
||||
1. **Vertical Slice Discipline:** Implement features in isolated, reviewable vertical slices on feature branches. Never push unverified, broad changes directly to `main`[cite: 3].
|
||||
2. **Inspection First:** Always inspect the codebase, verify existing tests, and review existing contracts before writing or modifying code[cite: 3].
|
||||
3. **Environment Isolation:** Maintain runtime environment modes (`fake`, `wp`, `square`, `multi`) specified exclusively via `--dart-define` keys[cite: 2, 3]. Never build hardcoded settings UIs or commit API keys/tokens to source control[cite: 3].
|
||||
4. **Strict Package Boundaries:**
|
||||
- Feature packages (`feature_*`) must remain decoupled[cite: 1, 3].
|
||||
- Cross-feature communication must use shared abstractions in `core`, `integrations`, or `data`[cite: 1, 3].
|
||||
- Platform-specific UI code must reside in `apps/kell_web` or `apps/kell_mobile`[cite: 1, 3]. Re-use shared presenters and controllers[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 2. Code & Design Standards
|
||||
|
||||
### Flutter & Dart Execution
|
||||
|
||||
- Strict typing required; avoid `dynamic` unless explicitly serializing raw JSON[cite: 2, 3].
|
||||
- Use Material Design 3 guidelines[cite: 2, 3]. On mobile (`kell_mobile`), ensure all interactive targets adhere to a minimum 48×48dp target bounding box[cite: 2, 3].
|
||||
- Wrap mobile screens in `SafeArea` to avoid system overlay clipping[cite: 2, 3].
|
||||
|
||||
### Architecture Layering (per Package)
|
||||
|
||||
lib/
|
||||
├── src/
|
||||
│ ├── domain/ # Entities, Value Objects, Repository Contracts (Pure Dart)
|
||||
│ ├── application/ # Use Cases, Controllers (ChangeNotifiers)
|
||||
│ ├── data/ # ApiClients, Mappers, Fake & Real Repositories
|
||||
│ └── presentation/ # Widgets, Pages, Dialogs, SnackBars
|
||||
└── feature_name.dart # Barrel File Exports
|
||||
|
||||
---
|
||||
|
||||
## 3. Standard Execution Workflow for AI Agents (Cline / VS Code)
|
||||
|
||||
Whenever initiating a new task or slice, execute the following workflow steps sequentially[cite: 3]:
|
||||
|
||||
1. **Step 1: Context Verification**
|
||||
- Read `docs/development/master_development_brief.md` and `docs/development/build_execution_tracker.md` to identify current status[cite: 2, 3].
|
||||
|
||||
2. **Step 2: Pre-flight Validation**
|
||||
- Run local test suite using the CI script:
|
||||
|
||||
```bash
|
||||
bash tools/run_all_tests.sh --analyze
|
||||
|
||||
```
|
||||
|
||||
- Confirm all tests pass and static analysis is clean prior to editing[cite: 2, 3].
|
||||
|
||||
3. **Step 3: Implementation**
|
||||
- Create target feature branch (e.g., `feat/square-catalog-sync`)[cite: 2].
|
||||
- Implement domain models, repository contracts, API mappers, application logic, and UI components[cite: 2, 3].
|
||||
|
||||
4. **Step 4: Testing & Hardening**
|
||||
- Write unit tests for domain models and application controllers[cite: 2].
|
||||
|
||||
- Write widget tests for presentation components[cite: 2].
|
||||
- Verify static analysis passes without warnings:
|
||||
|
||||
```bash
|
||||
dart analyze --fatal-infos
|
||||
```
|
||||
|
||||
5. **Step 5: Documentation & Tracking**
|
||||
- Update `docs/development/build_execution_tracker.md` with:
|
||||
- Files modified/added[cite: 2].
|
||||
- Test counts and pass state[cite: 2].
|
||||
- Verification/analysis result[cite: 2].
|
||||
- Update `docs/development/master_development_brief.md` next branch indicator[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 4. Prompt Template for AI Sessions
|
||||
|
||||
Continue the Kell Creations Flutter operations platform workstream from current main.
|
||||
|
||||
Read docs/development/master_development_brief.md first and follow it as the authoritative planning document.
|
||||
|
||||
Constraints:
|
||||
|
||||
- Preserve strict package boundaries.
|
||||
- Fake repositories remain in feature packages.
|
||||
- Real integrations are incremental alongside fake ones.
|
||||
- Runtime selection stays via --dart-define, not settings UI.
|
||||
- No credentials hardcoded or committed.
|
||||
- Keep WooCommerce details inside the WP repository layer.
|
||||
- Keep Square details inside a dedicated Square integration layer.
|
||||
- Multi-channel sync contracts before implementations.
|
||||
- Use small, reviewable steps only.
|
||||
|
||||
Working rules:
|
||||
|
||||
1. Inspect first. Do not edit immediately.
|
||||
2. Summarize current relevant state from main.
|
||||
3. Propose the smallest implementation plan for the requested slice.
|
||||
4. Implement only that slice.
|
||||
5. Add/update focused tests.
|
||||
6. Run validation (run_all_tests.sh --analyze).
|
||||
7. Report changed files and validation results.
|
||||
8. Update master_development_brief.md and build_execution_tracker.md.
|
||||
|
|
@ -1,694 +0,0 @@
|
|||
# PROJECT_STATE_AUDIT.md
|
||||
|
||||
## Kell Creations Operations Platform — Comprehensive Code Audit
|
||||
|
||||
**Audit Date:** 2026-07-25
|
||||
**Auditor:** Senior Software Engineering Review Board (Automated)
|
||||
**Baseline Commit:** `feat/square-catalog-sync-repositories` → `main` (Stage 10C complete)
|
||||
**Current Branch Work:** Stage 10D (`SquareCatalogSyncService`) — complete, pending merge
|
||||
**Total Tests (baseline):** 1,015 / 1,015 passing
|
||||
**Static Analysis:** `dart analyze --fatal-infos` — clean across all 11 packages/apps
|
||||
|
||||
---
|
||||
|
||||
## 1. Repository Architecture Overview
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
kell_creations/
|
||||
├── kell_creations_apps/
|
||||
│ ├── apps/
|
||||
│ │ ├── kell_web/ # Flutter Web app (Windows Desktop / Browser)
|
||||
│ │ └── kell_mobile/ # Flutter Android app
|
||||
│ ├── packages/
|
||||
│ │ ├── core/ # Shared composition abstractions
|
||||
│ │ ├── design_system/ # Shared UI components and theme
|
||||
│ │ ├── auth/ # Authentication contracts + fake impl
|
||||
│ │ ├── data/ # Data layer contracts + in-memory impls
|
||||
│ │ ├── integrations/ # API client contracts + Square/WC clients
|
||||
│ │ ├── feature_wordpress/ # Product catalog management (most mature)
|
||||
│ │ ├── feature_inventory/ # Inventory management
|
||||
│ │ ├── feature_orders/ # Order management
|
||||
│ │ ├── feature_policy/ # Policy/governance compliance
|
||||
│ │ ├── feature_finance/ # Financial reporting [STUB]
|
||||
│ │ ├── feature_mrp/ # Manufacturing/MRP [STUB]
|
||||
│ │ └── feature_social/ # Social media management [STUB]
|
||||
│ └── tools/ # CI helper scripts
|
||||
├── docs/ # MkDocs documentation site
|
||||
├── architecture/ # PlantUML C4 diagrams (19 diagrams)
|
||||
└── policies/ # Controlled policy document repository
|
||||
```
|
||||
|
||||
### Architectural Pattern
|
||||
|
||||
- **Domain-Driven Design** with strict package boundaries
|
||||
- **Repository pattern** with fake/real runtime selection via `--dart-define`
|
||||
- **ChangeNotifier controllers** for unidirectional state flow (no Riverpod yet)
|
||||
- **Shared composition pattern** (`KcAppConfig`, `KcBootstrap`, `KcAppScope`) across both app targets
|
||||
- **Vertical slice discipline** — each feature has domain → application → data → presentation layers
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit Matrix — Built vs. Remaining
|
||||
|
||||
### 2.1 Infrastructure Packages
|
||||
|
||||
#### `core` — [COMPLETE]
|
||||
|
||||
| Artifact | Status |
|
||||
| ------------------------------------------------------------ | ---------------- |
|
||||
| `KcAppConfig` / `KcAppEnvironment` (fake, wp, square, multi) | ✅ Complete |
|
||||
| `KcAppServices` abstract base | ✅ Complete |
|
||||
| `KcServiceFactory<T>` | ✅ Complete |
|
||||
| `KcBootstrap` | ✅ Complete |
|
||||
| `KcAppScope<T>` InheritedWidget | ✅ Complete |
|
||||
| Tests | ✅ 24/24 passing |
|
||||
|
||||
**Notes:** `KcBootstrap` only validates WooCommerce credentials. Square/multi mode credential validation is not yet wired into the bootstrap guard logic — `AppServices` and `MobileAppServices` have no `square` or `multi` factory constructors, meaning `KC_ENV=square` silently falls through to fake mode at runtime.
|
||||
|
||||
---
|
||||
|
||||
#### `design_system` — [COMPLETE]
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `KcColors`, `KcSpacing`, `KcTheme` | ✅ Complete |
|
||||
| `KcTypography` (full Material 3 scale) | ✅ Complete |
|
||||
| `KcBreakpoints` (responsive layout utilities) | ✅ Complete |
|
||||
| 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`) | ✅ Complete |
|
||||
| Tests | ✅ 41/41 passing, 100% line coverage |
|
||||
|
||||
**Notes:** No shared button/action component library exists. All action chips and buttons are defined inline per-page. This is a low-priority gap.
|
||||
|
||||
---
|
||||
|
||||
#### `auth` — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| ------------------------------------------------- | ---------------- |
|
||||
| `KcUser` / `KcUserRole` domain models | ✅ Complete |
|
||||
| `AuthState` sealed hierarchy | ✅ Complete |
|
||||
| `AuthService` abstract contract | ✅ Complete |
|
||||
| `AuthException` | ✅ Complete |
|
||||
| `CredentialStore` / `CredentialKeys` contract | ✅ Complete |
|
||||
| `InMemoryCredentialStore` | ✅ Complete |
|
||||
| `FakeAuthService` | ✅ Complete |
|
||||
| `SecureCredentialStore` (flutter_secure_storage) | ❌ Missing |
|
||||
| Real `AuthService` implementation (login/session) | ❌ Missing |
|
||||
| Auth guard / login screen wired into app shell | ❌ Missing |
|
||||
| Tests | ✅ 42/42 passing |
|
||||
|
||||
**Notes:** The auth package provides contracts and fakes only. No production implementation exists. The app is currently unguarded — any user can access all features without authentication. `auth` is not imported by either `AppServices` or `MobileAppServices`.
|
||||
|
||||
---
|
||||
|
||||
#### `data` — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| -------------------------------------------------- | ---------------- |
|
||||
| `LocalCache<T>` contract + `InMemoryCache<T>` | ✅ Complete |
|
||||
| `SyncQueue` contract + `InMemorySyncQueue` | ✅ Complete |
|
||||
| `ConflictResolver<T>` + 3 concrete implementations | ✅ Complete |
|
||||
| `ChangeTracker<T>` + `InMemoryChangeTracker<T>` | ✅ Complete |
|
||||
| `DataMapper<TLocal, TRemote>` contract | ✅ Complete |
|
||||
| SQLite-backed `LocalCache<T>` implementation | ❌ Missing |
|
||||
| SQLite-backed `SyncQueue` implementation | ❌ Missing |
|
||||
| Integration with any feature package | ❌ Missing |
|
||||
| Tests | ✅ 63/63 passing |
|
||||
|
||||
**Notes:** All implementations are in-memory only. The `data` package is not imported by any feature package or app. It is a well-designed contract layer with zero production wiring. SQLite persistence (required for offline capability, Stage 14) is not implemented.
|
||||
|
||||
---
|
||||
|
||||
#### `integrations` — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `ApiClient` / `ApiException` contracts | ✅ Complete |
|
||||
| `RetryPolicy` + `NoRetryPolicy` + `ExponentialBackoffRetryPolicy` | ✅ Complete |
|
||||
| `RateLimiter` + `NoRateLimiter` + `TokenBucketRateLimiter` | ✅ Complete |
|
||||
| `IntegrationHealthCheck` contract | ✅ Complete |
|
||||
| `WebhookHandler` contract | ✅ Complete |
|
||||
| `ChannelAdapter` contract | ✅ Complete |
|
||||
| `SquareApiClient` (full lifecycle, Catalog/Inventory/Orders/Payments APIs) | ✅ Complete |
|
||||
| `SquareApiException` | ✅ Complete |
|
||||
| `SquareProductMapper` (Square CatalogObject ↔ `ChannelProduct`) | ✅ Complete |
|
||||
| `SquareInventoryMapper` (Square InventoryCount ↔ `ChannelInventoryCount`) | ✅ Complete |
|
||||
| `WooCommerceApiClient` (refactored to implement `ApiClient`) | ✅ Complete (lives in `feature_wordpress`) |
|
||||
| `RetryPolicy` / `RateLimiter` wired into any real client | ❌ Missing |
|
||||
| `IntegrationHealthCheck` concrete implementation | ❌ Missing |
|
||||
| `WebhookHandler` concrete implementation | ❌ Missing |
|
||||
| `ChannelAdapter` concrete implementation for WooCommerce or Square | ❌ Missing |
|
||||
| Tests | ✅ 123/123 passing |
|
||||
|
||||
**Notes:** `RetryPolicy` and `RateLimiter` are defined but not wired into `SquareApiClient` or `WooCommerceApiClient`. All API calls are single-attempt with no retry or rate limiting in production paths. `ChannelAdapter` is a contract with no concrete implementation — the WooCommerce and Square clients do not implement it.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Feature Packages
|
||||
|
||||
#### `feature_wordpress` — [COMPLETE] (most mature)
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| `ProductDraft` domain model (full fields, `copyWith`) | ✅ Complete |
|
||||
| `PublishStatus` enum (draft, published, pendingReview, unpublished) | ✅ Complete |
|
||||
| `ProductCategory` domain model | ✅ Complete |
|
||||
| `ProductImage` domain model | ✅ Complete |
|
||||
| `ProductIdMapping` domain model | ✅ Complete |
|
||||
| `ProductPublishingRepository` contract (full CRUD + categories + images) | ✅ Complete |
|
||||
| `ProductIdMappingRepository` contract | ✅ Complete |
|
||||
| `FakeProductPublishingRepository` (seeded, all methods) | ✅ Complete |
|
||||
| `FakeProductIdMappingRepository` (seeded, 6 mappings) | ✅ Complete |
|
||||
| `WordPressProductPublishingRepository` (WooCommerce REST API v3) | ✅ Complete |
|
||||
| `SquareCatalogRepository` (Square API, in-memory cache) | ✅ Complete |
|
||||
| `WooCommerceApiClient` (Products, Orders, Stock, Categories, Images) | ✅ Complete |
|
||||
| `WordPressProductMapper` | ✅ Complete |
|
||||
| All use cases (GetProductDrafts, PublishProduct, UpdateProduct\*, CreateProduct, DeleteProduct, GetCategories, CreateCategory, UpdateProductImages) | ✅ Complete |
|
||||
| `ProductPublishingController` (full state: multi-select, bulk actions, create, categories) | ✅ Complete |
|
||||
| `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter` | ✅ Complete (Stage 10D, pending merge) |
|
||||
| `ProductPublishingPage` (web, full feature surface) | ✅ Complete |
|
||||
| `CategoryManagementPage` | ✅ Complete |
|
||||
| `ProductPreviewPanel` (all inline edits, touch targets) | ✅ Complete |
|
||||
| `ProductDraftCard` (compact/standard, multi-select, stale indicator) | ✅ Complete |
|
||||
| `CreateProductDialog` (web) | ✅ Complete |
|
||||
| `SquareSyncController` (Riverpod/ChangeNotifier wrapping `SquareCatalogSyncService`) | ❌ Missing (Stage 10E — next branch) |
|
||||
| Image picker / gallery management UI | ❌ Missing (deferred post-Stage 10) |
|
||||
| Tests | ✅ 506/506 passing (Stage 10D) |
|
||||
|
||||
**Notes:** The `SquareCatalogRepository` throws `UnsupportedError` for all category and image operations — this is intentional and documented, but callers must handle these exceptions. The `SquareSyncController` (Stage 10E) is the immediate next deliverable.
|
||||
|
||||
---
|
||||
|
||||
#### `feature_inventory` — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| -------------------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| `InventoryItem` domain model (full fields including `squareVariationId`, `wooCommerceProductId`, `syncStatus`) | ✅ Complete |
|
||||
| `InventoryStatus` enum | ✅ Complete |
|
||||
| `InventorySyncStatus` enum | ✅ Complete |
|
||||
| `InventoryAdjustmentResult` value object | ✅ Complete |
|
||||
| `InventoryRepository` contract (read + write + sync) | ✅ Complete |
|
||||
| `FakeInventoryRepository` (seeded, all write methods) | ✅ Complete |
|
||||
| `WooCommerceInventoryRepository` | ✅ Complete |
|
||||
| `SquareInventoryRepository` | ✅ Complete |
|
||||
| `InventorySyncService` (WooCommerce pull/push sync) | ✅ Complete |
|
||||
| All use cases (GetInventoryItems, AdjustInventoryQuantity, CreateInventoryItem, UpdateInventoryItem) | ✅ Complete |
|
||||
| `InventoryController` (read + write actions) | ✅ Complete |
|
||||
| `InventoryPage` (web, full filter/search/detail panel) | ✅ Complete |
|
||||
| `InventoryDetailPanel`, `AdjustQuantityDialog`, `InventoryStatusChip` | ✅ Complete |
|
||||
| `ItemType` enum (rawMaterial, finishedGood, packaging, tool) | ❌ Missing (Stage 12A) |
|
||||
| `InventoryAdjustment` audit log value object | ❌ Missing (Stage 12A) |
|
||||
| `Supplier` domain model | ❌ Missing (Stage 12B) |
|
||||
| `PurchaseOrder` domain model | ❌ Missing (Stage 12B) |
|
||||
| Supply receiving / material usage workflows | ❌ Missing (Stage 12B) |
|
||||
| Multi-channel inventory sync engine (`InventorySyncEngine`) | ❌ Missing (Stage 12C) |
|
||||
| `ChannelStockMapping` | ❌ Missing (Stage 12C) |
|
||||
| Sync status indicators in inventory UI | ❌ Missing |
|
||||
| Tests | ✅ 167/167 passing |
|
||||
|
||||
**Notes:** The inventory feature has a solid domain and data layer. The `InventorySyncService` is WooCommerce-only. Square inventory sync is handled separately via `SquareInventoryRepository` but there is no unified sync engine that coordinates both channels. The `InventoryController` is not wired to `InventorySyncService` — sync must be triggered manually.
|
||||
|
||||
---
|
||||
|
||||
#### `feature_orders` — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| `Order` domain model (full fields: customerPhone, notes, orderNotes, paymentMethod, source, customFields) | ✅ Complete |
|
||||
| `OrderItem` domain model (productId, variationId, customizations) | ✅ Complete |
|
||||
| `OrderStatus` enum (including onHold, refunded, failed) | ✅ Complete |
|
||||
| `OrderSource` enum (online, market, manual) | ✅ Complete |
|
||||
| `OrderActionResult` value object | ✅ Complete |
|
||||
| `OrdersRepository` contract (getOrders, getOrder, createOrder, updateOrderStatus, addOrderNote) | ✅ Complete |
|
||||
| `FakeOrdersRepository` (seeded, all write methods) | ✅ Complete |
|
||||
| `WooCommerceOrdersRepository` | ✅ Complete |
|
||||
| `WooCommerceOrderMapper` | ✅ Complete |
|
||||
| All use cases (GetOrders, UpdateOrderStatus, AddOrderNote, CreateOrder) | ✅ Complete |
|
||||
| `OrdersController` (read + write, filter/search persistence) | ✅ Complete |
|
||||
| `OrdersPage` (web, master-detail, status filter, cross-feature callbacks) | ✅ Complete |
|
||||
| `OrderDetailPanel`, `OrderCard`, `AddNoteDialog`, `OrderStatusChip` | ✅ Complete |
|
||||
| `OrderType` enum (standard, custom) | ❌ Missing (Stage 11A) |
|
||||
| `CustomOrderSpec` value object | ❌ Missing (Stage 11A) |
|
||||
| `DepositInfo` value object | ❌ Missing (Stage 11A) |
|
||||
| Custom order fulfillment status pipeline | ❌ Missing (Stage 11C) |
|
||||
| `CustomOrderFormPage` | ❌ Missing (Stage 11B) |
|
||||
| `CustomOrderTrackingPage` | ❌ Missing (Stage 11C) |
|
||||
| `OrderChannel` enum (woocommerce, square, manual) | ❌ Missing (Stage 13B) |
|
||||
| `UnifiedOrdersController` (multi-channel aggregation) | ❌ Missing (Stage 13B) |
|
||||
| Standard scaffolding files (`.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md`) | ❌ Missing |
|
||||
| Tests | ✅ 198/198 passing |
|
||||
|
||||
**Notes:** The `feature_orders` package is missing standard monorepo scaffolding files present in all other packages. The WooCommerce orders integration is production-ready. Square orders are not yet integrated into the orders feature (Square API client has `createOrder`/`listOrders`/`getOrder` but no `SquareOrdersRepository` exists).
|
||||
|
||||
---
|
||||
|
||||
#### `feature_policy` — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `PolicyCheckResult` domain model | ✅ Complete |
|
||||
| `GovernanceStatus` enum | ✅ Complete |
|
||||
| `PolicyRepository` contract | ✅ Complete |
|
||||
| `FakePolicyRepository` | ✅ Complete |
|
||||
| `GetPolicyChecks` use case | ✅ Complete |
|
||||
| `PolicyController` | ✅ Complete |
|
||||
| `PolicyPage` (master-detail, filter, search, cross-feature callbacks) | ✅ Complete |
|
||||
| `PolicyCheckCard`, `PolicyDetailPanel` | ✅ Complete |
|
||||
| Real backend (e.g., reading from policy register CSVs) | ❌ Missing |
|
||||
| Tests | ⚠️ Minimal (only smoke test in `feature_policy_test.dart`) |
|
||||
|
||||
**Notes:** The policy feature is fake-data only. The policy register CSVs in `policies/register/` contain headers only with no data rows — the controlled document lifecycle is not being followed for the governance documents themselves.
|
||||
|
||||
---
|
||||
|
||||
#### `feature_finance` — [MISSING/PENDING]
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| Package scaffolding | ✅ Exists |
|
||||
| Any domain model | ❌ Missing |
|
||||
| Any application logic | ❌ Missing |
|
||||
| Any data layer | ❌ Missing |
|
||||
| Any presentation | ❌ Missing |
|
||||
| Tests | ❌ Placeholder `Calculator` test only |
|
||||
|
||||
**Notes:** The barrel file (`feature_finance.dart`) contains only the Flutter template `Calculator` class. The Finance route in `kell_web` renders `FinancePlaceholderPage`. This is a pure stub.
|
||||
|
||||
---
|
||||
|
||||
#### `feature_mrp` — [MISSING/PENDING]
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| Package scaffolding | ✅ Exists |
|
||||
| Any domain model | ❌ Missing |
|
||||
| Any application logic | ❌ Missing |
|
||||
| Any data layer | ❌ Missing |
|
||||
| Any presentation | ❌ Missing |
|
||||
| Tests | ❌ Placeholder `Calculator` test only |
|
||||
|
||||
**Notes:** Pure stub. Scope should be re-evaluated after Stage 12 (supplies tracking) to determine if MRP adds distinct value beyond custom orders + inventory.
|
||||
|
||||
---
|
||||
|
||||
#### `feature_social` — [MISSING/PENDING]
|
||||
|
||||
| Artifact | Status |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| Package scaffolding | ✅ Exists |
|
||||
| Any domain model | ❌ Missing |
|
||||
| Any application logic | ❌ Missing |
|
||||
| Any data layer | ❌ Missing |
|
||||
| Any presentation | ❌ Missing |
|
||||
| Tests | ❌ Placeholder `Calculator` test only |
|
||||
|
||||
**Notes:** Pure stub. Deferred until core platform stabilizes. Facebook, Instagram, and X (Twitter) API integrations are all listed as "Stub only" in the integration landscape.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Application Targets
|
||||
|
||||
#### `kell_web` — [COMPLETE] (for current feature scope)
|
||||
|
||||
| Artifact | Status |
|
||||
| ----------------------------------------------------------------------------------- | ------------------- |
|
||||
| `AppServices` (fake + wordpress factories) | ✅ Complete |
|
||||
| `KcBootstrap` / `KcAppScope` wiring | ✅ Complete |
|
||||
| `AppShell` with sidebar navigation (7 routes) | ✅ Complete |
|
||||
| `AppRoutes` (dashboard, inventory, products, orders, finance, policy, integrations) | ✅ Complete |
|
||||
| `DashboardPage` (8 KPI cards, quick actions, cross-feature navigation) | ✅ Complete |
|
||||
| `ProductPublishingPage` (full feature surface) | ✅ Complete |
|
||||
| `InventoryPage` (full feature surface) | ✅ Complete |
|
||||
| `OrdersPage` (full feature surface) | ✅ Complete |
|
||||
| `PolicyPage` (full feature surface) | ✅ Complete |
|
||||
| `FinancePlaceholderPage` | ⚠️ Placeholder only |
|
||||
| `IntegrationsPlaceholderPage` | ⚠️ Placeholder only |
|
||||
| `square` / `multi` factory constructors in `AppServices` | ❌ Missing |
|
||||
| Tests | ✅ 24/24 passing |
|
||||
|
||||
**Notes:** `AppServices` has no `square()` or `multi()` factory. Setting `KC_ENV=square` or `KC_ENV=multi` at runtime will fall through to `fake` mode silently because `KcServiceFactory` only has `createFake` and `createWordPress` callbacks. This is a critical gap for Square integration testing.
|
||||
|
||||
---
|
||||
|
||||
#### `kell_mobile` — [COMPLETE] (for current feature scope)
|
||||
|
||||
| Artifact | Status |
|
||||
| ----------------------------------------------------------------------------------- | ---------------------- |
|
||||
| `MobileAppServices` (fake + wordpress factories) | ✅ Complete |
|
||||
| `KcBootstrap` / `KcAppScope` wiring | ✅ Complete |
|
||||
| `MobileShell` (5-tab NavigationBar: Dashboard, Inventory, Orders, Publishing, More) | ✅ Complete |
|
||||
| `DashboardPage` (mobile GridView layout) | ✅ Complete |
|
||||
| `MobilePublishingPage` + `MobileProductDetailPage` | ✅ Complete |
|
||||
| `MobileCreateProductPage` | ✅ Complete |
|
||||
| `MobileInventoryPage` + `MobileInventoryDetailPage` | ✅ Complete |
|
||||
| `MobileOrdersPage` + `MobileOrderDetailPage` | ✅ Complete |
|
||||
| Haptic feedback, confirmation dialogs, SafeArea, 48dp touch targets | ✅ Complete |
|
||||
| `FinancePlaceholderPage` | ⚠️ Placeholder only |
|
||||
| `IntegrationsPlaceholderPage` | ⚠️ Placeholder only |
|
||||
| `square` / `multi` factory constructors in `MobileAppServices` | ❌ Missing |
|
||||
| Square POS / checkout flow | ❌ Missing (Stage 10C) |
|
||||
| `MobileCheckoutPage` | ❌ Missing (Stage 10C) |
|
||||
| Tests | ✅ 26/26 passing |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Architecture & Documentation
|
||||
|
||||
#### Architecture Documentation — [COMPLETE]
|
||||
|
||||
| Artifact | Status |
|
||||
| ------------------------------------------------------------------------------ | ------------------------------- |
|
||||
| 19 PlantUML C4 diagrams (context, containers, components, deployment, dynamic) | ✅ Complete |
|
||||
| MkDocs documentation site (`mkdocs.yml`) | ✅ Complete |
|
||||
| ADR index (7 retroactive entries) | ✅ Complete |
|
||||
| Traceability index | ✅ Complete |
|
||||
| Square not represented in architecture diagrams | ❌ Missing (noted in brief §17) |
|
||||
| Multi-channel data model architecture | ❌ Missing (noted in brief §18) |
|
||||
|
||||
#### Policy Repository — [PARTIAL]
|
||||
|
||||
| Artifact | Status |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Policy templates (policy, procedure, standard, form, exception) | ✅ Complete |
|
||||
| Active governance documents (KC-POL-GOV-001, KC-PRO-GOV-001) | ✅ Complete |
|
||||
| Policy register CSVs (policy-register, document-control-log, review-calendar) | ❌ Empty (headers only, no data rows) |
|
||||
| Exception register | ❌ Empty |
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Debt & Code Hygiene Assessment
|
||||
|
||||
### 3.1 Dead Code / Placeholder Stubs
|
||||
|
||||
| Location | Issue | Severity |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------------- | -------- |
|
||||
| `feature_finance/lib/feature_finance.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
|
||||
| `feature_mrp/lib/feature_mrp.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
|
||||
| `feature_social/lib/feature_social.dart` | Flutter template `Calculator` class — entire package is a stub | HIGH |
|
||||
| `kell_web/lib/pages/finance_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
|
||||
| `kell_web/lib/pages/integrations_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
|
||||
| `kell_mobile/lib/pages/finance_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
|
||||
| `kell_mobile/lib/pages/integrations_placeholder_page.dart` | Placeholder page with no content | MEDIUM |
|
||||
| `kell_web/lib/pages/dashboard_page.dart` (Recent Activity section) | `KcEmptyState` hardcoded — no real activity feed | LOW |
|
||||
|
||||
### 3.2 Missing Error Handling / Defensive Gaps
|
||||
|
||||
| Location | Issue | Severity |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------- | -------- |
|
||||
| `AppServices` / `MobileAppServices` | No `square()` or `multi()` factory — `KC_ENV=square` silently falls to fake | CRITICAL |
|
||||
| `KcBootstrap` | Only validates WooCommerce credentials; Square credential validation absent | HIGH |
|
||||
| `SquareCatalogRepository` | 6 methods throw `UnsupportedError` (categories, images) — callers must handle | HIGH |
|
||||
| `integrations` — `RetryPolicy` / `RateLimiter` | Defined but not wired into `SquareApiClient` or `WooCommerceApiClient` | HIGH |
|
||||
| `auth` package | Not imported or used by any app or feature package | HIGH |
|
||||
| `data` package | Not imported or used by any app or feature package | MEDIUM |
|
||||
| `InventoryController` | Not wired to `InventorySyncService` — sync cannot be triggered from UI | MEDIUM |
|
||||
| `SquareCatalogSyncService` | No Riverpod/ChangeNotifier controller wrapper — cannot be triggered from UI | MEDIUM |
|
||||
| `WooCommerceApiClient` | No retry logic on transient failures (5xx, network timeouts) | MEDIUM |
|
||||
| `feature_policy` tests | Only a smoke test exists — no domain, controller, or widget tests | MEDIUM |
|
||||
|
||||
### 3.3 Hardcoded Values / Antipatterns
|
||||
|
||||
| Location | Issue | Severity |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `SquareCatalogSyncService.syncCatalog()` | Uses `product.id` as both `localId` and `squareCatalogObjectId` — conflates Square catalog IDs with local product IDs | HIGH |
|
||||
| `InventorySyncService._syncItem()` | `pushLocalStock` silently swallows push failures with `catch (_) {}` — no logging | MEDIUM |
|
||||
| `kell_web/lib/pages/dashboard_page.dart` | Revenue card navigates to Finance route which is a placeholder | LOW |
|
||||
| `policies/register/*.csv` | All register CSVs contain headers only — no data rows | MEDIUM |
|
||||
| `feature_orders` package | Missing `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` | LOW |
|
||||
|
||||
### 3.4 Test Coverage Assessment
|
||||
|
||||
| Package | Tests | Coverage | Assessment |
|
||||
| ------------------- | --------------- | -------- | ----------------------------------------------------- |
|
||||
| `core` | 24 | ~85.7% | Good — composition abstractions well covered |
|
||||
| `design_system` | 41 | 100% | Excellent |
|
||||
| `feature_wordpress` | 506 | ~84.7% | Good — broad coverage across all layers |
|
||||
| `feature_inventory` | 167 | Unknown | Adequate — domain, data, and widget tests present |
|
||||
| `feature_orders` | 198 | Unknown | Good — domain, data, mapper, and widget tests present |
|
||||
| `feature_policy` | ~5 | Very low | **Gap** — only smoke test; no controller/widget tests |
|
||||
| `auth` | 42 | Unknown | Good for contracts and fakes |
|
||||
| `data` | 63 | Unknown | Good for contracts and in-memory impls |
|
||||
| `integrations` | 123 | Unknown | Good — API client and mapper tests present |
|
||||
| `kell_web` | 24 | ~54.1% | **Gap** — low coverage; routing and shell untested |
|
||||
| `kell_mobile` | 26 | Unknown | Adequate — shell and page smoke tests |
|
||||
| `feature_finance` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
|
||||
| `feature_mrp` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
|
||||
| `feature_social` | 1 (placeholder) | ~0% | **Critical gap** — stub only |
|
||||
|
||||
**Key gaps:**
|
||||
|
||||
- `kell_web` at 54.1% line coverage — routing, shell, and navigation are largely untested
|
||||
- `feature_policy` has no meaningful tests beyond a barrel smoke test
|
||||
- No integration tests exist across any package boundary
|
||||
- No end-to-end tests exist for any user workflow
|
||||
- CI enforces no minimum coverage thresholds (visibility-only)
|
||||
|
||||
### 3.5 Architectural Antipatterns
|
||||
|
||||
| Issue | Description | Severity |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| **No state management framework** | All state uses `ChangeNotifier` + `AnimatedBuilder`. As complexity grows (sync engine, multi-channel, offline queue), this pattern will become difficult to compose. No Riverpod, BLoC, or equivalent. | MEDIUM |
|
||||
| **No dependency injection** | Services are constructed directly in factory methods. No DI container. Makes testing harder as the app grows. | MEDIUM |
|
||||
| **`SquareCatalogSyncService` ID conflation** | `syncCatalog()` uses `product.id` as the Square catalog object ID, but `product.id` is the local/WooCommerce ID. This will produce incorrect mappings when Square is the source. | HIGH |
|
||||
| **`AppServices` / `MobileAppServices` duplication** | Both files are nearly identical. A shared base class or factory could eliminate the duplication. | LOW |
|
||||
| **No logging infrastructure** | No structured logging anywhere in the codebase. Errors are caught and surfaced via SnackBars or swallowed silently. | MEDIUM |
|
||||
| **No crash reporting** | No Sentry, Firebase Crashlytics, or equivalent. Runtime errors are invisible in production. | MEDIUM |
|
||||
|
||||
---
|
||||
|
||||
## 4. Actionable Engineering Backlog
|
||||
|
||||
### Phase 1 — High-Priority / Blocking (Core Fixes & Missing Execution Paths)
|
||||
|
||||
These items block correct runtime behavior or represent data integrity risks.
|
||||
|
||||
#### P1.1 — Wire `square` and `multi` factory constructors into `AppServices` and `MobileAppServices`
|
||||
|
||||
**Why blocking:** Setting `KC_ENV=square` currently silently falls to fake mode. Square integration cannot be tested end-to-end without this.
|
||||
**Scope:** Add `AppServices.square()` and `AppServices.multi()` factories using `SquareCatalogRepository` and `SquareInventoryRepository`. Mirror in `MobileAppServices`. Update `KcServiceFactory` to accept `createSquare` and `createMulti` callbacks. Update `KcBootstrap` to validate Square credentials.
|
||||
**Branch:** `feat/square-catalog-sync-service-controller` (Stage 10E) should include this.
|
||||
|
||||
#### P1.2 — Fix `SquareCatalogSyncService.syncCatalog()` ID conflation
|
||||
|
||||
**Why blocking:** The current implementation uses `product.id` (a local/WooCommerce ID) as the `squareCatalogObjectId`. When `SquareCatalogRepository` is the source, `product.id` is the Square catalog object ID — but when `FakeProductPublishingRepository` or `WordPressProductPublishingRepository` is the source, `product.id` is a WooCommerce ID. The mapping will be incorrect in mixed-source scenarios.
|
||||
**Scope:** The sync service needs to know which repository type it is talking to, or the `ProductDraft` model needs a `squareCatalogObjectId` field, or the sync service should accept the Square catalog object ID separately.
|
||||
|
||||
#### P1.3 — Merge Stage 10D (`feat/square-catalog-sync-service`)
|
||||
|
||||
**Why blocking:** The build tracker shows Stage 10D as "ready for merge" but not yet merged. The master brief baseline is Stage 10C. Stage 10E depends on 10D being on `main`.
|
||||
**Action:** Merge `feat/square-catalog-sync-service` → `main` before starting Stage 10E.
|
||||
|
||||
#### P1.4 — `SquareSyncController` (Stage 10E)
|
||||
|
||||
**Why blocking:** `SquareCatalogSyncService` exists but has no UI trigger. Without a controller, sync cannot be initiated from the app.
|
||||
**Scope:** `ChangeNotifier` wrapping `SquareCatalogSyncService`, exposing `syncAll()`, `syncCatalog()`, `syncInventory()`, `isSyncing`, `lastSyncedAt`, `lastResult`. Wire into `AppServices` / `MobileAppServices`.
|
||||
**Branch:** `feat/square-catalog-sync-service-controller`
|
||||
|
||||
#### P1.5 — Add `square` / `multi` mode to `KcBootstrap` credential validation
|
||||
|
||||
**Why blocking:** `KcBootstrap` currently only validates WooCommerce credentials. Square mode will silently proceed with empty tokens.
|
||||
**Scope:** Add `hasSquareConfig` check in bootstrap for `square` and `multi` environments.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Feature Completion & Integration
|
||||
|
||||
These items complete planned stages and wire existing contracts into production paths.
|
||||
|
||||
#### P2.1 — Square Mobile POS / Checkout (Stage 10C)
|
||||
|
||||
**Scope:** `PaymentService` abstraction, `PaymentResult` domain model, `MobileCheckoutPage` (product selection → cart → payment), Square Reader SDK integration for Android, cash recording, digital receipt, post-payment order creation and inventory deduction, `Sales` tab in mobile navigation.
|
||||
**Dependencies:** Stage 10A ✅, Stage 10B ✅, Stage 10D ✅, P1.1, P1.4
|
||||
|
||||
#### P2.2 — Custom Orders Workflow (Stage 11)
|
||||
|
||||
**Scope:** `OrderType` enum, `CustomOrderSpec` value object, `DepositInfo` value object, extended `Order` model, `createCustomOrder()` / `updateCustomOrderSpec()` / `recordDeposit()` repository methods, `CustomOrderFormPage`, `CustomOrderConfirmationPage`, `CustomOrderTrackingPage`, fulfillment status pipeline.
|
||||
**Dependencies:** Stage 9A ✅
|
||||
|
||||
#### P2.3 — Inventory Write Operations & Supplies Tracking (Stage 12A/12B)
|
||||
|
||||
**Scope:** `ItemType` enum, `InventoryAdjustment` audit log, `Supplier` domain model, `PurchaseOrder` domain model, supply receiving workflow, material usage workflow, low-stock alert system, `SuppliesPage` UI.
|
||||
**Dependencies:** Stage 9B ✅
|
||||
|
||||
#### P2.4 — Multi-Channel Inventory Sync Engine (Stage 12C)
|
||||
|
||||
**Scope:** `InventorySyncEngine` (poll/webhook-triggered, conflict resolution, batch sync), `ChannelStockMapping`, `WooCommerceStockSync`, `SquareStockSync`, sync status indicators in inventory UI, manual sync trigger.
|
||||
**Dependencies:** P2.3, Stage 10B ✅
|
||||
|
||||
#### P2.5 — WooCommerce Orders Integration wired into `AppServices`
|
||||
|
||||
**Current state:** `WooCommerceOrdersRepository` exists but `AppServices.wordpress()` still uses `FakeOrdersRepository`.
|
||||
**Scope:** Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` and `MobileAppServices.wordpress()`.
|
||||
|
||||
#### P2.6 — WooCommerce Inventory Integration wired into `AppServices`
|
||||
|
||||
**Current state:** `WooCommerceInventoryRepository` exists but `AppServices.wordpress()` still uses `FakeInventoryRepository`.
|
||||
**Scope:** Wire `WooCommerceInventoryRepository` into `AppServices.wordpress()` and `MobileAppServices.wordpress()`.
|
||||
|
||||
#### P2.7 — Unified Order View (Stage 13B)
|
||||
|
||||
**Scope:** `OrderChannel` enum, `UnifiedOrdersController` aggregating WooCommerce + Square + manual orders, unified orders page with channel indicator.
|
||||
**Dependencies:** P2.1, Stage 9A ✅
|
||||
|
||||
#### P2.8 — Offline Data Cache & Sales Queue (Stage 14)
|
||||
|
||||
**Scope:** SQLite-backed `LocalCache<T>` implementation (using `sqflite` or `drift`), SQLite-backed `SyncQueue`, offline detection, "Prepare for Market" pre-sync action, auto-sync on reconnect, conflict handling.
|
||||
**Dependencies:** `data` package contracts ✅
|
||||
|
||||
#### P2.9 — `feature_finance` Activation (Stage 15)
|
||||
|
||||
**Scope:** Sales reporting (by channel, product, time period), inventory valuation, financial dashboard (revenue vs. COGS, gross margin, market event profitability).
|
||||
**Dependencies:** P2.1, Stage 9A ✅, P2.3
|
||||
|
||||
#### P2.10 — Populate Policy Register CSVs
|
||||
|
||||
**Scope:** Add data rows to `policies/register/policy-register.csv`, `document-control-log.csv`, `review-calendar.csv`, and `exception-register.csv` for the 2 active governance documents.
|
||||
**Dependencies:** None — can be done immediately.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Defensive Engineering, Logging & Test Harness Expansion
|
||||
|
||||
These items harden the platform for production deployment.
|
||||
|
||||
#### P3.1 — Wire `RetryPolicy` and `RateLimiter` into API clients
|
||||
|
||||
**Scope:** Apply `ExponentialBackoffRetryPolicy` to `SquareApiClient` and `WooCommerceApiClient` for 5xx and network errors. Apply `TokenBucketRateLimiter` to Square API calls (Square enforces rate limits).
|
||||
|
||||
#### P3.2 — Implement `SecureCredentialStore` (Stage 8C remainder)
|
||||
|
||||
**Scope:** `flutter_secure_storage`-backed `CredentialStore` implementation. First-run setup wizard for entering API credentials. Connection testing UI.
|
||||
|
||||
#### P3.3 — Auth guard wired into app shell
|
||||
|
||||
**Scope:** Login screen, `AuthService` wired into `AppServices`, route guard preventing unauthenticated access.
|
||||
**Dependencies:** P3.2
|
||||
|
||||
#### P3.4 — Structured logging infrastructure
|
||||
|
||||
**Scope:** Add a logging abstraction (e.g., `package:logging`) with structured output. Log all API calls, sync operations, errors, and state transitions. Replace silent `catch (_) {}` blocks with logged failures.
|
||||
|
||||
#### P3.5 — Crash reporting integration (Stage 16A)
|
||||
|
||||
**Scope:** Sentry or Firebase Crashlytics integration. Health check dashboard showing integration status per channel.
|
||||
|
||||
#### P3.6 — Expand `feature_policy` test coverage
|
||||
|
||||
**Scope:** Add controller tests, widget tests for `PolicyPage`, `PolicyCheckCard`, `PolicyDetailPanel`. Target >80% line coverage.
|
||||
|
||||
#### P3.7 — Expand `kell_web` test coverage from 54.1%
|
||||
|
||||
**Scope:** Add routing tests, shell navigation tests, `AppServices` factory tests. Target >75% line coverage.
|
||||
|
||||
#### P3.8 — Add `feature_orders` standard scaffolding files
|
||||
|
||||
**Scope:** Add `.gitignore`, `.metadata`, `CHANGELOG.md`, `LICENSE`, `README.md` to `feature_orders` to match all other packages.
|
||||
|
||||
#### P3.9 — Update architecture diagrams for Square and multi-channel
|
||||
|
||||
**Scope:** Add Square to `system-landscape.puml`, `enterprise-integration-orchestration-architecture.puml`, `context-kellcreations-platform.puml`. Create `dynamic-market-sale-checkout.puml`. Create `enterprise-multi-channel-architecture.puml`.
|
||||
|
||||
#### P3.10 — Implement `ChannelAdapter` for WooCommerce and Square
|
||||
|
||||
**Scope:** Concrete `WooCommerceChannelAdapter` and `SquareChannelAdapter` implementing the `ChannelAdapter` contract from `integrations`. This enables the multi-channel sync engine (Stage 13A) to operate against a channel-agnostic interface.
|
||||
|
||||
#### P3.11 — Enforce minimum test coverage thresholds in CI
|
||||
|
||||
**Scope:** Update `flutter-test.yml` to fail the build if any package drops below a defined threshold (e.g., 75% line coverage). Currently coverage is visibility-only.
|
||||
|
||||
#### P3.12 — Evaluate and activate `feature_mrp`
|
||||
|
||||
**Scope:** After Stage 12 completion, assess whether MRP adds distinct value (production scheduling, work orders, bill of materials) beyond custom orders + inventory. Activate or formally retire the package.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary Scorecard
|
||||
|
||||
| Subsystem | Status | Completeness | Blocking Issues |
|
||||
| ------------------- | ----------- | ------------ | ----------------------------------------------------------------- |
|
||||
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
|
||||
| `design_system` | ✅ COMPLETE | 100% | None |
|
||||
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
|
||||
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
|
||||
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
|
||||
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
|
||||
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
|
||||
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
|
||||
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
|
||||
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
|
||||
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
|
||||
| `feature_social` | ❌ MISSING | 0% | Pure stub |
|
||||
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
|
||||
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
|
||||
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
|
||||
| Policy register | ❌ MISSING | 10% | All CSVs empty |
|
||||
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
|
||||
|
||||
### Overall Platform Readiness
|
||||
|
||||
| Dimension | Assessment |
|
||||
| ---------------------------------- | ----------------------------------------------------------- |
|
||||
| **WooCommerce catalog management** | ✅ Production-ready |
|
||||
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
|
||||
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
|
||||
| **Square catalog sync** | ⚠️ Service layer complete; no UI trigger; ID conflation bug |
|
||||
| **Square inventory sync** | ⚠️ Repository complete; not wired into AppServices |
|
||||
| **Square payments / POS** | ❌ Not implemented |
|
||||
| **Custom orders** | ❌ Not implemented |
|
||||
| **Supplies tracking** | ❌ Not implemented |
|
||||
| **Multi-channel sync engine** | ❌ Not implemented |
|
||||
| **Offline capability** | ❌ Not implemented |
|
||||
| **Authentication** | ❌ Contracts only; not wired |
|
||||
| **Financial reporting** | ❌ Not implemented |
|
||||
| **Social media management** | ❌ Not implemented |
|
||||
|
||||
---
|
||||
|
||||
_Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25._
|
||||
---
|
||||
|
||||
## 5. Summary Scorecard
|
||||
|
||||
| Subsystem | Status | Completeness | Blocking Issues |
|
||||
|---|---|---|---|
|
||||
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
|
||||
| `design_system` | ✅ COMPLETE | 100% | None |
|
||||
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
|
||||
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
|
||||
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
|
||||
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
|
||||
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
|
||||
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
|
||||
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
|
||||
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
|
||||
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
|
||||
| `feature_social` | ❌ MISSING | 0% | Pure stub |
|
||||
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
|
||||
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
|
||||
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
|
||||
| Policy register | ❌ MISSING | 10% | All CSVs empty |
|
||||
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
|
||||
|
||||
### Overall Platform Readiness
|
||||
|
||||
| Dimension | Assessment |
|
||||
|---|---|
|
||||
| **WooCommerce catalog management** | ✅ Production-ready |
|
||||
| **WooCommerce orders** | ✅ Repository complete; not wired into AppServices |
|
||||
| **WooCommerce inventory sync** | ✅ Repository complete; not wired into AppServices |
|
||||
| **Square catalog sync** | ⚠️ Service layer complete; no UI trigger; ID conflation bug |
|
||||
| **Square inventory sync** | ⚠️ Repository complete; not wired into AppServices |
|
||||
| **Square payments / POS** | ❌ Not implemented |
|
||||
| **Custom orders** | ❌ Not implemented |
|
||||
| **Supplies tracking** | ❌ Not implemented |
|
||||
| **Multi-channel sync engine** | ❌ Not implemented |
|
||||
| **Offline capability** | ❌ Not implemented |
|
||||
| **Authentication** | ❌ Contracts only; not wired |
|
||||
| **Financial reporting** | ❌ Not implemented |
|
||||
| **Social media management** | ❌ Not implemented |
|
||||
|
||||
---
|
||||
|
||||
*Generated by automated repository audit. All findings are based on source code inspection, documentation review, and test suite analysis as of 2026-07-25.*
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
# Product Requirement Document (PRD): Kell Creations Operations Platform
|
||||
|
||||
## 1. Executive Summary & Vision
|
||||
|
||||
The **Kell Creations Operations Platform** is a cross-platform (Web & Android) multi-channel commerce management system. It unifies online storefront operations (WooCommerce), in-person craft market point-of-sale (Square), custom made-to-order requests, and raw supply/finished inventory management into a single operational interface[cite: 3].
|
||||
|
||||
## 2. Platform Architecture & Stack
|
||||
|
||||
- **Framework:** Flutter (Dart 3.11+)
|
||||
- **Target Platforms:** Windows Desktop / Web (`kell_web`), Android (`kell_mobile`)[cite: 1, 3]
|
||||
- **Monorepo Architecture:** Feature-first modular packages (`feature_wordpress`, `feature_inventory`, `feature_orders`, `feature_policy`, `auth`, `data`, `integrations`, `design_system`, `core`)[cite: 1, 3]
|
||||
- **State & Data Flow:** Unidirectional flow using `ChangeNotifier` controllers and repository interfaces with mock (`FAKE`) and production implementation modes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Functional Requirements
|
||||
|
||||
### 3.1 Multi-Channel Catalog Management
|
||||
|
||||
- **WooCommerce Storefront:**
|
||||
- View, filter, search, and sort product drafts and published items.
|
||||
- Single-field and controlled inline edits (Name, Price, Description, Category).
|
||||
- Controlled status transitions (`unpublished` -> `draft`, `pendingReview` -> `draft`/`published`)[cite: 3].
|
||||
- Full Product Creation pipeline (`CreateProductDialog` on Web, `MobileCreateProductPage` on Android)[cite: 2, 3].
|
||||
- Product category CRUD and management workspace[cite: 2, 3].
|
||||
- Controlled bulk actions (Bulk move-to-draft, bulk publish, bulk submit for review)[cite: 2, 3].
|
||||
- **Square Catalog Integration:**
|
||||
- Map WooCommerce items to Square Catalog API objects (`CatalogObject`)[cite: 2, 3].
|
||||
- Support cross-channel ID mapping (`ProductIdMapping`) to prevent duplicate listings[cite: 3].
|
||||
|
||||
### 3.2 Mobile Point-of-Sale (POS) & Market Sales
|
||||
|
||||
- Android-optimized UI using Material 3 `NavigationBar` and `IndexedStack`[cite: 2, 3].
|
||||
- In-person checkout workflow via Square API integration[cite: 2, 3].
|
||||
- Support cash recording and card processing via Square Reader SDK[cite: 3].
|
||||
- Automatic inventory deduction and digital receipt generation upon payment completion[cite: 3].
|
||||
|
||||
### 3.3 Custom Orders Workflow
|
||||
|
||||
- **Specification Tracking:** Record custom order specs (material, color, size, dimensions, reference images)[cite: 3].
|
||||
- **Financial Deposit Management:** Track deposit payments, deposit dates, remaining balance due, and final settlement[cite: 3].
|
||||
- **Fulfillment Pipeline:** Status tracking (`quoted` -> `depositReceived` -> `inProduction` -> `qualityCheck` -> `readyForPickup` -> `completed`)[cite: 3].
|
||||
|
||||
### 3.4 Inventory & Supply Chain Operations
|
||||
|
||||
- **Dual-Type Tracking:** Separate raw materials/supplies (inputs) from finished goods (catalog items)[cite: 3].
|
||||
- **Stock Adjustments:** Delta adjustments (+/-) and explicit quantity setting with reason logging[cite: 2, 3].
|
||||
- **Bidirectional Stock Sync:** Real-time and batch stock synchronization across WooCommerce stock levels and Square inventory counts[cite: 2, 3].
|
||||
- **Reorder Logic:** Automated low-stock indicators based on item reorder points[cite: 2, 3].
|
||||
|
||||
### 3.5 Infrastructure & Shared Services
|
||||
|
||||
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
|
||||
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Functional Requirements & Metrics
|
||||
|
||||
- **Code Coverage Visibility:** Maintain and expand line coverage visibility reported via Forgejo CI (`flutter test --coverage`)[cite: 2, 3].
|
||||
- **Touch Optimization:** Minimum 48×48dp touch target enforcement across all interactive controls on mobile surfaces[cite: 2, 3].
|
||||
- **Tactile & Visual Feedback:** Haptic feedback (`mediumImpact` for state changes, `lightImpact` for field edits) and explicit SnackBar feedback[cite: 2, 3].
|
||||
- **Zero Structural Regressions:** Mandatory static analysis passing (`dart analyze --fatal-infos`) on every commit[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 5. Milestone & Feature Release Schedule
|
||||
|
||||
| Stage | Feature Focus | Status |
|
||||
| :------------- | :------------------------------------------------------------------- | :------------------------------- |
|
||||
| **Stages 1–3** | Controlled Product Editing, UX Hardening & List Efficiency | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 4** | Shared Design System, Core Abstractions, CI/CD & Coverage Visibility | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 5** | Android Shell Bootstrap & Publishing Surface | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 6** | Android Touch Target Hardening & Mobile Surfaces (Orders/Inventory) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 7** | Bulk Status Workflows (Move to Draft, Publish, Submit for Review) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 10B** | **Square Catalog & Inventory Sync** | 🔄 **Current Stage**[cite: 2, 3] |
|
||||
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] |
|
||||
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] |
|
||||
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
|
||||
|
|
@ -2,46 +2,12 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- main baseline updated through: `feat/square-catalog-sync-repositories` (Stage 10C complete, 2026-07-25)
|
||||
- audit completed: `chore/audit-state-sync` — Comprehensive State Audit (`PROJECT_STATE_AUDIT.md`) completed 2026-07-25; all findings incorporated into `master_development_brief.md` and this tracker
|
||||
- merged: `feat/square-catalog-sync-service` (Stage 10D) — merged to main; 506/506 feature_wordpress, 167/167 feature_inventory; analyze clean
|
||||
- main baseline updated through: `feat/square-sync-ui` (Stage 10F complete, merged 2026-07-25)
|
||||
- active branch: `feat/square-mobile-pos` (Stage 10C) — Square Mobile POS / Checkout
|
||||
- next branch: `feat/custom-order-domain` (Stage 11A) — Custom order domain and data
|
||||
- total tests on branch: 1373/1373 passing (+45 new: 36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); dart analyze clean across all packages
|
||||
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
|
||||
- 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
|
||||
|
||||
### feat/square-mobile-pos
|
||||
|
||||
- status: in progress (ready to merge)
|
||||
- branch: `feat/square-mobile-pos`
|
||||
- stage: 10C — Square Mobile POS / Checkout
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `feature_orders/lib/src/domain/payment_method.dart` — `PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions
|
||||
- `feature_orders/lib/src/domain/payment_result.dart` — `PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted)
|
||||
- `feature_orders/lib/src/application/payment_service.dart` — `PaymentService` abstract contract + `PaymentException`
|
||||
- `feature_orders/lib/src/application/checkout_controller.dart` — `CheckoutController` (ChangeNotifier), `CartEntry`, sealed `CheckoutState` hierarchy (idle/processing/success/error)
|
||||
- `feature_orders/lib/src/data/fake_payment_service.dart` — `FakePaymentService` (always succeeds, counter-based IDs)
|
||||
- `feature_orders/lib/feature_orders.dart` — barrel updated with all new exports
|
||||
- `kell_mobile/lib/pages/mobile_checkout_page.dart` — `MobileCheckoutPage` (product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback)
|
||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Sales tab (index 4) added; `CheckoutController` initialised in `didChangeDependencies`; `_titles` updated to 6 entries
|
||||
- `kell_mobile/lib/composition/mobile_app_services.dart` — `paymentService` field + `checkoutCatalogue` field; `_fakeCatalogue` const; all factory constructors updated
|
||||
- `kell_mobile/test/widget_test.dart` — updated destination count assertion 5 → 6
|
||||
- tests added:
|
||||
- `feature_orders/test/payment_method_test.dart` — 5 tests
|
||||
- `feature_orders/test/payment_result_test.dart` — 11 tests
|
||||
- `feature_orders/test/fake_payment_service_test.dart` — 4 tests
|
||||
- `feature_orders/test/checkout_controller_test.dart` — 16 tests
|
||||
- `kell_mobile/test/composition/mobile_app_services_test.dart` — 11 new tests (paymentService + checkoutCatalogue groups)
|
||||
- `kell_mobile/test/pages/mobile_checkout_page_test.dart` — 11 widget tests
|
||||
- tests: passed (1373/1373 total; feature_orders 234/234 +36; kell_mobile 72/72 +18)
|
||||
- analyze: passed (dart analyze — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/description-only-edit
|
||||
|
||||
- status: merged to main
|
||||
|
|
@ -590,7 +556,7 @@
|
|||
|
||||
### feat/square-api-client
|
||||
|
||||
- status: merged to main
|
||||
- status: ready to merge to main
|
||||
- date: 2026-07-19
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
|
|
@ -606,121 +572,3 @@
|
|||
- 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
|
||||
|
||||
### feat/square-catalog-sync
|
||||
|
||||
- status: in progress on branch (commit `a6f888c`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10B slice)
|
||||
- files changed:
|
||||
- `integrations/lib/src/square_product_mapper.dart` — new `SquareProductMapper`; `fromSquareCatalogObject()` maps Square ITEM CatalogObject JSON → `ChannelProduct` (cents→dollars, `is_deleted` → `archived` status, first-variation SKU/price extraction); `fromSquareCatalogList()` batch variant skipping non-ITEM entries; `toSquareCatalogObject()` maps `ChannelProduct` → Square ITEM JSON (dollars→cents rounding, default variation with `FIXED_PRICING`, `location_overrides` with `track_inventory`, optional description/SKU omitted when null)
|
||||
- `integrations/lib/src/square_inventory_mapper.dart` — new `SquareInventoryMapper`; `fromSquareInventoryCount()` maps Square InventoryCount JSON → `ChannelInventoryCount` (IN_STOCK only, string quantity parsed safely); `fromSquareInventoryCountList()` batch variant; `toSquareBatchChangeEntry()` maps `ChannelInventoryCount` → Square PHYSICAL_COUNT batch-change entry (quantity as string, RFC 3339 timestamp); `toSquareBatchChangeEntries()` batch variant
|
||||
- `integrations/lib/integrations.dart` — added `square_inventory_mapper.dart` and `square_product_mapper.dart` to barrel exports
|
||||
- `integrations/test/square_catalog_sync_test.dart` — 35 new tests: `SquareProductMapper` (20: fromSquareCatalogObject ×9, fromSquareCatalogList ×3, toSquareCatalogObject ×6) and `SquareInventoryMapper` (15: fromSquareInventoryCount ×8, fromSquareInventoryCountList ×3, toSquareBatchChangeEntry ×3, toSquareBatchChangeEntries ×2)
|
||||
- `feature_wordpress/lib/src/domain/product_id_mapping.dart` — new `ProductIdMapping` value object with `localId`, `wooCommerceId`, `squareCatalogObjectId`, `squareVariationId`, `lastUpdated`; `copyWith()`, `==`/`hashCode`, `toString()`
|
||||
- `feature_wordpress/lib/src/domain/product_id_mapping_repository.dart` — new `ProductIdMappingRepository` abstract contract: `getAllMappings()`, `getMappingByLocalId()`, `getMappingByWooCommerceId()`, `getMappingBySquareId()`, `saveMapping()` (upsert), `deleteMapping()` (no-op safe)
|
||||
- `feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart` — new `FakeProductIdMappingRepository` in-memory implementation; seeded with 6 mappings matching fake catalog (products 1–3 fully synced with Square IDs, products 4–5 WooCommerce-only, product 6 draft/unsynced); 50ms simulated async delay on all operations; `getAllMappings()` returns `List.unmodifiable`
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `fake_product_id_mapping_repository.dart`, `product_id_mapping.dart`, `product_id_mapping_repository.dart` to barrel exports
|
||||
- `feature_wordpress/test/product_id_mapping_test.dart` — 23 new tests: `ProductIdMapping` (7: fields, null defaults, copyWith, equality, hashCode, toString) and `FakeProductIdMappingRepository` (16: getAllMappings, getMappingByLocalId, getMappingByWooCommerceId, getMappingBySquareId, seed state, saveMapping insert/replace/count, deleteMapping remove/count/no-op, unmodifiable list)
|
||||
- tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new)
|
||||
- analyze: clean (no new issues introduced)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service
|
||||
|
||||
- status: in progress on branch — ready for merge
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10D slice)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — new `SquareCatalogSyncService` application-layer service; `SyncError` value object with `itemId`, `message`, `==`/`hashCode`/`toString()`; `SquareSyncResult` value object with `catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`; `InventorySyncAdapter` abstract contract with `syncItem(itemId, remoteQuantity)`; `syncCatalog()` fetches all products via `ProductPublishingRepository.getProductDrafts()`, looks up or creates `ProductIdMapping` per product via `ProductIdMappingRepository`, captures per-item failures without aborting; `syncInventory()` iterates all mappings with a `squareVariationId`, calls `InventorySyncAdapter.syncItem()` for each, captures per-item failures; `syncAll()` runs `syncCatalog()` then `syncInventory()` and merges results; no-op when no adapter provided
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_sync_service.dart` to barrel exports (Application section)
|
||||
- `feature_wordpress/test/square_catalog_sync_service_test.dart` — 31 new tests: `SyncError` (3: fields, equality, toString), `SquareSyncResult` (7: defaults, totalProcessed, allSucceeded×3, merge, toString), `syncCatalog` (9: empty, single, id match, multiple, existing-not-duplicated, fetch-failure, saveMapping-failure, partial-failure, SyncError-details), `syncInventory` (7: no-adapter, no-variationId-skipped, synced, false-return, throw-continues, getAllMappings-failure, empty-mappings), `syncAll` (5: merges, pre-seeded-variation-IDs, totalProcessed, catalog-failure-no-prevent-inventory, no-adapter)
|
||||
- tests: passed (506/506 feature_wordpress ← +31 new, 167/167 feature_inventory — all passing)
|
||||
- analyze: clean (dart analyze — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### chore/audit-state-sync
|
||||
|
||||
- status: complete (documentation-only — no code changes)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete — full repository audit conducted against all 11 packages/apps
|
||||
- implementation: complete
|
||||
- audit reference: `PROJECT_STATE_AUDIT.md` — Kell Creations Operations Platform Comprehensive Code Audit
|
||||
- files changed:
|
||||
- `PROJECT_STATE_AUDIT.md` — new comprehensive audit document; covers monorepo architecture, built vs. remaining matrix for all 13 packages + 2 apps, technical debt assessment (dead code, error handling gaps, hardcoded values, test coverage, architectural antipatterns), and actionable engineering backlog (Phase 1/2/3 priorities)
|
||||
- `docs/development/master_development_brief.md` — updated validation state (core 24/24, feature_wordpress 506/506 post-10D, total 1,015/1,015); updated next recommended branch to include P1.1–P1.5 scope; added "Comprehensive State Audit (2026-07-25)" section with platform scorecard, Phase 1/2/3 backlog tables, and key technical debt findings; updated Appendix A (feature maturity matrix — all test counts, completeness percentages, kell_web/kell_mobile rows added); updated Appendix B (integration landscape — all Square repos now marked complete, WC repos status corrected); updated Appendix C (Square keys moved from "Planned" to "Active"; KC_ENV values table updated with current status)
|
||||
- `docs/development/build_execution_tracker.md` — updated current status section (audit completion noted, pending merge flagged, next branch scope documented); added this slice entry
|
||||
- tests: n/a (documentation-only change)
|
||||
- analyze: n/a (documentation-only change)
|
||||
- brief updated: yes
|
||||
- audit findings summary:
|
||||
- 1,015/1,015 tests passing across all 11 packages/apps
|
||||
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||
- CRITICAL: `AppServices`/`MobileAppServices` have no `square()` or `multi()` factory — `KC_ENV=square` silently falls to fake mode
|
||||
- HIGH: `SquareCatalogSyncService` ID conflation bug; `RetryPolicy`/`RateLimiter` not wired; `auth` package not imported by any app
|
||||
- Phase 1 (5 items) → target `feat/square-catalog-sync-service-controller` (Stage 10E)
|
||||
- Phase 2 (10 items) → Stages 10C, 11, 12, 13, 14, 15
|
||||
- Phase 3 (12 items) → Stages 16A/B + standalone hardening tasks
|
||||
|
||||
### feat/square-catalog-sync-repositories
|
||||
|
||||
- status: in progress on branch — ready for merge
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10C slice)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/data/square_catalog_repository.dart` — new `SquareCatalogRepository` implementing `ProductPublishingRepository`; backed by `SquareApiClient`; `getProductDrafts()` calls `listCatalogItems()` and maps via `SquareProductMapper`, populating an in-memory cache; `publishDraft()` returns cached draft with `published` status (no-op — Square items are always live); `updateProductStatus()`, `updateProductPrice()`, `updateProductName()`, `updateProductDescription()`, `updateProductCategory()` each mutate the cached `ProductDraft`, call `upsertCatalogItem()` with the updated Square JSON, and return the refreshed draft; `createProduct()` calls `upsertCatalogItem()` with a `#NEW_` idempotency key, maps the response, and adds to cache; `deleteProduct()` calls `deleteCatalogItem()` and removes from cache; `updateProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()` throw `UnsupportedError` (Square manages these separately)
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_catalog_repository.dart` to barrel exports
|
||||
- `feature_inventory/lib/src/domain/inventory_item.dart` — added `squareVariationId` (nullable String) field; updated `copyWith()` to preserve/update it
|
||||
- `feature_inventory/lib/src/data/square_inventory_repository.dart` — new `SquareInventoryRepository` implementing `InventoryRepository`; backed by `SquareApiClient` + `initialItems` seed list; `getInventoryItems()` returns `List.unmodifiable`; `getItemBySku()` linear scan; `adjustQuantity()` validates non-negative result, pushes `batchChangeInventory()` when `squareVariationId` is set (stamps `synced`/`lastSyncedAt`), otherwise marks `notSynced`; `setQuantity()` validates non-negative, same Square push pattern; `syncWithRemote()` validates non-negative, calls `getInventoryCount()` when `squareVariationId` is set and uses live count (falls back to `remoteQuantity` on empty response), stamps `synced`/`lastSyncedAt`; `createItem()` appends to local store; `updateItem()` replaces by id and stamps `lastUpdated`; `updateItemImage()` updates `imageUrl` by id; all write failures return `InventoryAdjustmentResult.failure()` with descriptive messages; status auto-derived from quantity vs `reorderPoint`
|
||||
- `feature_inventory/lib/feature_inventory.dart` — added `square_inventory_repository.dart` to barrel exports
|
||||
- `feature_inventory/pubspec.yaml` — added `integrations: path: ../integrations` dependency
|
||||
- `feature_wordpress/test/square_catalog_repository_test.dart` — 30 new tests: `getProductDrafts` (5: empty, single mapping, is_deleted status, multi+skip non-ITEM, cache population), `publishDraft` (2: success, StateError), `updateProductStatus` (2: upserts, StateError), `updateProductPrice` (2: upserts, StateError), `updateProductName` (1), `updateProductDescription` (1), `updateProductCategory` (1), `createProduct` (4: maps result, draft status, StateError on bad type, adds to cache), `deleteProduct` (1: removes from cache), unsupported operations (6: images/categories all throw UnsupportedError), status mapping (2), SquareApiException propagation (1)
|
||||
- `feature_inventory/test/square_inventory_repository_test.dart` — 37 new tests: `getInventoryItems` (3: all items, empty, unmodifiable), `getItemBySku` (2), `adjustQuantity` (8: local-only, Square push, not-found, below-zero, API failure, inStock/lowStock/outOfStock status derivation), `setQuantity` (5: local-only, Square push, negative, not-found, API failure), `syncWithRemote` (6: local-only, Square pull, empty-count fallback, negative, not-found, API failure), `createItem` (2), `updateItem` (3: updates, StateError, stamps lastUpdated), `updateItemImage` (2), `InventoryItem.squareVariationId` (4: default null, constructor, copyWith preserve, copyWith update)
|
||||
- tests: passed (123/123 integrations, 475/475 feature_wordpress ← +51 new, 167/167 feature_inventory ← +35 new)
|
||||
- analyze: clean
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-sync-ui
|
||||
|
||||
- status: ready for merge (branch `feat/square-sync-ui`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10F — Square sync UI surface)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart` — new `SquareSyncStatusPanel` `StatefulWidget`; listens to `SquareSyncController`; shows status chip (`Never synced` / `OK` / `Partial` / `Error`), last-synced timestamp, `Syncing…` progress indicator during sync, `Sync Now` `FilledButton` (disabled while syncing), and last-sync-result section with Catalog/Inventory row counts when `lastResult` is non-null
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_status_panel.dart` to barrel exports (Presentation section)
|
||||
- `kell_creations_apps/apps/kell_web/lib/pages/integrations_page.dart` — new `IntegrationsPage`; shows `SquareSyncStatusPanel` when `squareSyncController` is non-null; shows placeholder card with hub icon and "No integrations configured" message otherwise; "Square" section label when controller is present
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/pages/mobile_integrations_page.dart` — new `MobileIntegrationsPage`; mirrors `IntegrationsPage` layout for mobile; `SafeArea` wrapper
|
||||
- `kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart` — added `AppRoute.integrations` case routing to `IntegrationsPage(squareSyncController: services.squareSyncController)`
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart` — More tab (case 4) switched from `IntegrationsPlaceholderPage` to `MobileIntegrationsPage(squareSyncController: services.squareSyncController)`
|
||||
- `feature_wordpress/test/square_sync_status_panel_test.dart` — 8 new widget tests: renders title, shows Never synced chip, shows Last synced: Never, Sync Now button enabled, shows OK chip after sync, shows last sync result section, rebuilds on notify, unregisters listener on dispose; uses `tester.pump(Duration)` to advance fake time through `Future.delayed` timers
|
||||
- `kell_creations_apps/apps/kell_web/test/pages/integrations_page_test.dart` — 4 new widget tests: placeholder when controller null, shows `SquareSyncStatusPanel` when controller provided, shows Square section label, placeholder shows hub icon
|
||||
- `kell_creations_apps/apps/kell_mobile/test/pages/mobile_integrations_page_test.dart` — 4 new widget tests: mirroring kell_web integrations page tests for mobile
|
||||
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — expanded to 28 tests (added `squareSyncController` non-null assertion for `square()` factory, `multi()` factory tests, full `KcBootstrap` credential validation tests)
|
||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — expanded to 28 tests (mirrored kell_web composition tests)
|
||||
- tests: passed (545/545 feature_wordpress ← +8 new; 52/52 kell_web ← +5 new; 54/54 kell_mobile ← +5 new — 651 total across tested packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync-service-controller
|
||||
|
||||
- status: ready for merge (branch `feat/square-catalog-sync-service-controller`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10E — all P1.1–P1.5 items)
|
||||
- files changed:
|
||||
- `feature_wordpress/lib/src/application/square_sync_controller.dart` — new `SquareSyncController` `ChangeNotifier`; `SyncPhase` enum (`idle`, `syncing`, `done`, `error`); `syncAll()` delegates to `SquareCatalogSyncService`, sets `phase`/`lastResult`/`lastError`, guards against concurrent calls; `reset()` returns to idle; `isLoading` getter; disposal guard prevents post-dispose notifications
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `square_sync_controller.dart` to barrel exports (Application section)
|
||||
- `kell_creations_apps/apps/kell_web/lib/composition/app_services.dart` — added `square()` factory (P1.1): `SquareCatalogRepository` as `productPublishingRepository`, `SquareInventoryRepository` as `inventoryRepository`, `SquareSyncController` wired to `SquareCatalogSyncService`; added `multi()` factory: `SquareInventoryRepository` + `WordPressProductPublishingRepository`; updated `KcBootstrap` to validate Square credentials (P1.5) and fall back to fake with warning; added `squareSyncController` field to `AppServices`
|
||||
- `kell_creations_apps/apps/kell_mobile/lib/composition/mobile_app_services.dart` — mirrored `square()` and `multi()` factories; added `squareSyncController` field; same `KcBootstrap` Square credential validation
|
||||
- `feature_wordpress/lib/src/domain/product_draft.dart` — added `squareCatalogObjectId` (nullable String) field to `ProductDraft`; updated `copyWith()` to preserve/update it; updated `==`/`hashCode`/`toString()`
|
||||
- `feature_wordpress/lib/src/application/square_catalog_sync_service.dart` — fixed ID conflation bug (P1.2): `syncCatalog()` now reads `draft.squareCatalogObjectId` to look up existing mapping instead of using `draft.id`; mapping lookup uses `getMappingBySquareId()` when Square ID is present, falls back to `getMappingByLocalId()` for unsynced items; prevents duplicate mapping creation for already-synced products
|
||||
- `feature_wordpress/test/square_sync_controller_test.dart` — 18 new tests: `SyncPhase` enum (1), `SquareSyncController` construction (2), `syncAll` success (3), `syncAll` error (2), concurrent guard (2), `reset()` (2), `isLoading` (2), disposal guard (2), `lastResult`/`lastError` lifecycle (2)
|
||||
- `kell_creations_apps/apps/kell_web/test/composition/app_services_test.dart` — added `square()` factory tests (3: creates instance, `SquareCatalogRepository`, `SquareInventoryRepository`), `multi()` factory tests (2), `KcBootstrap` Square credential validation tests (3: selects square with credentials, selects multi with all credentials, falls back to fake without credentials) — 23 total kell_web composition tests
|
||||
- `kell_creations_apps/apps/kell_mobile/test/composition/mobile_app_services_test.dart` — mirrored kell_web composition tests — 23 total kell_mobile composition tests
|
||||
- tests: passed (537/537 feature_wordpress ← +31 new; 47/47 kell_web ← +23 new; 49/49 kell_mobile ← +23 new; 34/34 core; 167/167 feature_inventory — 834 total across tested packages)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 5 packages)
|
||||
- brief updated: yes
|
||||
|
|
@ -125,11 +125,6 @@ Rules:
|
|||
- ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11).
|
||||
- ✅ Shared data layer contracts activated (Stage 8B complete — merged `feat/data-layer-contracts` → `main`, 2026-07-11).
|
||||
- ✅ Authentication foundation activated (Stage 8C complete — merged `feat/auth-foundation` → `main`, 2026-07-11). Stage 8 complete.
|
||||
- ✅ Square API client and authentication activated (Stage 10A complete — merged `feat/square-api-client` → `main`, 2026-07-19).
|
||||
- ✅ Square catalog and inventory sync mappers + ProductIdMapping domain landed (Stage 10B complete — merged `feat/square-catalog-sync` → `main`, 2026-07-25).
|
||||
- ✅ `SquareCatalogRepository` and `SquareInventoryRepository` landed (Stage 10C complete — merged `feat/square-catalog-sync-repositories` → `main`, 2026-07-25).
|
||||
- ✅ `SquareSyncController`, `AppServices.square()`/`multi()`, `MobileAppServices.square()`/`multi()`, ID conflation fix, and `KcBootstrap` credential validation landed (Stage 10E complete — branch `feat/square-catalog-sync-service-controller` ready for merge, 2026-07-25).
|
||||
- ✅ `SquareSyncStatusPanel`, `IntegrationsPage`, `MobileIntegrationsPage`, and `squareSyncController` wired into both shells landed (Stage 10F complete — branch `feat/square-sync-ui` ready for merge, 2026-07-25).
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -141,24 +136,25 @@ Rules:
|
|||
|
||||
### Latest known validation state on `main`
|
||||
|
||||
> **Last verified:** 2026-07-25 — Comprehensive State Audit (`chore/audit-state-sync`)
|
||||
|
||||
- `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||
- All packages passing
|
||||
- latest reported count for `core`: `24/24 passed`
|
||||
- `dart analyze` clean
|
||||
- `core` tests passing
|
||||
- `design_system` tests passing
|
||||
- `feature_wordpress` tests passing
|
||||
- `feature_orders` tests passing
|
||||
- `kell_web` tests passing
|
||||
- `kell_mobile` tests passing
|
||||
- latest reported count for `core`: `21/21 passed`
|
||||
- latest reported count for `design_system`: `41/41 passed`
|
||||
- latest reported count for `feature_inventory`: `167/167 passed` ← +35 new (Stage 10C)
|
||||
- latest reported count for `feature_orders`: `234/234 passed` ← +36 new (Stage 10C POS)
|
||||
- latest reported count for `feature_wordpress`: `545/545 passed`
|
||||
- latest reported count for `kell_web`: `52/52 passed`
|
||||
- latest reported count for `integrations`: `123/123 passed`
|
||||
- latest reported count for `feature_inventory`: `75/75 passed`
|
||||
- latest reported count for `feature_orders`: `198/198 passed`
|
||||
- latest reported count for `feature_wordpress`: `319/319 passed`
|
||||
- latest reported count for `kell_web`: `24/24 passed`
|
||||
- latest reported count for `integrations`: `38/38 passed`
|
||||
- latest reported count for `data`: `63/63 passed`
|
||||
- latest reported count for `auth`: `42/42 passed`
|
||||
- latest reported count for `kell_mobile`: `72/72 passed` ← +18 new (Stage 10C POS)
|
||||
- total: `1,373/1,373 passed` (+45 new from Stage 10C POS branch `feat/square-mobile-pos`)
|
||||
- active branch: `feat/square-mobile-pos` (Stage 10C) — ready for merge
|
||||
|
||||
> **Note:** Stage 10D (`feat/square-catalog-sync-service`) is complete and ready for merge. The `feature_wordpress` count of 506/506 reflects the post-merge state. The `main` baseline currently shows 475/475 for `feature_wordpress` until Stage 10D is merged.
|
||||
- latest reported count for `kell_mobile`: `26/26 passed`
|
||||
- total: `847/847 passed`
|
||||
- baseline commit: merge of `feat/wc-orders-integration` into `main` (Stage 9A complete, 2026-07-11)
|
||||
|
||||
#### Baseline test coverage (established 2026-05-22)
|
||||
|
||||
|
|
@ -172,169 +168,10 @@ Rules:
|
|||
|
||||
No minimum thresholds are enforced — this is visibility-only tracking. Coverage is measured via `flutter test --coverage` (generates `lcov.info`) and reported in the CI workflow summary table.
|
||||
|
||||
### Stage 10D — complete (2026-07-25)
|
||||
|
||||
**`feat/square-catalog-sync-service`** — `SquareCatalogSyncService` + `SquareSyncResult` + `SyncError` + `InventorySyncAdapter`
|
||||
|
||||
Delivered:
|
||||
|
||||
- `SquareCatalogSyncService` in `feature_wordpress/lib/src/application/square_catalog_sync_service.dart`
|
||||
- `SyncError` value object (`itemId`, `message`, `==`/`hashCode`/`toString()`)
|
||||
- `SquareSyncResult` value object (`catalogSynced`, `inventorySynced`, `catalogFailed`, `inventoryFailed`, `errors`, `totalProcessed`, `allSucceeded`, `merge()`)
|
||||
- `InventorySyncAdapter` abstract contract (`syncItem(itemId, remoteQuantity) → Future<bool>`)
|
||||
- `syncCatalog()` — fetches products via `ProductPublishingRepository`, reconciles `ProductIdMappingRepository`, per-item error capture
|
||||
- `syncInventory()` — iterates mappings with `squareVariationId`, delegates to `InventorySyncAdapter`, per-item error capture
|
||||
- `syncAll()` — runs catalog then inventory, merges results
|
||||
- Exported from `feature_wordpress.dart` barrel
|
||||
- 31 new tests (506/506 feature_wordpress total, 167/167 feature_inventory — all passing, analyze clean)
|
||||
|
||||
### Stage 10E — complete (2026-07-25)
|
||||
|
||||
**`feat/square-catalog-sync-service-controller`** — `SquareSyncController` + `AppServices.square()`/`multi()` + ID conflation fix + `KcBootstrap` credential validation
|
||||
|
||||
Delivered:
|
||||
|
||||
- `SquareSyncController` `ChangeNotifier` in `feature_wordpress/lib/src/application/square_sync_controller.dart`
|
||||
- `AppServices.square()` and `AppServices.multi()` factory constructors (P1.1)
|
||||
- `MobileAppServices.square()` and `MobileAppServices.multi()` factory constructors (P1.1)
|
||||
- `KcBootstrap` Square credential validation with fallback to fake mode (P1.5)
|
||||
- `ProductDraft.squareCatalogObjectId` nullable field; ID conflation bug fixed in `syncCatalog()` (P1.2)
|
||||
- 18 new `SquareSyncController` tests + 10 new composition tests per app (834 total across tested packages)
|
||||
- Analyze clean
|
||||
|
||||
### Stage 10F — complete (2026-07-25)
|
||||
|
||||
**`feat/square-sync-ui`** — `SquareSyncStatusPanel` + `IntegrationsPage` + `MobileIntegrationsPage`
|
||||
|
||||
Delivered:
|
||||
|
||||
- `SquareSyncStatusPanel` `StatefulWidget` in `feature_wordpress/lib/src/presentation/widgets/square_sync_status_panel.dart`
|
||||
- `IntegrationsPage` in `kell_web/lib/pages/integrations_page.dart` — shows panel when `squareSyncController` is non-null, placeholder otherwise
|
||||
- `MobileIntegrationsPage` in `kell_mobile/lib/pages/mobile_integrations_page.dart` — mirrors web layout for mobile
|
||||
- `AppRoute.integrations` wired in `kell_web` routing
|
||||
- More tab in `kell_mobile` shell switched from placeholder to `MobileIntegrationsPage`
|
||||
- 8 new panel tests + 4 integrations page tests per app + 5 composition tests per app (651 total across tested packages)
|
||||
- Analyze clean
|
||||
|
||||
### Stage 10C — complete (2026-07-25)
|
||||
|
||||
**`feat/square-mobile-pos`** — Square Mobile POS / Checkout
|
||||
|
||||
Delivered:
|
||||
|
||||
- `PaymentMethod` enum (cash, card) with `label` and `iconHint` extensions in `feature_orders`
|
||||
- `PaymentResult` immutable value object (transactionId, amountCents, method, completedAt, receiptUrl, amountFormatted) in `feature_orders`
|
||||
- `PaymentService` abstract contract + `PaymentException` in `feature_orders`
|
||||
- `CheckoutController` (`ChangeNotifier`) with sealed `CheckoutState` hierarchy (idle/processing/success/error) and `CartEntry` in `feature_orders`
|
||||
- `FakePaymentService` (always succeeds, counter-based IDs) in `feature_orders`
|
||||
- `MobileCheckoutPage` — product selection → cart → payment → receipt flow; Cash + Card buttons; confirmation dialog; haptic feedback in `kell_mobile`
|
||||
- Sales tab (index 4) added to `MobileShell` bottom navigation (6 tabs total)
|
||||
- `paymentService` + `checkoutCatalogue` fields wired into all `MobileAppServices` factory constructors
|
||||
- 45 new tests (36 feature_orders + 18 kell_mobile − 9 pre-existing kell_mobile); 1373 total across all packages
|
||||
- Analyze clean
|
||||
|
||||
### Next recommended branch
|
||||
|
||||
**`feat/custom-order-domain`** — Stage 11A: Custom order domain and data
|
||||
|
||||
- Add `OrderType` enum, `CustomOrderSpec`, `DepositInfo` value objects
|
||||
- Extend `Order` model and `OrdersRepository` contract
|
||||
- Implement in `FakeOrdersRepository` and `WooCommerceOrdersRepository`
|
||||
- Tests for all new domain models and repository methods
|
||||
|
||||
---
|
||||
|
||||
## Comprehensive State Audit (2026-07-25)
|
||||
|
||||
**Audit reference:** `PROJECT_STATE_AUDIT.md` — Kell Creations Operations Platform Comprehensive Code Audit
|
||||
**Audit date:** 2026-07-25
|
||||
**Baseline:** Stage 10C complete (`feat/square-catalog-sync-repositories` → `main`)
|
||||
**Total tests verified:** 1,015 / 1,015 passing; `dart analyze --fatal-infos` clean across all 11 packages/apps
|
||||
|
||||
### Platform scorecard (as of 2026-07-25)
|
||||
|
||||
| Subsystem | Status | Completeness | Blocking Issues |
|
||||
| ------------------- | ----------- | ------------ | ----------------------------------------------------------------- |
|
||||
| `core` | ✅ COMPLETE | 95% | Square/multi bootstrap validation missing |
|
||||
| `design_system` | ✅ COMPLETE | 100% | None |
|
||||
| `auth` | ⚠️ PARTIAL | 40% | No production impl, not wired into app |
|
||||
| `data` | ⚠️ PARTIAL | 35% | No SQLite impl, not wired into any feature |
|
||||
| `integrations` | ⚠️ PARTIAL | 70% | RetryPolicy/RateLimiter not wired; ChannelAdapter not implemented |
|
||||
| `feature_wordpress` | ✅ COMPLETE | 95% | SquareSyncController (Stage 10E) pending |
|
||||
| `feature_inventory` | ⚠️ PARTIAL | 65% | No unified sync engine; ItemType/Supplier/PurchaseOrder missing |
|
||||
| `feature_orders` | ⚠️ PARTIAL | 70% | Custom orders missing; WC/Square repos not wired into AppServices |
|
||||
| `feature_policy` | ⚠️ PARTIAL | 50% | Fake-only; minimal tests; policy register empty |
|
||||
| `feature_finance` | ❌ MISSING | 0% | Pure stub |
|
||||
| `feature_mrp` | ❌ MISSING | 0% | Pure stub |
|
||||
| `feature_social` | ❌ MISSING | 0% | Pure stub |
|
||||
| `kell_web` | ⚠️ PARTIAL | 75% | Finance/Integrations placeholder; no square/multi factory |
|
||||
| `kell_mobile` | ⚠️ PARTIAL | 70% | No POS/checkout; no square/multi factory |
|
||||
| Architecture docs | ⚠️ PARTIAL | 80% | Square not represented; multi-channel model missing |
|
||||
| Policy register | ❌ MISSING | 10% | All CSVs empty |
|
||||
| CI/CD | ✅ COMPLETE | 85% | No coverage thresholds enforced |
|
||||
|
||||
### Audit engineering backlog
|
||||
|
||||
The full backlog is captured in `PROJECT_STATE_AUDIT.md` §4. The prioritized summary follows.
|
||||
|
||||
#### Phase 1 — High-priority / blocking (core fixes and missing execution paths)
|
||||
|
||||
These items block correct runtime behavior or represent data integrity risks. All Phase 1 items should be addressed in `feat/square-catalog-sync-service-controller` (Stage 10E) or immediately after.
|
||||
|
||||
| ID | Item | Branch |
|
||||
| ---- | -------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| P1.1 | Wire `square` and `multi` factory constructors into `AppServices`/`MobileAppServices` | `feat/square-catalog-sync-service-controller` |
|
||||
| P1.2 | Fix `SquareCatalogSyncService.syncCatalog()` ID conflation (`product.id` vs Square ID) | `feat/square-catalog-sync-service-controller` |
|
||||
| P1.3 | Merge Stage 10D (`feat/square-catalog-sync-service`) → `main` | Immediate — prerequisite for Stage 10E |
|
||||
| P1.4 | `SquareSyncController` (Stage 10E) — UI trigger for `SquareCatalogSyncService` | `feat/square-catalog-sync-service-controller` |
|
||||
| P1.5 | Add `hasSquareConfig` credential check in `KcBootstrap` for `square`/`multi` modes | `feat/square-catalog-sync-service-controller` |
|
||||
|
||||
#### Phase 2 — Feature completion and integration
|
||||
|
||||
These items complete planned stages and wire existing contracts into production paths.
|
||||
|
||||
| ID | Item | Target Stage |
|
||||
| ----- | ---------------------------------------------------------------------------------- | --------------------------- |
|
||||
| P2.1 | Square Mobile POS / Checkout (`MobileCheckoutPage`, Square Reader SDK) | Stage 10C |
|
||||
| P2.2 | Custom Orders Workflow (`OrderType`, `CustomOrderSpec`, `DepositInfo`, UI) | Stage 11 |
|
||||
| P2.3 | Inventory Write Ops & Supplies Tracking (`ItemType`, `Supplier`, `PurchaseOrder`) | Stage 12A/B |
|
||||
| P2.4 | Multi-Channel Inventory Sync Engine (`InventorySyncEngine`, `ChannelStockMapping`) | Stage 12C |
|
||||
| P2.5 | Wire `WooCommerceOrdersRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
|
||||
| P2.6 | Wire `WooCommerceInventoryRepository` into `AppServices.wordpress()` | Stage 10E or standalone |
|
||||
| P2.7 | Unified Order View (`OrderChannel`, `UnifiedOrdersController`) | Stage 13B |
|
||||
| P2.8 | Offline Data Cache & Sales Queue (SQLite `LocalCache`, `SyncQueue`) | Stage 14 |
|
||||
| P2.9 | `feature_finance` Activation (sales reporting, inventory valuation) | Stage 15 |
|
||||
| P2.10 | Populate Policy Register CSVs (2 active governance documents) | Immediate — no dependencies |
|
||||
|
||||
#### Phase 3 — Defensive engineering, logging, and test harness expansion
|
||||
|
||||
These items harden the platform for production deployment.
|
||||
|
||||
| ID | Item | Target Stage |
|
||||
| ----- | --------------------------------------------------------------------------------------- | ----------------- |
|
||||
| P3.1 | Wire `RetryPolicy` and `RateLimiter` into `SquareApiClient` and `WooCommerceApiClient` | Stage 16A |
|
||||
| P3.2 | Implement `SecureCredentialStore` (`flutter_secure_storage`) | Stage 16B |
|
||||
| P3.3 | Auth guard wired into app shell (login screen, route guard) | Stage 16B |
|
||||
| P3.4 | Structured logging infrastructure (`package:logging`, replace silent `catch (_) {}`) | Stage 16A |
|
||||
| P3.5 | Crash reporting integration (Sentry or Firebase Crashlytics) | Stage 16A |
|
||||
| P3.6 | Expand `feature_policy` test coverage (controller, widget tests; target >80%) | Standalone |
|
||||
| P3.7 | Expand `kell_web` test coverage from 54.1% (routing, shell, factory tests; target >75%) | Standalone |
|
||||
| P3.8 | Add `feature_orders` standard scaffolding files (`.gitignore`, `CHANGELOG.md`, etc.) | Standalone |
|
||||
| P3.9 | Update architecture diagrams for Square and multi-channel | Stage 13 planning |
|
||||
| P3.10 | Implement `ChannelAdapter` for WooCommerce and Square | Stage 13A |
|
||||
| P3.11 | Enforce minimum test coverage thresholds in CI (e.g., 75% floor) | Standalone |
|
||||
| P3.12 | Evaluate and activate `feature_mrp` after Stage 12 completion | Post-Stage 12 |
|
||||
|
||||
### Key technical debt findings
|
||||
|
||||
- **`AppServices`/`MobileAppServices` have no `square()` or `multi()` factory** — `KC_ENV=square` silently falls to fake mode (CRITICAL)
|
||||
- **`SquareCatalogSyncService` ID conflation** — `product.id` used as Square catalog object ID; incorrect in mixed-source scenarios (HIGH)
|
||||
- **`RetryPolicy`/`RateLimiter` defined but not wired** — all API calls are single-attempt with no retry or rate limiting (HIGH)
|
||||
- **`auth` package not imported by any app or feature** — app is completely unguarded (HIGH)
|
||||
- **`data` package not imported by any feature** — all data is in-memory only; no persistence path (MEDIUM)
|
||||
- **`InventoryController` not wired to `InventorySyncService`** — sync cannot be triggered from UI (MEDIUM)
|
||||
- **`feature_policy` has only a smoke test** — no controller or widget tests (MEDIUM)
|
||||
- **`kell_web` at 54.1% line coverage** — routing, shell, and navigation largely untested (MEDIUM)
|
||||
**`feat/wc-inventory-sync`** — Stage 9B: WooCommerce stock/inventory sync.
|
||||
Branch from latest `main`. Stage 9A (WooCommerce Orders API integration) is complete. Stage 9B adds bidirectional stock quantity synchronization between the app's inventory and WooCommerce product stock levels.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1401,7 +1238,7 @@ For every branch/stage:
|
|||
|
||||
Use this at the start of any new slice:
|
||||
|
||||
```plaintext
|
||||
```text
|
||||
Continue the Kell Creations Flutter operations platform workstream from current main.
|
||||
|
||||
Read docs/development/master_development_brief.md first and follow it as the authoritative planning document.
|
||||
|
|
@ -1434,55 +1271,46 @@ Working rules:
|
|||
|
||||
## Appendix A: Feature maturity matrix
|
||||
|
||||
> **Last updated:** 2026-07-25 — Comprehensive State Audit
|
||||
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
|
||||
| ------------------- | ------------ | ------------------ | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
|
||||
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** |
|
||||
| `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** |
|
||||
| `feature_orders` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Some | **Fake-only MVP** |
|
||||
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
|
||||
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||
| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None | N/A | 42 | **Foundation ready** |
|
||||
| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None | N/A | 63 | **Foundation ready** |
|
||||
| `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce | N/A | 38 | **Foundation ready** |
|
||||
| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Foundation ready** |
|
||||
| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 21 | **Foundation ready** |
|
||||
|
||||
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
|
||||
| ------------------- | ------------ | ------------------------ | ----------- | ------------------------- | -------------------------------------------------- | ----- | -------------------------- |
|
||||
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 506 | **Production-ready (95%)** |
|
||||
| `feature_inventory` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce + Square | ✅ Complete | 167 | **Partial (65%)** |
|
||||
| `feature_orders` | ✅ Complete | ✅ Write ops | ✅ Complete | ✅ WooCommerce | ✅ Complete | 198 | **Partial (70%)** |
|
||||
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | ~5 | **Fake-only (50%)** |
|
||||
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
|
||||
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
|
||||
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | 1 | **Scaffolded only (0%)** |
|
||||
| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None (SecureStore TBD) | N/A | 42 | **Foundation ready (40%)** |
|
||||
| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None (SQLite TBD) | N/A | 63 | **Foundation ready (35%)** |
|
||||
| `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce + Square | N/A | 123 | **Partial (70%)** |
|
||||
| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Complete (100%)** |
|
||||
| `core` | ✅ Complete | ✅ Composition | N/A | N/A | N/A | 24 | **Complete (95%)** |
|
||||
| `kell_web` | N/A | ✅ AppServices (wp only) | N/A | N/A | ✅ 7 routes (2 placeholder) | 24 | **Partial (75%)** |
|
||||
| `kell_mobile` | N/A | ✅ MobileAppServices | N/A | N/A | ✅ 5-tab shell (no POS) | 26 | **Partial (70%)** |
|
||||
### Key changes from previous matrix
|
||||
|
||||
### Key changes from previous matrix (updated 2026-07-25)
|
||||
|
||||
- `feature_wordpress` test count updated to 506 (Stage 10D, pending merge); real data layer now includes Square repositories.
|
||||
- `feature_inventory` and `feature_orders` upgraded from "Read-only" — write operations, real WooCommerce/Square repositories now complete.
|
||||
- `integrations` test count updated to 123; Square API client and mappers complete.
|
||||
- `core` test count updated to 24; `kell_web` and `kell_mobile` rows added.
|
||||
- Completeness percentages sourced from `PROJECT_STATE_AUDIT.md` §5 scorecard (2026-07-25).
|
||||
- `feature_inventory` and `feature_orders` downgraded from "Complete" to "⚠️ Read-only" in domain/application/data/presentation to reflect that they have no write operations — a critical gap for the target platform vision.
|
||||
- Feature maturity labels clarified: "Fake-only MVP" packages that are also read-only now carry the ⚠️ indicator.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Integration landscape
|
||||
|
||||
> **Last updated:** 2026-07-25 — Comprehensive State Audit
|
||||
|
||||
| Integration | Package/Location | Protocol | Current Status | Target Stage |
|
||||
| -------------------- | ------------------- | ------------- | ------------------------------------------------------ | ---------------- |
|
||||
| WooCommerce Products | `feature_wordpress` | REST API v3 | ✅ Production-ready (full CRUD + categories + images) | — |
|
||||
| WooCommerce Orders | `feature_orders` | REST API v3 | ✅ Repository complete; not wired into AppServices | Stage 9A ✅ |
|
||||
| WooCommerce Stock | `feature_inventory` | REST API v3 | ✅ Repository complete; not wired into AppServices | Stage 9B ✅ |
|
||||
| WooCommerce Catalog | `feature_wordpress` | REST API v3 | ✅ Full CRUD + categories + images | Stage 9C ✅ |
|
||||
| WooCommerce Media | `feature_wordpress` | REST API v3 | ⚠️ Use case wired; image picker UI deferred | Stage 10+ |
|
||||
| Square Catalog | `feature_wordpress` | Square API v2 | ✅ Repository complete; no UI trigger (Stage 10E next) | Stage 10B ✅ |
|
||||
| Square Inventory | `feature_inventory` | Square API v2 | ✅ Repository complete; not wired into AppServices | Stage 10B ✅ |
|
||||
| Square Payments | `kell_mobile` | Mobile SDK | ❌ Not implemented | Stage 10C (P2.1) |
|
||||
| Square Orders | `feature_orders` | Square API v2 | ❌ No SquareOrdersRepository exists | Stage 13B (P2.7) |
|
||||
| Facebook API | `feature_social` | Graph API | ❌ Stub only | Deferred |
|
||||
| Instagram API | `feature_social` | Graph API | ❌ Stub only | Deferred |
|
||||
| X (Twitter) API | `feature_social` | REST API v2 | ❌ Stub only | Deferred |
|
||||
| n8n Workflows | (external) | Webhooks/REST | ❌ Not implemented | Stage 13C |
|
||||
| Mail Server | (external) | SMTP | ❌ Not implemented | Deferred |
|
||||
| Integration | Package/Location | Protocol | Current Status | Target Stage |
|
||||
| -------------------- | ------------------- | ------------- | ------------------- | ------------ |
|
||||
| WooCommerce Products | `feature_wordpress` | REST API v3 | ✅ Production-ready | — |
|
||||
| WooCommerce Orders | `feature_orders` | REST API v3 | ✅ Production-ready | Stage 9A ✅ |
|
||||
| WooCommerce Stock | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9B |
|
||||
| WooCommerce Catalog | `feature_wordpress` | REST API v3 | ⚠️ Read + edit only | Stage 9C |
|
||||
| WooCommerce Media | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9C |
|
||||
| Square Catalog | (new) | Square API v2 | ❌ Not implemented | Stage 10A/B |
|
||||
| Square Inventory | (new) | Square API v2 | ❌ Not implemented | Stage 10B |
|
||||
| Square Payments | (new) | Mobile SDK | ❌ Not implemented | Stage 10C |
|
||||
| Square Orders | (new) | Square API v2 | ❌ Not implemented | Stage 10A |
|
||||
| Facebook API | `feature_social` | Graph API | ❌ Stub only | Deferred |
|
||||
| Instagram API | `feature_social` | Graph API | ❌ Stub only | Deferred |
|
||||
| X (Twitter) API | `feature_social` | REST API v2 | ❌ Stub only | Deferred |
|
||||
| n8n Workflows | (external) | Webhooks/REST | ❌ Not implemented | Stage 13C |
|
||||
| Mail Server | (external) | SMTP | ❌ Not implemented | Deferred |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1497,28 +1325,28 @@ Working rules:
|
|||
| `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | WP mode |
|
||||
| `KC_WC_CONSUMER_SECRET` | WooCommerce REST API consumer secret | (empty) | WP mode |
|
||||
|
||||
### Active Square keys (Stage 10A — live on `main`)
|
||||
### Planned (Stage 8+)
|
||||
|
||||
| Key | Description | Default | Used By |
|
||||
| ------------------------ | ------------------------- | --------- | ----------- |
|
||||
| `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) | Square mode |
|
||||
| `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) | Square mode |
|
||||
| `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` | Square mode |
|
||||
| Key | Description | Default | Target Stage |
|
||||
| ------------------------ | ------------------------- | --------- | ------------ |
|
||||
| `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) | Stage 10A |
|
||||
| `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) | Stage 10A |
|
||||
| `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` | Stage 10A |
|
||||
|
||||
### `KC_ENV` values
|
||||
### Planned `KC_ENV` values
|
||||
|
||||
| Value | Description | Status |
|
||||
| ----------- | -------------------------------- | ---------------------------------------------------------------- |
|
||||
| `fake` | All fake repositories | ✅ Active |
|
||||
| `wordpress` | WooCommerce real + others fake | ✅ Active |
|
||||
| `square` | Square real + others fake | ⚠️ Keys defined (Stage 10A); factory constructors missing (P1.1) |
|
||||
| `multi` | Both WooCommerce and Square real | ⚠️ Enum value defined; factory constructors missing (P1.1) |
|
||||
| Value | Description | Target Stage |
|
||||
| ----------- | -------------------------------- | ------------ |
|
||||
| `fake` | All fake repositories (current) | — |
|
||||
| `wordpress` | WooCommerce real + others fake | — |
|
||||
| `square` | Square real + others fake | Stage 10A |
|
||||
| `multi` | Both WooCommerce and Square real | Stage 13A |
|
||||
|
||||
---
|
||||
|
||||
## Appendix D: Stage dependency graph
|
||||
|
||||
```plaintext
|
||||
```
|
||||
Stages 1–6 (COMPLETE/IN PROGRESS) — Foundation
|
||||
│
|
||||
├── Stage 7 — Bulk actions (independent, can proceed in parallel)
|
||||
|
|
|
|||
BIN
filetree.txt
BIN
filetree.txt
Binary file not shown.
|
|
@ -3,17 +3,6 @@ import 'package:feature_inventory/feature_inventory.dart';
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
/// Default catalogue items used in fake/wordpress mode where no Square catalog
|
||||
/// is available. These seed the [MobileCheckoutPage] product list.
|
||||
const _fakeCatalogue = [
|
||||
OrderItem(productName: 'Macramé Wall Hanging', sku: 'MWH-001', quantity: 1, unitPrice: 45.00),
|
||||
OrderItem(productName: 'Beaded Bracelet', sku: 'BB-002', quantity: 1, unitPrice: 12.00),
|
||||
OrderItem(productName: 'Ceramic Mug', sku: 'CM-003', quantity: 1, unitPrice: 18.00),
|
||||
OrderItem(productName: 'Soy Candle — Lavender', sku: 'SC-004', quantity: 1, unitPrice: 14.00),
|
||||
OrderItem(productName: 'Knitted Scarf', sku: 'KS-005', quantity: 1, unitPrice: 32.00),
|
||||
];
|
||||
|
||||
/// Holds the concrete service implementations used by `kell_mobile`.
|
||||
///
|
||||
|
|
@ -29,28 +18,11 @@ class MobileAppServices extends KcAppServices {
|
|||
final PolicyRepository policyRepository;
|
||||
final ProductPublishingRepository productPublishingRepository;
|
||||
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
///
|
||||
/// Present only in `square` and `multi` environments.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
/// Payment service used by the mobile POS checkout flow.
|
||||
final PaymentService paymentService;
|
||||
|
||||
/// Catalogue items available for sale in the [MobileCheckoutPage].
|
||||
///
|
||||
/// In fake/wordpress mode this is seeded with [_fakeCatalogue].
|
||||
/// In square/multi mode this could be populated from the Square catalog.
|
||||
final List<OrderItem> checkoutCatalogue;
|
||||
|
||||
const MobileAppServices({
|
||||
required this.inventoryRepository,
|
||||
required this.ordersRepository,
|
||||
required this.policyRepository,
|
||||
required this.productPublishingRepository,
|
||||
required this.paymentService,
|
||||
this.squareSyncController,
|
||||
this.checkoutCatalogue = _fakeCatalogue,
|
||||
});
|
||||
|
||||
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
|
||||
|
|
@ -60,7 +32,6 @@ class MobileAppServices extends KcAppServices {
|
|||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: FakeProductPublishingRepository(),
|
||||
paymentService: FakePaymentService(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,91 +54,10 @@ class MobileAppServices extends KcAppServices {
|
|||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
|
||||
paymentService: FakePaymentService(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a [MobileAppServices] backed by the real Square catalog and
|
||||
/// inventory APIs.
|
||||
///
|
||||
/// [accessToken] – Square API access token (Bearer token).
|
||||
/// [locationId] – Square location ID for inventory counts.
|
||||
/// [environment] – `'sandbox'` or `'production'` (defaults to `'sandbox'`).
|
||||
factory MobileAppServices.square({
|
||||
required String accessToken,
|
||||
required String locationId,
|
||||
String environment = 'sandbox',
|
||||
}) {
|
||||
final apiClient = environment == 'production'
|
||||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
|
||||
|
||||
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: catalogRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: catalogRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
paymentService: FakePaymentService(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a [MobileAppServices] with both WooCommerce and Square backends
|
||||
/// active.
|
||||
///
|
||||
/// WooCommerce is used for the product catalog and orders. Square is used for
|
||||
/// inventory counts. This mirrors the multi-channel commerce model where
|
||||
/// WooCommerce is the catalog source of truth and Square tracks in-person
|
||||
/// inventory.
|
||||
factory MobileAppServices.multi({
|
||||
required String wcSiteUrl,
|
||||
required String wcConsumerKey,
|
||||
required String wcConsumerSecret,
|
||||
required String squareAccessToken,
|
||||
required String squareLocationId,
|
||||
String squareEnvironment = 'sandbox',
|
||||
}) {
|
||||
final wcApiClient = WooCommerceApiClient(
|
||||
siteUrl: wcSiteUrl,
|
||||
consumerKey: wcConsumerKey,
|
||||
consumerSecret: wcConsumerSecret,
|
||||
);
|
||||
final squareApiClient = squareEnvironment == 'production'
|
||||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
|
||||
|
||||
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: wcPublishingRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return MobileAppServices(
|
||||
inventoryRepository: SquareInventoryRepository(
|
||||
apiClient: squareApiClient,
|
||||
locationId: squareLocationId,
|
||||
),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: wcPublishingRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
paymentService: FakePaymentService(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||
///
|
||||
/// Includes [createSquare] and [createMulti] callbacks so that
|
||||
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
|
||||
/// `KC_ENV=multi` without falling back to fake mode.
|
||||
static KcServiceFactory<MobileAppServices> get serviceFactory {
|
||||
return KcServiceFactory<MobileAppServices>(
|
||||
createFake: () => MobileAppServices.fake(),
|
||||
|
|
@ -176,19 +66,6 @@ class MobileAppServices extends KcAppServices {
|
|||
consumerKey: config.wcConsumerKey,
|
||||
consumerSecret: config.wcConsumerSecret,
|
||||
),
|
||||
createSquare: (config) => MobileAppServices.square(
|
||||
accessToken: config.squareAccessToken,
|
||||
locationId: config.squareLocationId,
|
||||
environment: config.squareEnvironment,
|
||||
),
|
||||
createMulti: (config) => MobileAppServices.multi(
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
squareAccessToken: config.squareAccessToken,
|
||||
squareLocationId: config.squareLocationId,
|
||||
squareEnvironment: config.squareEnvironment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,471 +0,0 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// The mobile POS checkout page.
|
||||
///
|
||||
/// Provides a three-step flow:
|
||||
/// 1. **Product selection** — browse a list of [OrderItem] catalogue entries
|
||||
/// and add them to the cart.
|
||||
/// 2. **Cart review** — see line items, quantities, and the running total.
|
||||
/// 3. **Payment** — choose Cash or Card and confirm the charge.
|
||||
///
|
||||
/// After a successful payment the page shows a receipt summary and offers a
|
||||
/// "New Sale" button to reset the controller and start again.
|
||||
///
|
||||
/// The [CheckoutController] is created externally and injected so that the
|
||||
/// parent shell can hold a reference to it (e.g. to badge the Sales tab with
|
||||
/// the cart item count in a future iteration).
|
||||
class MobileCheckoutPage extends StatefulWidget {
|
||||
final CheckoutController controller;
|
||||
|
||||
/// Catalogue items available for sale. In fake mode these are seeded by
|
||||
/// [MobileAppServices]; in a real deployment they come from the product
|
||||
/// publishing repository.
|
||||
final List<OrderItem> catalogue;
|
||||
|
||||
const MobileCheckoutPage({super.key, required this.controller, required this.catalogue});
|
||||
|
||||
@override
|
||||
State<MobileCheckoutPage> createState() => _MobileCheckoutPageState();
|
||||
}
|
||||
|
||||
class _MobileCheckoutPageState extends State<MobileCheckoutPage> {
|
||||
// ── lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onStateChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onStateChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStateChange() => setState(() {});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
CheckoutController get _ctrl => widget.controller;
|
||||
|
||||
String _formatCents(int cents) {
|
||||
final d = cents ~/ 100;
|
||||
final c = cents % 100;
|
||||
return '\$$d.${c.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = _ctrl.state;
|
||||
|
||||
if (state is CheckoutSuccess) {
|
||||
return _ReceiptView(result: state.result, onNewSale: _ctrl.reset);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// ── cart summary bar ────────────────────────────────────────────────
|
||||
_CartSummaryBar(
|
||||
itemCount: _ctrl.cart.fold(0, (s, e) => s + e.quantity),
|
||||
totalCents: _ctrl.totalCents,
|
||||
formatCents: _formatCents,
|
||||
onClear: _ctrl.cart.isEmpty ? null : _ctrl.clearCart,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// ── body ────────────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: state is CheckoutProcessing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildBody(context, state),
|
||||
),
|
||||
// ── error banner ────────────────────────────────────────────────────
|
||||
if (state is CheckoutError) _ErrorBanner(message: state.message),
|
||||
// ── pay button ──────────────────────────────────────────────────────
|
||||
if (_ctrl.cart.isNotEmpty && state is! CheckoutProcessing)
|
||||
_PaymentBar(
|
||||
totalCents: _ctrl.totalCents,
|
||||
formatCents: _formatCents,
|
||||
onPay: (method) => _ctrl.processPayment(method),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, CheckoutState state) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.storefront_outlined), text: 'Products'),
|
||||
Tab(icon: Icon(Icons.shopping_cart_outlined), text: 'Cart'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_ProductList(catalogue: widget.catalogue, onAdd: _ctrl.addItem),
|
||||
_CartList(cart: _ctrl.cart, formatCents: _formatCents, onRemove: _ctrl.removeItem),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cart summary bar ──────────────────────────────────────────────────────────
|
||||
|
||||
class _CartSummaryBar extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final int totalCents;
|
||||
final String Function(int) formatCents;
|
||||
final VoidCallback? onClear;
|
||||
|
||||
const _CartSummaryBar({
|
||||
required this.itemCount,
|
||||
required this.totalCents,
|
||||
required this.formatCents,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.shopping_cart, color: KcColors.denimBlue, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$itemCount item${itemCount == 1 ? '' : 's'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
formatCents(totalCents),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: KcColors.denimBlue,
|
||||
),
|
||||
),
|
||||
if (onClear != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: onClear, child: const Text('Clear')),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product list ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _ProductList extends StatelessWidget {
|
||||
final List<OrderItem> catalogue;
|
||||
final void Function(OrderItem) onAdd;
|
||||
|
||||
const _ProductList({required this.catalogue, required this.onAdd});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (catalogue.isEmpty) {
|
||||
return const KcEmptyState(
|
||||
icon: Icons.storefront_outlined,
|
||||
message: 'No products available. Add products to the catalogue to start selling.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: catalogue.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final item = catalogue[i];
|
||||
return KcCard(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
title: Text(item.productName, style: Theme.of(context).textTheme.bodyMedium),
|
||||
subtitle: Text('SKU: ${item.sku}', style: Theme.of(context).textTheme.bodySmall),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'\$${item.unitPrice.toStringAsFixed(2)}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onAdd(item);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(40, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Icon(Icons.add, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cart list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _CartList extends StatelessWidget {
|
||||
final List<CartEntry> cart;
|
||||
final String Function(int) formatCents;
|
||||
final void Function(String sku) onRemove;
|
||||
|
||||
const _CartList({required this.cart, required this.formatCents, required this.onRemove});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (cart.isEmpty) {
|
||||
return const KcEmptyState(
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
message: 'Cart is empty. Tap + on a product to add it to the cart.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: cart.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final entry = cart[i];
|
||||
return KcCard(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
title: Text(entry.item.productName, style: Theme.of(context).textTheme.bodyMedium),
|
||||
subtitle: Text(
|
||||
'${formatCents(entry.lineTotalCents)} · qty ${entry.quantity}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
tooltip: 'Remove one',
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onRemove(entry.item.sku);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _PaymentBar extends StatelessWidget {
|
||||
final int totalCents;
|
||||
final String Function(int) formatCents;
|
||||
final void Function(PaymentMethod) onPay;
|
||||
|
||||
const _PaymentBar({required this.totalCents, required this.formatCents, required this.onPay});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Divider(),
|
||||
Text(
|
||||
'Charge ${formatCents(totalCents)}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.payments_outlined),
|
||||
label: const Text('Cash'),
|
||||
onPressed: () => _confirmPayment(context, PaymentMethod.cash),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.credit_card),
|
||||
label: const Text('Card'),
|
||||
onPressed: () => _confirmPayment(context, PaymentMethod.card),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmPayment(BuildContext context, PaymentMethod method) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('Confirm ${method.label} Payment'),
|
||||
content: Text('Charge ${formatCents(totalCents)} via ${method.label}?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Confirm')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
HapticFeedback.mediumImpact();
|
||||
onPay(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receipt view ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _ReceiptView extends StatelessWidget {
|
||||
final PaymentResult result;
|
||||
final VoidCallback onNewSale;
|
||||
|
||||
const _ReceiptView({required this.result, required this.onNewSale});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green.shade600, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Payment Complete',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
result.amountFormatted,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: KcColors.denimBlue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'via ${result.method.label}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
KcCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ReceiptRow(label: 'Transaction ID', value: result.transactionId),
|
||||
_ReceiptRow(label: 'Completed', value: _formatTime(result.completedAt)),
|
||||
if (result.receiptUrl != null)
|
||||
_ReceiptRow(label: 'Receipt', value: result.receiptUrl!),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.add_shopping_cart),
|
||||
label: const Text('New Sale'),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onNewSale();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
return '${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')} '
|
||||
'${local.month}/${local.day}/${local.year}';
|
||||
}
|
||||
}
|
||||
|
||||
class _ReceiptRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _ReceiptRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error banner ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Colors.red.shade50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red.shade700, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The Integrations page for `kell_mobile`.
|
||||
///
|
||||
/// When a [SquareSyncController] is provided (i.e. the app is running in
|
||||
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
|
||||
/// operator can trigger and monitor Square catalog + inventory syncs.
|
||||
///
|
||||
/// When no controller is available (fake or wordpress mode), a placeholder
|
||||
/// message is shown instead.
|
||||
class MobileIntegrationsPage extends StatelessWidget {
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const MobileIntegrationsPage({super.key, this.squareSyncController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = squareSyncController;
|
||||
|
||||
if (controller == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No integrations configured',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Set KC_ENV=square or KC_ENV=multi with Square credentials '
|
||||
'to enable the Square sync panel.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Square',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SquareSyncStatusPanel(controller: controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -8,8 +7,7 @@ import '../dashboard/application/dashboard_controller.dart';
|
|||
import '../dashboard/application/get_dashboard_summary.dart';
|
||||
import '../pages/dashboard_page.dart';
|
||||
import '../pages/finance_placeholder_page.dart';
|
||||
import '../pages/mobile_checkout_page.dart';
|
||||
import '../pages/mobile_integrations_page.dart';
|
||||
import '../pages/integrations_placeholder_page.dart';
|
||||
import '../pages/mobile_inventory_page.dart';
|
||||
import '../pages/mobile_orders_page.dart';
|
||||
import '../pages/mobile_publishing_page.dart';
|
||||
|
|
@ -35,9 +33,8 @@ class _MobileShellState extends State<MobileShell> {
|
|||
/// Dashboard controller is created once and reused across rebuilds to avoid
|
||||
/// re-triggering data loads on every [setState] call (e.g. tab switches).
|
||||
late DashboardController _dashboardController;
|
||||
late CheckoutController _checkoutController;
|
||||
|
||||
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'Sales', 'More'];
|
||||
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More'];
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
|
|
@ -53,7 +50,6 @@ class _MobileShellState extends State<MobileShell> {
|
|||
ordersRepository: services.ordersRepository,
|
||||
),
|
||||
);
|
||||
_checkoutController = CheckoutController(paymentService: services.paymentService);
|
||||
_dashboardControllerInitialised = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +59,6 @@ class _MobileShellState extends State<MobileShell> {
|
|||
@override
|
||||
void dispose() {
|
||||
_dashboardController.dispose();
|
||||
_checkoutController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -106,11 +101,6 @@ class _MobileShellState extends State<MobileShell> {
|
|||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Orders',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.point_of_sale_outlined),
|
||||
selectedIcon: Icon(Icons.point_of_sale),
|
||||
label: 'Sales',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.more_horiz),
|
||||
selectedIcon: Icon(Icons.more_horiz),
|
||||
|
|
@ -148,11 +138,6 @@ class _MobileShellState extends State<MobileShell> {
|
|||
},
|
||||
);
|
||||
case 4:
|
||||
return MobileCheckoutPage(
|
||||
controller: _checkoutController,
|
||||
catalogue: services.checkoutCatalogue,
|
||||
);
|
||||
case 5:
|
||||
return const _MorePage();
|
||||
default:
|
||||
return const Center(child: Text('Unknown section'));
|
||||
|
|
@ -207,12 +192,11 @@ class _MorePage extends StatelessWidget {
|
|||
title: const Text('Integrations'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
final services = KcAppScope.of<MobileAppServices>(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Integrations')),
|
||||
body: MobileIntegrationsPage(squareSyncController: services.squareSyncController),
|
||||
body: const IntegrationsPlaceholderPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/composition/mobile_app_services.dart';
|
||||
|
||||
void main() {
|
||||
group('MobileAppServices', () {
|
||||
// ── fake factory ──────────────────────────────────────────────────────────
|
||||
|
||||
group('fake()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services, isA<MobileAppServices>());
|
||||
expect(services, isA<KcAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is FakeProductPublishingRepository', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── wordpress factory ─────────────────────────────────────────────────────
|
||||
|
||||
group('wordpress()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── square factory ────────────────────────────────────────────────────────
|
||||
|
||||
group('square()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is SquareCatalogRepository', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('squareSyncController is non-null', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.squareSyncController, isNotNull);
|
||||
expect(services.squareSyncController, isA<SquareSyncController>());
|
||||
services.squareSyncController!.dispose();
|
||||
});
|
||||
|
||||
test('defaults to sandbox environment', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('accepts production environment', () {
|
||||
final services = MobileAppServices.square(
|
||||
accessToken: 'EAAAl_test',
|
||||
locationId: 'LOC123',
|
||||
environment: 'production',
|
||||
);
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── multi factory ─────────────────────────────────────────────────────────
|
||||
|
||||
group('multi()', () {
|
||||
test('creates MobileAppServices instance', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services, isA<MobileAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── serviceFactory ────────────────────────────────────────────────────────
|
||||
|
||||
group('serviceFactory', () {
|
||||
test('createFake returns fake services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
final services = factory.createFake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createWordPress returns wordpress services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.wordpress,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
);
|
||||
final services = factory.createWordPress(config);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createSquare is non-null', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
expect(factory.createSquare, isNotNull);
|
||||
});
|
||||
|
||||
test('createSquare returns square services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createSquare!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('createMulti is non-null', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
expect(factory.createMulti, isNotNull);
|
||||
});
|
||||
|
||||
test('createMulti returns multi services', () {
|
||||
final factory = MobileAppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createMulti!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.square);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.multi);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(
|
||||
result.services.productPublishingRepository,
|
||||
isA<WordPressProductPublishingRepository>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: '',
|
||||
);
|
||||
final result = KcBootstrap.run(config, MobileAppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── paymentService composition (Stage 10C) ────────────────────────────────
|
||||
|
||||
group('paymentService', () {
|
||||
test('fake() provides a FakePaymentService', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services.paymentService, isA<FakePaymentService>());
|
||||
});
|
||||
|
||||
test('wordpress() provides a FakePaymentService', () {
|
||||
final services = MobileAppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.paymentService, isA<FakePaymentService>());
|
||||
});
|
||||
|
||||
test('square() provides a FakePaymentService', () {
|
||||
final services = MobileAppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.paymentService, isA<FakePaymentService>());
|
||||
});
|
||||
|
||||
test('multi() provides a FakePaymentService', () {
|
||||
final services = MobileAppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.paymentService, isA<FakePaymentService>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkoutCatalogue composition (Stage 10C) ─────────────────────────────
|
||||
|
||||
group('checkoutCatalogue', () {
|
||||
test('fake() provides a non-empty default catalogue', () {
|
||||
final services = MobileAppServices.fake();
|
||||
expect(services.checkoutCatalogue, isNotEmpty);
|
||||
});
|
||||
|
||||
test('default catalogue items have valid SKUs and prices', () {
|
||||
final services = MobileAppServices.fake();
|
||||
for (final item in services.checkoutCatalogue) {
|
||||
expect(item.sku, isNotEmpty);
|
||||
expect(item.unitPrice, greaterThan(0));
|
||||
expect(item.productName, isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom catalogue can be injected', () {
|
||||
const custom = [
|
||||
OrderItem(productName: 'Custom Item', sku: 'CI-001', quantity: 1, unitPrice: 99.99),
|
||||
];
|
||||
final services = MobileAppServices(
|
||||
inventoryRepository: FakeInventoryRepository(),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: FakeProductPublishingRepository(),
|
||||
paymentService: FakePaymentService(),
|
||||
checkoutCatalogue: custom,
|
||||
);
|
||||
expect(services.checkoutCatalogue.length, 1);
|
||||
expect(services.checkoutCatalogue.first.sku, 'CI-001');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/pages/mobile_checkout_page.dart';
|
||||
|
||||
const _catalogue = [
|
||||
OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00),
|
||||
OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50),
|
||||
];
|
||||
|
||||
Widget _buildPage({CheckoutController? controller, List<OrderItem> catalogue = _catalogue}) {
|
||||
final ctrl = controller ?? CheckoutController(paymentService: FakePaymentService());
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MobileCheckoutPage(controller: ctrl, catalogue: catalogue),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MobileCheckoutPage', () {
|
||||
late CheckoutController controller;
|
||||
|
||||
setUp(() {
|
||||
controller = CheckoutController(paymentService: FakePaymentService());
|
||||
});
|
||||
|
||||
tearDown(() => controller.dispose());
|
||||
|
||||
// ── initial render ────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('renders Products and Cart tabs', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
expect(find.text('Products'), findsOneWidget);
|
||||
expect(find.text('Cart'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows cart summary bar with 0 items initially', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
expect(find.text('0 items'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows product names from catalogue', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
expect(find.text('Widget A'), findsOneWidget);
|
||||
expect(find.text('Widget B'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows empty state when catalogue is empty', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller, catalogue: const []));
|
||||
expect(
|
||||
find.text('No products available. Add products to the catalogue to start selling.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
// ── add to cart ───────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('tapping + adds item to cart and updates summary', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
|
||||
// Tap the + button for Widget A (first add icon)
|
||||
final addButtons = find.byIcon(Icons.add);
|
||||
expect(addButtons, findsWidgets);
|
||||
await tester.tap(addButtons.first);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('1 item'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('adding two different items shows 2 items in summary', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
|
||||
final addButtons = find.byIcon(Icons.add);
|
||||
await tester.tap(addButtons.first);
|
||||
await tester.pump();
|
||||
await tester.tap(addButtons.last);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('2 items'), findsOneWidget);
|
||||
});
|
||||
|
||||
// ── payment bar ───────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('payment bar appears after adding item', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Cash'), findsOneWidget);
|
||||
expect(find.text('Card'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('payment bar is hidden when cart is empty', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
expect(find.text('Cash'), findsNothing);
|
||||
expect(find.text('Card'), findsNothing);
|
||||
});
|
||||
|
||||
// ── cart tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('switching to Cart tab shows empty state when cart is empty', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
|
||||
// Tap the Cart tab by its icon to avoid ambiguity with the 'Cart' label
|
||||
// in the summary bar (which also contains the word 'Cart').
|
||||
await tester.tap(find.byIcon(Icons.shopping_cart_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The KcEmptyState message for an empty cart.
|
||||
expect(find.text('Cart is empty. Tap + on a product to add it to the cart.'), findsOneWidget);
|
||||
});
|
||||
|
||||
// ── receipt view ──────────────────────────────────────────────────────────
|
||||
|
||||
testWidgets('shows receipt view after successful payment', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
|
||||
// Add item
|
||||
await tester.tap(find.byIcon(Icons.add).first);
|
||||
await tester.pump();
|
||||
|
||||
// Tap Cash button
|
||||
await tester.tap(find.text('Cash'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm dialog
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Payment Complete'), findsOneWidget);
|
||||
expect(find.text('New Sale'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('New Sale button resets to idle state', (tester) async {
|
||||
await tester.pumpWidget(_buildPage(controller: controller));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add).first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Cash'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Payment Complete'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('New Sale'));
|
||||
await tester.pump();
|
||||
|
||||
// Back to product list
|
||||
expect(find.text('Products'), findsOneWidget);
|
||||
expect(find.text('0 items'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_mobile/pages/mobile_integrations_page.dart';
|
||||
|
||||
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
|
||||
|
||||
SquareSyncController _fakeController() {
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: FakeProductPublishingRepository(),
|
||||
mappingRepository: FakeProductIdMappingRepository(),
|
||||
);
|
||||
return SquareSyncController(syncService: syncService);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MobileIntegrationsPage', () {
|
||||
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('No integrations configured'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square Sync'), findsOneWidget);
|
||||
expect(find.text('Sync Now'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Square section label when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(MobileIntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('placeholder shows hub icon', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const MobileIntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -42,13 +42,12 @@ void main() {
|
|||
expect(find.text('FAKE'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('bottom navigation bar has 6 destinations', (WidgetTester tester) async {
|
||||
testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_buildTestApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
// Dashboard, Inventory, Products, Orders, Sales, More
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(6));
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(5));
|
||||
});
|
||||
|
||||
testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'package:feature_inventory/feature_inventory.dart';
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:feature_policy/feature_policy.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
/// Holds the concrete service implementations used by `kell_web`.
|
||||
///
|
||||
|
|
@ -13,27 +12,19 @@ import 'package:integrations/integrations.dart';
|
|||
///
|
||||
/// The [AppServices.fake] factory wires up the in-memory fakes that live
|
||||
/// inside each feature package. The [AppServices.wordpress] factory wires up
|
||||
/// a real WooCommerce-backed product repository. The [AppServices.square]
|
||||
/// factory wires up a Square-backed catalog and inventory repository.
|
||||
/// The [AppServices.multi] factory wires up both WooCommerce and Square
|
||||
/// backends simultaneously.
|
||||
/// a real WooCommerce-backed product repository while keeping other services
|
||||
/// fake until their backends are ready.
|
||||
class AppServices extends KcAppServices {
|
||||
final InventoryRepository inventoryRepository;
|
||||
final OrdersRepository ordersRepository;
|
||||
final PolicyRepository policyRepository;
|
||||
final ProductPublishingRepository productPublishingRepository;
|
||||
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
///
|
||||
/// Present only in `square` and `multi` environments.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const AppServices({
|
||||
required this.inventoryRepository,
|
||||
required this.ordersRepository,
|
||||
required this.policyRepository,
|
||||
required this.productPublishingRepository,
|
||||
this.squareSyncController,
|
||||
});
|
||||
|
||||
/// Creates an [AppServices] backed by fake, in-memory repositories.
|
||||
|
|
@ -72,84 +63,7 @@ class AppServices extends KcAppServices {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates an [AppServices] backed by the real Square catalog and inventory
|
||||
/// APIs.
|
||||
///
|
||||
/// [accessToken] – Square API access token (Bearer token).
|
||||
/// [locationId] – Square location ID for inventory counts.
|
||||
/// [environment] – `'sandbox'` or `'production'` (defaults to `'sandbox'`).
|
||||
factory AppServices.square({
|
||||
required String accessToken,
|
||||
required String locationId,
|
||||
String environment = 'sandbox',
|
||||
}) {
|
||||
final apiClient = environment == 'production'
|
||||
? SquareApiClient.production(accessToken: accessToken, locationId: locationId)
|
||||
: SquareApiClient.sandbox(accessToken: accessToken, locationId: locationId);
|
||||
|
||||
final catalogRepo = SquareCatalogRepository(apiClient: apiClient, locationId: locationId);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: catalogRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return AppServices(
|
||||
inventoryRepository: SquareInventoryRepository(apiClient: apiClient, locationId: locationId),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: catalogRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an [AppServices] with both WooCommerce and Square backends active.
|
||||
///
|
||||
/// WooCommerce is used for the product catalog and orders. Square is used for
|
||||
/// inventory counts. This mirrors the multi-channel commerce model where
|
||||
/// WooCommerce is the catalog source of truth and Square tracks in-person
|
||||
/// inventory.
|
||||
factory AppServices.multi({
|
||||
required String wcSiteUrl,
|
||||
required String wcConsumerKey,
|
||||
required String wcConsumerSecret,
|
||||
required String squareAccessToken,
|
||||
required String squareLocationId,
|
||||
String squareEnvironment = 'sandbox',
|
||||
}) {
|
||||
final wcApiClient = WooCommerceApiClient(
|
||||
siteUrl: wcSiteUrl,
|
||||
consumerKey: wcConsumerKey,
|
||||
consumerSecret: wcConsumerSecret,
|
||||
);
|
||||
final squareApiClient = squareEnvironment == 'production'
|
||||
? SquareApiClient.production(accessToken: squareAccessToken, locationId: squareLocationId)
|
||||
: SquareApiClient.sandbox(accessToken: squareAccessToken, locationId: squareLocationId);
|
||||
|
||||
final wcPublishingRepo = WordPressProductPublishingRepository(apiClient: wcApiClient);
|
||||
final mappingRepo = FakeProductIdMappingRepository();
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: wcPublishingRepo,
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
return AppServices(
|
||||
inventoryRepository: SquareInventoryRepository(
|
||||
apiClient: squareApiClient,
|
||||
locationId: squareLocationId,
|
||||
),
|
||||
ordersRepository: FakeOrdersRepository(),
|
||||
policyRepository: FakePolicyRepository(),
|
||||
productPublishingRepository: wcPublishingRepo,
|
||||
squareSyncController: SquareSyncController(syncService: syncService),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
|
||||
///
|
||||
/// Includes [createSquare] and [createMulti] callbacks so that
|
||||
/// [KcBootstrap] can select the correct services for `KC_ENV=square` and
|
||||
/// `KC_ENV=multi` without falling back to fake mode.
|
||||
static KcServiceFactory<AppServices> get serviceFactory {
|
||||
return KcServiceFactory<AppServices>(
|
||||
createFake: () => AppServices.fake(),
|
||||
|
|
@ -158,19 +72,6 @@ class AppServices extends KcAppServices {
|
|||
consumerKey: config.wcConsumerKey,
|
||||
consumerSecret: config.wcConsumerSecret,
|
||||
),
|
||||
createSquare: (config) => AppServices.square(
|
||||
accessToken: config.squareAccessToken,
|
||||
locationId: config.squareLocationId,
|
||||
environment: config.squareEnvironment,
|
||||
),
|
||||
createMulti: (config) => AppServices.multi(
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
squareAccessToken: config.squareAccessToken,
|
||||
squareLocationId: config.squareLocationId,
|
||||
squareEnvironment: config.squareEnvironment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The Integrations page for `kell_web`.
|
||||
///
|
||||
/// When a [SquareSyncController] is provided (i.e. the app is running in
|
||||
/// `square` or `multi` mode), it renders the [SquareSyncStatusPanel] so the
|
||||
/// operator can trigger and monitor Square catalog + inventory syncs.
|
||||
///
|
||||
/// When no controller is available (fake or wordpress mode), a placeholder
|
||||
/// message is shown instead.
|
||||
class IntegrationsPage extends StatelessWidget {
|
||||
/// The Square sync controller, or `null` when Square is not configured.
|
||||
final SquareSyncController? squareSyncController;
|
||||
|
||||
const IntegrationsPage({super.key, this.squareSyncController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = squareSyncController;
|
||||
|
||||
if (controller == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.hub_outlined, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text('No integrations configured', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Set KC_ENV=square or KC_ENV=multi with Square credentials\n'
|
||||
'to enable the Square sync panel.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Square',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SquareSyncStatusPanel(controller: controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import '../dashboard/application/get_dashboard_summary.dart';
|
|||
import '../navigation/app_navigation.dart';
|
||||
import '../pages/dashboard_page.dart';
|
||||
import '../pages/finance_placeholder_page.dart';
|
||||
import '../pages/integrations_page.dart';
|
||||
import '../pages/integrations_placeholder_page.dart';
|
||||
import '../shell/app_shell.dart';
|
||||
|
||||
abstract final class AppRoutes {
|
||||
|
|
@ -117,12 +117,10 @@ abstract final class AppRoutes {
|
|||
case integrations:
|
||||
return _buildRoute(
|
||||
settings,
|
||||
(context) => AppShell(
|
||||
(context) => const AppShell(
|
||||
selectedRoute: integrations,
|
||||
title: 'Integrations',
|
||||
child: IntegrationsPage(
|
||||
squareSyncController: AppScope.of(context).squareSyncController,
|
||||
),
|
||||
child: IntegrationsPlaceholderPage(),
|
||||
),
|
||||
);
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
import 'package:core/core.dart';
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_web/composition/app_services.dart';
|
||||
|
||||
void main() {
|
||||
group('AppServices', () {
|
||||
// ── fake factory ──────────────────────────────────────────────────────────
|
||||
|
||||
group('fake()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.fake();
|
||||
expect(services, isA<AppServices>());
|
||||
expect(services, isA<KcAppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository', () {
|
||||
final services = AppServices.fake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is FakeProductPublishingRepository', () {
|
||||
final services = AppServices.fake();
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── wordpress factory ─────────────────────────────────────────────────────
|
||||
|
||||
group('wordpress()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is FakeInventoryRepository (not yet wired)', () {
|
||||
final services = AppServices.wordpress(
|
||||
siteUrl: 'https://store.kellcreations.com',
|
||||
consumerKey: 'ck_test',
|
||||
consumerSecret: 'cs_test',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── square factory ────────────────────────────────────────────────────────
|
||||
|
||||
group('square()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is SquareCatalogRepository', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('squareSyncController is non-null', () {
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services.squareSyncController, isNotNull);
|
||||
expect(services.squareSyncController, isA<SquareSyncController>());
|
||||
services.squareSyncController!.dispose();
|
||||
});
|
||||
|
||||
test('defaults to sandbox environment', () {
|
||||
// Constructing with no environment param should not throw.
|
||||
final services = AppServices.square(accessToken: 'EAAAl_test', locationId: 'LOC123');
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('accepts production environment', () {
|
||||
final services = AppServices.square(
|
||||
accessToken: 'EAAAl_test',
|
||||
locationId: 'LOC123',
|
||||
environment: 'production',
|
||||
);
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── multi factory ─────────────────────────────────────────────────────────
|
||||
|
||||
group('multi()', () {
|
||||
test('creates AppServices instance', () {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services, isA<AppServices>());
|
||||
});
|
||||
|
||||
test('inventoryRepository is SquareInventoryRepository', () {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('productPublishingRepository is WordPressProductPublishingRepository', () {
|
||||
final services = AppServices.multi(
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── serviceFactory ────────────────────────────────────────────────────────
|
||||
|
||||
group('serviceFactory', () {
|
||||
test('createFake returns fake services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
final services = factory.createFake();
|
||||
expect(services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<FakeProductPublishingRepository>());
|
||||
expect(services.squareSyncController, isNull);
|
||||
});
|
||||
|
||||
test('createWordPress returns wordpress services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.wordpress,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
);
|
||||
final services = factory.createWordPress(config);
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('createSquare is non-null', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
expect(factory.createSquare, isNotNull);
|
||||
});
|
||||
|
||||
test('createSquare returns square services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createSquare!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<SquareCatalogRepository>());
|
||||
});
|
||||
|
||||
test('createMulti is non-null', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
expect(factory.createMulti, isNotNull);
|
||||
});
|
||||
|
||||
test('createMulti returns multi services', () {
|
||||
final factory = AppServices.serviceFactory;
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final services = factory.createMulti!(config);
|
||||
expect(services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(services.productPublishingRepository, isA<WordPressProductPublishingRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects square services when KC_ENV=square with credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, AppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.square);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
});
|
||||
|
||||
test('KcBootstrap selects multi services when KC_ENV=multi with all credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://store.kellcreations.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final result = KcBootstrap.run(config, AppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.multi);
|
||||
expect(result.services.inventoryRepository, isA<SquareInventoryRepository>());
|
||||
expect(
|
||||
result.services.productPublishingRepository,
|
||||
isA<WordPressProductPublishingRepository>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('KcBootstrap falls back to fake when KC_ENV=square without credentials', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: '',
|
||||
);
|
||||
final result = KcBootstrap.run(config, AppServices.serviceFactory);
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
expect(result.services.inventoryRepository, isA<FakeInventoryRepository>());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kell_web/pages/integrations_page.dart';
|
||||
|
||||
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
|
||||
|
||||
SquareSyncController _fakeController() {
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: FakeProductPublishingRepository(),
|
||||
mappingRepository: FakeProductIdMappingRepository(),
|
||||
);
|
||||
return SquareSyncController(syncService: syncService);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('IntegrationsPage', () {
|
||||
testWidgets('shows placeholder when squareSyncController is null', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const IntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('No integrations configured'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows SquareSyncStatusPanel when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square Sync'), findsOneWidget);
|
||||
expect(find.text('Sync Now'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Square section label when controller is provided', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(IntegrationsPage(squareSyncController: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('placeholder shows hub icon', (tester) async {
|
||||
await tester.pumpWidget(_wrap(const IntegrationsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ export 'src/application/inventory_controller.dart';
|
|||
export 'src/application/inventory_sync_service.dart';
|
||||
export 'src/application/update_inventory_item.dart';
|
||||
export 'src/data/fake_inventory_repository.dart';
|
||||
export 'src/data/square_inventory_repository.dart';
|
||||
export 'src/data/woo_commerce_inventory_repository.dart';
|
||||
export 'src/domain/inventory_adjustment_result.dart';
|
||||
export 'src/domain/inventory_item.dart';
|
||||
|
|
|
|||
|
|
@ -1,236 +0,0 @@
|
|||
import 'package:integrations/integrations.dart';
|
||||
|
||||
import '../domain/inventory_adjustment_result.dart';
|
||||
import '../domain/inventory_item.dart';
|
||||
import '../domain/inventory_repository.dart';
|
||||
import '../domain/inventory_status.dart';
|
||||
import '../domain/inventory_sync_status.dart';
|
||||
|
||||
/// Square-backed implementation of [InventoryRepository].
|
||||
///
|
||||
/// Inventory counts are read from and written to Square via [SquareApiClient],
|
||||
/// mapping between Square InventoryCount JSON and the [InventoryItem] domain
|
||||
/// model using [SquareInventoryMapper].
|
||||
///
|
||||
/// ## Scope
|
||||
///
|
||||
/// - [getInventoryItems] — returns the local in-memory item list (Square does
|
||||
/// not provide a full item catalog; items are managed via [SquareCatalogRepository])
|
||||
/// - [adjustQuantity] — applies a delta to local quantity and pushes an
|
||||
/// absolute PHYSICAL_COUNT to Square via [batchChangeInventory]
|
||||
/// - [setQuantity] — sets an absolute quantity locally and pushes to Square
|
||||
/// - [syncWithRemote] — pulls the current IN_STOCK count from Square for a
|
||||
/// single item's variation and updates the local record
|
||||
/// - [getItemBySku] — looks up a local item by SKU (no Square API call)
|
||||
/// - [createItem] / [updateItem] / [updateItemImage] — local-only operations
|
||||
/// (Square item metadata is managed via the catalog API)
|
||||
///
|
||||
/// ## Square variation IDs
|
||||
///
|
||||
/// Square inventory counts are tracked at the ITEM_VARIATION level, not the
|
||||
/// ITEM level. Each [InventoryItem] must have a non-null
|
||||
/// [InventoryItem.squareVariationId] for inventory sync to work. Items without
|
||||
/// a variation ID are stored locally only and are not synced to Square.
|
||||
///
|
||||
/// ## Location
|
||||
///
|
||||
/// All Square inventory operations are scoped to [locationId].
|
||||
class SquareInventoryRepository implements InventoryRepository {
|
||||
final SquareApiClient _apiClient;
|
||||
final SquareInventoryMapper _mapper;
|
||||
|
||||
/// The Square location ID used for inventory counts.
|
||||
final String locationId;
|
||||
|
||||
/// In-memory store for all inventory items (local source of truth).
|
||||
final List<InventoryItem> _items;
|
||||
|
||||
SquareInventoryRepository({
|
||||
required SquareApiClient apiClient,
|
||||
required this.locationId,
|
||||
List<InventoryItem> initialItems = const [],
|
||||
SquareInventoryMapper mapper = const SquareInventoryMapper(),
|
||||
}) : _apiClient = apiClient,
|
||||
_mapper = mapper,
|
||||
_items = List.of(initialItems);
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<List<InventoryItem>> getInventoryItems() async {
|
||||
return List.unmodifiable(_items);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryItem?> getItemBySku(String sku) async {
|
||||
try {
|
||||
return _items.firstWhere((i) => i.sku == sku);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<InventoryAdjustmentResult> adjustQuantity(String id, int delta, String reason) async {
|
||||
final index = _items.indexWhere((i) => i.id == id);
|
||||
if (index == -1) {
|
||||
return const InventoryAdjustmentResult.failure('Item not found');
|
||||
}
|
||||
|
||||
final item = _items[index];
|
||||
final newQty = item.quantityOnHand + delta;
|
||||
if (newQty < 0) {
|
||||
return const InventoryAdjustmentResult.failure('Quantity cannot go below zero');
|
||||
}
|
||||
|
||||
// Push to Square if this item has a linked variation.
|
||||
if (item.squareVariationId != null) {
|
||||
try {
|
||||
final count = ChannelInventoryCount(
|
||||
productId: item.squareVariationId!,
|
||||
sku: item.sku.isNotEmpty ? item.sku : null,
|
||||
quantity: newQty,
|
||||
);
|
||||
final entry = _mapper.toSquareBatchChangeEntry(count, locationId: locationId);
|
||||
await _apiClient.batchChangeInventory([entry]);
|
||||
} catch (e) {
|
||||
return InventoryAdjustmentResult.failure('Square inventory update failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final updated = item.copyWith(
|
||||
quantityOnHand: newQty,
|
||||
status: _deriveStatus(newQty, item.reorderPoint),
|
||||
lastUpdated: DateTime.now(),
|
||||
lastSyncedAt: item.squareVariationId != null ? DateTime.now() : item.lastSyncedAt,
|
||||
syncStatus: item.squareVariationId != null
|
||||
? InventorySyncStatus.synced
|
||||
: InventorySyncStatus.notSynced,
|
||||
);
|
||||
_items[index] = updated;
|
||||
return InventoryAdjustmentResult.success(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryAdjustmentResult> setQuantity(String id, int quantity, String reason) async {
|
||||
if (quantity < 0) {
|
||||
return const InventoryAdjustmentResult.failure('Quantity cannot be negative');
|
||||
}
|
||||
|
||||
final index = _items.indexWhere((i) => i.id == id);
|
||||
if (index == -1) {
|
||||
return const InventoryAdjustmentResult.failure('Item not found');
|
||||
}
|
||||
|
||||
final item = _items[index];
|
||||
|
||||
// Push to Square if this item has a linked variation.
|
||||
if (item.squareVariationId != null) {
|
||||
try {
|
||||
final count = ChannelInventoryCount(
|
||||
productId: item.squareVariationId!,
|
||||
sku: item.sku.isNotEmpty ? item.sku : null,
|
||||
quantity: quantity,
|
||||
);
|
||||
final entry = _mapper.toSquareBatchChangeEntry(count, locationId: locationId);
|
||||
await _apiClient.batchChangeInventory([entry]);
|
||||
} catch (e) {
|
||||
return InventoryAdjustmentResult.failure('Square inventory update failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final updated = item.copyWith(
|
||||
quantityOnHand: quantity,
|
||||
status: _deriveStatus(quantity, item.reorderPoint),
|
||||
lastUpdated: DateTime.now(),
|
||||
lastSyncedAt: item.squareVariationId != null ? DateTime.now() : item.lastSyncedAt,
|
||||
syncStatus: item.squareVariationId != null
|
||||
? InventorySyncStatus.synced
|
||||
: InventorySyncStatus.notSynced,
|
||||
);
|
||||
_items[index] = updated;
|
||||
return InventoryAdjustmentResult.success(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryAdjustmentResult> syncWithRemote(
|
||||
String id,
|
||||
int remoteQuantity,
|
||||
String reason,
|
||||
) async {
|
||||
if (remoteQuantity < 0) {
|
||||
return const InventoryAdjustmentResult.failure('Remote quantity cannot be negative');
|
||||
}
|
||||
|
||||
final index = _items.indexWhere((i) => i.id == id);
|
||||
if (index == -1) {
|
||||
return const InventoryAdjustmentResult.failure('Item not found');
|
||||
}
|
||||
|
||||
final item = _items[index];
|
||||
|
||||
// If the item has a Square variation ID, pull the live count from Square.
|
||||
int resolvedQuantity = remoteQuantity;
|
||||
if (item.squareVariationId != null) {
|
||||
try {
|
||||
final rawCount = await _apiClient.getInventoryCount(item.squareVariationId!);
|
||||
if (rawCount != null) {
|
||||
final channelCount = _mapper.fromSquareInventoryCount(rawCount);
|
||||
if (channelCount != null) {
|
||||
resolvedQuantity = channelCount.quantity;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return InventoryAdjustmentResult.failure('Square inventory fetch failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final updated = item.copyWith(
|
||||
quantityOnHand: resolvedQuantity,
|
||||
status: _deriveStatus(resolvedQuantity, item.reorderPoint),
|
||||
lastUpdated: DateTime.now(),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
syncStatus: InventorySyncStatus.synced,
|
||||
);
|
||||
_items[index] = updated;
|
||||
return InventoryAdjustmentResult.success(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryItem> createItem(InventoryItem item) async {
|
||||
_items.add(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryItem> updateItem(InventoryItem item) async {
|
||||
final index = _items.indexWhere((i) => i.id == item.id);
|
||||
if (index == -1) {
|
||||
throw StateError('Item with id ${item.id} not found');
|
||||
}
|
||||
final updated = item.copyWith(lastUpdated: DateTime.now());
|
||||
_items[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryAdjustmentResult> updateItemImage(String id, String imageUrl) async {
|
||||
final index = _items.indexWhere((i) => i.id == id);
|
||||
if (index == -1) {
|
||||
return const InventoryAdjustmentResult.failure('Item not found');
|
||||
}
|
||||
final updated = _items[index].copyWith(imageUrl: imageUrl, lastUpdated: DateTime.now());
|
||||
_items[index] = updated;
|
||||
return InventoryAdjustmentResult.success(updated);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
static InventoryStatus _deriveStatus(int quantity, int reorderPoint) {
|
||||
if (quantity == 0) return InventoryStatus.outOfStock;
|
||||
if (quantity <= reorderPoint) return InventoryStatus.lowStock;
|
||||
return InventoryStatus.inStock;
|
||||
}
|
||||
}
|
||||
|
|
@ -36,15 +36,6 @@ class InventoryItem {
|
|||
/// WooCommerce product ID.
|
||||
final InventorySyncStatus syncStatus;
|
||||
|
||||
// ── Square sync fields ─────────────────────────────────────────────────────
|
||||
|
||||
/// The Square CatalogObject ID for the primary ITEM_VARIATION linked to this
|
||||
/// inventory item.
|
||||
///
|
||||
/// Square tracks inventory at the variation level, not the item level.
|
||||
/// `null` if this item has no Square counterpart.
|
||||
final String? squareVariationId;
|
||||
|
||||
const InventoryItem({
|
||||
required this.id,
|
||||
required this.sku,
|
||||
|
|
@ -62,7 +53,6 @@ class InventoryItem {
|
|||
this.wooCommerceProductId,
|
||||
this.lastSyncedAt,
|
||||
this.syncStatus = InventorySyncStatus.notSynced,
|
||||
this.squareVariationId,
|
||||
});
|
||||
|
||||
/// Returns a copy of this [InventoryItem] with the given fields replaced.
|
||||
|
|
@ -83,7 +73,6 @@ class InventoryItem {
|
|||
int? wooCommerceProductId,
|
||||
DateTime? lastSyncedAt,
|
||||
InventorySyncStatus? syncStatus,
|
||||
String? squareVariationId,
|
||||
}) {
|
||||
return InventoryItem(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -102,7 +91,6 @@ class InventoryItem {
|
|||
wooCommerceProductId: wooCommerceProductId ?? this.wooCommerceProductId,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
syncStatus: syncStatus ?? this.syncStatus,
|
||||
squareVariationId: squareVariationId ?? this.squareVariationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ dependencies:
|
|||
path: ../design_system
|
||||
feature_wordpress:
|
||||
path: ../feature_wordpress
|
||||
integrations:
|
||||
path: ../integrations
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -1,386 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds a minimal [InventoryItem] for testing.
|
||||
InventoryItem _item({
|
||||
String id = 'INV-001',
|
||||
String sku = 'BC-001',
|
||||
String name = 'Bowl Cozy',
|
||||
int quantity = 10,
|
||||
int reorderPoint = 3,
|
||||
String? squareVariationId,
|
||||
}) {
|
||||
return InventoryItem(
|
||||
id: id,
|
||||
sku: sku,
|
||||
name: name,
|
||||
description: 'Test item',
|
||||
imageUrl: '',
|
||||
category: 'Accessories',
|
||||
quantityOnHand: quantity,
|
||||
unitPrice: 12.99,
|
||||
reorderPoint: reorderPoint,
|
||||
status: InventoryStatus.inStock,
|
||||
lastUpdated: DateTime(2026, 7, 1),
|
||||
squareVariationId: squareVariationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a mock [http.Client] that returns [body] with [statusCode].
|
||||
http.Client _mockClient(String body, {int statusCode = 200}) {
|
||||
return MockClient((_) async => http.Response(body, statusCode));
|
||||
}
|
||||
|
||||
/// Builds a Square inventory count JSON response.
|
||||
String _inventoryCountResponse(String variationId, int quantity) {
|
||||
return jsonEncode({
|
||||
'counts': [
|
||||
{
|
||||
'catalog_object_id': variationId,
|
||||
'catalog_object_type': 'ITEM_VARIATION',
|
||||
'state': 'IN_STOCK',
|
||||
'location_id': 'LOC-001',
|
||||
'quantity': quantity.toString(),
|
||||
'calculated_at': '2026-07-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a Square batch change inventory response.
|
||||
String _batchChangeResponse(String variationId, int quantity) {
|
||||
return jsonEncode({
|
||||
'counts': [
|
||||
{'catalog_object_id': variationId, 'state': 'IN_STOCK', 'quantity': quantity.toString()},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a [SquareInventoryRepository] with the given [items] and [client].
|
||||
SquareInventoryRepository _repo(List<InventoryItem> items, http.Client client) {
|
||||
final apiClient = SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: client,
|
||||
);
|
||||
return SquareInventoryRepository(
|
||||
apiClient: apiClient,
|
||||
locationId: 'LOC-001',
|
||||
initialItems: items,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SquareInventoryRepository', () {
|
||||
// ── getInventoryItems ───────────────────────────────────────────────────
|
||||
|
||||
group('getInventoryItems', () {
|
||||
test('returns all items from initial list', () async {
|
||||
final items = [_item(id: 'INV-001'), _item(id: 'INV-002')];
|
||||
final repo = _repo(items, _mockClient('{}'));
|
||||
final result = await repo.getInventoryItems();
|
||||
expect(result, hasLength(2));
|
||||
expect(result.map((i) => i.id), containsAll(['INV-001', 'INV-002']));
|
||||
});
|
||||
|
||||
test('returns empty list when no items', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final result = await repo.getInventoryItems();
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('returns unmodifiable list', () async {
|
||||
final repo = _repo([_item()], _mockClient('{}'));
|
||||
final result = await repo.getInventoryItems();
|
||||
expect(() => (result as List).add(_item(id: 'NEW')), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getItemBySku ────────────────────────────────────────────────────────
|
||||
|
||||
group('getItemBySku', () {
|
||||
test('returns item when SKU matches', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', sku: 'BC-001')], _mockClient('{}'));
|
||||
final result = await repo.getItemBySku('BC-001');
|
||||
expect(result, isNotNull);
|
||||
expect(result!.id, 'INV-001');
|
||||
});
|
||||
|
||||
test('returns null when SKU not found', () async {
|
||||
final repo = _repo([_item(sku: 'BC-001')], _mockClient('{}'));
|
||||
final result = await repo.getItemBySku('UNKNOWN');
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── adjustQuantity ──────────────────────────────────────────────────────
|
||||
|
||||
group('adjustQuantity', () {
|
||||
test('adjusts quantity locally when no Square variation ID', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}'));
|
||||
final result = await repo.adjustQuantity('INV-001', 5, 'received');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.quantityOnHand, 15);
|
||||
expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced);
|
||||
});
|
||||
|
||||
test('pushes to Square when variation ID is set', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient(_batchChangeResponse('SQ-VAR-001', 15)));
|
||||
final result = await repo.adjustQuantity('INV-001', 5, 'received');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.quantityOnHand, 15);
|
||||
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
|
||||
expect(result.updatedItem!.lastSyncedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('returns failure when item not found', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final result = await repo.adjustQuantity('UNKNOWN', 1, 'test');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('returns failure when quantity would go below zero', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 2)], _mockClient('{}'));
|
||||
final result = await repo.adjustQuantity('INV-001', -5, 'used');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('below zero'));
|
||||
});
|
||||
|
||||
test('returns failure when Square API call fails', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 401));
|
||||
final result = await repo.adjustQuantity('INV-001', 5, 'received');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('Square inventory update failed'));
|
||||
});
|
||||
|
||||
test('derives inStock status when quantity above reorder point', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 5, reorderPoint: 3)], _mockClient('{}'));
|
||||
final result = await repo.adjustQuantity('INV-001', 2, 'received');
|
||||
expect(result.updatedItem!.status, InventoryStatus.inStock);
|
||||
});
|
||||
|
||||
test('derives lowStock status when quantity at or below reorder point', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 5, reorderPoint: 5)], _mockClient('{}'));
|
||||
final result = await repo.adjustQuantity('INV-001', -2, 'used');
|
||||
expect(result.updatedItem!.status, InventoryStatus.lowStock);
|
||||
});
|
||||
|
||||
test('derives outOfStock status when quantity reaches zero', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 3)], _mockClient('{}'));
|
||||
final result = await repo.adjustQuantity('INV-001', -3, 'used');
|
||||
expect(result.updatedItem!.status, InventoryStatus.outOfStock);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setQuantity ─────────────────────────────────────────────────────────
|
||||
|
||||
group('setQuantity', () {
|
||||
test('sets quantity locally when no Square variation ID', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}'));
|
||||
final result = await repo.setQuantity('INV-001', 20, 'count');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.quantityOnHand, 20);
|
||||
expect(result.updatedItem!.syncStatus, InventorySyncStatus.notSynced);
|
||||
});
|
||||
|
||||
test('pushes to Square when variation ID is set', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient(_batchChangeResponse('SQ-VAR-001', 20)));
|
||||
final result = await repo.setQuantity('INV-001', 20, 'count');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.quantityOnHand, 20);
|
||||
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
|
||||
});
|
||||
|
||||
test('returns failure for negative quantity', () async {
|
||||
final repo = _repo([_item()], _mockClient('{}'));
|
||||
final result = await repo.setQuantity('INV-001', -1, 'count');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('negative'));
|
||||
});
|
||||
|
||||
test('returns failure when item not found', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final result = await repo.setQuantity('UNKNOWN', 5, 'count');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('returns failure when Square API call fails', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 500));
|
||||
final result = await repo.setQuantity('INV-001', 20, 'count');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('Square inventory update failed'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncWithRemote ──────────────────────────────────────────────────────
|
||||
|
||||
group('syncWithRemote', () {
|
||||
test('uses remoteQuantity when no Square variation ID', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', quantity: 10)], _mockClient('{}'));
|
||||
final result = await repo.syncWithRemote('INV-001', 25, 'sync');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.quantityOnHand, 25);
|
||||
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
|
||||
expect(result.updatedItem!.lastSyncedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('pulls live count from Square when variation ID is set', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient(_inventoryCountResponse('SQ-VAR-001', 18)));
|
||||
final result = await repo.syncWithRemote('INV-001', 0, 'sync');
|
||||
expect(result.success, isTrue);
|
||||
// Should use the Square count (18), not the passed remoteQuantity (0).
|
||||
expect(result.updatedItem!.quantityOnHand, 18);
|
||||
expect(result.updatedItem!.syncStatus, InventorySyncStatus.synced);
|
||||
});
|
||||
|
||||
test('falls back to remoteQuantity when Square returns null count', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient(jsonEncode({'counts': []})));
|
||||
final result = await repo.syncWithRemote('INV-001', 7, 'sync');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.quantityOnHand, 7);
|
||||
});
|
||||
|
||||
test('returns failure for negative remoteQuantity', () async {
|
||||
final repo = _repo([_item()], _mockClient('{}'));
|
||||
final result = await repo.syncWithRemote('INV-001', -1, 'sync');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('negative'));
|
||||
});
|
||||
|
||||
test('returns failure when item not found', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final result = await repo.syncWithRemote('UNKNOWN', 5, 'sync');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('returns failure when Square API call fails', () async {
|
||||
final item = _item(id: 'INV-001', quantity: 10, squareVariationId: 'SQ-VAR-001');
|
||||
final repo = _repo([item], _mockClient('{"errors":[]}', statusCode: 404));
|
||||
final result = await repo.syncWithRemote('INV-001', 5, 'sync');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('Square inventory fetch failed'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── createItem ──────────────────────────────────────────────────────────
|
||||
|
||||
group('createItem', () {
|
||||
test('adds item to local store and returns it', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final newItem = _item(id: 'INV-NEW');
|
||||
final result = await repo.createItem(newItem);
|
||||
expect(result.id, 'INV-NEW');
|
||||
|
||||
final all = await repo.getInventoryItems();
|
||||
expect(all, hasLength(1));
|
||||
expect(all.first.id, 'INV-NEW');
|
||||
});
|
||||
|
||||
test('can create item with Square variation ID', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final newItem = _item(id: 'INV-NEW', squareVariationId: 'SQ-VAR-NEW');
|
||||
final result = await repo.createItem(newItem);
|
||||
expect(result.squareVariationId, 'SQ-VAR-NEW');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateItem ──────────────────────────────────────────────────────────
|
||||
|
||||
group('updateItem', () {
|
||||
test('updates item in local store', () async {
|
||||
final repo = _repo([_item(id: 'INV-001', name: 'Old Name')], _mockClient('{}'));
|
||||
final updated = _item(id: 'INV-001', name: 'New Name');
|
||||
final result = await repo.updateItem(updated);
|
||||
expect(result.name, 'New Name');
|
||||
|
||||
final all = await repo.getInventoryItems();
|
||||
expect(all.first.name, 'New Name');
|
||||
});
|
||||
|
||||
test('throws StateError when item not found', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
expect(() => repo.updateItem(_item(id: 'UNKNOWN')), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('stamps lastUpdated on update', () async {
|
||||
final before = DateTime(2026, 1, 1);
|
||||
final item = InventoryItem(
|
||||
id: 'INV-001',
|
||||
sku: 'BC-001',
|
||||
name: 'Item',
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
category: '',
|
||||
quantityOnHand: 5,
|
||||
unitPrice: 10.0,
|
||||
reorderPoint: 2,
|
||||
status: InventoryStatus.inStock,
|
||||
lastUpdated: before,
|
||||
);
|
||||
final repo = _repo([item], _mockClient('{}'));
|
||||
final result = await repo.updateItem(item);
|
||||
expect(result.lastUpdated.isAfter(before), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateItemImage ─────────────────────────────────────────────────────
|
||||
|
||||
group('updateItemImage', () {
|
||||
test('updates image URL in local store', () async {
|
||||
final repo = _repo([_item(id: 'INV-001')], _mockClient('{}'));
|
||||
final result = await repo.updateItemImage('INV-001', 'https://example.com/img.jpg');
|
||||
expect(result.success, isTrue);
|
||||
expect(result.updatedItem!.imageUrl, 'https://example.com/img.jpg');
|
||||
});
|
||||
|
||||
test('returns failure when item not found', () async {
|
||||
final repo = _repo([], _mockClient('{}'));
|
||||
final result = await repo.updateItemImage('UNKNOWN', 'https://example.com/img.jpg');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── InventoryItem.squareVariationId field ───────────────────────────────
|
||||
|
||||
group('InventoryItem.squareVariationId', () {
|
||||
test('defaults to null', () {
|
||||
final item = _item();
|
||||
expect(item.squareVariationId, isNull);
|
||||
});
|
||||
|
||||
test('can be set via constructor', () {
|
||||
final item = _item(squareVariationId: 'SQ-VAR-001');
|
||||
expect(item.squareVariationId, 'SQ-VAR-001');
|
||||
});
|
||||
|
||||
test('copyWith preserves squareVariationId when not overridden', () {
|
||||
final item = _item(squareVariationId: 'SQ-VAR-001');
|
||||
final copy = item.copyWith(name: 'New Name');
|
||||
expect(copy.squareVariationId, 'SQ-VAR-001');
|
||||
});
|
||||
|
||||
test('copyWith can update squareVariationId', () {
|
||||
final item = _item(squareVariationId: 'SQ-VAR-001');
|
||||
final copy = item.copyWith(squareVariationId: 'SQ-VAR-002');
|
||||
expect(copy.squareVariationId, 'SQ-VAR-002');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
library;
|
||||
|
||||
export 'src/application/add_order_note.dart';
|
||||
export 'src/application/checkout_controller.dart';
|
||||
export 'src/application/create_order.dart';
|
||||
export 'src/application/get_orders.dart';
|
||||
export 'src/application/orders_controller.dart';
|
||||
export 'src/application/payment_service.dart';
|
||||
export 'src/application/update_order_status.dart';
|
||||
export 'src/data/fake_orders_repository.dart';
|
||||
export 'src/data/fake_payment_service.dart';
|
||||
export 'src/data/woo_commerce_order_mapper.dart';
|
||||
export 'src/data/woo_commerce_orders_repository.dart';
|
||||
export 'src/domain/order.dart';
|
||||
|
|
@ -17,8 +14,6 @@ export 'src/domain/order_item.dart';
|
|||
export 'src/domain/order_source.dart';
|
||||
export 'src/domain/order_status.dart';
|
||||
export 'src/domain/orders_repository.dart';
|
||||
export 'src/domain/payment_method.dart';
|
||||
export 'src/domain/payment_result.dart';
|
||||
export 'src/presentation/orders_action_snack_bar.dart';
|
||||
export 'src/presentation/orders_page.dart';
|
||||
export 'src/presentation/widgets/add_note_dialog.dart';
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../domain/order_item.dart';
|
||||
import '../domain/payment_method.dart';
|
||||
import '../domain/payment_result.dart';
|
||||
import 'payment_service.dart';
|
||||
|
||||
/// Represents a single item in the active checkout cart.
|
||||
///
|
||||
/// Wraps an [OrderItem] with a mutable quantity so the cart can be updated
|
||||
/// without mutating the underlying domain model.
|
||||
class CartEntry {
|
||||
final OrderItem item;
|
||||
final int quantity;
|
||||
|
||||
const CartEntry({required this.item, required this.quantity});
|
||||
|
||||
CartEntry copyWith({int? quantity}) => CartEntry(item: item, quantity: quantity ?? this.quantity);
|
||||
|
||||
/// Total price for this line in cents.
|
||||
int get lineTotalCents => (item.unitPrice * quantity * 100).round();
|
||||
}
|
||||
|
||||
/// Sealed state hierarchy for the checkout flow.
|
||||
sealed class CheckoutState {}
|
||||
|
||||
/// The cart is being built — no payment in progress.
|
||||
class CheckoutIdle extends CheckoutState {
|
||||
final List<CartEntry> cart;
|
||||
CheckoutIdle(this.cart);
|
||||
}
|
||||
|
||||
/// A payment is being processed.
|
||||
class CheckoutProcessing extends CheckoutState {
|
||||
final List<CartEntry> cart;
|
||||
CheckoutProcessing(this.cart);
|
||||
}
|
||||
|
||||
/// Payment completed successfully.
|
||||
class CheckoutSuccess extends CheckoutState {
|
||||
final PaymentResult result;
|
||||
CheckoutSuccess(this.result);
|
||||
}
|
||||
|
||||
/// Payment failed.
|
||||
class CheckoutError extends CheckoutState {
|
||||
final String message;
|
||||
final List<CartEntry> cart;
|
||||
CheckoutError(this.message, this.cart);
|
||||
}
|
||||
|
||||
/// Application-layer controller for the mobile POS checkout flow.
|
||||
///
|
||||
/// Manages the cart (add / remove / clear) and delegates payment processing
|
||||
/// to the injected [PaymentService].
|
||||
///
|
||||
/// Extends [ChangeNotifier] so [MobileCheckoutPage] can listen for state
|
||||
/// changes via [ListenableBuilder] or [AnimatedBuilder].
|
||||
class CheckoutController extends ChangeNotifier {
|
||||
final PaymentService paymentService;
|
||||
|
||||
CheckoutController({required this.paymentService});
|
||||
|
||||
CheckoutState _state = CheckoutIdle([]);
|
||||
|
||||
CheckoutState get state => _state;
|
||||
|
||||
List<CartEntry> get cart {
|
||||
final s = _state;
|
||||
if (s is CheckoutIdle) return s.cart;
|
||||
if (s is CheckoutProcessing) return s.cart;
|
||||
if (s is CheckoutError) return s.cart;
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Total cart value in cents.
|
||||
int get totalCents => cart.fold(0, (sum, e) => sum + e.lineTotalCents);
|
||||
|
||||
/// Adds [item] to the cart, incrementing quantity if already present.
|
||||
void addItem(OrderItem item) {
|
||||
final current = List<CartEntry>.from(cart);
|
||||
final idx = current.indexWhere((e) => e.item.sku == item.sku);
|
||||
if (idx >= 0) {
|
||||
current[idx] = current[idx].copyWith(quantity: current[idx].quantity + 1);
|
||||
} else {
|
||||
current.add(CartEntry(item: item, quantity: 1));
|
||||
}
|
||||
_state = CheckoutIdle(current);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Removes one unit of [sku] from the cart. Removes the entry entirely
|
||||
/// when quantity reaches zero.
|
||||
void removeItem(String sku) {
|
||||
final current = List<CartEntry>.from(cart);
|
||||
final idx = current.indexWhere((e) => e.item.sku == sku);
|
||||
if (idx < 0) return;
|
||||
if (current[idx].quantity > 1) {
|
||||
current[idx] = current[idx].copyWith(quantity: current[idx].quantity - 1);
|
||||
} else {
|
||||
current.removeAt(idx);
|
||||
}
|
||||
_state = CheckoutIdle(current);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Empties the cart and resets to idle.
|
||||
void clearCart() {
|
||||
_state = CheckoutIdle([]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Resets to idle after a success or error so a new sale can begin.
|
||||
void reset() {
|
||||
_state = CheckoutIdle([]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Processes payment for the current cart using [method].
|
||||
///
|
||||
/// Transitions: idle → processing → success | error.
|
||||
Future<void> processPayment(PaymentMethod method) async {
|
||||
if (cart.isEmpty) return;
|
||||
final snapshot = List<CartEntry>.from(cart);
|
||||
_state = CheckoutProcessing(snapshot);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await paymentService.processPayment(amountCents: totalCents, method: method);
|
||||
_state = CheckoutSuccess(result);
|
||||
} on PaymentException catch (e) {
|
||||
_state = CheckoutError(e.message, snapshot);
|
||||
} catch (e) {
|
||||
_state = CheckoutError('Unexpected error: $e', snapshot);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import '../domain/payment_method.dart';
|
||||
import '../domain/payment_result.dart';
|
||||
|
||||
/// Abstract contract for processing point-of-sale payments.
|
||||
///
|
||||
/// Implementations:
|
||||
/// - [FakePaymentService] — always succeeds instantly; used in fake/test mode.
|
||||
/// - `SquarePaymentService` (future) — delegates to the Square Reader SDK.
|
||||
///
|
||||
/// All amounts are expressed in the smallest currency unit (cents for USD).
|
||||
abstract class PaymentService {
|
||||
const PaymentService();
|
||||
|
||||
/// Processes a payment for [amountCents] using [method].
|
||||
///
|
||||
/// Returns a [PaymentResult] on success.
|
||||
/// Throws a [PaymentException] on failure.
|
||||
Future<PaymentResult> processPayment({required int amountCents, required PaymentMethod method});
|
||||
}
|
||||
|
||||
/// Thrown when a payment attempt fails.
|
||||
class PaymentException implements Exception {
|
||||
final String message;
|
||||
final Object? cause;
|
||||
|
||||
const PaymentException(this.message, {this.cause});
|
||||
|
||||
@override
|
||||
String toString() => 'PaymentException: $message${cause != null ? ' (cause: $cause)' : ''}';
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import '../application/payment_service.dart';
|
||||
import '../domain/payment_method.dart';
|
||||
import '../domain/payment_result.dart';
|
||||
|
||||
/// In-memory [PaymentService] that always succeeds instantly.
|
||||
///
|
||||
/// Used in `fake` mode and in unit/widget tests. Generates a deterministic
|
||||
/// transaction ID from a counter so tests can assert on the returned value.
|
||||
class FakePaymentService extends PaymentService {
|
||||
int _counter = 0;
|
||||
|
||||
/// Simulates a successful payment with a generated transaction ID.
|
||||
///
|
||||
/// Never throws. Returns immediately (no network delay).
|
||||
@override
|
||||
Future<PaymentResult> processPayment({
|
||||
required int amountCents,
|
||||
required PaymentMethod method,
|
||||
}) async {
|
||||
_counter++;
|
||||
final id = 'fake-txn-${_counter.toString().padLeft(4, '0')}-${math.Random().nextInt(9999)}';
|
||||
return PaymentResult(
|
||||
transactionId: id,
|
||||
amountCents: amountCents,
|
||||
method: method,
|
||||
completedAt: DateTime.now().toUtc(),
|
||||
receiptUrl: method == PaymentMethod.card ? 'https://squareup.com/receipt/$id' : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/// The method used to complete a point-of-sale payment.
|
||||
enum PaymentMethod {
|
||||
/// Cash tendered by the customer; no hardware required.
|
||||
cash,
|
||||
|
||||
/// Card payment processed via Square Reader SDK or manual entry.
|
||||
card,
|
||||
}
|
||||
|
||||
extension PaymentMethodLabel on PaymentMethod {
|
||||
/// Human-readable label for display in the UI.
|
||||
String get label {
|
||||
switch (this) {
|
||||
case PaymentMethod.cash:
|
||||
return 'Cash';
|
||||
case PaymentMethod.card:
|
||||
return 'Card';
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon name hint for the UI layer.
|
||||
String get iconHint {
|
||||
switch (this) {
|
||||
case PaymentMethod.cash:
|
||||
return 'payments';
|
||||
case PaymentMethod.card:
|
||||
return 'credit_card';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import 'payment_method.dart';
|
||||
|
||||
/// The outcome of a completed point-of-sale payment transaction.
|
||||
///
|
||||
/// Returned by [PaymentService.processPayment] after a successful charge.
|
||||
/// Immutable value object — all fields are set at construction time.
|
||||
class PaymentResult {
|
||||
/// Unique transaction identifier assigned by the payment processor.
|
||||
///
|
||||
/// For cash payments this is a locally-generated UUID.
|
||||
/// For card payments this is the Square payment ID.
|
||||
final String transactionId;
|
||||
|
||||
/// Total amount charged, in the smallest currency unit (e.g. cents for USD).
|
||||
final int amountCents;
|
||||
|
||||
/// The payment method used.
|
||||
final PaymentMethod method;
|
||||
|
||||
/// URL to the digital receipt, or `null` for cash transactions.
|
||||
final String? receiptUrl;
|
||||
|
||||
/// UTC timestamp when the payment was completed.
|
||||
final DateTime completedAt;
|
||||
|
||||
const PaymentResult({
|
||||
required this.transactionId,
|
||||
required this.amountCents,
|
||||
required this.method,
|
||||
required this.completedAt,
|
||||
this.receiptUrl,
|
||||
});
|
||||
|
||||
/// Convenience: amount as a decimal dollars string (e.g. `"12.50"`).
|
||||
String get amountFormatted {
|
||||
final dollars = amountCents ~/ 100;
|
||||
final cents = amountCents % 100;
|
||||
return '\$$dollars.${cents.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PaymentResult &&
|
||||
runtimeType == other.runtimeType &&
|
||||
transactionId == other.transactionId &&
|
||||
amountCents == other.amountCents &&
|
||||
method == other.method &&
|
||||
receiptUrl == other.receiptUrl &&
|
||||
completedAt == other.completedAt;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(transactionId, amountCents, method, receiptUrl, completedAt);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PaymentResult(transactionId: $transactionId, amount: $amountFormatted, '
|
||||
'method: ${method.label}, completedAt: $completedAt)';
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A [PaymentService] that always throws [PaymentException].
|
||||
class _FailingPaymentService extends PaymentService {
|
||||
@override
|
||||
Future<PaymentResult> processPayment({
|
||||
required int amountCents,
|
||||
required PaymentMethod method,
|
||||
}) async {
|
||||
throw const PaymentException('Card declined');
|
||||
}
|
||||
}
|
||||
|
||||
const _item1 = OrderItem(productName: 'Widget A', sku: 'WA-001', quantity: 1, unitPrice: 10.00);
|
||||
const _item2 = OrderItem(productName: 'Widget B', sku: 'WB-002', quantity: 1, unitPrice: 5.50);
|
||||
|
||||
void main() {
|
||||
group('CheckoutController', () {
|
||||
late CheckoutController controller;
|
||||
|
||||
setUp(() {
|
||||
controller = CheckoutController(paymentService: FakePaymentService());
|
||||
});
|
||||
|
||||
tearDown(() => controller.dispose());
|
||||
|
||||
// ── initial state ────────────────────────────────────────────────────────
|
||||
|
||||
test('starts in idle state with empty cart', () {
|
||||
expect(controller.state, isA<CheckoutIdle>());
|
||||
expect(controller.cart, isEmpty);
|
||||
expect(controller.totalCents, 0);
|
||||
});
|
||||
|
||||
// ── addItem ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('addItem adds a new entry to the cart', () {
|
||||
controller.addItem(_item1);
|
||||
expect(controller.cart.length, 1);
|
||||
expect(controller.cart.first.item.sku, 'WA-001');
|
||||
expect(controller.cart.first.quantity, 1);
|
||||
});
|
||||
|
||||
test('addItem increments quantity for duplicate SKU', () {
|
||||
controller.addItem(_item1);
|
||||
controller.addItem(_item1);
|
||||
expect(controller.cart.length, 1);
|
||||
expect(controller.cart.first.quantity, 2);
|
||||
});
|
||||
|
||||
test('addItem handles multiple distinct items', () {
|
||||
controller.addItem(_item1);
|
||||
controller.addItem(_item2);
|
||||
expect(controller.cart.length, 2);
|
||||
});
|
||||
|
||||
// ── totalCents ───────────────────────────────────────────────────────────
|
||||
|
||||
test('totalCents sums all line totals', () {
|
||||
controller.addItem(_item1); // 10.00
|
||||
controller.addItem(_item2); // 5.50
|
||||
// 1000 + 550 = 1550
|
||||
expect(controller.totalCents, 1550);
|
||||
});
|
||||
|
||||
test('totalCents accounts for quantity', () {
|
||||
controller.addItem(_item1);
|
||||
controller.addItem(_item1); // qty 2 × $10 = $20
|
||||
expect(controller.totalCents, 2000);
|
||||
});
|
||||
|
||||
// ── removeItem ───────────────────────────────────────────────────────────
|
||||
|
||||
test('removeItem decrements quantity', () {
|
||||
controller.addItem(_item1);
|
||||
controller.addItem(_item1);
|
||||
controller.removeItem('WA-001');
|
||||
expect(controller.cart.first.quantity, 1);
|
||||
});
|
||||
|
||||
test('removeItem removes entry when quantity reaches zero', () {
|
||||
controller.addItem(_item1);
|
||||
controller.removeItem('WA-001');
|
||||
expect(controller.cart, isEmpty);
|
||||
});
|
||||
|
||||
test('removeItem is a no-op for unknown SKU', () {
|
||||
controller.addItem(_item1);
|
||||
controller.removeItem('UNKNOWN');
|
||||
expect(controller.cart.length, 1);
|
||||
});
|
||||
|
||||
// ── clearCart ────────────────────────────────────────────────────────────
|
||||
|
||||
test('clearCart empties the cart', () {
|
||||
controller.addItem(_item1);
|
||||
controller.addItem(_item2);
|
||||
controller.clearCart();
|
||||
expect(controller.cart, isEmpty);
|
||||
expect(controller.totalCents, 0);
|
||||
});
|
||||
|
||||
// ── processPayment — success ─────────────────────────────────────────────
|
||||
|
||||
test('processPayment transitions to CheckoutSuccess on success', () async {
|
||||
controller.addItem(_item1);
|
||||
await controller.processPayment(PaymentMethod.cash);
|
||||
expect(controller.state, isA<CheckoutSuccess>());
|
||||
final success = controller.state as CheckoutSuccess;
|
||||
expect(success.result.amountCents, 1000);
|
||||
expect(success.result.method, PaymentMethod.cash);
|
||||
});
|
||||
|
||||
test('processPayment is a no-op when cart is empty', () async {
|
||||
await controller.processPayment(PaymentMethod.cash);
|
||||
expect(controller.state, isA<CheckoutIdle>());
|
||||
});
|
||||
|
||||
// ── processPayment — failure ─────────────────────────────────────────────
|
||||
|
||||
test('processPayment transitions to CheckoutError on PaymentException', () async {
|
||||
final failCtrl = CheckoutController(paymentService: _FailingPaymentService());
|
||||
addTearDown(failCtrl.dispose);
|
||||
failCtrl.addItem(_item1);
|
||||
await failCtrl.processPayment(PaymentMethod.card);
|
||||
expect(failCtrl.state, isA<CheckoutError>());
|
||||
final err = failCtrl.state as CheckoutError;
|
||||
expect(err.message, 'Card declined');
|
||||
// Cart is preserved after error
|
||||
expect(err.cart.length, 1);
|
||||
});
|
||||
|
||||
// ── reset ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('reset returns to idle with empty cart after success', () async {
|
||||
controller.addItem(_item1);
|
||||
await controller.processPayment(PaymentMethod.cash);
|
||||
expect(controller.state, isA<CheckoutSuccess>());
|
||||
controller.reset();
|
||||
expect(controller.state, isA<CheckoutIdle>());
|
||||
expect(controller.cart, isEmpty);
|
||||
});
|
||||
|
||||
// ── CartEntry ────────────────────────────────────────────────────────────
|
||||
|
||||
test('CartEntry.lineTotalCents is quantity × unitPrice × 100', () {
|
||||
const entry = CartEntry(item: _item1, quantity: 3);
|
||||
expect(entry.lineTotalCents, 3000); // 3 × $10.00
|
||||
});
|
||||
|
||||
test('CartEntry.copyWith updates quantity', () {
|
||||
const entry = CartEntry(item: _item1, quantity: 1);
|
||||
final updated = entry.copyWith(quantity: 5);
|
||||
expect(updated.quantity, 5);
|
||||
expect(updated.item.sku, 'WA-001');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FakePaymentService', () {
|
||||
late FakePaymentService service;
|
||||
|
||||
setUp(() => service = FakePaymentService());
|
||||
|
||||
test('returns a PaymentResult for cash', () async {
|
||||
final result = await service.processPayment(amountCents: 1000, method: PaymentMethod.cash);
|
||||
expect(result.amountCents, 1000);
|
||||
expect(result.method, PaymentMethod.cash);
|
||||
expect(result.receiptUrl, isNull);
|
||||
expect(result.transactionId, isNotEmpty);
|
||||
});
|
||||
|
||||
test('returns a PaymentResult for card with receiptUrl', () async {
|
||||
final result = await service.processPayment(amountCents: 2500, method: PaymentMethod.card);
|
||||
expect(result.amountCents, 2500);
|
||||
expect(result.method, PaymentMethod.card);
|
||||
expect(result.receiptUrl, isNotNull);
|
||||
expect(result.receiptUrl, contains('squareup.com'));
|
||||
});
|
||||
|
||||
test('generates unique transaction IDs across calls', () async {
|
||||
final r1 = await service.processPayment(amountCents: 100, method: PaymentMethod.cash);
|
||||
final r2 = await service.processPayment(amountCents: 100, method: PaymentMethod.cash);
|
||||
// IDs include a counter prefix that differs
|
||||
expect(r1.transactionId, isNot(equals(r2.transactionId)));
|
||||
});
|
||||
|
||||
test('completedAt is set to a recent UTC time', () async {
|
||||
final before = DateTime.now().toUtc().subtract(const Duration(seconds: 1));
|
||||
final result = await service.processPayment(amountCents: 500, method: PaymentMethod.cash);
|
||||
final after = DateTime.now().toUtc().add(const Duration(seconds: 1));
|
||||
expect(result.completedAt.isAfter(before), isTrue);
|
||||
expect(result.completedAt.isBefore(after), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('PaymentMethod', () {
|
||||
test('cash has correct label', () {
|
||||
expect(PaymentMethod.cash.label, 'Cash');
|
||||
});
|
||||
|
||||
test('card has correct label', () {
|
||||
expect(PaymentMethod.card.label, 'Card');
|
||||
});
|
||||
|
||||
test('cash has payments iconHint', () {
|
||||
expect(PaymentMethod.cash.iconHint, 'payments');
|
||||
});
|
||||
|
||||
test('card has credit_card iconHint', () {
|
||||
expect(PaymentMethod.card.iconHint, 'credit_card');
|
||||
});
|
||||
|
||||
test('all values covered', () {
|
||||
expect(PaymentMethod.values.length, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import 'package:feature_orders/feature_orders.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final baseTime = DateTime.utc(2026, 7, 25, 14, 30);
|
||||
|
||||
PaymentResult makeResult({
|
||||
String transactionId = 'txn-001',
|
||||
int amountCents = 1250,
|
||||
PaymentMethod method = PaymentMethod.cash,
|
||||
String? receiptUrl,
|
||||
}) {
|
||||
return PaymentResult(
|
||||
transactionId: transactionId,
|
||||
amountCents: amountCents,
|
||||
method: method,
|
||||
completedAt: baseTime,
|
||||
receiptUrl: receiptUrl,
|
||||
);
|
||||
}
|
||||
|
||||
group('PaymentResult', () {
|
||||
test('amountFormatted formats cents correctly', () {
|
||||
expect(makeResult(amountCents: 1250).amountFormatted, r'$12.50');
|
||||
});
|
||||
|
||||
test('amountFormatted pads single-digit cents', () {
|
||||
expect(makeResult(amountCents: 1205).amountFormatted, r'$12.05');
|
||||
});
|
||||
|
||||
test('amountFormatted handles zero cents', () {
|
||||
expect(makeResult(amountCents: 0).amountFormatted, r'$0.00');
|
||||
});
|
||||
|
||||
test('amountFormatted handles whole dollars', () {
|
||||
expect(makeResult(amountCents: 2000).amountFormatted, r'$20.00');
|
||||
});
|
||||
|
||||
test('equality — same fields are equal', () {
|
||||
final a = makeResult();
|
||||
final b = makeResult();
|
||||
expect(a, equals(b));
|
||||
});
|
||||
|
||||
test('equality — different transactionId not equal', () {
|
||||
final a = makeResult(transactionId: 'txn-001');
|
||||
final b = makeResult(transactionId: 'txn-002');
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('equality — different amountCents not equal', () {
|
||||
final a = makeResult(amountCents: 100);
|
||||
final b = makeResult(amountCents: 200);
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('receiptUrl is null for cash by default', () {
|
||||
expect(makeResult(method: PaymentMethod.cash).receiptUrl, isNull);
|
||||
});
|
||||
|
||||
test('receiptUrl is set for card', () {
|
||||
final r = makeResult(method: PaymentMethod.card, receiptUrl: 'https://example.com/receipt');
|
||||
expect(r.receiptUrl, 'https://example.com/receipt');
|
||||
});
|
||||
|
||||
test('toString contains transactionId', () {
|
||||
final r = makeResult(transactionId: 'txn-abc');
|
||||
expect(r.toString(), contains('txn-abc'));
|
||||
});
|
||||
|
||||
test('hashCode is consistent', () {
|
||||
final a = makeResult();
|
||||
final b = makeResult();
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
library;
|
||||
|
||||
// Data
|
||||
export 'src/data/fake_product_id_mapping_repository.dart';
|
||||
export 'src/data/fake_product_publishing_repository.dart';
|
||||
export 'src/data/square_catalog_repository.dart';
|
||||
export 'src/data/woo_commerce_api_client.dart';
|
||||
export 'src/data/wordpress_product_mapper.dart';
|
||||
export 'src/data/wordpress_product_publishing_repository.dart';
|
||||
|
|
@ -11,15 +9,11 @@ export 'src/data/wordpress_product_publishing_repository.dart';
|
|||
// Domain
|
||||
export 'src/domain/product_category.dart';
|
||||
export 'src/domain/product_draft.dart';
|
||||
export 'src/domain/product_id_mapping.dart';
|
||||
export 'src/domain/product_id_mapping_repository.dart';
|
||||
export 'src/domain/product_image.dart';
|
||||
export 'src/domain/product_publishing_repository.dart';
|
||||
export 'src/domain/publish_status.dart';
|
||||
|
||||
// Application
|
||||
export 'src/application/square_catalog_sync_service.dart';
|
||||
export 'src/application/square_sync_controller.dart';
|
||||
export 'src/application/create_category.dart';
|
||||
export 'src/application/create_product.dart';
|
||||
export 'src/application/delete_product.dart';
|
||||
|
|
@ -35,7 +29,6 @@ export 'src/application/update_product_price.dart';
|
|||
export 'src/application/update_product_status.dart';
|
||||
|
||||
// Presentation
|
||||
export 'src/presentation/widgets/square_sync_status_panel.dart';
|
||||
export 'src/presentation/category_management_page.dart';
|
||||
export 'src/presentation/product_publishing_page.dart';
|
||||
export 'src/presentation/widgets/create_product_dialog.dart';
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
import '../domain/product_draft.dart';
|
||||
import '../domain/product_id_mapping.dart';
|
||||
import '../domain/product_id_mapping_repository.dart';
|
||||
import '../domain/product_publishing_repository.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value objects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Describes a single item that failed during a sync operation.
|
||||
class SyncError {
|
||||
/// The local product / inventory item ID that failed.
|
||||
final String itemId;
|
||||
|
||||
/// A human-readable description of the failure.
|
||||
final String message;
|
||||
|
||||
const SyncError({required this.itemId, required this.message});
|
||||
|
||||
@override
|
||||
String toString() => 'SyncError(itemId: $itemId, message: $message)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncError &&
|
||||
runtimeType == other.runtimeType &&
|
||||
itemId == other.itemId &&
|
||||
message == other.message;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(itemId, message);
|
||||
}
|
||||
|
||||
/// Aggregate result returned by [SquareCatalogSyncService.syncAll],
|
||||
/// [SquareCatalogSyncService.syncCatalog], and
|
||||
/// [SquareCatalogSyncService.syncInventory].
|
||||
class SquareSyncResult {
|
||||
/// Number of catalog items successfully synced (mapping created/updated).
|
||||
final int catalogSynced;
|
||||
|
||||
/// Number of inventory items successfully synced (quantity pulled from Square).
|
||||
final int inventorySynced;
|
||||
|
||||
/// Number of catalog items that failed to sync.
|
||||
final int catalogFailed;
|
||||
|
||||
/// Number of inventory items that failed to sync.
|
||||
final int inventoryFailed;
|
||||
|
||||
/// Detailed error list for failed items.
|
||||
final List<SyncError> errors;
|
||||
|
||||
const SquareSyncResult({
|
||||
this.catalogSynced = 0,
|
||||
this.inventorySynced = 0,
|
||||
this.catalogFailed = 0,
|
||||
this.inventoryFailed = 0,
|
||||
this.errors = const [],
|
||||
});
|
||||
|
||||
/// Total number of items processed (catalog + inventory).
|
||||
int get totalProcessed => catalogSynced + catalogFailed + inventorySynced + inventoryFailed;
|
||||
|
||||
/// `true` when no errors occurred.
|
||||
bool get allSucceeded => catalogFailed == 0 && inventoryFailed == 0;
|
||||
|
||||
/// Returns a new [SquareSyncResult] that merges [other] into this result.
|
||||
SquareSyncResult merge(SquareSyncResult other) {
|
||||
return SquareSyncResult(
|
||||
catalogSynced: catalogSynced + other.catalogSynced,
|
||||
inventorySynced: inventorySynced + other.inventorySynced,
|
||||
catalogFailed: catalogFailed + other.catalogFailed,
|
||||
inventoryFailed: inventoryFailed + other.inventoryFailed,
|
||||
errors: [...errors, ...other.errors],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SquareSyncResult(catalogSynced: $catalogSynced, '
|
||||
'inventorySynced: $inventorySynced, '
|
||||
'catalogFailed: $catalogFailed, '
|
||||
'inventoryFailed: $inventoryFailed, '
|
||||
'errors: ${errors.length})';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory adapter contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimal contract used by [SquareCatalogSyncService] to sync inventory
|
||||
/// without taking a hard dependency on the full [InventoryRepository] from
|
||||
/// `feature_inventory`.
|
||||
///
|
||||
/// Implementations should delegate to [SquareInventoryRepository] or
|
||||
/// [FakeInventoryRepository] as appropriate.
|
||||
abstract class InventorySyncAdapter {
|
||||
/// Pulls the current quantity for [itemId] from Square and updates the local
|
||||
/// record.
|
||||
///
|
||||
/// [remoteQuantity] is used as a fallback when the Square API returns an
|
||||
/// empty count list.
|
||||
///
|
||||
/// Returns `true` on success, `false` on failure.
|
||||
Future<bool> syncItem(String itemId, int remoteQuantity);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Application-layer service that orchestrates a full Square catalog and
|
||||
/// inventory sync.
|
||||
///
|
||||
/// ## Responsibilities
|
||||
///
|
||||
/// 1. **Catalog sync** ([syncCatalog]):
|
||||
/// - Fetches all products from Square via [ProductPublishingRepository.getProductDrafts].
|
||||
/// - For each product, looks up or creates a [ProductIdMapping] in
|
||||
/// [ProductIdMappingRepository].
|
||||
/// - Updates the mapping's [ProductIdMapping.squareCatalogObjectId] and
|
||||
/// [ProductIdMapping.squareVariationId] if they have changed.
|
||||
///
|
||||
/// 2. **Inventory sync** ([syncInventory]):
|
||||
/// - Iterates over all [ProductIdMapping] records that have a
|
||||
/// [ProductIdMapping.squareVariationId].
|
||||
/// - Calls [InventorySyncAdapter.syncItem] for each, passing `0` as the
|
||||
/// fallback quantity (the adapter pulls the live count from Square).
|
||||
///
|
||||
/// 3. **Full sync** ([syncAll]):
|
||||
/// - Runs [syncCatalog] then [syncInventory] and merges the results.
|
||||
///
|
||||
/// ## Error handling
|
||||
///
|
||||
/// Individual item failures are captured as [SyncError] entries in the
|
||||
/// returned [SquareSyncResult]. A failure on one item does not abort the
|
||||
/// remaining items.
|
||||
class SquareCatalogSyncService {
|
||||
final ProductPublishingRepository _catalogRepository;
|
||||
final ProductIdMappingRepository _mappingRepository;
|
||||
final InventorySyncAdapter? _inventoryAdapter;
|
||||
|
||||
SquareCatalogSyncService({
|
||||
required ProductPublishingRepository catalogRepository,
|
||||
required ProductIdMappingRepository mappingRepository,
|
||||
InventorySyncAdapter? inventoryAdapter,
|
||||
}) : _catalogRepository = catalogRepository,
|
||||
_mappingRepository = mappingRepository,
|
||||
_inventoryAdapter = inventoryAdapter;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Pulls the full Square catalog and reconciles [ProductIdMappingRepository].
|
||||
///
|
||||
/// For each product returned by [ProductPublishingRepository.getProductDrafts]:
|
||||
/// - Uses [ProductDraft.squareCatalogObjectId] as the Square catalog object
|
||||
/// ID when present. Falls back to [ProductDraft.id] only when
|
||||
/// [ProductDraft.squareCatalogObjectId] is `null` (e.g. when the catalog
|
||||
/// repository is [SquareCatalogRepository], which sets `product.id` to the
|
||||
/// Square object ID directly).
|
||||
/// - Looks up an existing mapping by the resolved Square catalog object ID.
|
||||
/// - Creates or updates the mapping with the current Square IDs and
|
||||
/// timestamps.
|
||||
///
|
||||
/// This avoids conflating local / WooCommerce product IDs with Square catalog
|
||||
/// object IDs in mixed-source scenarios.
|
||||
///
|
||||
/// Returns a [SquareSyncResult] with [SquareSyncResult.catalogSynced] and
|
||||
/// [SquareSyncResult.catalogFailed] populated.
|
||||
Future<SquareSyncResult> syncCatalog() async {
|
||||
List<ProductDraft> products;
|
||||
try {
|
||||
products = await _catalogRepository.getProductDrafts();
|
||||
} catch (e) {
|
||||
return SquareSyncResult(
|
||||
catalogFailed: 1,
|
||||
errors: [SyncError(itemId: 'catalog', message: e.toString())],
|
||||
);
|
||||
}
|
||||
|
||||
if (products.isEmpty) {
|
||||
return const SquareSyncResult();
|
||||
}
|
||||
|
||||
int synced = 0;
|
||||
int failed = 0;
|
||||
final errors = <SyncError>[];
|
||||
|
||||
for (final product in products) {
|
||||
// Prefer the explicit squareCatalogObjectId; fall back to product.id
|
||||
// only when the catalog repository is Square-backed (in which case
|
||||
// product.id IS the Square catalog object ID).
|
||||
final squareId = product.squareCatalogObjectId ?? product.id;
|
||||
|
||||
try {
|
||||
final existing = await _mappingRepository.getMappingBySquareId(squareId);
|
||||
|
||||
final now = DateTime.now();
|
||||
final mapping = existing != null
|
||||
? existing.copyWith(squareCatalogObjectId: squareId, lastUpdated: now)
|
||||
: ProductIdMapping(
|
||||
localId: product.id,
|
||||
squareCatalogObjectId: squareId,
|
||||
lastUpdated: now,
|
||||
);
|
||||
|
||||
await _mappingRepository.saveMapping(mapping);
|
||||
synced++;
|
||||
} catch (e) {
|
||||
failed++;
|
||||
errors.add(SyncError(itemId: product.id, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
return SquareSyncResult(catalogSynced: synced, catalogFailed: failed, errors: errors);
|
||||
}
|
||||
|
||||
/// Syncs inventory counts from Square for all mapped items that have a
|
||||
/// [ProductIdMapping.squareVariationId].
|
||||
///
|
||||
/// Requires an [InventorySyncAdapter] to be provided at construction time.
|
||||
/// If no adapter is provided, returns an empty [SquareSyncResult].
|
||||
///
|
||||
/// Returns a [SquareSyncResult] with [SquareSyncResult.inventorySynced] and
|
||||
/// [SquareSyncResult.inventoryFailed] populated.
|
||||
Future<SquareSyncResult> syncInventory() async {
|
||||
if (_inventoryAdapter == null) {
|
||||
return const SquareSyncResult();
|
||||
}
|
||||
|
||||
List<ProductIdMapping> mappings;
|
||||
try {
|
||||
mappings = await _mappingRepository.getAllMappings();
|
||||
} catch (e) {
|
||||
return SquareSyncResult(
|
||||
inventoryFailed: 1,
|
||||
errors: [SyncError(itemId: 'mappings', message: e.toString())],
|
||||
);
|
||||
}
|
||||
|
||||
final syncable = mappings.where((m) => m.squareVariationId != null).toList();
|
||||
|
||||
if (syncable.isEmpty) {
|
||||
return const SquareSyncResult();
|
||||
}
|
||||
|
||||
int synced = 0;
|
||||
int failed = 0;
|
||||
final errors = <SyncError>[];
|
||||
|
||||
for (final mapping in syncable) {
|
||||
try {
|
||||
final success = await _inventoryAdapter.syncItem(mapping.localId, 0);
|
||||
if (success) {
|
||||
synced++;
|
||||
} else {
|
||||
failed++;
|
||||
errors.add(SyncError(itemId: mapping.localId, message: 'syncItem returned false'));
|
||||
}
|
||||
} catch (e) {
|
||||
failed++;
|
||||
errors.add(SyncError(itemId: mapping.localId, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
return SquareSyncResult(inventorySynced: synced, inventoryFailed: failed, errors: errors);
|
||||
}
|
||||
|
||||
/// Runs [syncCatalog] followed by [syncInventory] and merges the results.
|
||||
Future<SquareSyncResult> syncAll() async {
|
||||
final catalogResult = await syncCatalog();
|
||||
final inventoryResult = await syncInventory();
|
||||
return catalogResult.merge(inventoryResult);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'square_catalog_sync_service.dart';
|
||||
|
||||
/// Application-layer controller that wraps [SquareCatalogSyncService] and
|
||||
/// exposes sync actions as async operations with observable state.
|
||||
///
|
||||
/// ## State exposed
|
||||
///
|
||||
/// - [isSyncing] — `true` while any sync operation is in progress.
|
||||
/// - [lastResult] — the [SquareSyncResult] from the most recent completed sync,
|
||||
/// or `null` if no sync has been run yet.
|
||||
/// - [lastSyncedAt] — the [DateTime] at which the last sync completed
|
||||
/// successfully, or `null` if no sync has completed.
|
||||
/// - [lastError] — a human-readable error message if the last sync threw an
|
||||
/// unexpected exception, or `null` if the last sync completed normally
|
||||
/// (even with partial failures captured in [lastResult]).
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// final controller = SquareSyncController(syncService: service);
|
||||
/// await controller.syncAll();
|
||||
/// print(controller.lastResult?.allSucceeded); // true / false
|
||||
/// ```
|
||||
class SquareSyncController extends ChangeNotifier {
|
||||
final SquareCatalogSyncService _syncService;
|
||||
|
||||
SquareSyncController({required SquareCatalogSyncService syncService})
|
||||
: _syncService = syncService;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
bool _isSyncing = false;
|
||||
SquareSyncResult? _lastResult;
|
||||
DateTime? _lastSyncedAt;
|
||||
String? _lastError;
|
||||
|
||||
/// Whether a sync operation is currently in progress.
|
||||
bool get isSyncing => _isSyncing;
|
||||
|
||||
/// The result of the most recently completed sync, or `null` if no sync has
|
||||
/// been run yet.
|
||||
SquareSyncResult? get lastResult => _lastResult;
|
||||
|
||||
/// The timestamp at which the last sync completed (regardless of success or
|
||||
/// partial failure), or `null` if no sync has completed.
|
||||
DateTime? get lastSyncedAt => _lastSyncedAt;
|
||||
|
||||
/// A human-readable error message if the last sync threw an unexpected
|
||||
/// exception. `null` when the last sync completed normally (partial failures
|
||||
/// are captured in [lastResult] instead).
|
||||
String? get lastError => _lastError;
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs a full sync: catalog followed by inventory.
|
||||
///
|
||||
/// Sets [isSyncing] to `true` for the duration of the operation, then
|
||||
/// updates [lastResult], [lastSyncedAt], and [lastError] on completion.
|
||||
Future<void> syncAll() => _runSync(() => _syncService.syncAll());
|
||||
|
||||
/// Syncs only the product catalog (creates/updates [ProductIdMapping] records).
|
||||
Future<void> syncCatalog() => _runSync(() => _syncService.syncCatalog());
|
||||
|
||||
/// Syncs only inventory counts from Square for all mapped items.
|
||||
Future<void> syncInventory() => _runSync(() => _syncService.syncInventory());
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _runSync(Future<SquareSyncResult> Function() action) async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
_isSyncing = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await action();
|
||||
_lastResult = result;
|
||||
_lastSyncedAt = DateTime.now();
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import '../domain/product_id_mapping.dart';
|
||||
import '../domain/product_id_mapping_repository.dart';
|
||||
|
||||
/// In-memory implementation of [ProductIdMappingRepository] for use in
|
||||
/// `FAKE` mode and unit tests.
|
||||
///
|
||||
/// Seeded with mappings that correspond to the products in
|
||||
/// [FakeProductPublishingRepository] and the inventory items in
|
||||
/// [FakeInventoryRepository].
|
||||
class FakeProductIdMappingRepository implements ProductIdMappingRepository {
|
||||
final Map<String, ProductIdMapping> _store = {};
|
||||
|
||||
FakeProductIdMappingRepository() {
|
||||
// Seed mappings for the 6 fake products.
|
||||
// Products 1–3 have both WooCommerce and Square IDs (fully synced).
|
||||
// Products 4–5 have WooCommerce IDs only (not yet pushed to Square).
|
||||
// Product 6 has no channel IDs (draft, not synced anywhere).
|
||||
final now = DateTime(2026, 7, 1);
|
||||
final seeds = [
|
||||
ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: now,
|
||||
),
|
||||
ProductIdMapping(
|
||||
localId: '2',
|
||||
wooCommerceId: '102',
|
||||
squareCatalogObjectId: 'SQ-ITEM-002',
|
||||
squareVariationId: 'SQ-VAR-002',
|
||||
lastUpdated: now,
|
||||
),
|
||||
ProductIdMapping(
|
||||
localId: '3',
|
||||
wooCommerceId: '103',
|
||||
squareCatalogObjectId: 'SQ-ITEM-003',
|
||||
squareVariationId: 'SQ-VAR-003',
|
||||
lastUpdated: now,
|
||||
),
|
||||
ProductIdMapping(localId: '4', wooCommerceId: '104', lastUpdated: now),
|
||||
ProductIdMapping(localId: '5', wooCommerceId: '105', lastUpdated: now),
|
||||
ProductIdMapping(localId: '6', lastUpdated: now),
|
||||
];
|
||||
for (final m in seeds) {
|
||||
_store[m.localId] = m;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return List.unmodifiable(_store.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return _store[localId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
try {
|
||||
return _store.values.firstWhere((m) => m.wooCommerceId == wooCommerceId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
try {
|
||||
return _store.values.firstWhere((m) => m.squareCatalogObjectId == squareCatalogObjectId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
_store[mapping.localId] = mapping;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
_store.remove(localId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
import 'package:integrations/integrations.dart';
|
||||
|
||||
import '../domain/product_category.dart';
|
||||
import '../domain/product_draft.dart';
|
||||
import '../domain/product_image.dart';
|
||||
import '../domain/product_publishing_repository.dart';
|
||||
import '../domain/publish_status.dart';
|
||||
|
||||
/// Square-backed implementation of [ProductPublishingRepository].
|
||||
///
|
||||
/// Reads and writes the product catalog via [SquareApiClient], mapping between
|
||||
/// Square CatalogObject JSON and the [ProductDraft] domain model using
|
||||
/// [SquareProductMapper].
|
||||
///
|
||||
/// ## Scope
|
||||
///
|
||||
/// This repository covers the Square catalog surface:
|
||||
/// - [getProductDrafts] — lists all ITEM-type catalog objects from Square
|
||||
/// - [publishDraft] — marks a product active (no-op for Square; Square items
|
||||
/// are always visible once created); updates local cache status
|
||||
/// - [updateProductStatus] — archives or restores a catalog item via upsert
|
||||
/// - [updateProductPrice] — updates the first variation's price via upsert
|
||||
/// - [updateProductName] — updates the item name via upsert
|
||||
/// - [updateProductDescription] — updates the item description via upsert
|
||||
/// - [updateProductCategory] — updates the item category_id via upsert
|
||||
/// - [createProduct] — creates a new ITEM catalog object in Square
|
||||
/// - [deleteProduct] — deletes a catalog object from Square
|
||||
///
|
||||
/// ## Unsupported operations
|
||||
///
|
||||
/// The following [ProductPublishingRepository] methods are not supported by
|
||||
/// Square's catalog model and throw [UnsupportedError]:
|
||||
/// - [updateProductImages] — Square image management uses a separate media API
|
||||
/// - [getCategories] / [createCategory] / [updateCategory] / [deleteCategory]
|
||||
/// — Square categories are managed separately via CATEGORY-type catalog
|
||||
/// objects; this is deferred to a future stage
|
||||
/// - [getProductImages] — Square does not expose image URLs via the catalog API
|
||||
/// in the same way as WooCommerce
|
||||
///
|
||||
/// ## ID mapping
|
||||
///
|
||||
/// Square uses string IDs for catalog objects. The [ProductDraft.id] field
|
||||
/// holds the Square catalog object ID (the ITEM-level ID, not the variation).
|
||||
/// The variation ID is derived deterministically for upsert operations; in
|
||||
/// production the [ProductIdMappingRepository] should be used to look up the
|
||||
/// real variation ID.
|
||||
///
|
||||
/// ## Location
|
||||
///
|
||||
/// All inventory-related operations require a Square location ID, which is
|
||||
/// provided at construction time via [locationId].
|
||||
class SquareCatalogRepository implements ProductPublishingRepository {
|
||||
final SquareApiClient _apiClient;
|
||||
final SquareProductMapper _mapper;
|
||||
|
||||
/// The Square location ID used for variation pricing and inventory.
|
||||
final String locationId;
|
||||
|
||||
/// In-memory cache of the last fetched product list.
|
||||
///
|
||||
/// Used to reconstruct full [ProductDraft] objects when performing partial
|
||||
/// field updates (Square requires the full object for upsert).
|
||||
final List<ProductDraft> _cache = [];
|
||||
|
||||
SquareCatalogRepository({
|
||||
required SquareApiClient apiClient,
|
||||
required this.locationId,
|
||||
SquareProductMapper mapper = const SquareProductMapper(),
|
||||
}) : _apiClient = apiClient,
|
||||
_mapper = mapper;
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<List<ProductDraft>> getProductDrafts() async {
|
||||
final rawObjects = await _apiClient.listCatalogItems(types: ['ITEM']);
|
||||
final channelProducts = _mapper.fromSquareCatalogList(rawObjects.cast<Map<String, dynamic>>());
|
||||
|
||||
final drafts = channelProducts.map(_channelProductToDraft).toList();
|
||||
_cache
|
||||
..clear()
|
||||
..addAll(drafts);
|
||||
return List.unmodifiable(drafts);
|
||||
}
|
||||
|
||||
// ── Status operations ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<ProductDraft> publishDraft(String id) async {
|
||||
// Square items are always visible once created; publishing is a no-op.
|
||||
// Update the local cache to reflect the published status.
|
||||
return _updateCachedDraft(
|
||||
id,
|
||||
(draft) => draft.copyWith(status: PublishStatus.published, lastModified: DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) async {
|
||||
final draft = _findCached(id);
|
||||
final updated = draft.copyWith(status: status, lastModified: DateTime.now());
|
||||
|
||||
// Upsert to Square to keep the catalog in sync (name/price unchanged).
|
||||
await _upsertDraft(updated);
|
||||
|
||||
_replaceCached(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Field update operations ────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductPrice(String id, double price) async {
|
||||
final draft = _findCached(id);
|
||||
final updated = draft.copyWith(price: price, lastModified: DateTime.now());
|
||||
await _upsertDraft(updated);
|
||||
_replaceCached(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductName(String id, String name) async {
|
||||
final draft = _findCached(id);
|
||||
final updated = draft.copyWith(name: name, lastModified: DateTime.now());
|
||||
await _upsertDraft(updated);
|
||||
_replaceCached(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductDescription(String id, String description) async {
|
||||
final draft = _findCached(id);
|
||||
final updated = draft.copyWith(description: description, lastModified: DateTime.now());
|
||||
await _upsertDraft(updated);
|
||||
_replaceCached(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductCategory(String id, String category) async {
|
||||
final draft = _findCached(id);
|
||||
final updated = draft.copyWith(category: category, lastModified: DateTime.now());
|
||||
await _upsertDraft(updated);
|
||||
_replaceCached(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Create / Delete ────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<ProductDraft> createProduct({
|
||||
required String name,
|
||||
required double price,
|
||||
String description = '',
|
||||
String sku = '',
|
||||
int? categoryId,
|
||||
PublishStatus status = PublishStatus.draft,
|
||||
}) async {
|
||||
final channelProduct = ChannelProduct(
|
||||
id: '#new-item',
|
||||
name: name,
|
||||
price: price,
|
||||
status: _publishStatusToSquareStatus(status),
|
||||
description: description.isNotEmpty ? description : null,
|
||||
sku: sku.isNotEmpty ? sku : null,
|
||||
);
|
||||
|
||||
final squareObject = _mapper.toSquareCatalogObject(
|
||||
channelProduct,
|
||||
squareItemId: '#new-item',
|
||||
squareVariationId: '#new-variation',
|
||||
locationId: locationId,
|
||||
);
|
||||
|
||||
final created = await _apiClient.upsertCatalogItem(squareObject);
|
||||
final createdProduct = _mapper.fromSquareCatalogObject(created.cast<String, dynamic>());
|
||||
|
||||
if (createdProduct == null) {
|
||||
throw StateError('Square returned an invalid catalog object after create');
|
||||
}
|
||||
|
||||
final draft = _channelProductToDraft(
|
||||
createdProduct,
|
||||
).copyWith(description: description, sku: sku, status: status);
|
||||
_cache.add(draft);
|
||||
return draft;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteProduct(String id, {bool force = false}) async {
|
||||
await _apiClient.deleteCatalogItem(id);
|
||||
_cache.removeWhere((d) => d.id == id);
|
||||
}
|
||||
|
||||
// ── Unsupported operations ─────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) {
|
||||
throw UnsupportedError(
|
||||
'SquareCatalogRepository does not support image management. '
|
||||
'Use the Square media API directly.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProductCategory>> getCategories() {
|
||||
throw UnsupportedError(
|
||||
'SquareCatalogRepository does not support category listing. '
|
||||
'Square categories are managed via CATEGORY-type catalog objects.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductCategory> createCategory({required String name, int parentId = 0}) {
|
||||
throw UnsupportedError('SquareCatalogRepository does not support category creation.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) {
|
||||
throw UnsupportedError('SquareCatalogRepository does not support category updates.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCategory(int categoryId, {bool force = true}) {
|
||||
throw UnsupportedError('SquareCatalogRepository does not support category deletion.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProductImage>> getProductImages(String id) {
|
||||
throw UnsupportedError(
|
||||
'SquareCatalogRepository does not support image retrieval. '
|
||||
'Square image URLs are embedded in catalog objects.',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Converts a [ChannelProduct] from the mapper to a [ProductDraft].
|
||||
ProductDraft _channelProductToDraft(ChannelProduct channelProduct) {
|
||||
return ProductDraft(
|
||||
id: channelProduct.id,
|
||||
name: channelProduct.name,
|
||||
description: channelProduct.description ?? '',
|
||||
price: channelProduct.price,
|
||||
sku: channelProduct.sku ?? '',
|
||||
category: channelProduct.category ?? '',
|
||||
imageUrl: channelProduct.imageUrl ?? '',
|
||||
status: _squareStatusToPublishStatus(channelProduct.status),
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a [ProductDraft] to a [ChannelProduct] for the mapper.
|
||||
ChannelProduct _draftToChannelProduct(ProductDraft draft) {
|
||||
return ChannelProduct(
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
price: draft.price,
|
||||
status: _publishStatusToSquareStatus(draft.status),
|
||||
description: draft.description.isNotEmpty ? draft.description : null,
|
||||
sku: draft.sku.isNotEmpty ? draft.sku : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts a [ProductDraft] to Square using the mapper.
|
||||
Future<void> _upsertDraft(ProductDraft draft) async {
|
||||
final channelProduct = _draftToChannelProduct(draft);
|
||||
final squareObject = _mapper.toSquareCatalogObject(
|
||||
channelProduct,
|
||||
squareItemId: draft.id,
|
||||
squareVariationId: _variationIdFor(draft.id),
|
||||
locationId: locationId,
|
||||
);
|
||||
await _apiClient.upsertCatalogItem(squareObject);
|
||||
}
|
||||
|
||||
/// Returns the Square variation ID for a given item ID.
|
||||
///
|
||||
/// Uses a deterministic temporary variation ID based on the item ID.
|
||||
/// In production, the [ProductIdMappingRepository] should be used to look
|
||||
/// up the real variation ID stored from the initial catalog sync.
|
||||
String _variationIdFor(String itemId) => '#var-$itemId';
|
||||
|
||||
/// Finds a cached [ProductDraft] by [id], throwing [StateError] if not found.
|
||||
ProductDraft _findCached(String id) {
|
||||
try {
|
||||
return _cache.firstWhere((d) => d.id == id);
|
||||
} catch (_) {
|
||||
throw StateError('Product $id not found in cache. Call getProductDrafts() first.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a cached [ProductDraft] by [id].
|
||||
void _replaceCached(String id, ProductDraft updated) {
|
||||
final index = _cache.indexWhere((d) => d.id == id);
|
||||
if (index != -1) _cache[index] = updated;
|
||||
}
|
||||
|
||||
/// Updates a cached draft using [updater] and returns the result.
|
||||
ProductDraft _updateCachedDraft(String id, ProductDraft Function(ProductDraft) updater) {
|
||||
final draft = _findCached(id);
|
||||
final updated = updater(draft);
|
||||
_replaceCached(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Maps a Square status string to a [PublishStatus].
|
||||
static PublishStatus _squareStatusToPublishStatus(String status) {
|
||||
return switch (status) {
|
||||
'archived' => PublishStatus.unpublished,
|
||||
_ => PublishStatus.published,
|
||||
};
|
||||
}
|
||||
|
||||
/// Maps a [PublishStatus] to a Square status string.
|
||||
static String _publishStatusToSquareStatus(PublishStatus status) {
|
||||
return switch (status) {
|
||||
PublishStatus.unpublished => 'archived',
|
||||
_ => 'active',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -12,17 +12,6 @@ class ProductDraft {
|
|||
final PublishStatus status;
|
||||
final DateTime lastModified;
|
||||
|
||||
/// The Square catalog object ID for this product, if it has been synced to
|
||||
/// Square.
|
||||
///
|
||||
/// This is distinct from [id], which is the local / WooCommerce product ID.
|
||||
/// Keeping these separate prevents the [SquareCatalogSyncService] from
|
||||
/// conflating local IDs with Square catalog object IDs when reconciling
|
||||
/// [ProductIdMapping] records.
|
||||
///
|
||||
/// `null` when the product has not yet been synced to Square.
|
||||
final String? squareCatalogObjectId;
|
||||
|
||||
const ProductDraft({
|
||||
required this.id,
|
||||
required this.name,
|
||||
|
|
@ -33,15 +22,9 @@ class ProductDraft {
|
|||
required this.imageUrl,
|
||||
required this.status,
|
||||
required this.lastModified,
|
||||
this.squareCatalogObjectId,
|
||||
});
|
||||
|
||||
/// Returns a copy of this [ProductDraft] with the given fields replaced.
|
||||
///
|
||||
/// To explicitly clear [squareCatalogObjectId], pass `squareCatalogObjectId: null`
|
||||
/// — the field uses a sentinel pattern: if the named parameter is omitted the
|
||||
/// existing value is preserved; if `null` is passed explicitly the field is
|
||||
/// cleared.
|
||||
ProductDraft copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
|
|
@ -52,7 +35,6 @@ class ProductDraft {
|
|||
String? imageUrl,
|
||||
PublishStatus? status,
|
||||
DateTime? lastModified,
|
||||
Object? squareCatalogObjectId = _sentinel,
|
||||
}) {
|
||||
return ProductDraft(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -64,13 +46,6 @@ class ProductDraft {
|
|||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
status: status ?? this.status,
|
||||
lastModified: lastModified ?? this.lastModified,
|
||||
squareCatalogObjectId: squareCatalogObjectId == _sentinel
|
||||
? this.squareCatalogObjectId
|
||||
: squareCatalogObjectId as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel value used by [ProductDraft.copyWith] to distinguish "not provided"
|
||||
/// from an explicit `null` for [ProductDraft.squareCatalogObjectId].
|
||||
const Object _sentinel = Object();
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
/// A cross-channel product ID mapping that links a product's identity across
|
||||
/// WooCommerce and Square.
|
||||
///
|
||||
/// The app treats WooCommerce as the primary catalog source. When a product is
|
||||
/// synced to Square, the resulting Square catalog object ID is stored here so
|
||||
/// that future sync operations can locate the correct remote record without
|
||||
/// performing an expensive search.
|
||||
///
|
||||
/// Either [wooCommerceId] or [squareCatalogObjectId] may be `null` when a
|
||||
/// product exists in only one channel (e.g. a Square-only in-person item that
|
||||
/// has not yet been published to WooCommerce, or a WooCommerce product that has
|
||||
/// not yet been pushed to Square).
|
||||
class ProductIdMapping {
|
||||
/// The local app-internal product identifier (matches [ProductDraft.id]).
|
||||
final String localId;
|
||||
|
||||
/// The WooCommerce product ID (integer stored as string for consistency).
|
||||
///
|
||||
/// `null` if this product has no WooCommerce counterpart.
|
||||
final String? wooCommerceId;
|
||||
|
||||
/// The Square CatalogObject ID for the ITEM-level object.
|
||||
///
|
||||
/// `null` if this product has not been pushed to Square.
|
||||
final String? squareCatalogObjectId;
|
||||
|
||||
/// The Square CatalogObject ID for the primary ITEM_VARIATION.
|
||||
///
|
||||
/// Square products have at least one variation. This stores the ID of the
|
||||
/// default/primary variation so inventory counts can be fetched directly.
|
||||
///
|
||||
/// `null` if the product has not been pushed to Square.
|
||||
final String? squareVariationId;
|
||||
|
||||
/// The timestamp when this mapping was last updated.
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const ProductIdMapping({
|
||||
required this.localId,
|
||||
this.wooCommerceId,
|
||||
this.squareCatalogObjectId,
|
||||
this.squareVariationId,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
/// Returns a copy of this [ProductIdMapping] with the given fields replaced.
|
||||
ProductIdMapping copyWith({
|
||||
String? localId,
|
||||
String? wooCommerceId,
|
||||
String? squareCatalogObjectId,
|
||||
String? squareVariationId,
|
||||
DateTime? lastUpdated,
|
||||
}) {
|
||||
return ProductIdMapping(
|
||||
localId: localId ?? this.localId,
|
||||
wooCommerceId: wooCommerceId ?? this.wooCommerceId,
|
||||
squareCatalogObjectId: squareCatalogObjectId ?? this.squareCatalogObjectId,
|
||||
squareVariationId: squareVariationId ?? this.squareVariationId,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProductIdMapping &&
|
||||
runtimeType == other.runtimeType &&
|
||||
localId == other.localId &&
|
||||
wooCommerceId == other.wooCommerceId &&
|
||||
squareCatalogObjectId == other.squareCatalogObjectId &&
|
||||
squareVariationId == other.squareVariationId &&
|
||||
lastUpdated == other.lastUpdated;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(localId, wooCommerceId, squareCatalogObjectId, squareVariationId, lastUpdated);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ProductIdMapping(localId: $localId, wooCommerceId: $wooCommerceId, '
|
||||
'squareCatalogObjectId: $squareCatalogObjectId, '
|
||||
'squareVariationId: $squareVariationId)';
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import 'product_id_mapping.dart';
|
||||
|
||||
/// Contract for persisting and querying cross-channel product ID mappings.
|
||||
///
|
||||
/// Implementations may store mappings in memory (fake), SQLite (local
|
||||
/// persistent), or a remote database. The contract is channel-agnostic —
|
||||
/// callers look up by local ID, WooCommerce ID, or Square catalog object ID.
|
||||
abstract class ProductIdMappingRepository {
|
||||
/// Returns all stored [ProductIdMapping] records.
|
||||
Future<List<ProductIdMapping>> getAllMappings();
|
||||
|
||||
/// Returns the [ProductIdMapping] for the given [localId], or `null` if
|
||||
/// no mapping exists for that local product.
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId);
|
||||
|
||||
/// Returns the [ProductIdMapping] whose [ProductIdMapping.wooCommerceId]
|
||||
/// matches [wooCommerceId], or `null` if not found.
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId);
|
||||
|
||||
/// Returns the [ProductIdMapping] whose
|
||||
/// [ProductIdMapping.squareCatalogObjectId] matches [squareCatalogObjectId],
|
||||
/// or `null` if not found.
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId);
|
||||
|
||||
/// Saves a [ProductIdMapping].
|
||||
///
|
||||
/// If a mapping with the same [ProductIdMapping.localId] already exists it
|
||||
/// is replaced; otherwise a new record is inserted.
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping);
|
||||
|
||||
/// Deletes the mapping for the given [localId].
|
||||
///
|
||||
/// No-ops silently if no mapping exists for that ID.
|
||||
Future<void> deleteMapping(String localId);
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../application/square_sync_controller.dart';
|
||||
import '../../application/square_catalog_sync_service.dart';
|
||||
|
||||
/// A panel that displays the current Square sync status and provides a
|
||||
/// "Sync Now" button to trigger a full catalog + inventory sync.
|
||||
///
|
||||
/// Listens to [SquareSyncController] and rebuilds whenever sync state changes.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// SquareSyncStatusPanel(controller: squareSyncController)
|
||||
/// ```
|
||||
class SquareSyncStatusPanel extends StatefulWidget {
|
||||
final SquareSyncController controller;
|
||||
|
||||
const SquareSyncStatusPanel({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<SquareSyncStatusPanel> createState() => _SquareSyncStatusPanelState();
|
||||
}
|
||||
|
||||
class _SquareSyncStatusPanelState extends State<SquareSyncStatusPanel> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SquareSyncStatusPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onControllerChanged);
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.sync, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('Square Sync', style: theme.textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
_SyncStatusChip(controller: controller),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_LastSyncedRow(controller: controller),
|
||||
const SizedBox(height: 12),
|
||||
if (controller.lastResult != null) ...[
|
||||
_SyncResultSummary(result: controller.lastResult!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (controller.lastError != null) ...[
|
||||
_ErrorBanner(message: controller.lastError!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: controller.isSyncing ? null : () => controller.syncAll(),
|
||||
icon: controller.isSyncing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
label: Text(controller.isSyncing ? 'Syncing…' : 'Sync Now'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small chip showing the current sync phase.
|
||||
class _SyncStatusChip extends StatelessWidget {
|
||||
final SquareSyncController controller;
|
||||
|
||||
const _SyncStatusChip({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
final String label;
|
||||
|
||||
if (controller.isSyncing) {
|
||||
bg = Colors.blue.shade100;
|
||||
fg = Colors.blue.shade900;
|
||||
label = 'Syncing';
|
||||
} else if (controller.lastError != null) {
|
||||
bg = Colors.red.shade100;
|
||||
fg = Colors.red.shade900;
|
||||
label = 'Error';
|
||||
} else if (controller.lastResult != null) {
|
||||
if (controller.lastResult!.allSucceeded) {
|
||||
bg = Colors.green.shade100;
|
||||
fg = Colors.green.shade900;
|
||||
label = 'OK';
|
||||
} else {
|
||||
bg = Colors.orange.shade100;
|
||||
fg = Colors.orange.shade900;
|
||||
label = 'Partial';
|
||||
}
|
||||
} else {
|
||||
bg = Colors.grey.shade200;
|
||||
fg = Colors.grey.shade700;
|
||||
label = 'Never synced';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row showing the last synced timestamp.
|
||||
class _LastSyncedRow extends StatelessWidget {
|
||||
final SquareSyncController controller;
|
||||
|
||||
const _LastSyncedRow({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ts = controller.lastSyncedAt;
|
||||
final label = ts == null ? 'Never' : _formatDateTime(ts);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.access_time, size: 16, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Last synced: $label',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey.shade700),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDateTime(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final m = local.minute.toString().padLeft(2, '0');
|
||||
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} $h:$m';
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary row showing catalog/inventory synced and failed counts.
|
||||
class _SyncResultSummary extends StatelessWidget {
|
||||
final SquareSyncResult result;
|
||||
|
||||
const _SyncResultSummary({required this.result});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
Text('Last sync result', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 6),
|
||||
_ResultRow(label: 'Catalog', synced: result.catalogSynced, failed: result.catalogFailed),
|
||||
const SizedBox(height: 4),
|
||||
_ResultRow(
|
||||
label: 'Inventory',
|
||||
synced: result.inventorySynced,
|
||||
failed: result.inventoryFailed,
|
||||
),
|
||||
if (result.errors.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${result.errors.length} error(s) — see logs for details',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade800),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultRow extends StatelessWidget {
|
||||
final String label;
|
||||
final int synced;
|
||||
final int failed;
|
||||
|
||||
const _ResultRow({required this.label, required this.synced, required this.failed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 72, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
|
||||
Icon(Icons.check_circle_outline, size: 14, color: Colors.green.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text('$synced', style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(width: 12),
|
||||
if (failed > 0) ...[
|
||||
Icon(Icons.error_outline, size: 14, color: Colors.red.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$failed failed',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade700),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Red banner shown when the last sync threw an unexpected exception.
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 16, color: Colors.red.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.red.shade800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
void main() {
|
||||
// ── ProductIdMapping domain model ──────────────────────────────────────────
|
||||
|
||||
group('ProductIdMapping', () {
|
||||
final baseTime = DateTime(2026, 7, 1);
|
||||
|
||||
test('stores all fields', () {
|
||||
final mapping = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
expect(mapping.localId, '1');
|
||||
expect(mapping.wooCommerceId, '101');
|
||||
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
expect(mapping.squareVariationId, 'SQ-VAR-001');
|
||||
expect(mapping.lastUpdated, baseTime);
|
||||
});
|
||||
|
||||
test('optional fields default to null', () {
|
||||
final mapping = ProductIdMapping(localId: '6', lastUpdated: baseTime);
|
||||
|
||||
expect(mapping.wooCommerceId, isNull);
|
||||
expect(mapping.squareCatalogObjectId, isNull);
|
||||
expect(mapping.squareVariationId, isNull);
|
||||
});
|
||||
|
||||
test('copyWith replaces specified fields', () {
|
||||
final original = ProductIdMapping(localId: '1', wooCommerceId: '101', lastUpdated: baseTime);
|
||||
|
||||
final updated = original.copyWith(
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: DateTime(2026, 8, 1),
|
||||
);
|
||||
|
||||
expect(updated.localId, '1');
|
||||
expect(updated.wooCommerceId, '101');
|
||||
expect(updated.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
expect(updated.squareVariationId, 'SQ-VAR-001');
|
||||
expect(updated.lastUpdated, DateTime(2026, 8, 1));
|
||||
});
|
||||
|
||||
test('copyWith preserves unchanged fields', () {
|
||||
final original = ProductIdMapping(
|
||||
localId: '2',
|
||||
wooCommerceId: '102',
|
||||
squareCatalogObjectId: 'SQ-ITEM-002',
|
||||
squareVariationId: 'SQ-VAR-002',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
final copy = original.copyWith();
|
||||
|
||||
expect(copy.localId, original.localId);
|
||||
expect(copy.wooCommerceId, original.wooCommerceId);
|
||||
expect(copy.squareCatalogObjectId, original.squareCatalogObjectId);
|
||||
expect(copy.squareVariationId, original.squareVariationId);
|
||||
expect(copy.lastUpdated, original.lastUpdated);
|
||||
});
|
||||
|
||||
test('equality holds for identical values', () {
|
||||
final a = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
final b = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
expect(a, equals(b));
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('equality fails when localId differs', () {
|
||||
final a = ProductIdMapping(localId: '1', lastUpdated: baseTime);
|
||||
final b = ProductIdMapping(localId: '2', lastUpdated: baseTime);
|
||||
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('toString includes localId and channel IDs', () {
|
||||
final mapping = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
final str = mapping.toString();
|
||||
expect(str, contains('1'));
|
||||
expect(str, contains('101'));
|
||||
expect(str, contains('SQ-ITEM-001'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── FakeProductIdMappingRepository ────────────────────────────────────────
|
||||
|
||||
group('FakeProductIdMappingRepository', () {
|
||||
late FakeProductIdMappingRepository repo;
|
||||
|
||||
setUp(() {
|
||||
repo = FakeProductIdMappingRepository();
|
||||
});
|
||||
|
||||
test('getAllMappings returns 6 seeded mappings', () async {
|
||||
final mappings = await repo.getAllMappings();
|
||||
expect(mappings.length, 6);
|
||||
});
|
||||
|
||||
test('getMappingByLocalId returns correct mapping', () async {
|
||||
final mapping = await repo.getMappingByLocalId('1');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.localId, '1');
|
||||
expect(mapping.wooCommerceId, '101');
|
||||
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
expect(mapping.squareVariationId, 'SQ-VAR-001');
|
||||
});
|
||||
|
||||
test('getMappingByLocalId returns null for unknown id', () async {
|
||||
final mapping = await repo.getMappingByLocalId('999');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('getMappingByWooCommerceId returns correct mapping', () async {
|
||||
final mapping = await repo.getMappingByWooCommerceId('102');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.localId, '2');
|
||||
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-002');
|
||||
});
|
||||
|
||||
test('getMappingByWooCommerceId returns null for unknown id', () async {
|
||||
final mapping = await repo.getMappingByWooCommerceId('999');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('getMappingBySquareId returns correct mapping', () async {
|
||||
final mapping = await repo.getMappingBySquareId('SQ-ITEM-003');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.localId, '3');
|
||||
expect(mapping.wooCommerceId, '103');
|
||||
});
|
||||
|
||||
test('getMappingBySquareId returns null for unknown id', () async {
|
||||
final mapping = await repo.getMappingBySquareId('SQ-ITEM-UNKNOWN');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('products 4 and 5 have WooCommerce IDs but no Square IDs', () async {
|
||||
final m4 = await repo.getMappingByLocalId('4');
|
||||
final m5 = await repo.getMappingByLocalId('5');
|
||||
|
||||
expect(m4!.wooCommerceId, '104');
|
||||
expect(m4.squareCatalogObjectId, isNull);
|
||||
expect(m5!.wooCommerceId, '105');
|
||||
expect(m5.squareCatalogObjectId, isNull);
|
||||
});
|
||||
|
||||
test('product 6 has no channel IDs', () async {
|
||||
final m6 = await repo.getMappingByLocalId('6');
|
||||
expect(m6!.wooCommerceId, isNull);
|
||||
expect(m6.squareCatalogObjectId, isNull);
|
||||
expect(m6.squareVariationId, isNull);
|
||||
});
|
||||
|
||||
test('saveMapping inserts a new mapping', () async {
|
||||
final newMapping = ProductIdMapping(
|
||||
localId: '99',
|
||||
wooCommerceId: '999',
|
||||
squareCatalogObjectId: 'SQ-ITEM-999',
|
||||
squareVariationId: 'SQ-VAR-999',
|
||||
lastUpdated: DateTime(2026, 7, 25),
|
||||
);
|
||||
|
||||
final saved = await repo.saveMapping(newMapping);
|
||||
expect(saved.localId, '99');
|
||||
|
||||
final retrieved = await repo.getMappingByLocalId('99');
|
||||
expect(retrieved, isNotNull);
|
||||
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-999');
|
||||
});
|
||||
|
||||
test('saveMapping replaces an existing mapping', () async {
|
||||
final updated = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001-UPDATED',
|
||||
squareVariationId: 'SQ-VAR-001-UPDATED',
|
||||
lastUpdated: DateTime(2026, 7, 25),
|
||||
);
|
||||
|
||||
await repo.saveMapping(updated);
|
||||
|
||||
final retrieved = await repo.getMappingByLocalId('1');
|
||||
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-001-UPDATED');
|
||||
expect(retrieved.squareVariationId, 'SQ-VAR-001-UPDATED');
|
||||
});
|
||||
|
||||
test('saveMapping does not increase count when replacing', () async {
|
||||
final before = await repo.getAllMappings();
|
||||
|
||||
await repo.saveMapping(ProductIdMapping(localId: '1', lastUpdated: DateTime(2026, 7, 25)));
|
||||
|
||||
final after = await repo.getAllMappings();
|
||||
expect(after.length, before.length);
|
||||
});
|
||||
|
||||
test('deleteMapping removes the mapping', () async {
|
||||
await repo.deleteMapping('1');
|
||||
final mapping = await repo.getMappingByLocalId('1');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('deleteMapping reduces total count by one', () async {
|
||||
final before = await repo.getAllMappings();
|
||||
await repo.deleteMapping('2');
|
||||
final after = await repo.getAllMappings();
|
||||
expect(after.length, before.length - 1);
|
||||
});
|
||||
|
||||
test('deleteMapping is a no-op for unknown id', () async {
|
||||
final before = await repo.getAllMappings();
|
||||
await repo.deleteMapping('999'); // does not exist
|
||||
final after = await repo.getAllMappings();
|
||||
expect(after.length, before.length);
|
||||
});
|
||||
|
||||
test('getAllMappings returns unmodifiable list', () async {
|
||||
final mappings = await repo.getAllMappings();
|
||||
final extra = ProductIdMapping(localId: 'x', lastUpdated: DateTime(2026, 1, 1));
|
||||
expect(() => (mappings as dynamic).add(extra), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,514 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds a minimal Square ITEM CatalogObject JSON map.
|
||||
Map<String, dynamic> _squareItem({
|
||||
String id = 'SQ-ITEM-001',
|
||||
String name = 'Floral Bowl Cozy',
|
||||
int priceCents = 1299,
|
||||
String sku = 'BC-FLR-001',
|
||||
String? description,
|
||||
bool isDeleted = false,
|
||||
}) {
|
||||
return {
|
||||
'type': 'ITEM',
|
||||
'id': id,
|
||||
if (isDeleted) 'is_deleted': true,
|
||||
'item_data': {
|
||||
'name': name,
|
||||
'description': ?description,
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': '${id}_VAR',
|
||||
'item_variation_data': {
|
||||
'name': 'Regular',
|
||||
'price_money': {'amount': priceCents, 'currency': 'USD'},
|
||||
'sku': sku,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds a mock [http.Client] that returns [body] with [statusCode] for all
|
||||
/// requests.
|
||||
http.Client _mockClient(String body, {int statusCode = 200}) {
|
||||
return MockClient((_) async => http.Response(body, statusCode));
|
||||
}
|
||||
|
||||
/// Builds a [SquareCatalogRepository] backed by a mock HTTP client.
|
||||
SquareCatalogRepository _repo(http.Client client) {
|
||||
final apiClient = SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: client,
|
||||
);
|
||||
return SquareCatalogRepository(apiClient: apiClient, locationId: 'LOC-001');
|
||||
}
|
||||
|
||||
/// Seeds the repository cache by calling [getProductDrafts] with a mock
|
||||
/// response containing [items].
|
||||
Future<SquareCatalogRepository> _seededRepo(List<Map<String, dynamic>> items) async {
|
||||
final listBody = jsonEncode({'objects': items});
|
||||
final repo = _repo(_mockClient(listBody));
|
||||
await repo.getProductDrafts();
|
||||
return repo;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SquareCatalogRepository', () {
|
||||
// ── getProductDrafts ────────────────────────────────────────────────────
|
||||
|
||||
group('getProductDrafts', () {
|
||||
test('returns empty list when Square returns no objects', () async {
|
||||
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
|
||||
final drafts = await repo.getProductDrafts();
|
||||
expect(drafts, isEmpty);
|
||||
});
|
||||
|
||||
test('maps a single Square ITEM to a ProductDraft', () async {
|
||||
final item = _squareItem(
|
||||
id: 'SQ-001',
|
||||
name: 'Bowl Cozy',
|
||||
priceCents: 1299,
|
||||
sku: 'BC-001',
|
||||
description: 'A cozy for bowls',
|
||||
);
|
||||
final repo = _repo(
|
||||
_mockClient(
|
||||
jsonEncode({
|
||||
'objects': [item],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final drafts = await repo.getProductDrafts();
|
||||
|
||||
expect(drafts, hasLength(1));
|
||||
expect(drafts.first.id, 'SQ-001');
|
||||
expect(drafts.first.name, 'Bowl Cozy');
|
||||
expect(drafts.first.price, closeTo(12.99, 0.001));
|
||||
expect(drafts.first.sku, 'BC-001');
|
||||
expect(drafts.first.description, 'A cozy for bowls');
|
||||
expect(drafts.first.status, PublishStatus.published);
|
||||
});
|
||||
|
||||
test('maps is_deleted=true to PublishStatus.unpublished', () async {
|
||||
final item = _squareItem(id: 'SQ-002', isDeleted: true);
|
||||
final repo = _repo(
|
||||
_mockClient(
|
||||
jsonEncode({
|
||||
'objects': [item],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final drafts = await repo.getProductDrafts();
|
||||
|
||||
expect(drafts.first.status, PublishStatus.unpublished);
|
||||
});
|
||||
|
||||
test('maps multiple items and skips non-ITEM types', () async {
|
||||
final objects = [
|
||||
_squareItem(id: 'SQ-001', name: 'Item A'),
|
||||
{'type': 'CATEGORY', 'id': 'CAT-001', 'category_data': {}},
|
||||
_squareItem(id: 'SQ-002', name: 'Item B'),
|
||||
];
|
||||
final repo = _repo(_mockClient(jsonEncode({'objects': objects})));
|
||||
final drafts = await repo.getProductDrafts();
|
||||
|
||||
expect(drafts, hasLength(2));
|
||||
expect(drafts.map((d) => d.id), containsAll(['SQ-001', 'SQ-002']));
|
||||
});
|
||||
|
||||
test('populates cache for subsequent field updates', () async {
|
||||
final item = _squareItem(id: 'SQ-001', name: 'Cached Item');
|
||||
final repo = _repo(
|
||||
_mockClient(
|
||||
jsonEncode({
|
||||
'objects': [item],
|
||||
}),
|
||||
),
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
|
||||
// If cache is populated, updateProductName should not throw StateError.
|
||||
final upsertBody = jsonEncode({
|
||||
'catalog_object': _squareItem(id: 'SQ-001', name: 'Updated Item'),
|
||||
});
|
||||
// Seed the cache manually by calling getProductDrafts first.
|
||||
final listBody = jsonEncode({
|
||||
'objects': [item],
|
||||
});
|
||||
final seededRepo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(listBody, 200);
|
||||
}
|
||||
return http.Response(upsertBody, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await seededRepo.getProductDrafts();
|
||||
final updated = await seededRepo.updateProductName('SQ-001', 'Updated Item');
|
||||
expect(updated.name, 'Updated Item');
|
||||
});
|
||||
});
|
||||
|
||||
// ── publishDraft ────────────────────────────────────────────────────────
|
||||
|
||||
group('publishDraft', () {
|
||||
test('returns draft with published status (no-op for Square)', () async {
|
||||
final repo = await _seededRepo([_squareItem(id: 'SQ-001')]);
|
||||
final result = await repo.publishDraft('SQ-001');
|
||||
expect(result.status, PublishStatus.published);
|
||||
expect(result.id, 'SQ-001');
|
||||
});
|
||||
|
||||
test('throws StateError when product not in cache', () async {
|
||||
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
|
||||
expect(() => repo.publishDraft('UNKNOWN'), throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProductStatus ─────────────────────────────────────────────────
|
||||
|
||||
group('updateProductStatus', () {
|
||||
test('updates status to unpublished and upserts to Square', () async {
|
||||
final upsertResponse = jsonEncode({
|
||||
'catalog_object': _squareItem(id: 'SQ-001', isDeleted: true),
|
||||
});
|
||||
final repo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [_squareItem(id: 'SQ-001')],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(upsertResponse, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
final result = await repo.updateProductStatus('SQ-001', PublishStatus.unpublished);
|
||||
expect(result.status, PublishStatus.unpublished);
|
||||
});
|
||||
|
||||
test('throws StateError when product not in cache', () async {
|
||||
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
|
||||
expect(
|
||||
() => repo.updateProductStatus('UNKNOWN', PublishStatus.draft),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProductPrice ──────────────────────────────────────────────────
|
||||
|
||||
group('updateProductPrice', () {
|
||||
test('updates price and upserts to Square', () async {
|
||||
final upsertResponse = jsonEncode({
|
||||
'catalog_object': _squareItem(id: 'SQ-001', priceCents: 1999),
|
||||
});
|
||||
final repo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [_squareItem(id: 'SQ-001')],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(upsertResponse, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
final result = await repo.updateProductPrice('SQ-001', 19.99);
|
||||
expect(result.price, closeTo(19.99, 0.001));
|
||||
});
|
||||
|
||||
test('throws StateError when product not in cache', () async {
|
||||
final repo = _repo(_mockClient(jsonEncode({'objects': []})));
|
||||
expect(() => repo.updateProductPrice('UNKNOWN', 9.99), throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProductName ───────────────────────────────────────────────────
|
||||
|
||||
group('updateProductName', () {
|
||||
test('updates name and upserts to Square', () async {
|
||||
final upsertResponse = jsonEncode({
|
||||
'catalog_object': _squareItem(id: 'SQ-001', name: 'New Name'),
|
||||
});
|
||||
final repo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [_squareItem(id: 'SQ-001')],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(upsertResponse, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
final result = await repo.updateProductName('SQ-001', 'New Name');
|
||||
expect(result.name, 'New Name');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProductDescription ────────────────────────────────────────────
|
||||
|
||||
group('updateProductDescription', () {
|
||||
test('updates description and upserts to Square', () async {
|
||||
final upsertResponse = jsonEncode({
|
||||
'catalog_object': _squareItem(id: 'SQ-001', description: 'New desc'),
|
||||
});
|
||||
final repo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [_squareItem(id: 'SQ-001')],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(upsertResponse, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
final result = await repo.updateProductDescription('SQ-001', 'New desc');
|
||||
expect(result.description, 'New desc');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProductCategory ───────────────────────────────────────────────
|
||||
|
||||
group('updateProductCategory', () {
|
||||
test('updates category and upserts to Square', () async {
|
||||
final upsertResponse = jsonEncode({'catalog_object': _squareItem(id: 'SQ-001')});
|
||||
final repo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [_squareItem(id: 'SQ-001')],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(upsertResponse, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
final result = await repo.updateProductCategory('SQ-001', 'Jewelry');
|
||||
expect(result.category, 'Jewelry');
|
||||
});
|
||||
});
|
||||
|
||||
// ── createProduct ───────────────────────────────────────────────────────
|
||||
|
||||
group('createProduct', () {
|
||||
test('creates a product and returns a ProductDraft', () async {
|
||||
final createdItem = _squareItem(
|
||||
id: 'SQ-NEW-001',
|
||||
name: 'New Bowl',
|
||||
priceCents: 2500,
|
||||
sku: 'NB-001',
|
||||
description: 'A new bowl',
|
||||
);
|
||||
final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem})));
|
||||
final draft = await repo.createProduct(
|
||||
name: 'New Bowl',
|
||||
price: 25.00,
|
||||
description: 'A new bowl',
|
||||
sku: 'NB-001',
|
||||
);
|
||||
|
||||
expect(draft.id, 'SQ-NEW-001');
|
||||
expect(draft.name, 'New Bowl');
|
||||
expect(draft.price, closeTo(25.00, 0.001));
|
||||
expect(draft.description, 'A new bowl');
|
||||
expect(draft.sku, 'NB-001');
|
||||
});
|
||||
|
||||
test('applies draft status from parameter', () async {
|
||||
final createdItem = _squareItem(id: 'SQ-NEW-002', name: 'Draft Item');
|
||||
final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem})));
|
||||
final draft = await repo.createProduct(
|
||||
name: 'Draft Item',
|
||||
price: 10.00,
|
||||
status: PublishStatus.draft,
|
||||
);
|
||||
expect(draft.status, PublishStatus.draft);
|
||||
});
|
||||
|
||||
test('throws StateError when Square returns invalid catalog object', () async {
|
||||
// Return a non-ITEM type so mapper returns null.
|
||||
final badResponse = jsonEncode({
|
||||
'catalog_object': {'type': 'CATEGORY', 'id': 'CAT-001'},
|
||||
});
|
||||
final repo = _repo(_mockClient(badResponse));
|
||||
expect(() => repo.createProduct(name: 'Bad', price: 1.00), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('adds created product to cache', () async {
|
||||
final createdItem = _squareItem(id: 'SQ-NEW-003', name: 'Cached New');
|
||||
final repo = _repo(_mockClient(jsonEncode({'catalog_object': createdItem})));
|
||||
await repo.createProduct(name: 'Cached New', price: 5.00);
|
||||
|
||||
// publishDraft should succeed (product is in cache).
|
||||
final published = await repo.publishDraft('SQ-NEW-003');
|
||||
expect(published.id, 'SQ-NEW-003');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteProduct ───────────────────────────────────────────────────────
|
||||
|
||||
group('deleteProduct', () {
|
||||
test('deletes product from Square and removes from cache', () async {
|
||||
final deleteResponse = jsonEncode({
|
||||
'deleted_object_ids': ['SQ-001'],
|
||||
});
|
||||
final repo = SquareCatalogRepository(
|
||||
apiClient: SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC-001',
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path.contains('list')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [_squareItem(id: 'SQ-001')],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(deleteResponse, 200);
|
||||
}),
|
||||
),
|
||||
locationId: 'LOC-001',
|
||||
);
|
||||
await repo.getProductDrafts();
|
||||
await repo.deleteProduct('SQ-001');
|
||||
|
||||
// After deletion, publishDraft should throw StateError (not in cache).
|
||||
expect(() => repo.publishDraft('SQ-001'), throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unsupported operations ──────────────────────────────────────────────
|
||||
|
||||
group('unsupported operations', () {
|
||||
late SquareCatalogRepository repo;
|
||||
|
||||
setUp(() {
|
||||
repo = _repo(_mockClient(jsonEncode({'objects': []})));
|
||||
});
|
||||
|
||||
test('updateProductImages throws UnsupportedError', () {
|
||||
expect(() => repo.updateProductImages('SQ-001', [1, 2]), throwsA(isA<UnsupportedError>()));
|
||||
});
|
||||
|
||||
test('getCategories throws UnsupportedError', () {
|
||||
expect(() => repo.getCategories(), throwsA(isA<UnsupportedError>()));
|
||||
});
|
||||
|
||||
test('createCategory throws UnsupportedError', () {
|
||||
expect(() => repo.createCategory(name: 'Jewelry'), throwsA(isA<UnsupportedError>()));
|
||||
});
|
||||
|
||||
test('updateCategory throws UnsupportedError', () {
|
||||
expect(() => repo.updateCategory(1, name: 'Updated'), throwsA(isA<UnsupportedError>()));
|
||||
});
|
||||
|
||||
test('deleteCategory throws UnsupportedError', () {
|
||||
expect(() => repo.deleteCategory(1), throwsA(isA<UnsupportedError>()));
|
||||
});
|
||||
|
||||
test('getProductImages throws UnsupportedError', () {
|
||||
expect(() => repo.getProductImages('SQ-001'), throwsA(isA<UnsupportedError>()));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Status mapping ──────────────────────────────────────────────────────
|
||||
|
||||
group('status mapping', () {
|
||||
test('active Square status maps to PublishStatus.published', () async {
|
||||
final item = _squareItem(id: 'SQ-001', isDeleted: false);
|
||||
final repo = _repo(
|
||||
_mockClient(
|
||||
jsonEncode({
|
||||
'objects': [item],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final drafts = await repo.getProductDrafts();
|
||||
expect(drafts.first.status, PublishStatus.published);
|
||||
});
|
||||
|
||||
test('is_deleted=true maps to PublishStatus.unpublished', () async {
|
||||
final item = _squareItem(id: 'SQ-001', isDeleted: true);
|
||||
final repo = _repo(
|
||||
_mockClient(
|
||||
jsonEncode({
|
||||
'objects': [item],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final drafts = await repo.getProductDrafts();
|
||||
expect(drafts.first.status, PublishStatus.unpublished);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SquareApiException propagation ──────────────────────────────────────
|
||||
|
||||
group('SquareApiException propagation', () {
|
||||
test('getProductDrafts propagates SquareApiException on 401', () async {
|
||||
final repo = _repo(_mockClient('{"errors":[]}', statusCode: 401));
|
||||
expect(() => repo.getProductDrafts(), throwsA(isA<SquareApiException>()));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,605 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test doubles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A [ProductPublishingRepository] stub that returns a fixed list of drafts.
|
||||
class _FakeCatalogRepository implements ProductPublishingRepository {
|
||||
final List<ProductDraft> drafts;
|
||||
bool shouldThrow;
|
||||
|
||||
_FakeCatalogRepository({this.drafts = const [], this.shouldThrow = false});
|
||||
|
||||
@override
|
||||
Future<List<ProductDraft>> getProductDrafts() async {
|
||||
if (shouldThrow) throw Exception('catalog fetch failed');
|
||||
return List.unmodifiable(drafts);
|
||||
}
|
||||
|
||||
// Unsupported stubs — not exercised by the sync service
|
||||
@override
|
||||
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> updateProductPrice(String id, double price) => throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> updateProductDescription(String id, String description) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> updateProductCategory(String id, String category) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> createProduct({
|
||||
required String name,
|
||||
required double price,
|
||||
String description = '',
|
||||
String sku = '',
|
||||
int? categoryId,
|
||||
PublishStatus status = PublishStatus.draft,
|
||||
}) => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> deleteProduct(String id, {bool force = false}) => throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<ProductImage>> getProductImages(String id) => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<ProductCategory>> getCategories() => throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductCategory> createCategory({required String name, int parentId = 0}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> deleteCategory(int categoryId, {bool force = true}) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// A [ProductIdMappingRepository] backed by an in-memory map.
|
||||
class _InMemoryMappingRepository implements ProductIdMappingRepository {
|
||||
final Map<String, ProductIdMapping> _store = {};
|
||||
bool shouldThrowOnSave;
|
||||
bool shouldThrowOnGetAll;
|
||||
|
||||
_InMemoryMappingRepository({this.shouldThrowOnSave = false, this.shouldThrowOnGetAll = false});
|
||||
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async {
|
||||
if (shouldThrowOnGetAll) throw Exception('getAllMappings failed');
|
||||
return List.unmodifiable(_store.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => _store[localId];
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async {
|
||||
return _store.values.where((m) => m.wooCommerceId == wooCommerceId).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) async {
|
||||
return _store.values.where((m) => m.squareCatalogObjectId == squareCatalogObjectId).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
|
||||
if (shouldThrowOnSave) throw Exception('saveMapping failed');
|
||||
_store[mapping.localId] = mapping;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {
|
||||
_store.remove(localId);
|
||||
}
|
||||
|
||||
/// Seed a mapping directly (bypasses shouldThrowOnSave).
|
||||
void seed(ProductIdMapping mapping) => _store[mapping.localId] = mapping;
|
||||
|
||||
int get count => _store.length;
|
||||
}
|
||||
|
||||
/// A simple [InventorySyncAdapter] that records calls and returns a
|
||||
/// configurable result.
|
||||
class _FakeInventoryAdapter implements InventorySyncAdapter {
|
||||
final Map<String, bool> _results;
|
||||
final List<String> calledWith = [];
|
||||
|
||||
_FakeInventoryAdapter({Map<String, bool>? results}) : _results = results ?? {};
|
||||
|
||||
@override
|
||||
Future<bool> syncItem(String itemId, int remoteQuantity) async {
|
||||
calledWith.add(itemId);
|
||||
return _results[itemId] ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProductDraft _draft(String id, {String name = 'Product'}) => ProductDraft(
|
||||
id: id,
|
||||
name: name,
|
||||
description: '',
|
||||
price: 10.0,
|
||||
sku: '',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
ProductIdMapping _mapping(
|
||||
String localId, {
|
||||
String? squareCatalogObjectId,
|
||||
String? squareVariationId,
|
||||
}) => ProductIdMapping(
|
||||
localId: localId,
|
||||
squareCatalogObjectId: squareCatalogObjectId,
|
||||
squareVariationId: squareVariationId,
|
||||
lastUpdated: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
// -------------------------------------------------------------------------
|
||||
// SyncError
|
||||
// -------------------------------------------------------------------------
|
||||
group('SyncError', () {
|
||||
test('stores itemId and message', () {
|
||||
const e = SyncError(itemId: 'abc', message: 'oops');
|
||||
expect(e.itemId, 'abc');
|
||||
expect(e.message, 'oops');
|
||||
});
|
||||
|
||||
test('equality', () {
|
||||
const a = SyncError(itemId: 'x', message: 'm');
|
||||
const b = SyncError(itemId: 'x', message: 'm');
|
||||
const c = SyncError(itemId: 'y', message: 'm');
|
||||
expect(a, equals(b));
|
||||
expect(a, isNot(equals(c)));
|
||||
});
|
||||
|
||||
test('toString includes itemId and message', () {
|
||||
const e = SyncError(itemId: 'id1', message: 'fail');
|
||||
expect(e.toString(), contains('id1'));
|
||||
expect(e.toString(), contains('fail'));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SquareSyncResult
|
||||
// -------------------------------------------------------------------------
|
||||
group('SquareSyncResult', () {
|
||||
test('default constructor has zero counts and empty errors', () {
|
||||
const r = SquareSyncResult();
|
||||
expect(r.catalogSynced, 0);
|
||||
expect(r.inventorySynced, 0);
|
||||
expect(r.catalogFailed, 0);
|
||||
expect(r.inventoryFailed, 0);
|
||||
expect(r.errors, isEmpty);
|
||||
});
|
||||
|
||||
test('totalProcessed sums all four counts', () {
|
||||
const r = SquareSyncResult(
|
||||
catalogSynced: 2,
|
||||
inventorySynced: 3,
|
||||
catalogFailed: 1,
|
||||
inventoryFailed: 1,
|
||||
);
|
||||
expect(r.totalProcessed, 7);
|
||||
});
|
||||
|
||||
test('allSucceeded is true when no failures', () {
|
||||
const r = SquareSyncResult(catalogSynced: 5, inventorySynced: 3);
|
||||
expect(r.allSucceeded, isTrue);
|
||||
});
|
||||
|
||||
test('allSucceeded is false when catalogFailed > 0', () {
|
||||
const r = SquareSyncResult(catalogFailed: 1);
|
||||
expect(r.allSucceeded, isFalse);
|
||||
});
|
||||
|
||||
test('allSucceeded is false when inventoryFailed > 0', () {
|
||||
const r = SquareSyncResult(inventoryFailed: 1);
|
||||
expect(r.allSucceeded, isFalse);
|
||||
});
|
||||
|
||||
test('merge combines counts and errors', () {
|
||||
const a = SquareSyncResult(
|
||||
catalogSynced: 2,
|
||||
catalogFailed: 1,
|
||||
errors: [SyncError(itemId: 'a', message: 'err')],
|
||||
);
|
||||
const b = SquareSyncResult(
|
||||
inventorySynced: 3,
|
||||
inventoryFailed: 1,
|
||||
errors: [SyncError(itemId: 'b', message: 'err2')],
|
||||
);
|
||||
final merged = a.merge(b);
|
||||
expect(merged.catalogSynced, 2);
|
||||
expect(merged.catalogFailed, 1);
|
||||
expect(merged.inventorySynced, 3);
|
||||
expect(merged.inventoryFailed, 1);
|
||||
expect(merged.errors.length, 2);
|
||||
});
|
||||
|
||||
test('toString includes all field names', () {
|
||||
const r = SquareSyncResult(catalogSynced: 1, inventorySynced: 2);
|
||||
final s = r.toString();
|
||||
expect(s, contains('catalogSynced'));
|
||||
expect(s, contains('inventorySynced'));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SquareCatalogSyncService — syncCatalog
|
||||
// -------------------------------------------------------------------------
|
||||
group('SquareCatalogSyncService — syncCatalog', () {
|
||||
test('empty catalog returns zero counts', () async {
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: _InMemoryMappingRepository(),
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.catalogSynced, 0);
|
||||
expect(result.catalogFailed, 0);
|
||||
expect(result.errors, isEmpty);
|
||||
});
|
||||
|
||||
test('single product creates a new mapping', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-001')]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.catalogSynced, 1);
|
||||
expect(result.catalogFailed, 0);
|
||||
expect(mappingRepo.count, 1);
|
||||
});
|
||||
|
||||
test('mapping squareCatalogObjectId matches product id', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-abc')]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
await service.syncCatalog();
|
||||
final mapping = await mappingRepo.getMappingBySquareId('sq-abc');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.squareCatalogObjectId, 'sq-abc');
|
||||
});
|
||||
|
||||
test('multiple products all synced', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(
|
||||
drafts: [_draft('sq-1'), _draft('sq-2'), _draft('sq-3')],
|
||||
),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.catalogSynced, 3);
|
||||
expect(result.catalogFailed, 0);
|
||||
expect(mappingRepo.count, 3);
|
||||
});
|
||||
|
||||
test('existing mapping is updated not duplicated', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('sq-001', squareCatalogObjectId: 'sq-001'));
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-001')]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
await service.syncCatalog();
|
||||
expect(mappingRepo.count, 1); // still 1, not 2
|
||||
});
|
||||
|
||||
test('catalog fetch failure returns catalogFailed=1 with error', () async {
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(shouldThrow: true),
|
||||
mappingRepository: _InMemoryMappingRepository(),
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.catalogFailed, 1);
|
||||
expect(result.catalogSynced, 0);
|
||||
expect(result.errors.length, 1);
|
||||
expect(result.errors.first.itemId, 'catalog');
|
||||
});
|
||||
|
||||
test('saveMapping failure records error and continues', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository(shouldThrowOnSave: true);
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1'), _draft('sq-2')]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.catalogFailed, 2);
|
||||
expect(result.catalogSynced, 0);
|
||||
expect(result.errors.length, 2);
|
||||
});
|
||||
|
||||
test('partial failure — first item fails, second succeeds', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-fail'), _draft('sq-ok')]),
|
||||
mappingRepository: _PartialFailMappingRepository(
|
||||
failIds: {'sq-fail'},
|
||||
delegate: mappingRepo,
|
||||
),
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.catalogSynced, 1);
|
||||
expect(result.catalogFailed, 1);
|
||||
expect(result.errors.first.itemId, 'sq-fail');
|
||||
});
|
||||
|
||||
test('SyncError details populated on failure', () async {
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(shouldThrow: true),
|
||||
mappingRepository: _InMemoryMappingRepository(),
|
||||
);
|
||||
final result = await service.syncCatalog();
|
||||
expect(result.errors.first.message, contains('catalog fetch failed'));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SquareCatalogSyncService — syncInventory
|
||||
// -------------------------------------------------------------------------
|
||||
group('SquareCatalogSyncService — syncInventory', () {
|
||||
test('no adapter returns empty result', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventorySynced, 0);
|
||||
expect(result.inventoryFailed, 0);
|
||||
});
|
||||
|
||||
test('items without squareVariationId are skipped', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('id1')); // no squareVariationId
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventorySynced, 0);
|
||||
expect(result.inventoryFailed, 0);
|
||||
expect(adapter.calledWith, isEmpty);
|
||||
});
|
||||
|
||||
test('items with squareVariationId are synced', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
mappingRepo.seed(_mapping('id2', squareCatalogObjectId: 'sq-2', squareVariationId: 'var-2'));
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventorySynced, 2);
|
||||
expect(result.inventoryFailed, 0);
|
||||
expect(adapter.calledWith, containsAll(['id1', 'id2']));
|
||||
});
|
||||
|
||||
test('adapter returning false records failure', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
final adapter = _FakeInventoryAdapter(results: {'id1': false});
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventorySynced, 0);
|
||||
expect(result.inventoryFailed, 1);
|
||||
expect(result.errors.first.itemId, 'id1');
|
||||
});
|
||||
|
||||
test('adapter throwing records failure and continues', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('id1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
mappingRepo.seed(_mapping('id2', squareCatalogObjectId: 'sq-2', squareVariationId: 'var-2'));
|
||||
final adapter = _MixedInventoryAdapter(throwIds: {'id1'});
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventorySynced, 1);
|
||||
expect(result.inventoryFailed, 1);
|
||||
expect(result.errors.first.itemId, 'id1');
|
||||
});
|
||||
|
||||
test('getAllMappings failure returns inventoryFailed=1', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository(shouldThrowOnGetAll: true);
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventoryFailed, 1);
|
||||
expect(result.errors.first.itemId, 'mappings');
|
||||
});
|
||||
|
||||
test('empty mappings returns empty result', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncInventory();
|
||||
expect(result.inventorySynced, 0);
|
||||
expect(result.inventoryFailed, 0);
|
||||
expect(adapter.calledWith, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SquareCatalogSyncService — syncAll
|
||||
// -------------------------------------------------------------------------
|
||||
group('SquareCatalogSyncService — syncAll', () {
|
||||
test('syncAll merges catalog and inventory results', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1'), _draft('sq-2')]),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
// syncAll calls syncCatalog first which creates mappings without
|
||||
// squareVariationId, so inventory sync skips them — correct behaviour:
|
||||
// variation IDs are set by the catalog repository, not the sync service.
|
||||
final result = await service.syncAll();
|
||||
expect(result.catalogSynced, 2);
|
||||
expect(result.catalogFailed, 0);
|
||||
expect(result.inventorySynced, 0);
|
||||
expect(result.inventoryFailed, 0);
|
||||
expect(result.allSucceeded, isTrue);
|
||||
});
|
||||
|
||||
test('syncAll with pre-seeded variation IDs syncs inventory', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncAll();
|
||||
expect(result.catalogSynced, 1);
|
||||
expect(result.inventorySynced, 1);
|
||||
expect(result.allSucceeded, isTrue);
|
||||
});
|
||||
|
||||
test('syncAll totalProcessed equals sum of all counts', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncAll();
|
||||
expect(
|
||||
result.totalProcessed,
|
||||
result.catalogSynced +
|
||||
result.catalogFailed +
|
||||
result.inventorySynced +
|
||||
result.inventoryFailed,
|
||||
);
|
||||
});
|
||||
|
||||
test('syncAll catalog failure does not prevent inventory sync', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
mappingRepo.seed(_mapping('sq-1', squareCatalogObjectId: 'sq-1', squareVariationId: 'var-1'));
|
||||
final adapter = _FakeInventoryAdapter();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(shouldThrow: true),
|
||||
mappingRepository: mappingRepo,
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
final result = await service.syncAll();
|
||||
expect(result.catalogFailed, 1);
|
||||
// Inventory sync still runs using existing mappings
|
||||
expect(result.inventorySynced, 1);
|
||||
});
|
||||
|
||||
test('syncAll without adapter skips inventory', () async {
|
||||
final mappingRepo = _InMemoryMappingRepository();
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _FakeCatalogRepository(drafts: [_draft('sq-1')]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
final result = await service.syncAll();
|
||||
expect(result.catalogSynced, 1);
|
||||
expect(result.inventorySynced, 0);
|
||||
expect(result.inventoryFailed, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional test doubles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A mapping repository that delegates to [_InMemoryMappingRepository] but
|
||||
/// throws on [saveMapping] for specific IDs.
|
||||
class _PartialFailMappingRepository implements ProductIdMappingRepository {
|
||||
final Set<String> failIds;
|
||||
final _InMemoryMappingRepository delegate;
|
||||
|
||||
_PartialFailMappingRepository({required this.failIds, required this.delegate});
|
||||
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() => delegate.getAllMappings();
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) =>
|
||||
delegate.getMappingByLocalId(localId);
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) =>
|
||||
delegate.getMappingByWooCommerceId(wooCommerceId);
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) =>
|
||||
delegate.getMappingBySquareId(squareCatalogObjectId);
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
|
||||
if (failIds.contains(mapping.localId)) {
|
||||
throw Exception('forced failure for ${mapping.localId}');
|
||||
}
|
||||
return delegate.saveMapping(mapping);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) => delegate.deleteMapping(localId);
|
||||
}
|
||||
|
||||
/// An [InventorySyncAdapter] that throws for specific IDs and succeeds for
|
||||
/// others.
|
||||
class _MixedInventoryAdapter implements InventorySyncAdapter {
|
||||
final Set<String> throwIds;
|
||||
|
||||
_MixedInventoryAdapter({required this.throwIds});
|
||||
|
||||
@override
|
||||
Future<bool> syncItem(String itemId, int remoteQuantity) async {
|
||||
if (throwIds.contains(itemId)) {
|
||||
throw Exception('inventory sync failed for $itemId');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,529 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ── Fake sync service helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// A [ProductIdMappingRepository] that always returns an empty list.
|
||||
class _EmptyMappingRepository implements ProductIdMappingRepository {
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async => [];
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async => mapping;
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {}
|
||||
}
|
||||
|
||||
/// A [ProductPublishingRepository] stub that returns a configurable list of
|
||||
/// drafts and optionally throws on [getProductDrafts].
|
||||
class _StubCatalogRepository implements ProductPublishingRepository {
|
||||
final List<ProductDraft> drafts;
|
||||
final bool throwOnGet;
|
||||
|
||||
_StubCatalogRepository({this.drafts = const [], this.throwOnGet = false});
|
||||
|
||||
@override
|
||||
Future<List<ProductDraft>> getProductDrafts() async {
|
||||
if (throwOnGet) throw Exception('catalog fetch failed');
|
||||
return drafts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductPrice(String id, double price) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductDescription(String id, String description) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductCategory(String id, String category) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> createProduct({
|
||||
required String name,
|
||||
required double price,
|
||||
String description = '',
|
||||
String sku = '',
|
||||
int? categoryId,
|
||||
PublishStatus status = PublishStatus.draft,
|
||||
}) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> deleteProduct(String id, {bool force = false}) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductDraft> updateProductImages(String id, List<int> mediaIds) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<List<ProductImage>> getProductImages(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<List<ProductCategory>> getCategories() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductCategory> createCategory({required String name, int parentId = 0}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> deleteCategory(int id, {bool force = true}) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Builds a [SquareCatalogSyncService] backed by the given stubs.
|
||||
SquareCatalogSyncService _makeService({
|
||||
List<ProductDraft> drafts = const [],
|
||||
bool throwOnGet = false,
|
||||
InventorySyncAdapter? adapter,
|
||||
}) {
|
||||
return SquareCatalogSyncService(
|
||||
catalogRepository: _StubCatalogRepository(drafts: drafts, throwOnGet: throwOnGet),
|
||||
mappingRepository: _EmptyMappingRepository(),
|
||||
inventoryAdapter: adapter,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a [SquareSyncController] backed by the given stubs.
|
||||
SquareSyncController _makeController({
|
||||
List<ProductDraft> drafts = const [],
|
||||
bool throwOnGet = false,
|
||||
InventorySyncAdapter? adapter,
|
||||
}) {
|
||||
return SquareSyncController(
|
||||
syncService: _makeService(drafts: drafts, throwOnGet: throwOnGet, adapter: adapter),
|
||||
);
|
||||
}
|
||||
|
||||
/// A [SquareCatalogSyncService] that throws synchronously from [syncAll].
|
||||
class _ThrowingSyncService extends SquareCatalogSyncService {
|
||||
_ThrowingSyncService()
|
||||
: super(
|
||||
catalogRepository: _StubCatalogRepository(throwOnGet: true),
|
||||
mappingRepository: _EmptyMappingRepository(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<SquareSyncResult> syncAll() async {
|
||||
throw Exception('total failure');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SquareSyncResult> syncCatalog() async {
|
||||
throw Exception('catalog failure');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SquareSyncResult> syncInventory() async {
|
||||
throw Exception('inventory failure');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SquareSyncController', () {
|
||||
// ── Initial state ─────────────────────────────────────────────────────────
|
||||
|
||||
group('initial state', () {
|
||||
test('isSyncing is false initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('lastResult is null initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.lastResult, isNull);
|
||||
});
|
||||
|
||||
test('lastSyncedAt is null initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.lastSyncedAt, isNull);
|
||||
});
|
||||
|
||||
test('lastError is null initially', () {
|
||||
final controller = _makeController();
|
||||
expect(controller.lastError, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncAll happy path ────────────────────────────────────────────────────
|
||||
|
||||
group('syncAll', () {
|
||||
test('sets lastResult after successful syncAll', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastResult, isNotNull);
|
||||
expect(controller.lastResult, isA<SquareSyncResult>());
|
||||
});
|
||||
|
||||
test('stamps lastSyncedAt after syncAll completes', () async {
|
||||
final before = DateTime.now();
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
final after = DateTime.now();
|
||||
|
||||
expect(controller.lastSyncedAt, isNotNull);
|
||||
expect(
|
||||
controller.lastSyncedAt!.isAfter(before) || controller.lastSyncedAt == before,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
controller.lastSyncedAt!.isBefore(after) || controller.lastSyncedAt == after,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('isSyncing is false after syncAll completes', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('lastError is null after successful syncAll', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncAll();
|
||||
expect(controller.lastError, isNull);
|
||||
});
|
||||
|
||||
test('syncAll with drafts returns catalogSynced count', () async {
|
||||
final draft = ProductDraft(
|
||||
id: 'P-001',
|
||||
name: 'Bowl Cozy',
|
||||
description: '',
|
||||
price: 12.99,
|
||||
sku: 'BC-001',
|
||||
category: 'Jewelry',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
);
|
||||
final controller = _makeController(drafts: [draft]);
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastResult!.catalogSynced, 1);
|
||||
expect(controller.lastResult!.catalogFailed, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncAll partial failure ───────────────────────────────────────────────
|
||||
|
||||
group('syncAll partial failure', () {
|
||||
test('lastResult reflects partial failure when catalog fetch fails', () async {
|
||||
final controller = _makeController(throwOnGet: true);
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastResult, isNotNull);
|
||||
expect(controller.lastResult!.catalogFailed, greaterThan(0));
|
||||
expect(controller.lastResult!.errors, isNotEmpty);
|
||||
});
|
||||
|
||||
test('lastError is null even on partial failure (errors in lastResult)', () async {
|
||||
final controller = _makeController(throwOnGet: true);
|
||||
await controller.syncAll();
|
||||
|
||||
// Partial failures are captured in lastResult.errors, not lastError.
|
||||
expect(controller.lastError, isNull);
|
||||
expect(controller.lastResult!.errors, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isSyncing during sync ─────────────────────────────────────────────────
|
||||
|
||||
group('isSyncing state', () {
|
||||
test('isSyncing is true during sync and false after', () async {
|
||||
final controller = _makeController();
|
||||
bool wasSyncingDuringSync = false;
|
||||
|
||||
// Listen for the first notification (isSyncing = true).
|
||||
controller.addListener(() {
|
||||
if (controller.isSyncing) {
|
||||
wasSyncingDuringSync = true;
|
||||
}
|
||||
});
|
||||
|
||||
await controller.syncAll();
|
||||
|
||||
expect(wasSyncingDuringSync, isTrue);
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('concurrent syncAll calls are no-ops while already syncing', () async {
|
||||
int notifyCount = 0;
|
||||
final controller = _makeController();
|
||||
controller.addListener(() => notifyCount++);
|
||||
|
||||
// Fire two concurrent calls — second should be a no-op.
|
||||
final f1 = controller.syncAll();
|
||||
final f2 = controller.syncAll(); // should return immediately
|
||||
await Future.wait([f1, f2]);
|
||||
|
||||
// Only 2 notifications: isSyncing=true and isSyncing=false.
|
||||
expect(notifyCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncCatalog ───────────────────────────────────────────────────────────
|
||||
|
||||
group('syncCatalog', () {
|
||||
test('syncCatalog sets lastResult', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncCatalog();
|
||||
expect(controller.lastResult, isNotNull);
|
||||
});
|
||||
|
||||
test('syncCatalog stamps lastSyncedAt', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncCatalog();
|
||||
expect(controller.lastSyncedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('syncCatalog sets lastError on unexpected exception', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncCatalog();
|
||||
expect(controller.lastError, isNotNull);
|
||||
expect(controller.lastError, contains('catalog failure'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncInventory ─────────────────────────────────────────────────────────
|
||||
|
||||
group('syncInventory', () {
|
||||
test('syncInventory sets lastResult', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncInventory();
|
||||
expect(controller.lastResult, isNotNull);
|
||||
});
|
||||
|
||||
test('syncInventory stamps lastSyncedAt', () async {
|
||||
final controller = _makeController();
|
||||
await controller.syncInventory();
|
||||
expect(controller.lastSyncedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('syncInventory sets lastError on unexpected exception', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncInventory();
|
||||
expect(controller.lastError, isNotNull);
|
||||
expect(controller.lastError, contains('inventory failure'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── error state on total failure ──────────────────────────────────────────
|
||||
|
||||
group('error state on total failure', () {
|
||||
test('lastError is set when syncAll throws unexpectedly', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncAll();
|
||||
|
||||
expect(controller.lastError, isNotNull);
|
||||
expect(controller.lastError, contains('total failure'));
|
||||
});
|
||||
|
||||
test('isSyncing is false after total failure', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncAll();
|
||||
expect(controller.isSyncing, isFalse);
|
||||
});
|
||||
|
||||
test('lastSyncedAt is null after total failure (no successful completion)', () async {
|
||||
final controller = SquareSyncController(syncService: _ThrowingSyncService());
|
||||
await controller.syncAll();
|
||||
// lastSyncedAt is only stamped on successful completion.
|
||||
expect(controller.lastSyncedAt, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── notifyListeners ───────────────────────────────────────────────────────
|
||||
|
||||
group('notifyListeners', () {
|
||||
test('notifies listeners exactly twice per sync (start and end)', () async {
|
||||
int count = 0;
|
||||
final controller = _makeController();
|
||||
controller.addListener(() => count++);
|
||||
await controller.syncAll();
|
||||
expect(count, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProductDraft.squareCatalogObjectId ────────────────────────────────────
|
||||
|
||||
group('ProductDraft.squareCatalogObjectId', () {
|
||||
ProductDraft draft0({String? squareCatalogObjectId}) => ProductDraft(
|
||||
id: 'P-001',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
price: 10.0,
|
||||
sku: 'SKU-001',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
squareCatalogObjectId: squareCatalogObjectId,
|
||||
);
|
||||
|
||||
test('defaults to null when not provided', () {
|
||||
final draft = draft0();
|
||||
expect(draft.squareCatalogObjectId, isNull);
|
||||
});
|
||||
|
||||
test('stores provided squareCatalogObjectId', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
expect(draft.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
});
|
||||
|
||||
test('copyWith preserves squareCatalogObjectId when not specified', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
final copy = draft.copyWith(name: 'Updated');
|
||||
expect(copy.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
});
|
||||
|
||||
test('copyWith updates squareCatalogObjectId when specified', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
final copy = draft.copyWith(squareCatalogObjectId: 'SQ-ITEM-002');
|
||||
expect(copy.squareCatalogObjectId, 'SQ-ITEM-002');
|
||||
});
|
||||
|
||||
test('copyWith clears squareCatalogObjectId when null is passed explicitly', () {
|
||||
final draft = draft0(squareCatalogObjectId: 'SQ-ITEM-001');
|
||||
final copy = draft.copyWith(squareCatalogObjectId: null);
|
||||
expect(copy.squareCatalogObjectId, isNull);
|
||||
});
|
||||
|
||||
test('copyWith preserves null squareCatalogObjectId when not specified', () {
|
||||
final draft = draft0();
|
||||
final copy = draft.copyWith(name: 'Updated');
|
||||
expect(copy.squareCatalogObjectId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SquareCatalogSyncService ID conflation fix ────────────────────────────
|
||||
|
||||
group('SquareCatalogSyncService ID conflation fix', () {
|
||||
test('uses squareCatalogObjectId when present instead of product.id', () async {
|
||||
// Product has a WooCommerce local ID ('WC-001') and a separate Square ID.
|
||||
final draft = ProductDraft(
|
||||
id: 'WC-001',
|
||||
name: 'Bowl Cozy',
|
||||
description: '',
|
||||
price: 12.99,
|
||||
sku: 'BC-001',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
);
|
||||
|
||||
String? savedLocalId;
|
||||
String? savedSquareId;
|
||||
|
||||
final mappingRepo = _CapturingMappingRepository(
|
||||
onSave: (m) {
|
||||
savedLocalId = m.localId;
|
||||
savedSquareId = m.squareCatalogObjectId;
|
||||
},
|
||||
);
|
||||
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _StubCatalogRepository(drafts: [draft]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
await service.syncCatalog();
|
||||
|
||||
// localId should be the WooCommerce ID, squareCatalogObjectId the Square ID.
|
||||
expect(savedLocalId, 'WC-001');
|
||||
expect(savedSquareId, 'SQ-ITEM-001');
|
||||
});
|
||||
|
||||
test('falls back to product.id when squareCatalogObjectId is null', () async {
|
||||
// Square-backed repo: product.id IS the Square catalog object ID.
|
||||
final draft = ProductDraft(
|
||||
id: 'SQ-ITEM-001',
|
||||
name: 'Bowl Cozy',
|
||||
description: '',
|
||||
price: 12.99,
|
||||
sku: 'BC-001',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 1, 1),
|
||||
// squareCatalogObjectId is null — fall back to product.id
|
||||
);
|
||||
|
||||
String? savedLocalId;
|
||||
String? savedSquareId;
|
||||
|
||||
final mappingRepo = _CapturingMappingRepository(
|
||||
onSave: (m) {
|
||||
savedLocalId = m.localId;
|
||||
savedSquareId = m.squareCatalogObjectId;
|
||||
},
|
||||
);
|
||||
|
||||
final service = SquareCatalogSyncService(
|
||||
catalogRepository: _StubCatalogRepository(drafts: [draft]),
|
||||
mappingRepository: mappingRepo,
|
||||
);
|
||||
|
||||
await service.syncCatalog();
|
||||
|
||||
expect(savedLocalId, 'SQ-ITEM-001');
|
||||
expect(savedSquareId, 'SQ-ITEM-001');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test doubles ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A [ProductIdMappingRepository] that captures [saveMapping] calls.
|
||||
class _CapturingMappingRepository implements ProductIdMappingRepository {
|
||||
final void Function(ProductIdMapping mapping) onSave;
|
||||
|
||||
_CapturingMappingRepository({required this.onSave});
|
||||
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async => [];
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareId) async => null;
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
|
||||
onSave(mapping);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wraps [widget] in a minimal [MaterialApp] so that [Theme] and [Navigator]
|
||||
/// are available.
|
||||
Widget _wrap(Widget widget) => MaterialApp(home: Scaffold(body: widget));
|
||||
|
||||
/// Creates a [SquareSyncController] backed by a [SquareCatalogSyncService]
|
||||
/// that uses fake repositories — no network calls.
|
||||
SquareSyncController _fakeController() {
|
||||
final syncService = SquareCatalogSyncService(
|
||||
catalogRepository: FakeProductPublishingRepository(),
|
||||
mappingRepository: FakeProductIdMappingRepository(),
|
||||
);
|
||||
return SquareSyncController(syncService: syncService);
|
||||
}
|
||||
|
||||
/// Advances fake time enough to drain all [FakeProductPublishingRepository]
|
||||
/// delays (300 ms per call × a few calls) and pumps the widget tree.
|
||||
Future<void> _drainSync(WidgetTester tester) async {
|
||||
// Advance time in steps to drain all fake delays (300 ms each).
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('SquareSyncStatusPanel', () {
|
||||
testWidgets('renders Square Sync title', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Square Sync'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Never synced chip when no sync has run', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Never synced'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Last synced: Never when lastSyncedAt is null', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Last synced: Never'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Sync Now button is enabled when not syncing', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('shows OK chip after a successful sync', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
// Trigger sync and advance fake time to drain all delays.
|
||||
await tester.tap(find.text('Sync Now'));
|
||||
await _drainSync(tester);
|
||||
|
||||
expect(find.text('OK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows last sync result section after sync', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Sync Now'));
|
||||
await _drainSync(tester);
|
||||
|
||||
expect(find.text('Last sync result'), findsOneWidget);
|
||||
expect(find.text('Catalog'), findsOneWidget);
|
||||
expect(find.text('Inventory'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rebuilds when controller notifies listeners', (tester) async {
|
||||
final controller = _fakeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Never synced'), findsOneWidget);
|
||||
|
||||
// Run a sync via the button and advance fake time.
|
||||
await tester.tap(find.text('Sync Now'));
|
||||
await _drainSync(tester);
|
||||
|
||||
expect(find.text('OK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unregisters listener on dispose', (tester) async {
|
||||
final controller = _fakeController();
|
||||
|
||||
await tester.pumpWidget(_wrap(SquareSyncStatusPanel(controller: controller)));
|
||||
await tester.pump();
|
||||
|
||||
// Replace the widget tree — this disposes the panel.
|
||||
await tester.pumpWidget(_wrap(const SizedBox()));
|
||||
await tester.pump();
|
||||
|
||||
// Notifying after dispose should not throw.
|
||||
expect(() => controller.notifyListeners(), returnsNormally);
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -12,6 +12,4 @@ export 'src/integration_health_check.dart';
|
|||
export 'src/rate_limiter.dart';
|
||||
export 'src/retry_policy.dart';
|
||||
export 'src/square_api_client.dart';
|
||||
export 'src/square_inventory_mapper.dart';
|
||||
export 'src/square_product_mapper.dart';
|
||||
export 'src/webhook_handler.dart';
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import 'channel_adapter.dart';
|
||||
|
||||
/// Maps between Square InventoryCount JSON maps and the channel-neutral
|
||||
/// [ChannelInventoryCount] representation.
|
||||
///
|
||||
/// Square inventory counts are returned from the
|
||||
/// `GET /v2/inventory/{catalog_object_id}` endpoint as:
|
||||
/// ```
|
||||
/// {
|
||||
/// "catalog_object_id": "SQ-VAR-001",
|
||||
/// "catalog_object_type": "ITEM_VARIATION",
|
||||
/// "state": "IN_STOCK",
|
||||
/// "location_id": "LOC123",
|
||||
/// "quantity": "18",
|
||||
/// "calculated_at": "2026-07-01T00:00:00.000Z"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Note: Square returns `quantity` as a **string**, not an integer.
|
||||
/// This mapper parses it safely, defaulting to 0 on parse failure.
|
||||
///
|
||||
/// Only counts with `state == 'IN_STOCK'` represent sellable on-hand
|
||||
/// inventory. Other states (`WASTE`, `UNLINKED_RETURN`, etc.) are ignored
|
||||
/// when converting to a domain quantity.
|
||||
class SquareInventoryMapper {
|
||||
const SquareInventoryMapper();
|
||||
|
||||
// ── Square InventoryCount → ChannelInventoryCount ─────────────────────────
|
||||
|
||||
/// Maps a single Square InventoryCount [json] map to a
|
||||
/// [ChannelInventoryCount].
|
||||
///
|
||||
/// Returns `null` if the count is not in `IN_STOCK` state or is missing
|
||||
/// required fields.
|
||||
ChannelInventoryCount? fromSquareInventoryCount(Map<String, dynamic> json) {
|
||||
final catalogObjectId = json['catalog_object_id'] as String?;
|
||||
if (catalogObjectId == null || catalogObjectId.isEmpty) return null;
|
||||
|
||||
// Only map IN_STOCK counts to on-hand quantity.
|
||||
final state = json['state'] as String? ?? '';
|
||||
if (state != 'IN_STOCK') return null;
|
||||
|
||||
final quantityStr = json['quantity'] as String? ?? '0';
|
||||
final quantity = int.tryParse(quantityStr) ?? 0;
|
||||
|
||||
return ChannelInventoryCount(productId: catalogObjectId, quantity: quantity);
|
||||
}
|
||||
|
||||
/// Maps a list of Square InventoryCount [jsonList] maps to
|
||||
/// [ChannelInventoryCount]s, skipping non-IN_STOCK entries and invalid
|
||||
/// records.
|
||||
List<ChannelInventoryCount> fromSquareInventoryCountList(List<Map<String, dynamic>> jsonList) {
|
||||
final results = <ChannelInventoryCount>[];
|
||||
for (final json in jsonList) {
|
||||
final count = fromSquareInventoryCount(json);
|
||||
if (count != null) results.add(count);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── ChannelInventoryCount → Square batch-change entry ────────────────────
|
||||
|
||||
/// Converts a [ChannelInventoryCount] to a Square inventory batch-change
|
||||
/// entry suitable for use with [SquareApiClient.batchChangeInventory].
|
||||
///
|
||||
/// Uses `PHYSICAL_COUNT` type to set an absolute quantity (not a delta).
|
||||
///
|
||||
/// [locationId] is the Square location ID for the count.
|
||||
/// [occurredAt] defaults to the current UTC time in RFC 3339 format.
|
||||
Map<String, dynamic> toSquareBatchChangeEntry(
|
||||
ChannelInventoryCount count, {
|
||||
required String locationId,
|
||||
DateTime? occurredAt,
|
||||
}) {
|
||||
final timestamp = (occurredAt ?? DateTime.now().toUtc()).toIso8601String();
|
||||
|
||||
return {
|
||||
'type': 'PHYSICAL_COUNT',
|
||||
'physical_count': {
|
||||
'catalog_object_id': count.productId,
|
||||
'state': 'IN_STOCK',
|
||||
'location_id': locationId,
|
||||
'quantity': count.quantity.toString(),
|
||||
'occurred_at': timestamp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts a list of [ChannelInventoryCount]s to Square batch-change
|
||||
/// entries.
|
||||
List<Map<String, dynamic>> toSquareBatchChangeEntries(
|
||||
List<ChannelInventoryCount> counts, {
|
||||
required String locationId,
|
||||
DateTime? occurredAt,
|
||||
}) {
|
||||
return counts
|
||||
.map((c) => toSquareBatchChangeEntry(c, locationId: locationId, occurredAt: occurredAt))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import 'channel_adapter.dart';
|
||||
|
||||
/// Maps between Square CatalogObject JSON maps and the channel-neutral
|
||||
/// [ChannelProduct] representation.
|
||||
///
|
||||
/// Square catalog items have a nested structure:
|
||||
/// ```
|
||||
/// {
|
||||
/// "type": "ITEM",
|
||||
/// "id": "SQ-ITEM-001",
|
||||
/// "item_data": {
|
||||
/// "name": "Floral Bowl Cozy",
|
||||
/// "description": "...",
|
||||
/// "variations": [
|
||||
/// {
|
||||
/// "type": "ITEM_VARIATION",
|
||||
/// "id": "SQ-VAR-001",
|
||||
/// "item_variation_data": {
|
||||
/// "name": "Regular",
|
||||
/// "price_money": { "amount": 1299, "currency": "USD" },
|
||||
/// "sku": "BC-FLR-001"
|
||||
/// }
|
||||
/// }
|
||||
/// ],
|
||||
/// "category_id": "SQ-CAT-001"
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Price is stored in **cents** by Square; this mapper converts to/from
|
||||
/// dollars (double) for the domain layer.
|
||||
///
|
||||
/// Only the first variation is used for price and SKU when mapping from
|
||||
/// Square → domain. When mapping domain → Square, a single default variation
|
||||
/// is generated.
|
||||
class SquareProductMapper {
|
||||
const SquareProductMapper();
|
||||
|
||||
// ── Square → ChannelProduct ───────────────────────────────────────────────
|
||||
|
||||
/// Maps a Square CatalogObject [json] map (type `ITEM`) to a
|
||||
/// [ChannelProduct].
|
||||
///
|
||||
/// Returns `null` if the object is not a valid ITEM type or is missing
|
||||
/// required fields.
|
||||
ChannelProduct? fromSquareCatalogObject(Map<String, dynamic> json) {
|
||||
if (json['type'] != 'ITEM') return null;
|
||||
|
||||
final id = json['id'] as String?;
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
final itemData = json['item_data'];
|
||||
if (itemData is! Map<String, dynamic>) return null;
|
||||
|
||||
final name = itemData['name'] as String? ?? '';
|
||||
final description = itemData['description'] as String?;
|
||||
|
||||
// Extract price and SKU from the first variation.
|
||||
double price = 0.0;
|
||||
String? sku;
|
||||
final variations = itemData['variations'];
|
||||
if (variations is List && variations.isNotEmpty) {
|
||||
final firstVariation = variations.first;
|
||||
if (firstVariation is Map<String, dynamic>) {
|
||||
final varData = firstVariation['item_variation_data'];
|
||||
if (varData is Map<String, dynamic>) {
|
||||
sku = varData['sku'] as String?;
|
||||
final priceMoney = varData['price_money'];
|
||||
if (priceMoney is Map<String, dynamic>) {
|
||||
final amountCents = priceMoney['amount'];
|
||||
if (amountCents is int) {
|
||||
price = amountCents / 100.0;
|
||||
} else if (amountCents is double) {
|
||||
price = amountCents / 100.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Square present_at_all_locations / is_deleted to a status string.
|
||||
final isDeleted = json['is_deleted'] as bool? ?? false;
|
||||
final status = isDeleted ? 'archived' : 'active';
|
||||
|
||||
return ChannelProduct(
|
||||
id: id,
|
||||
name: name,
|
||||
price: price,
|
||||
status: status,
|
||||
description: description,
|
||||
sku: sku,
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps a list of Square CatalogObject [jsonList] maps to [ChannelProduct]s,
|
||||
/// skipping any entries that cannot be parsed.
|
||||
List<ChannelProduct> fromSquareCatalogList(List<Map<String, dynamic>> jsonList) {
|
||||
final results = <ChannelProduct>[];
|
||||
for (final json in jsonList) {
|
||||
final product = fromSquareCatalogObject(json);
|
||||
if (product != null) results.add(product);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── ChannelProduct → Square CatalogObject ────────────────────────────────
|
||||
|
||||
/// Converts a [ChannelProduct] to a Square CatalogObject map suitable for
|
||||
/// use with [SquareApiClient.upsertCatalogItem].
|
||||
///
|
||||
/// [squareItemId] is the existing Square ITEM object ID for updates, or a
|
||||
/// temporary ID (e.g. `'#new-item'`) for creates.
|
||||
///
|
||||
/// [squareVariationId] is the existing variation ID for updates, or a
|
||||
/// temporary ID (e.g. `'#new-variation'`) for creates.
|
||||
///
|
||||
/// [locationId] is required by Square for variation pricing.
|
||||
Map<String, dynamic> toSquareCatalogObject(
|
||||
ChannelProduct product, {
|
||||
String squareItemId = '#new-item',
|
||||
String squareVariationId = '#new-variation',
|
||||
required String locationId,
|
||||
}) {
|
||||
final amountCents = (product.price * 100).round();
|
||||
|
||||
final variationData = <String, dynamic>{
|
||||
'name': 'Regular',
|
||||
'pricing_type': 'FIXED_PRICING',
|
||||
'price_money': {'amount': amountCents, 'currency': 'USD'},
|
||||
'location_overrides': [
|
||||
{'location_id': locationId, 'track_inventory': true},
|
||||
],
|
||||
};
|
||||
if (product.sku != null && product.sku!.isNotEmpty) {
|
||||
variationData['sku'] = product.sku;
|
||||
}
|
||||
|
||||
final itemData = <String, dynamic>{
|
||||
'name': product.name,
|
||||
'variations': [
|
||||
{'type': 'ITEM_VARIATION', 'id': squareVariationId, 'item_variation_data': variationData},
|
||||
],
|
||||
};
|
||||
if (product.description != null && product.description!.isNotEmpty) {
|
||||
itemData['description'] = product.description;
|
||||
}
|
||||
|
||||
return {'type': 'ITEM', 'id': squareItemId, 'item_data': itemData};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,593 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
void main() {
|
||||
// ── SquareProductMapper ────────────────────────────────────────────────────
|
||||
|
||||
group('SquareProductMapper', () {
|
||||
const mapper = SquareProductMapper();
|
||||
|
||||
// ── fromSquareCatalogObject ──────────────────────────────────────────────
|
||||
|
||||
group('fromSquareCatalogObject', () {
|
||||
test('maps a full ITEM object to ChannelProduct', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-001',
|
||||
'item_data': {
|
||||
'name': 'Floral Bowl Cozy',
|
||||
'description': 'A beautifully crafted fabric bowl cozy.',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-001',
|
||||
'item_variation_data': {
|
||||
'name': 'Regular',
|
||||
'sku': 'BC-FLR-001',
|
||||
'price_money': {'amount': 1299, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
|
||||
expect(product, isNotNull);
|
||||
expect(product!.id, 'SQ-ITEM-001');
|
||||
expect(product.name, 'Floral Bowl Cozy');
|
||||
expect(product.description, 'A beautifully crafted fabric bowl cozy.');
|
||||
expect(product.price, closeTo(12.99, 0.001));
|
||||
expect(product.sku, 'BC-FLR-001');
|
||||
expect(product.status, 'active');
|
||||
});
|
||||
|
||||
test('maps price in cents to dollars correctly', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-002',
|
||||
'item_data': {
|
||||
'name': 'Coaster Set',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-002',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 1650, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.price, closeTo(16.50, 0.001));
|
||||
});
|
||||
|
||||
test('returns null for non-ITEM type', () {
|
||||
final json = {
|
||||
'type': 'CATEGORY',
|
||||
'id': 'SQ-CAT-001',
|
||||
'category_data': {'name': 'Kitchen'},
|
||||
};
|
||||
|
||||
expect(mapper.fromSquareCatalogObject(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when id is missing', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'item_data': {'name': 'No ID Item'},
|
||||
};
|
||||
|
||||
expect(mapper.fromSquareCatalogObject(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when item_data is missing', () {
|
||||
final json = {'type': 'ITEM', 'id': 'SQ-ITEM-003'};
|
||||
|
||||
expect(mapper.fromSquareCatalogObject(json), isNull);
|
||||
});
|
||||
|
||||
test('maps is_deleted=true to archived status', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-004',
|
||||
'is_deleted': true,
|
||||
'item_data': {
|
||||
'name': 'Archived Item',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-004',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 999, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.status, 'archived');
|
||||
});
|
||||
|
||||
test('defaults price to 0.0 when no variations present', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-005',
|
||||
'item_data': {'name': 'No Price Item', 'variations': []},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.price, 0.0);
|
||||
});
|
||||
|
||||
test('defaults price to 0.0 when price_money is absent', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-006',
|
||||
'item_data': {
|
||||
'name': 'No Price Money',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-006',
|
||||
'item_variation_data': {'name': 'Regular'},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.price, 0.0);
|
||||
});
|
||||
|
||||
test('sku is null when not present in variation', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-007',
|
||||
'item_data': {
|
||||
'name': 'No SKU Item',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-007',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 500, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.sku, isNull);
|
||||
});
|
||||
|
||||
test('description is null when not present in item_data', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-008',
|
||||
'item_data': {
|
||||
'name': 'No Description',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-008',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 800, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.description, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fromSquareCatalogList ────────────────────────────────────────────────
|
||||
|
||||
group('fromSquareCatalogList', () {
|
||||
test('maps a list of ITEM objects', () {
|
||||
final jsonList = [
|
||||
{
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-A',
|
||||
'item_data': {
|
||||
'name': 'Item A',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-A',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 1000, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-B',
|
||||
'item_data': {
|
||||
'name': 'Item B',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-B',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 2000, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
final products = mapper.fromSquareCatalogList(jsonList);
|
||||
expect(products.length, 2);
|
||||
expect(products[0].id, 'SQ-ITEM-A');
|
||||
expect(products[1].id, 'SQ-ITEM-B');
|
||||
});
|
||||
|
||||
test('skips non-ITEM entries in a mixed list', () {
|
||||
final jsonList = [
|
||||
{
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-A',
|
||||
'item_data': {
|
||||
'name': 'Item A',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-A',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 1000, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{'type': 'CATEGORY', 'id': 'SQ-CAT-001', 'category_data': {}},
|
||||
{'type': 'TAX', 'id': 'SQ-TAX-001'},
|
||||
];
|
||||
|
||||
final products = mapper.fromSquareCatalogList(jsonList);
|
||||
expect(products.length, 1);
|
||||
expect(products.first.id, 'SQ-ITEM-A');
|
||||
});
|
||||
|
||||
test('returns empty list for empty input', () {
|
||||
expect(mapper.fromSquareCatalogList([]), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toSquareCatalogObject ────────────────────────────────────────────────
|
||||
|
||||
group('toSquareCatalogObject', () {
|
||||
test('converts ChannelProduct to Square CatalogObject', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-1',
|
||||
name: 'Floral Bowl Cozy',
|
||||
price: 12.99,
|
||||
status: 'active',
|
||||
description: 'A fabric bowl cozy.',
|
||||
sku: 'BC-FLR-001',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(
|
||||
product,
|
||||
squareItemId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
locationId: 'LOC123',
|
||||
);
|
||||
|
||||
expect(json['type'], 'ITEM');
|
||||
expect(json['id'], 'SQ-ITEM-001');
|
||||
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
expect(itemData['name'], 'Floral Bowl Cozy');
|
||||
expect(itemData['description'], 'A fabric bowl cozy.');
|
||||
|
||||
final variations = itemData['variations'] as List;
|
||||
expect(variations.length, 1);
|
||||
|
||||
final variation = variations.first as Map<String, dynamic>;
|
||||
expect(variation['type'], 'ITEM_VARIATION');
|
||||
expect(variation['id'], 'SQ-VAR-001');
|
||||
|
||||
final varData = variation['item_variation_data'] as Map<String, dynamic>;
|
||||
expect(varData['sku'], 'BC-FLR-001');
|
||||
|
||||
final priceMoney = varData['price_money'] as Map<String, dynamic>;
|
||||
expect(priceMoney['amount'], 1299);
|
||||
expect(priceMoney['currency'], 'USD');
|
||||
});
|
||||
|
||||
test('converts price to cents correctly', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-2',
|
||||
name: 'Coaster Set',
|
||||
price: 16.50,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
final varData =
|
||||
(variations.first as Map<String, dynamic>)['item_variation_data']
|
||||
as Map<String, dynamic>;
|
||||
final priceMoney = varData['price_money'] as Map<String, dynamic>;
|
||||
|
||||
expect(priceMoney['amount'], 1650);
|
||||
});
|
||||
|
||||
test('uses default temporary IDs when not provided', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-3',
|
||||
name: 'New Item',
|
||||
price: 9.99,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
expect(json['id'], '#new-item');
|
||||
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
expect((variations.first as Map<String, dynamic>)['id'], '#new-variation');
|
||||
});
|
||||
|
||||
test('omits description when null', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-4',
|
||||
name: 'No Description',
|
||||
price: 5.00,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
expect(itemData.containsKey('description'), isFalse);
|
||||
});
|
||||
|
||||
test('omits sku from variation when null', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-5',
|
||||
name: 'No SKU',
|
||||
price: 5.00,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
final varData =
|
||||
(variations.first as Map<String, dynamic>)['item_variation_data']
|
||||
as Map<String, dynamic>;
|
||||
expect(varData.containsKey('sku'), isFalse);
|
||||
});
|
||||
|
||||
test('includes location_overrides with track_inventory', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-6',
|
||||
name: 'Track Me',
|
||||
price: 10.00,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC456');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
final varData =
|
||||
(variations.first as Map<String, dynamic>)['item_variation_data']
|
||||
as Map<String, dynamic>;
|
||||
final overrides = varData['location_overrides'] as List;
|
||||
|
||||
expect(overrides.length, 1);
|
||||
final override = overrides.first as Map<String, dynamic>;
|
||||
expect(override['location_id'], 'LOC456');
|
||||
expect(override['track_inventory'], isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── SquareInventoryMapper ──────────────────────────────────────────────────
|
||||
|
||||
group('SquareInventoryMapper', () {
|
||||
const mapper = SquareInventoryMapper();
|
||||
|
||||
// ── fromSquareInventoryCount ─────────────────────────────────────────────
|
||||
|
||||
group('fromSquareInventoryCount', () {
|
||||
test('maps a valid IN_STOCK count to ChannelInventoryCount', () {
|
||||
final json = {
|
||||
'catalog_object_id': 'SQ-VAR-001',
|
||||
'catalog_object_type': 'ITEM_VARIATION',
|
||||
'state': 'IN_STOCK',
|
||||
'location_id': 'LOC123',
|
||||
'quantity': '18',
|
||||
'calculated_at': '2026-07-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
|
||||
expect(count, isNotNull);
|
||||
expect(count!.productId, 'SQ-VAR-001');
|
||||
expect(count.quantity, 18);
|
||||
});
|
||||
|
||||
test('returns null for non-IN_STOCK state', () {
|
||||
final json = {'catalog_object_id': 'SQ-VAR-001', 'state': 'WASTE', 'quantity': '5'};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when catalog_object_id is missing', () {
|
||||
final json = {'state': 'IN_STOCK', 'quantity': '10'};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when catalog_object_id is empty string', () {
|
||||
final json = {'catalog_object_id': '', 'state': 'IN_STOCK', 'quantity': '10'};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
|
||||
test('defaults quantity to 0 when quantity field is missing', () {
|
||||
final json = {'catalog_object_id': 'SQ-VAR-002', 'state': 'IN_STOCK'};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
expect(count!.quantity, 0);
|
||||
});
|
||||
|
||||
test('defaults quantity to 0 when quantity is not parseable', () {
|
||||
final json = {
|
||||
'catalog_object_id': 'SQ-VAR-003',
|
||||
'state': 'IN_STOCK',
|
||||
'quantity': 'not-a-number',
|
||||
};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
expect(count!.quantity, 0);
|
||||
});
|
||||
|
||||
test('parses quantity of zero correctly', () {
|
||||
final json = {'catalog_object_id': 'SQ-VAR-004', 'state': 'IN_STOCK', 'quantity': '0'};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
expect(count!.quantity, 0);
|
||||
});
|
||||
|
||||
test('returns null for UNLINKED_RETURN state', () {
|
||||
final json = {
|
||||
'catalog_object_id': 'SQ-VAR-005',
|
||||
'state': 'UNLINKED_RETURN',
|
||||
'quantity': '3',
|
||||
};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fromSquareInventoryCountList ─────────────────────────────────────────
|
||||
|
||||
group('fromSquareInventoryCountList', () {
|
||||
test('maps a list of IN_STOCK counts', () {
|
||||
final jsonList = [
|
||||
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
|
||||
{'catalog_object_id': 'SQ-VAR-B', 'state': 'IN_STOCK', 'quantity': '5'},
|
||||
];
|
||||
|
||||
final counts = mapper.fromSquareInventoryCountList(jsonList);
|
||||
expect(counts.length, 2);
|
||||
expect(counts[0].productId, 'SQ-VAR-A');
|
||||
expect(counts[0].quantity, 10);
|
||||
expect(counts[1].productId, 'SQ-VAR-B');
|
||||
expect(counts[1].quantity, 5);
|
||||
});
|
||||
|
||||
test('skips non-IN_STOCK entries in a mixed list', () {
|
||||
final jsonList = [
|
||||
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
|
||||
{'catalog_object_id': 'SQ-VAR-B', 'state': 'WASTE', 'quantity': '2'},
|
||||
{'catalog_object_id': 'SQ-VAR-C', 'state': 'IN_STOCK', 'quantity': '7'},
|
||||
];
|
||||
|
||||
final counts = mapper.fromSquareInventoryCountList(jsonList);
|
||||
expect(counts.length, 2);
|
||||
expect(counts.map((c) => c.productId), containsAll(['SQ-VAR-A', 'SQ-VAR-C']));
|
||||
});
|
||||
|
||||
test('returns empty list for empty input', () {
|
||||
expect(mapper.fromSquareInventoryCountList([]), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toSquareBatchChangeEntry ─────────────────────────────────────────────
|
||||
|
||||
group('toSquareBatchChangeEntry', () {
|
||||
test('converts ChannelInventoryCount to PHYSICAL_COUNT entry', () {
|
||||
const count = ChannelInventoryCount(productId: 'SQ-VAR-001', quantity: 18);
|
||||
final fixedTime = DateTime.utc(2026, 7, 1, 12, 0, 0);
|
||||
|
||||
final entry = mapper.toSquareBatchChangeEntry(
|
||||
count,
|
||||
locationId: 'LOC123',
|
||||
occurredAt: fixedTime,
|
||||
);
|
||||
|
||||
expect(entry['type'], 'PHYSICAL_COUNT');
|
||||
|
||||
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
|
||||
expect(physicalCount['catalog_object_id'], 'SQ-VAR-001');
|
||||
expect(physicalCount['state'], 'IN_STOCK');
|
||||
expect(physicalCount['location_id'], 'LOC123');
|
||||
expect(physicalCount['quantity'], '18');
|
||||
expect(physicalCount['occurred_at'], '2026-07-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('quantity is serialised as a string', () {
|
||||
const count = ChannelInventoryCount(productId: 'SQ-VAR-002', quantity: 0);
|
||||
final entry = mapper.toSquareBatchChangeEntry(
|
||||
count,
|
||||
locationId: 'LOC123',
|
||||
occurredAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
|
||||
expect(physicalCount['quantity'], isA<String>());
|
||||
expect(physicalCount['quantity'], '0');
|
||||
});
|
||||
|
||||
test('uses current time when occurredAt is not provided', () {
|
||||
const count = ChannelInventoryCount(productId: 'SQ-VAR-003', quantity: 5);
|
||||
final before = DateTime.now().toUtc();
|
||||
final entry = mapper.toSquareBatchChangeEntry(count, locationId: 'LOC123');
|
||||
final after = DateTime.now().toUtc();
|
||||
|
||||
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
|
||||
final timestamp = DateTime.parse(physicalCount['occurred_at'] as String);
|
||||
expect(timestamp.isAfter(before.subtract(const Duration(seconds: 1))), isTrue);
|
||||
expect(timestamp.isBefore(after.add(const Duration(seconds: 1))), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toSquareBatchChangeEntries ───────────────────────────────────────────
|
||||
|
||||
group('toSquareBatchChangeEntries', () {
|
||||
test('converts a list of counts to batch-change entries', () {
|
||||
const counts = [
|
||||
ChannelInventoryCount(productId: 'SQ-VAR-A', quantity: 10),
|
||||
ChannelInventoryCount(productId: 'SQ-VAR-B', quantity: 3),
|
||||
];
|
||||
final fixedTime = DateTime.utc(2026, 7, 1);
|
||||
|
||||
final entries = mapper.toSquareBatchChangeEntries(
|
||||
counts,
|
||||
locationId: 'LOC123',
|
||||
occurredAt: fixedTime,
|
||||
);
|
||||
|
||||
expect(entries.length, 2);
|
||||
expect(
|
||||
(entries[0]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
|
||||
'SQ-VAR-A',
|
||||
);
|
||||
expect(
|
||||
(entries[1]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
|
||||
'SQ-VAR-B',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty list for empty input', () {
|
||||
expect(mapper.toSquareBatchChangeEntries([], locationId: 'LOC123'), isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue