feat(auth): Stage 8C - authentication foundation

- Add KcUser/KcUserRole domain models with copyWith, equality, toString
- Add AuthState sealed hierarchy (unauthenticated, loading, authenticated, error)
- Add AuthService abstract contract (signIn, signOut, refreshSession, stream)
- Add AuthException with optional HTTP statusCode
- Add CredentialStore abstract contract with CredentialKeys constants
- Add InMemoryCredentialStore implementation
- Add FakeAuthService with role-based sign-in simulation
- Add KcAppEnvironment.square to core enum with SQ label
- Add KcBootstrap square case (falls back to fake until Stage 10A)
- Update kell_web and kell_mobile shell environment badges for square case
- Add integrations dependency to kell_web and kell_mobile pubspec.yaml
- Add auth to run_all_tests.sh TESTABLE and ANALYZABLE lists
- Add 42 auth tests (42/42 passing)
- Add 1 new core test for square bootstrap fallback (21/21 passing)
- Update master_development_brief.md: Stage 8C complete, Stage 8 complete
- Total: 764/764 tests passing, dart analyze clean

Stage 8 (infrastructure package activation) is now complete.
Next: feat/wc-orders-integration (Stage 9A)
This commit is contained in:
Mike Kell 2026-07-11 08:48:18 -04:00
parent 0ef6ecceb0
commit bda93640e4
18 changed files with 864 additions and 33 deletions

View File

@ -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.
--- ---
@ -1261,7 +1263,7 @@ 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** |
@ -1269,11 +1271,11 @@ Working rules:
| `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

View File

@ -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(

View File

@ -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:

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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)' : ''}';
}

View File

@ -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)';
}

View File

@ -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);
}

View File

@ -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,
}

View File

@ -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>());
});
}); });
} }

View File

@ -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;

View File

@ -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);
} }
} }
} }

View File

@ -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', () {

View File

@ -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