merge: feat/auth-foundation into main (Stage 8C complete)
Publish Docs / publish-docs (push) Successful in 52s
Details
Publish Docs / publish-docs (push) Successful in 52s
Details
This commit is contained in:
commit
003347c89c
|
|
@ -69,7 +69,7 @@ Rules:
|
||||||
- `feature_orders` — domain, application, data (fake), and presentation layers
|
- `feature_orders` — domain, application, data (fake), and presentation layers
|
||||||
- `feature_policy` — domain, application, data (fake), and presentation layers
|
- `feature_policy` — domain, application, data (fake), and presentation layers
|
||||||
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
||||||
- `auth` — authentication/authorization (stub only)
|
- `auth` — ✅ authentication foundation activated (Stage 8C complete); `KcUser`/`KcUserRole` domain models, `AuthState` sealed hierarchy, `AuthService` contract, `AuthException`, `CredentialStore`/`CredentialKeys` contract, `InMemoryCredentialStore`, `FakeAuthService` implemented; `KcAppEnvironment.square` added to `core`
|
||||||
- `integrations` — ✅ integration contracts activated (Stage 8A complete); `ApiClient`, `ApiException`, `RetryPolicy`, `RateLimiter`, `IntegrationHealthCheck`, `WebhookHandler`, `ChannelAdapter` contracts defined; `WooCommerceApiClient` and `WooCommerceApiException` refactored to implement shared contracts
|
- `integrations` — ✅ integration contracts activated (Stage 8A complete); `ApiClient`, `ApiException`, `RetryPolicy`, `RateLimiter`, `IntegrationHealthCheck`, `WebhookHandler`, `ChannelAdapter` contracts defined; `WooCommerceApiClient` and `WooCommerceApiException` refactored to implement shared contracts
|
||||||
- `data` — ✅ shared data layer contracts activated (Stage 8B complete); `LocalCache<T>`, `SyncQueue`, `ConflictResolver<T>`, `ChangeTracker<T>`, `DataMapper<TLocal, TRemote>` contracts defined with in-memory implementations
|
- `data` — ✅ shared data layer contracts activated (Stage 8B complete); `LocalCache<T>`, `SyncQueue`, `ConflictResolver<T>`, `ChangeTracker<T>`, `DataMapper<TLocal, TRemote>` contracts defined with in-memory implementations
|
||||||
- `feature_finance` — financial analysis feature (stub only)
|
- `feature_finance` — financial analysis feature (stub only)
|
||||||
|
|
@ -124,6 +124,7 @@ Rules:
|
||||||
- ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-07-11). Stage 7 complete.
|
- ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-07-11). Stage 7 complete.
|
||||||
- ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11).
|
- ✅ 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).
|
- ✅ 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.
|
||||||
|
|
||||||
### Current narrow edit capabilities on `main`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -141,7 +142,7 @@ Rules:
|
||||||
- `feature_wordpress` tests passing
|
- `feature_wordpress` tests passing
|
||||||
- `kell_web` tests passing
|
- `kell_web` tests passing
|
||||||
- `kell_mobile` tests passing
|
- `kell_mobile` tests passing
|
||||||
- latest reported count for `core`: `20/20 passed`
|
- latest reported count for `core`: `21/21 passed`
|
||||||
- latest reported count for `design_system`: `41/41 passed`
|
- latest reported count for `design_system`: `41/41 passed`
|
||||||
- latest reported count for `feature_inventory`: `75/75 passed`
|
- latest reported count for `feature_inventory`: `75/75 passed`
|
||||||
- latest reported count for `feature_orders`: `115/115 passed`
|
- latest reported count for `feature_orders`: `115/115 passed`
|
||||||
|
|
@ -149,9 +150,10 @@ Rules:
|
||||||
- latest reported count for `kell_web`: `24/24 passed`
|
- latest reported count for `kell_web`: `24/24 passed`
|
||||||
- latest reported count for `integrations`: `38/38 passed`
|
- latest reported count for `integrations`: `38/38 passed`
|
||||||
- latest reported count for `data`: `63/63 passed`
|
- latest reported count for `data`: `63/63 passed`
|
||||||
|
- latest reported count for `auth`: `42/42 passed`
|
||||||
- latest reported count for `kell_mobile`: `26/26 passed`
|
- latest reported count for `kell_mobile`: `26/26 passed`
|
||||||
- total: `721/721 passed`
|
- total: `764/764 passed`
|
||||||
- baseline commit: merge of `feat/data-layer-contracts` into `main` (Stage 8B complete, 2026-07-11)
|
- baseline commit: merge of `feat/auth-foundation` into `main` (Stage 8C complete, 2026-07-11)
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
|
@ -167,8 +169,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/auth-foundation`** — Stage 8C: Authentication foundation.
|
**`feat/wc-orders-integration`** — Stage 9A: WooCommerce Orders API integration.
|
||||||
Branch from latest `main`. Stages 8A (integration abstractions) and 8B (data layer contracts) are complete. Stage 8C provides the `AuthService` contract, `CredentialStore`, `User`/`AuthState` domain models, and `SecureCredentialStore` implementation required before multi-user or production deployment.
|
Branch from latest `main`. Stage 8 (infrastructure package activation) is complete. Stage 9A connects the orders feature to WooCommerce so orders placed online appear in the app and order status can be updated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1260,20 +1262,20 @@ Working rules:
|
||||||
|
|
||||||
## Appendix A: Feature maturity matrix
|
## Appendix A: Feature maturity matrix
|
||||||
|
|
||||||
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
|
| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity |
|
||||||
| ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
|
| ------------------- | ------------ | ------------------ | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
|
||||||
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** |
|
| `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_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_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_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
|
||||||
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
| `feature_mrp` | ❌ 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** |
|
| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
| `auth` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | N/A | None | **Scaffolded only** |
|
| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None | N/A | 42 | **Foundation ready** |
|
||||||
| `data` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** |
|
| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None | N/A | 63 | **Foundation ready** |
|
||||||
| `integrations` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** |
|
| `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** |
|
| `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 | 20 | **Foundation ready** |
|
| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 21 | **Foundation ready** |
|
||||||
|
|
||||||
### Key changes from previous matrix
|
### Key changes from previous matrix
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
||||||
case KcAppEnvironment.wordpress:
|
case KcAppEnvironment.wordpress:
|
||||||
backgroundColor = Colors.green.shade100;
|
backgroundColor = Colors.green.shade100;
|
||||||
foregroundColor = Colors.green.shade900;
|
foregroundColor = Colors.green.shade900;
|
||||||
|
case KcAppEnvironment.square:
|
||||||
|
backgroundColor = Colors.blue.shade100;
|
||||||
|
foregroundColor = Colors.blue.shade900;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,13 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
integrations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../../packages/integrations"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ dependencies:
|
||||||
path: ../../packages/feature_orders
|
path: ../../packages/feature_orders
|
||||||
feature_policy:
|
feature_policy:
|
||||||
path: ../../packages/feature_policy
|
path: ../../packages/feature_policy
|
||||||
|
integrations:
|
||||||
|
path: ../../packages/integrations
|
||||||
feature_wordpress:
|
feature_wordpress:
|
||||||
path: ../../packages/feature_wordpress
|
path: ../../packages/feature_wordpress
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
||||||
case AppEnvironment.wordpress:
|
case AppEnvironment.wordpress:
|
||||||
backgroundColor = Colors.green.shade100;
|
backgroundColor = Colors.green.shade100;
|
||||||
foregroundColor = Colors.green.shade900;
|
foregroundColor = Colors.green.shade900;
|
||||||
|
case AppEnvironment.square:
|
||||||
|
backgroundColor = Colors.blue.shade100;
|
||||||
|
foregroundColor = Colors.blue.shade900;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,13 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
integrations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../../packages/integrations"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ dependencies:
|
||||||
path: ../../packages/feature_orders
|
path: ../../packages/feature_orders
|
||||||
feature_policy:
|
feature_policy:
|
||||||
path: ../../packages/feature_policy
|
path: ../../packages/feature_policy
|
||||||
|
integrations:
|
||||||
|
path: ../../packages/integrations
|
||||||
feature_wordpress:
|
feature_wordpress:
|
||||||
path: ../../packages/feature_wordpress
|
path: ../../packages/feature_wordpress
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,23 @@
|
||||||
/// A Calculator.
|
/// Authentication and credential management for the Kell Creations platform.
|
||||||
class Calculator {
|
///
|
||||||
/// Returns [value] plus 1.
|
/// This package provides:
|
||||||
int addOne(int value) => value + 1;
|
///
|
||||||
}
|
/// - [KcUser] / [KcUserRole] — user identity domain models
|
||||||
|
/// - [AuthState] hierarchy — [AuthStateUnauthenticated], [AuthStateLoading],
|
||||||
|
/// [AuthStateAuthenticated], [AuthStateError]
|
||||||
|
/// - [AuthService] — abstract contract for sign-in, sign-out, and token refresh
|
||||||
|
/// - [AuthException] — thrown by [AuthService] on failure
|
||||||
|
/// - [CredentialStore] / [CredentialKeys] — secure key/value storage contract
|
||||||
|
/// for API keys and session tokens
|
||||||
|
/// - [InMemoryCredentialStore] — in-memory implementation for tests and fake mode
|
||||||
|
/// - [FakeAuthService] — in-memory [AuthService] for fake mode and tests
|
||||||
|
library;
|
||||||
|
|
||||||
|
// Domain
|
||||||
|
export 'src/domain/auth_service.dart';
|
||||||
|
export 'src/domain/auth_state.dart';
|
||||||
|
export 'src/domain/credential_store.dart';
|
||||||
|
export 'src/domain/user.dart';
|
||||||
|
|
||||||
|
// Data
|
||||||
|
export 'src/data/fake_auth_service.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../domain/auth_service.dart';
|
||||||
|
import '../domain/auth_state.dart';
|
||||||
|
import '../domain/user.dart';
|
||||||
|
|
||||||
|
/// An in-memory [AuthService] implementation for use in `fake` mode and tests.
|
||||||
|
///
|
||||||
|
/// Accepts any non-empty email/password combination and returns a synthetic
|
||||||
|
/// [KcUser]. No network calls are made. State is held in memory and lost
|
||||||
|
/// when the process exits.
|
||||||
|
///
|
||||||
|
/// ## Fake credentials
|
||||||
|
///
|
||||||
|
/// | Email | Role |
|
||||||
|
/// |------------------------------|----------|
|
||||||
|
/// | `admin@kellcreations.com` | admin |
|
||||||
|
/// | `operator@kellcreations.com` | operator |
|
||||||
|
/// | any other non-empty email | viewer |
|
||||||
|
///
|
||||||
|
/// Any non-empty password is accepted. An empty password throws [AuthException].
|
||||||
|
class FakeAuthService implements AuthService {
|
||||||
|
final StreamController<AuthState> _controller = StreamController<AuthState>.broadcast();
|
||||||
|
|
||||||
|
AuthState _state = const AuthStateUnauthenticated();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<AuthState> get authStateStream async* {
|
||||||
|
// Emit current state immediately on subscription.
|
||||||
|
yield _state;
|
||||||
|
yield* _controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthState get currentState => _state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
KcUser? get currentUser => _state.user;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<KcUser> signIn({required String email, required String password}) async {
|
||||||
|
_emit(const AuthStateLoading());
|
||||||
|
|
||||||
|
// Simulate a short async delay.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
|
||||||
|
if (email.isEmpty) {
|
||||||
|
final error = const AuthException(message: 'Email must not be empty.');
|
||||||
|
_emit(AuthStateError(message: error.message));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.isEmpty) {
|
||||||
|
final error = const AuthException(message: 'Password must not be empty.');
|
||||||
|
_emit(AuthStateError(message: error.message));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
final role = _roleForEmail(email);
|
||||||
|
final user = KcUser(
|
||||||
|
id: 'fake-${email.hashCode.abs()}',
|
||||||
|
displayName: _displayNameForEmail(email),
|
||||||
|
email: email,
|
||||||
|
role: role,
|
||||||
|
);
|
||||||
|
|
||||||
|
_emit(AuthStateAuthenticated(user: user));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {
|
||||||
|
_emit(const AuthStateLoading());
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
_emit(const AuthStateUnauthenticated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> refreshSession() async {
|
||||||
|
// In fake mode the session never expires.
|
||||||
|
return _state.isAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emit(AuthState state) {
|
||||||
|
_state = state;
|
||||||
|
if (!_controller.isClosed) {
|
||||||
|
_controller.add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KcUserRole _roleForEmail(String email) {
|
||||||
|
if (email == 'admin@kellcreations.com') return KcUserRole.admin;
|
||||||
|
if (email == 'operator@kellcreations.com') return KcUserRole.operator;
|
||||||
|
return KcUserRole.viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayNameForEmail(String email) {
|
||||||
|
if (email == 'admin@kellcreations.com') return 'Admin User';
|
||||||
|
if (email == 'operator@kellcreations.com') return 'Operator User';
|
||||||
|
// Derive a display name from the local part of the email.
|
||||||
|
final local = email.split('@').first;
|
||||||
|
return local.isNotEmpty ? local : email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'auth_state.dart';
|
||||||
|
import 'user.dart';
|
||||||
|
|
||||||
|
/// Abstract contract for authentication operations.
|
||||||
|
///
|
||||||
|
/// Implementations handle login, logout, session persistence, and token
|
||||||
|
/// refresh. The concrete implementation is selected at runtime — a
|
||||||
|
/// [FakeAuthService] is used in `fake` mode; a real implementation (e.g.
|
||||||
|
/// local credential validation or an OAuth flow) is used in production.
|
||||||
|
///
|
||||||
|
/// The service exposes the current [AuthState] as a [Stream] so that widgets
|
||||||
|
/// and controllers can reactively rebuild when auth state changes.
|
||||||
|
abstract class AuthService {
|
||||||
|
/// A stream that emits the current [AuthState] whenever it changes.
|
||||||
|
///
|
||||||
|
/// The stream always emits the current state immediately upon subscription
|
||||||
|
/// (i.e. it behaves like a `BehaviorSubject`).
|
||||||
|
Stream<AuthState> get authStateStream;
|
||||||
|
|
||||||
|
/// The current [AuthState] synchronously.
|
||||||
|
AuthState get currentState;
|
||||||
|
|
||||||
|
/// The currently authenticated [KcUser], or `null` if not signed in.
|
||||||
|
KcUser? get currentUser;
|
||||||
|
|
||||||
|
/// Attempts to sign in with the given [email] and [password].
|
||||||
|
///
|
||||||
|
/// Returns the authenticated [KcUser] on success.
|
||||||
|
/// Throws [AuthException] on failure (wrong credentials, network error, etc.).
|
||||||
|
Future<KcUser> signIn({required String email, required String password});
|
||||||
|
|
||||||
|
/// Signs out the current user and clears any persisted session.
|
||||||
|
Future<void> signOut();
|
||||||
|
|
||||||
|
/// Attempts to refresh the current session token.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the session was successfully refreshed.
|
||||||
|
/// Returns `false` if the session has expired and the user must sign in again.
|
||||||
|
Future<bool> refreshSession();
|
||||||
|
|
||||||
|
/// Releases resources held by this service (stream controllers, timers).
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown when an authentication operation fails.
|
||||||
|
class AuthException implements Exception {
|
||||||
|
/// A human-readable description of the failure.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// The HTTP status code, if the failure originated from an API call.
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
/// The underlying cause, if available.
|
||||||
|
final Object? cause;
|
||||||
|
|
||||||
|
const AuthException({required this.message, this.statusCode, this.cause});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'AuthException: $message'
|
||||||
|
'${statusCode != null ? ' (HTTP $statusCode)' : ''}';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'user.dart';
|
||||||
|
|
||||||
|
/// Represents the current authentication state of the application.
|
||||||
|
///
|
||||||
|
/// [AuthState] is a sealed-style class hierarchy using a discriminated union
|
||||||
|
/// pattern. The app always holds exactly one of:
|
||||||
|
///
|
||||||
|
/// - [AuthStateUnauthenticated] — no user is signed in
|
||||||
|
/// - [AuthStateLoading] — an auth operation is in progress
|
||||||
|
/// - [AuthStateAuthenticated] — a user is signed in
|
||||||
|
/// - [AuthStateError] — an auth operation failed
|
||||||
|
///
|
||||||
|
/// Use [AuthState.isAuthenticated], [AuthState.user], and the `when`-style
|
||||||
|
/// helpers to branch on state without casting.
|
||||||
|
sealed class AuthState {
|
||||||
|
const AuthState();
|
||||||
|
|
||||||
|
/// Returns `true` if the user is currently authenticated.
|
||||||
|
bool get isAuthenticated => this is AuthStateAuthenticated;
|
||||||
|
|
||||||
|
/// Returns `true` if an auth operation is in progress.
|
||||||
|
bool get isLoading => this is AuthStateLoading;
|
||||||
|
|
||||||
|
/// Returns the authenticated [KcUser], or `null` if not authenticated.
|
||||||
|
KcUser? get user => switch (this) {
|
||||||
|
AuthStateAuthenticated(:final user) => user,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No user is currently signed in.
|
||||||
|
final class AuthStateUnauthenticated extends AuthState {
|
||||||
|
const AuthStateUnauthenticated();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthState.unauthenticated';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authentication operation (login, logout, token refresh) is in progress.
|
||||||
|
final class AuthStateLoading extends AuthState {
|
||||||
|
const AuthStateLoading();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthState.loading';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user is signed in and the session is valid.
|
||||||
|
final class AuthStateAuthenticated extends AuthState {
|
||||||
|
/// The currently authenticated user.
|
||||||
|
@override
|
||||||
|
final KcUser user;
|
||||||
|
|
||||||
|
const AuthStateAuthenticated({required this.user});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) || other is AuthStateAuthenticated && user == other.user;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => user.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthState.authenticated(user: $user)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An authentication operation failed.
|
||||||
|
final class AuthStateError extends AuthState {
|
||||||
|
/// A human-readable description of what went wrong.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// The underlying exception, if available.
|
||||||
|
final Object? cause;
|
||||||
|
|
||||||
|
const AuthStateError({required this.message, this.cause});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) || other is AuthStateError && message == other.message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => message.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthState.error(message: $message)';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/// Abstract contract for secure storage of API credentials.
|
||||||
|
///
|
||||||
|
/// [CredentialStore] provides a key/value interface for storing sensitive
|
||||||
|
/// values (API keys, tokens, secrets) in a secure location appropriate for
|
||||||
|
/// the platform. On Android/iOS this maps to the OS keystore; on desktop/web
|
||||||
|
/// it maps to an encrypted local store.
|
||||||
|
///
|
||||||
|
/// Concrete implementations:
|
||||||
|
/// - [InMemoryCredentialStore] — in-memory, non-persistent (for tests and fake mode)
|
||||||
|
/// - `SecureCredentialStore` — backed by `flutter_secure_storage` (production)
|
||||||
|
///
|
||||||
|
/// ## Well-known credential keys
|
||||||
|
///
|
||||||
|
/// Use the constants on [CredentialKeys] rather than raw strings to avoid
|
||||||
|
/// typos and ensure consistency across the codebase.
|
||||||
|
abstract class CredentialStore {
|
||||||
|
/// Reads the credential stored under [key].
|
||||||
|
///
|
||||||
|
/// Returns `null` if no value has been stored for [key].
|
||||||
|
Future<String?> read(String key);
|
||||||
|
|
||||||
|
/// Writes [value] under [key], replacing any existing value.
|
||||||
|
Future<void> write(String key, String value);
|
||||||
|
|
||||||
|
/// Deletes the credential stored under [key].
|
||||||
|
///
|
||||||
|
/// No-op if [key] is not present.
|
||||||
|
Future<void> delete(String key);
|
||||||
|
|
||||||
|
/// Deletes all stored credentials.
|
||||||
|
///
|
||||||
|
/// Use with caution — this clears all API keys and tokens.
|
||||||
|
Future<void> deleteAll();
|
||||||
|
|
||||||
|
/// Returns `true` if a non-null value is stored under [key].
|
||||||
|
Future<bool> containsKey(String key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Well-known keys for credentials stored in [CredentialStore].
|
||||||
|
///
|
||||||
|
/// Using these constants prevents typos and ensures all parts of the
|
||||||
|
/// codebase refer to the same storage keys.
|
||||||
|
abstract final class CredentialKeys {
|
||||||
|
/// WooCommerce REST API consumer key (`ck_...`).
|
||||||
|
static const String wcConsumerKey = 'kc.wc.consumer_key';
|
||||||
|
|
||||||
|
/// WooCommerce REST API consumer secret (`cs_...`).
|
||||||
|
static const String wcConsumerSecret = 'kc.wc.consumer_secret';
|
||||||
|
|
||||||
|
/// WooCommerce site URL.
|
||||||
|
static const String wcSiteUrl = 'kc.wc.site_url';
|
||||||
|
|
||||||
|
/// Square API access token.
|
||||||
|
static const String squareAccessToken = 'kc.square.access_token';
|
||||||
|
|
||||||
|
/// Square location ID.
|
||||||
|
static const String squareLocationId = 'kc.square.location_id';
|
||||||
|
|
||||||
|
/// The currently authenticated user's session token (if applicable).
|
||||||
|
static const String sessionToken = 'kc.auth.session_token';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-memory [CredentialStore] backed by a plain [Map].
|
||||||
|
///
|
||||||
|
/// Values are lost when the process exits. Suitable for tests and fake mode
|
||||||
|
/// where no real credentials need to be persisted.
|
||||||
|
class InMemoryCredentialStore implements CredentialStore {
|
||||||
|
final Map<String, String> _store = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async => _store[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async => _store[key] = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async => _store.remove(key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteAll() async => _store.clear();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> containsKey(String key) async => _store.containsKey(key);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/// Represents an authenticated user of the Kell Creations platform.
|
||||||
|
///
|
||||||
|
/// This is a lightweight identity model — it carries the user's display
|
||||||
|
/// information and role but does not hold credentials or tokens directly.
|
||||||
|
/// Credentials are managed separately by [CredentialStore].
|
||||||
|
class KcUser {
|
||||||
|
/// Unique identifier for this user (e.g. a UUID or username).
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Human-readable display name.
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
/// Email address (used as the primary login identifier).
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
/// The role assigned to this user, controlling what actions they may perform.
|
||||||
|
final KcUserRole role;
|
||||||
|
|
||||||
|
const KcUser({
|
||||||
|
required this.id,
|
||||||
|
required this.displayName,
|
||||||
|
required this.email,
|
||||||
|
required this.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns a copy of this user with the given fields replaced.
|
||||||
|
KcUser copyWith({String? id, String? displayName, String? email, KcUserRole? role}) {
|
||||||
|
return KcUser(
|
||||||
|
id: id ?? this.id,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
email: email ?? this.email,
|
||||||
|
role: role ?? this.role,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is KcUser &&
|
||||||
|
id == other.id &&
|
||||||
|
displayName == other.displayName &&
|
||||||
|
email == other.email &&
|
||||||
|
role == other.role;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, displayName, email, role);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'KcUser(id: $id, displayName: $displayName, email: $email, role: ${role.name})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The role assigned to a [KcUser], controlling access to platform features.
|
||||||
|
enum KcUserRole {
|
||||||
|
/// Full access to all platform features including configuration.
|
||||||
|
admin,
|
||||||
|
|
||||||
|
/// Standard operator access — can manage products, orders, and inventory.
|
||||||
|
operator,
|
||||||
|
|
||||||
|
/// Read-only access — can view but not modify data.
|
||||||
|
viewer,
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,350 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:auth/auth.dart';
|
import 'package:auth/auth.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('adds one to input values', () {
|
// ---------------------------------------------------------------------------
|
||||||
final calculator = Calculator();
|
// KcUser
|
||||||
expect(calculator.addOne(2), 3);
|
// ---------------------------------------------------------------------------
|
||||||
expect(calculator.addOne(-7), -6);
|
group('KcUser', () {
|
||||||
expect(calculator.addOne(0), 1);
|
const user = KcUser(
|
||||||
|
id: 'u1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: KcUserRole.operator,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('stores all fields', () {
|
||||||
|
expect(user.id, 'u1');
|
||||||
|
expect(user.displayName, 'Alice');
|
||||||
|
expect(user.email, 'alice@example.com');
|
||||||
|
expect(user.role, KcUserRole.operator);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith replaces specified fields', () {
|
||||||
|
final updated = user.copyWith(displayName: 'Bob', role: KcUserRole.admin);
|
||||||
|
expect(updated.id, 'u1');
|
||||||
|
expect(updated.displayName, 'Bob');
|
||||||
|
expect(updated.email, 'alice@example.com');
|
||||||
|
expect(updated.role, KcUserRole.admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith with no args returns equivalent user', () {
|
||||||
|
final copy = user.copyWith();
|
||||||
|
expect(copy, equals(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equality compares all fields', () {
|
||||||
|
const same = KcUser(
|
||||||
|
id: 'u1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: KcUserRole.operator,
|
||||||
|
);
|
||||||
|
const different = KcUser(
|
||||||
|
id: 'u2',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: KcUserRole.operator,
|
||||||
|
);
|
||||||
|
expect(user, equals(same));
|
||||||
|
expect(user, isNot(equals(different)));
|
||||||
|
expect(user.hashCode, equals(same.hashCode));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes key fields', () {
|
||||||
|
expect(user.toString(), contains('u1'));
|
||||||
|
expect(user.toString(), contains('Alice'));
|
||||||
|
expect(user.toString(), contains('operator'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// KcUserRole
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
group('KcUserRole', () {
|
||||||
|
test('has three values', () {
|
||||||
|
expect(KcUserRole.values.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('values are admin, operator, viewer', () {
|
||||||
|
expect(
|
||||||
|
KcUserRole.values,
|
||||||
|
containsAll([KcUserRole.admin, KcUserRole.operator, KcUserRole.viewer]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AuthState
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
group('AuthState', () {
|
||||||
|
const user = KcUser(
|
||||||
|
id: 'u1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: KcUserRole.admin,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('unauthenticated: isAuthenticated is false', () {
|
||||||
|
const state = AuthStateUnauthenticated();
|
||||||
|
expect(state.isAuthenticated, isFalse);
|
||||||
|
expect(state.isLoading, isFalse);
|
||||||
|
expect(state.user, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loading: isLoading is true', () {
|
||||||
|
const state = AuthStateLoading();
|
||||||
|
expect(state.isLoading, isTrue);
|
||||||
|
expect(state.isAuthenticated, isFalse);
|
||||||
|
expect(state.user, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated: isAuthenticated is true and user is set', () {
|
||||||
|
final state = AuthStateAuthenticated(user: user);
|
||||||
|
expect(state.isAuthenticated, isTrue);
|
||||||
|
expect(state.isLoading, isFalse);
|
||||||
|
expect(state.user, equals(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated: equality compares user', () {
|
||||||
|
final a = AuthStateAuthenticated(user: user);
|
||||||
|
final b = AuthStateAuthenticated(user: user);
|
||||||
|
const c = KcUser(
|
||||||
|
id: 'u2',
|
||||||
|
displayName: 'Bob',
|
||||||
|
email: 'bob@example.com',
|
||||||
|
role: KcUserRole.viewer,
|
||||||
|
);
|
||||||
|
final d = AuthStateAuthenticated(user: c);
|
||||||
|
expect(a, equals(b));
|
||||||
|
expect(a, isNot(equals(d)));
|
||||||
|
expect(a.hashCode, equals(b.hashCode));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error: message is stored', () {
|
||||||
|
const state = AuthStateError(message: 'Invalid credentials');
|
||||||
|
expect(state.isAuthenticated, isFalse);
|
||||||
|
expect(state.user, isNull);
|
||||||
|
expect(state.message, 'Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error: equality compares message', () {
|
||||||
|
const a = AuthStateError(message: 'oops');
|
||||||
|
const b = AuthStateError(message: 'oops');
|
||||||
|
const c = AuthStateError(message: 'different');
|
||||||
|
expect(a, equals(b));
|
||||||
|
expect(a, isNot(equals(c)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes state type', () {
|
||||||
|
expect(const AuthStateUnauthenticated().toString(), contains('unauthenticated'));
|
||||||
|
expect(const AuthStateLoading().toString(), contains('loading'));
|
||||||
|
expect(AuthStateAuthenticated(user: user).toString(), contains('authenticated'));
|
||||||
|
expect(const AuthStateError(message: 'x').toString(), contains('error'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AuthException
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
group('AuthException', () {
|
||||||
|
test('stores message and optional statusCode', () {
|
||||||
|
const ex = AuthException(message: 'Unauthorized', statusCode: 401);
|
||||||
|
expect(ex.message, 'Unauthorized');
|
||||||
|
expect(ex.statusCode, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes message', () {
|
||||||
|
const ex = AuthException(message: 'Forbidden');
|
||||||
|
expect(ex.toString(), contains('Forbidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes HTTP status when present', () {
|
||||||
|
const ex = AuthException(message: 'Unauthorized', statusCode: 401);
|
||||||
|
expect(ex.toString(), contains('401'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statusCode is optional', () {
|
||||||
|
const ex = AuthException(message: 'Network error');
|
||||||
|
expect(ex.statusCode, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CredentialStore / CredentialKeys
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
group('InMemoryCredentialStore', () {
|
||||||
|
late InMemoryCredentialStore store;
|
||||||
|
|
||||||
|
setUp(() => store = InMemoryCredentialStore());
|
||||||
|
|
||||||
|
test('read returns null for missing key', () async {
|
||||||
|
expect(await store.read('missing'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write and read round-trip', () async {
|
||||||
|
await store.write(CredentialKeys.wcConsumerKey, 'ck_test');
|
||||||
|
expect(await store.read(CredentialKeys.wcConsumerKey), 'ck_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write overwrites existing value', () async {
|
||||||
|
await store.write('key', 'first');
|
||||||
|
await store.write('key', 'second');
|
||||||
|
expect(await store.read('key'), 'second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete removes key', () async {
|
||||||
|
await store.write('key', 'value');
|
||||||
|
await store.delete('key');
|
||||||
|
expect(await store.read('key'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete is no-op for missing key', () async {
|
||||||
|
await expectLater(store.delete('missing'), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteAll clears all keys', () async {
|
||||||
|
await store.write('a', '1');
|
||||||
|
await store.write('b', '2');
|
||||||
|
await store.deleteAll();
|
||||||
|
expect(await store.read('a'), isNull);
|
||||||
|
expect(await store.read('b'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containsKey returns true for present key', () async {
|
||||||
|
await store.write('key', 'value');
|
||||||
|
expect(await store.containsKey('key'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containsKey returns false for missing key', () async {
|
||||||
|
expect(await store.containsKey('missing'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CredentialKeys', () {
|
||||||
|
test('all keys are non-empty strings', () {
|
||||||
|
expect(CredentialKeys.wcConsumerKey, isNotEmpty);
|
||||||
|
expect(CredentialKeys.wcConsumerSecret, isNotEmpty);
|
||||||
|
expect(CredentialKeys.wcSiteUrl, isNotEmpty);
|
||||||
|
expect(CredentialKeys.squareAccessToken, isNotEmpty);
|
||||||
|
expect(CredentialKeys.squareLocationId, isNotEmpty);
|
||||||
|
expect(CredentialKeys.sessionToken, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all keys are unique', () {
|
||||||
|
final keys = [
|
||||||
|
CredentialKeys.wcConsumerKey,
|
||||||
|
CredentialKeys.wcConsumerSecret,
|
||||||
|
CredentialKeys.wcSiteUrl,
|
||||||
|
CredentialKeys.squareAccessToken,
|
||||||
|
CredentialKeys.squareLocationId,
|
||||||
|
CredentialKeys.sessionToken,
|
||||||
|
];
|
||||||
|
expect(keys.toSet().length, keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FakeAuthService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
group('FakeAuthService', () {
|
||||||
|
late FakeAuthService service;
|
||||||
|
|
||||||
|
setUp(() => service = FakeAuthService());
|
||||||
|
tearDown(() => service.dispose());
|
||||||
|
|
||||||
|
test('initial state is unauthenticated', () {
|
||||||
|
expect(service.currentState, isA<AuthStateUnauthenticated>());
|
||||||
|
expect(service.currentUser, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authStateStream emits current state immediately', () async {
|
||||||
|
final first = await service.authStateStream.first;
|
||||||
|
expect(first, isA<AuthStateUnauthenticated>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with valid credentials returns user', () async {
|
||||||
|
final user = await service.signIn(email: 'operator@kellcreations.com', password: 'secret');
|
||||||
|
expect(user.email, 'operator@kellcreations.com');
|
||||||
|
expect(user.role, KcUserRole.operator);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn sets state to authenticated', () async {
|
||||||
|
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
|
||||||
|
expect(service.currentState, isA<AuthStateAuthenticated>());
|
||||||
|
expect(service.currentUser?.role, KcUserRole.admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with admin email assigns admin role', () async {
|
||||||
|
final user = await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
|
||||||
|
expect(user.role, KcUserRole.admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with operator email assigns operator role', () async {
|
||||||
|
final user = await service.signIn(email: 'operator@kellcreations.com', password: 'pw');
|
||||||
|
expect(user.role, KcUserRole.operator);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with unknown email assigns viewer role', () async {
|
||||||
|
final user = await service.signIn(email: 'someone@example.com', password: 'pw');
|
||||||
|
expect(user.role, KcUserRole.viewer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with empty email throws AuthException', () async {
|
||||||
|
await expectLater(service.signIn(email: '', password: 'pw'), throwsA(isA<AuthException>()));
|
||||||
|
expect(service.currentState, isA<AuthStateError>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with empty password throws AuthException', () async {
|
||||||
|
await expectLater(
|
||||||
|
service.signIn(email: 'user@example.com', password: ''),
|
||||||
|
throwsA(isA<AuthException>()),
|
||||||
|
);
|
||||||
|
expect(service.currentState, isA<AuthStateError>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signOut sets state to unauthenticated', () async {
|
||||||
|
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
|
||||||
|
await service.signOut();
|
||||||
|
expect(service.currentState, isA<AuthStateUnauthenticated>());
|
||||||
|
expect(service.currentUser, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshSession returns true when authenticated', () async {
|
||||||
|
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
|
||||||
|
expect(await service.refreshSession(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshSession returns false when unauthenticated', () async {
|
||||||
|
expect(await service.refreshSession(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authStateStream emits loading then authenticated on signIn', () async {
|
||||||
|
final states = <AuthState>[];
|
||||||
|
final sub = service.authStateStream.listen(states.add);
|
||||||
|
|
||||||
|
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
|
||||||
|
// Allow any pending microtasks to flush.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await sub.cancel();
|
||||||
|
|
||||||
|
// Stream: unauthenticated (initial), loading, authenticated
|
||||||
|
expect(states.length, greaterThanOrEqualTo(2));
|
||||||
|
expect(states.any((s) => s is AuthStateLoading), isTrue);
|
||||||
|
expect(states.last, isA<AuthStateAuthenticated>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authStateStream emits loading then unauthenticated on signOut', () async {
|
||||||
|
await service.signIn(email: 'admin@kellcreations.com', password: 'pw');
|
||||||
|
|
||||||
|
final states = <AuthState>[];
|
||||||
|
final sub = service.authStateStream.listen(states.add);
|
||||||
|
|
||||||
|
await service.signOut();
|
||||||
|
// Allow any pending microtasks to flush.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await sub.cancel();
|
||||||
|
|
||||||
|
expect(states.last, isA<AuthStateUnauthenticated>());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
///
|
///
|
||||||
/// | Key | Description | Default |
|
/// | Key | Description | Default |
|
||||||
/// |------------------------|----------------------------------------------|----------|
|
/// |------------------------|----------------------------------------------|----------|
|
||||||
/// | `KC_ENV` | `fake` or `wordpress` | `fake` |
|
/// | `KC_ENV` | `fake`, `wordpress`, or `square` | `fake` |
|
||||||
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
|
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
|
||||||
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
||||||
/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) |
|
/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) |
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
||||||
/// ```
|
/// ```
|
||||||
class KcAppConfig {
|
class KcAppConfig {
|
||||||
/// The environment mode: `fake` or `wordpress`.
|
/// The environment mode: `fake`, `wordpress`, or `square`.
|
||||||
final KcAppEnvironment environment;
|
final KcAppEnvironment environment;
|
||||||
|
|
||||||
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]).
|
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]).
|
||||||
|
|
@ -88,7 +88,13 @@ enum KcAppEnvironment {
|
||||||
fake('fake', 'FAKE'),
|
fake('fake', 'FAKE'),
|
||||||
|
|
||||||
/// Real WooCommerce backend.
|
/// Real WooCommerce backend.
|
||||||
wordpress('wordpress', 'WP');
|
wordpress('wordpress', 'WP'),
|
||||||
|
|
||||||
|
/// Real Square backend (catalog, inventory, payments).
|
||||||
|
///
|
||||||
|
/// Planned for Stage 10A. Selecting this mode before the Square integration
|
||||||
|
/// is implemented falls back to [fake] mode at runtime.
|
||||||
|
square('square', 'SQ');
|
||||||
|
|
||||||
final String key;
|
final String key;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,24 @@ class KcBootstrap {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (services: factory.createWordPress(config), config: config);
|
return (services: factory.createWordPress(config), config: config);
|
||||||
|
|
||||||
|
case KcAppEnvironment.square:
|
||||||
|
// Square integration is planned for Stage 10A. Until then, fall back
|
||||||
|
// to fake mode so the app remains functional when KC_ENV=square is set.
|
||||||
|
if (kDebugMode) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ KC_ENV=square is not yet implemented.\n'
|
||||||
|
' Falling back to fake mode until Stage 10A is complete.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final fallbackConfig = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.fake,
|
||||||
|
wcSiteUrl: config.wcSiteUrl,
|
||||||
|
wcConsumerKey: config.wcConsumerKey,
|
||||||
|
wcConsumerSecret: config.wcConsumerSecret,
|
||||||
|
);
|
||||||
|
return (services: factory.createFake(), config: fallbackConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,13 @@ void main() {
|
||||||
test('fromString parses known values', () {
|
test('fromString parses known values', () {
|
||||||
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
||||||
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
||||||
|
expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fromString is case-insensitive', () {
|
test('fromString is case-insensitive', () {
|
||||||
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
||||||
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
||||||
|
expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fromString defaults to fake for unknown values', () {
|
test('fromString defaults to fake for unknown values', () {
|
||||||
|
|
@ -98,11 +100,13 @@ void main() {
|
||||||
test('label returns expected display string', () {
|
test('label returns expected display string', () {
|
||||||
expect(KcAppEnvironment.fake.label, 'FAKE');
|
expect(KcAppEnvironment.fake.label, 'FAKE');
|
||||||
expect(KcAppEnvironment.wordpress.label, 'WP');
|
expect(KcAppEnvironment.wordpress.label, 'WP');
|
||||||
|
expect(KcAppEnvironment.square.label, 'SQ');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('key returns expected raw string', () {
|
test('key returns expected raw string', () {
|
||||||
expect(KcAppEnvironment.fake.key, 'fake');
|
expect(KcAppEnvironment.fake.key, 'fake');
|
||||||
expect(KcAppEnvironment.wordpress.key, 'wordpress');
|
expect(KcAppEnvironment.wordpress.key, 'wordpress');
|
||||||
|
expect(KcAppEnvironment.square.key, 'square');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -207,6 +211,20 @@ void main() {
|
||||||
expect(result.config.environment, KcAppEnvironment.fake);
|
expect(result.config.environment, KcAppEnvironment.fake);
|
||||||
expect(result.config.wcSiteUrl, 'https://example.com');
|
expect(result.config.wcSiteUrl, 'https://example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('square environment falls back to fake (not yet implemented)', () {
|
||||||
|
const config = KcAppConfig(
|
||||||
|
environment: KcAppEnvironment.square,
|
||||||
|
wcSiteUrl: '',
|
||||||
|
wcConsumerKey: '',
|
||||||
|
wcConsumerSecret: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = KcBootstrap.run(config, factory);
|
||||||
|
|
||||||
|
expect(result.services.mode, 'fake');
|
||||||
|
expect(result.config.environment, KcAppEnvironment.fake);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('KcAppScope', () {
|
group('KcAppScope', () {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
TESTABLE=(
|
TESTABLE=(
|
||||||
packages/core
|
packages/core
|
||||||
packages/design_system
|
packages/design_system
|
||||||
|
packages/auth
|
||||||
packages/integrations
|
packages/integrations
|
||||||
packages/data
|
packages/data
|
||||||
packages/feature_wordpress
|
packages/feature_wordpress
|
||||||
|
|
@ -32,6 +33,7 @@ TESTABLE=(
|
||||||
ANALYZABLE=(
|
ANALYZABLE=(
|
||||||
packages/core
|
packages/core
|
||||||
packages/design_system
|
packages/design_system
|
||||||
|
packages/auth
|
||||||
packages/integrations
|
packages/integrations
|
||||||
packages/data
|
packages/data
|
||||||
packages/feature_wordpress
|
packages/feature_wordpress
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue