Compare commits
2 Commits
0ef6ecceb0
...
003347c89c
| Author | SHA1 | Date |
|---|---|---|
|
|
003347c89c | |
|
|
bda93640e4 |
|
|
@ -69,7 +69,7 @@ Rules:
|
|||
- `feature_orders` — 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):
|
||||
- `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
|
||||
- `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)
|
||||
|
|
@ -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.
|
||||
- ✅ 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.
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ Rules:
|
|||
- `feature_wordpress` tests passing
|
||||
- `kell_web` 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 `feature_inventory`: `75/75 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 `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`: `26/26 passed`
|
||||
- total: `721/721 passed`
|
||||
- baseline commit: merge of `feat/data-layer-contracts` into `main` (Stage 8B complete, 2026-07-11)
|
||||
- total: `764/764 passed`
|
||||
- baseline commit: merge of `feat/auth-foundation` into `main` (Stage 8C complete, 2026-07-11)
|
||||
|
||||
#### 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
|
||||
|
||||
**`feat/auth-foundation`** — Stage 8C: Authentication foundation.
|
||||
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.
|
||||
**`feat/wc-orders-integration`** — Stage 9A: WooCommerce Orders API integration.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1261,7 +1263,7 @@ Working rules:
|
|||
## Appendix A: Feature maturity matrix
|
||||
|
||||
| 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** |
|
||||
|
|
@ -1269,11 +1271,11 @@ Working rules:
|
|||
| `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` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | N/A | None | **Scaffolded only** |
|
||||
| `data` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** |
|
||||
| `integrations` | ❌ Stub | N/A | N/A | ❌ None | N/A | 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 | 20 | **Foundation ready** |
|
||||
| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 21 | **Foundation ready** |
|
||||
|
||||
### Key changes from previous matrix
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
|||
case KcAppEnvironment.wordpress:
|
||||
backgroundColor = Colors.green.shade100;
|
||||
foregroundColor = Colors.green.shade900;
|
||||
case KcAppEnvironment.square:
|
||||
backgroundColor = Colors.blue.shade100;
|
||||
foregroundColor = Colors.blue.shade900;
|
||||
}
|
||||
|
||||
return Center(
|
||||
|
|
|
|||
|
|
@ -133,6 +133,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
integrations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/integrations"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ dependencies:
|
|||
path: ../../packages/feature_orders
|
||||
feature_policy:
|
||||
path: ../../packages/feature_policy
|
||||
integrations:
|
||||
path: ../../packages/integrations
|
||||
feature_wordpress:
|
||||
path: ../../packages/feature_wordpress
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
|||
case AppEnvironment.wordpress:
|
||||
backgroundColor = Colors.green.shade100;
|
||||
foregroundColor = Colors.green.shade900;
|
||||
case AppEnvironment.square:
|
||||
backgroundColor = Colors.blue.shade100;
|
||||
foregroundColor = Colors.blue.shade900;
|
||||
}
|
||||
|
||||
return Center(
|
||||
|
|
|
|||
|
|
@ -133,6 +133,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
integrations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/integrations"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ dependencies:
|
|||
path: ../../packages/feature_orders
|
||||
feature_policy:
|
||||
path: ../../packages/feature_policy
|
||||
integrations:
|
||||
path: ../../packages/integrations
|
||||
feature_wordpress:
|
||||
path: ../../packages/feature_wordpress
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
/// Authentication and credential management for the Kell Creations platform.
|
||||
///
|
||||
/// This package provides:
|
||||
///
|
||||
/// - [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';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
// ---------------------------------------------------------------------------
|
||||
// KcUser
|
||||
// ---------------------------------------------------------------------------
|
||||
group('KcUser', () {
|
||||
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 |
|
||||
/// |------------------------|----------------------------------------------|----------|
|
||||
/// | `KC_ENV` | `fake` or `wordpress` | `fake` |
|
||||
/// | `KC_ENV` | `fake`, `wordpress`, or `square` | `fake` |
|
||||
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
|
||||
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
||||
/// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) |
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
||||
/// ```
|
||||
class KcAppConfig {
|
||||
/// The environment mode: `fake` or `wordpress`.
|
||||
/// The environment mode: `fake`, `wordpress`, or `square`.
|
||||
final KcAppEnvironment environment;
|
||||
|
||||
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]).
|
||||
|
|
@ -88,7 +88,13 @@ enum KcAppEnvironment {
|
|||
fake('fake', 'FAKE'),
|
||||
|
||||
/// 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 label;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,24 @@ class KcBootstrap {
|
|||
}
|
||||
|
||||
return (services: factory.createWordPress(config), config: config);
|
||||
|
||||
case KcAppEnvironment.square:
|
||||
// Square integration is planned for Stage 10A. Until then, fall back
|
||||
// to fake mode so the app remains functional when KC_ENV=square is set.
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
debugPrint(
|
||||
'⚠️ KC_ENV=square is not yet implemented.\n'
|
||||
' Falling back to fake mode until Stage 10A is complete.',
|
||||
);
|
||||
}
|
||||
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', () {
|
||||
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
||||
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
||||
expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square);
|
||||
});
|
||||
|
||||
test('fromString is case-insensitive', () {
|
||||
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
||||
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
||||
expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square);
|
||||
});
|
||||
|
||||
test('fromString defaults to fake for unknown values', () {
|
||||
|
|
@ -98,11 +100,13 @@ void main() {
|
|||
test('label returns expected display string', () {
|
||||
expect(KcAppEnvironment.fake.label, 'FAKE');
|
||||
expect(KcAppEnvironment.wordpress.label, 'WP');
|
||||
expect(KcAppEnvironment.square.label, 'SQ');
|
||||
});
|
||||
|
||||
test('key returns expected raw string', () {
|
||||
expect(KcAppEnvironment.fake.key, 'fake');
|
||||
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.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', () {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|||
TESTABLE=(
|
||||
packages/core
|
||||
packages/design_system
|
||||
packages/auth
|
||||
packages/integrations
|
||||
packages/data
|
||||
packages/feature_wordpress
|
||||
|
|
@ -32,6 +33,7 @@ TESTABLE=(
|
|||
ANALYZABLE=(
|
||||
packages/core
|
||||
packages/design_system
|
||||
packages/auth
|
||||
packages/integrations
|
||||
packages/data
|
||||
packages/feature_wordpress
|
||||
|
|
|
|||
Loading…
Reference in New Issue