Compare commits
2 Commits
7bd4db6a95
...
0ef6ecceb0
| Author | SHA1 | Date |
|---|---|---|
|
|
0ef6ecceb0 | |
|
|
4a935e4989 |
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- main baseline updated through: integrations-contracts (Stage 8A complete)
|
||||
- next branch: feat/data-layer-contracts (Stage 8B)
|
||||
- current stage: Stage 8A complete — integration abstractions activated
|
||||
- main baseline updated through: data-layer-contracts (Stage 8B complete)
|
||||
- next branch: feat/auth-foundation (Stage 8C)
|
||||
- current stage: Stage 8B complete — shared data layer abstractions activated
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -402,6 +402,25 @@
|
|||
- analyze: passed (dart analyze --fatal-infos — no issues found in integrations and feature_wordpress)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/data-layer-contracts
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-11
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `data/lib/src/local_cache.dart` — new `LocalCache<T>` abstract contract; `InMemoryCache<T>` implementation with TTL-based expiry, key enumeration, and dispose
|
||||
- `data/lib/src/sync_queue.dart` — new `SyncOperationStatus` enum, `SyncOperation` value object with `copyWith()`; `SyncFlushResult` with `success`/`failure` constructors; `SyncQueue` abstract contract; `InMemorySyncQueue` implementation with enqueue, process (success/failure tracking), remove, pruneCompleted, and length
|
||||
- `data/lib/src/conflict_resolver.dart` — new `ConflictResolution` enum, `ConflictResult<T>` with `useLocal`/`useRemote`/`merged`/`requiresReview` constructors; `ConflictResolver<T>` abstract contract; `LastWriteWinsResolver<T>`, `LocalAlwaysWinsResolver<T>`, `RemoteAlwaysWinsResolver<T>` concrete implementations
|
||||
- `data/lib/src/change_tracker.dart` — new `ChangeState` enum, `TrackedChange<T>` value object with `copyWith()`; `ChangeTracker<T>` abstract contract; `InMemoryChangeTracker<T>` implementation with trackCreated/trackModified/trackDeleted/markSynced/markSyncError/pendingChanges/hasPendingChanges/clear
|
||||
- `data/lib/src/data_mapper.dart` — new `DataMapper<TLocal, TRemote>` abstract contract with `fromRemote`/`toRemote`/`fromRemoteList`/`toRemoteList`; `DataMappingException` with field, message, rawValue
|
||||
- `data/lib/data.dart` — replaced `Calculator` placeholder with barrel exports for all 5 new contract files
|
||||
- `data/test/data_test.dart` — replaced placeholder test with 63 tests covering all contracts and implementations
|
||||
- `kell_creations_apps/tools/run_all_tests.sh` — added `packages/data` to both `TESTABLE` and `ANALYZABLE` lists
|
||||
- tests: passed (63/63 data)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/bulk-operator-workflows
|
||||
|
||||
- status: merged to main
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ Rules:
|
|||
- `feature_policy` — domain, application, data (fake), and presentation layers
|
||||
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
||||
- `auth` — authentication/authorization (stub only)
|
||||
- `data` — shared data layer (stub only)
|
||||
- `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)
|
||||
- `feature_mrp` — craft manufacturing/MRP feature (stub only)
|
||||
- `feature_social` — social media management feature (stub only)
|
||||
|
|
@ -123,6 +123,7 @@ Rules:
|
|||
- ✅ Inventory mobile surface landed (Stage 6C complete — merged `feat/inventory-mobile-surface` → `main`, 2026-07-10). Stage 6 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).
|
||||
- ✅ Shared data layer contracts activated (Stage 8B complete — merged `feat/data-layer-contracts` → `main`, 2026-07-11).
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -147,9 +148,10 @@ Rules:
|
|||
- latest reported count for `feature_wordpress`: `319/319 passed`
|
||||
- latest reported count for `kell_web`: `24/24 passed`
|
||||
- latest reported count for `integrations`: `38/38 passed`
|
||||
- latest reported count for `data`: `63/63 passed`
|
||||
- latest reported count for `kell_mobile`: `26/26 passed`
|
||||
- total: `658/658 passed`
|
||||
- baseline commit: merge of `feat/integrations-contracts` into `main` (Stage 8A complete, 2026-07-11)
|
||||
- total: `721/721 passed`
|
||||
- baseline commit: merge of `feat/data-layer-contracts` into `main` (Stage 8B complete, 2026-07-11)
|
||||
|
||||
#### Baseline test coverage (established 2026-05-22)
|
||||
|
||||
|
|
@ -165,8 +167,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
|||
|
||||
### Next recommended branch
|
||||
|
||||
**`feat/data-layer-contracts`** — Stage 8B: Shared data layer abstractions.
|
||||
Branch from latest `main`. Stage 8A (integration abstractions) is complete. Stage 8B provides shared data abstractions for local caching, offline storage, and cross-feature data patterns required by Stages 9–16.
|
||||
**`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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
/// Shared data layer abstractions for the Kell Creations platform.
|
||||
///
|
||||
/// This library defines the contracts for local caching, offline operation
|
||||
/// queuing, multi-channel conflict resolution, change tracking, and
|
||||
/// local ↔ remote model mapping. Concrete implementations (SQLite-backed,
|
||||
/// in-memory, etc.) are provided alongside each contract.
|
||||
library;
|
||||
|
||||
export 'src/change_tracker.dart';
|
||||
export 'src/conflict_resolver.dart';
|
||||
export 'src/data_mapper.dart';
|
||||
export 'src/local_cache.dart';
|
||||
export 'src/sync_queue.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
/// The sync state of a locally-tracked record.
|
||||
enum ChangeState {
|
||||
/// The record matches the last known remote state — no action needed.
|
||||
synced,
|
||||
|
||||
/// The record was created locally and has not yet been pushed to the remote.
|
||||
created,
|
||||
|
||||
/// The record was modified locally since the last successful sync.
|
||||
modified,
|
||||
|
||||
/// The record was deleted locally and the deletion has not yet been applied
|
||||
/// on the remote.
|
||||
deleted,
|
||||
|
||||
/// A sync attempt failed; the record needs to be retried.
|
||||
syncError,
|
||||
}
|
||||
|
||||
/// Tracks the local modification state of a single record pending sync.
|
||||
class TrackedChange<T> {
|
||||
/// Identifier of the record being tracked (matches the domain entity's id).
|
||||
final String id;
|
||||
|
||||
/// The current local state of the record, or `null` if [state] is
|
||||
/// [ChangeState.deleted].
|
||||
final T? value;
|
||||
|
||||
/// The sync state of this record.
|
||||
final ChangeState state;
|
||||
|
||||
/// When this change was first recorded locally.
|
||||
final DateTime changedAt;
|
||||
|
||||
/// Error message from the last failed sync attempt, if any.
|
||||
final String? syncError;
|
||||
|
||||
const TrackedChange({
|
||||
required this.id,
|
||||
required this.state,
|
||||
required this.changedAt,
|
||||
this.value,
|
||||
this.syncError,
|
||||
});
|
||||
|
||||
/// Returns a copy with the given fields replaced.
|
||||
TrackedChange<T> copyWith({T? value, ChangeState? state, String? syncError}) {
|
||||
return TrackedChange<T>(
|
||||
id: id,
|
||||
value: value ?? this.value,
|
||||
state: state ?? this.state,
|
||||
changedAt: changedAt,
|
||||
syncError: syncError ?? this.syncError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'TrackedChange(id: $id, state: $state, changedAt: $changedAt)';
|
||||
}
|
||||
|
||||
/// Tracks local modifications to domain records that are pending synchronisation
|
||||
/// with a remote channel.
|
||||
///
|
||||
/// [T] is the domain type being tracked (e.g. `InventoryItem`, `Order`).
|
||||
///
|
||||
/// Implementations record which records have been created, modified, or deleted
|
||||
/// locally so that a sync engine can determine exactly what needs to be pushed
|
||||
/// to the remote on the next sync pass.
|
||||
abstract class ChangeTracker<T> {
|
||||
/// Records that [record] with [id] was created locally.
|
||||
Future<void> trackCreated(String id, T record);
|
||||
|
||||
/// Records that [record] with [id] was modified locally.
|
||||
Future<void> trackModified(String id, T record);
|
||||
|
||||
/// Records that the record with [id] was deleted locally.
|
||||
Future<void> trackDeleted(String id);
|
||||
|
||||
/// Marks the record with [id] as successfully synced (removes it from the
|
||||
/// pending-changes set).
|
||||
Future<void> markSynced(String id);
|
||||
|
||||
/// Records a sync error for [id] with the given [errorMessage].
|
||||
Future<void> markSyncError(String id, String errorMessage);
|
||||
|
||||
/// Returns all records that have pending local changes (created, modified,
|
||||
/// deleted, or errored).
|
||||
Future<List<TrackedChange<T>>> pendingChanges();
|
||||
|
||||
/// Returns the [TrackedChange] for [id], or `null` if the record is synced
|
||||
/// or not tracked.
|
||||
Future<TrackedChange<T>?> getChange(String id);
|
||||
|
||||
/// Returns `true` if there are any pending local changes.
|
||||
Future<bool> get hasPendingChanges;
|
||||
|
||||
/// Clears all tracked changes (e.g. after a full re-sync from the remote).
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// An in-memory [ChangeTracker] suitable for testing and development.
|
||||
class InMemoryChangeTracker<T> implements ChangeTracker<T> {
|
||||
final Map<String, TrackedChange<T>> _changes = {};
|
||||
|
||||
@override
|
||||
Future<void> trackCreated(String id, T record) async {
|
||||
_changes[id] = TrackedChange<T>(
|
||||
id: id,
|
||||
value: record,
|
||||
state: ChangeState.created,
|
||||
changedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> trackModified(String id, T record) async {
|
||||
// Preserve the original changedAt if already tracked as created.
|
||||
final existing = _changes[id];
|
||||
_changes[id] = TrackedChange<T>(
|
||||
id: id,
|
||||
value: record,
|
||||
state: existing?.state == ChangeState.created ? ChangeState.created : ChangeState.modified,
|
||||
changedAt: existing?.changedAt ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> trackDeleted(String id) async {
|
||||
final existing = _changes[id];
|
||||
_changes[id] = TrackedChange<T>(
|
||||
id: id,
|
||||
value: null,
|
||||
state: ChangeState.deleted,
|
||||
changedAt: existing?.changedAt ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markSynced(String id) async {
|
||||
_changes.remove(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markSyncError(String id, String errorMessage) async {
|
||||
final existing = _changes[id];
|
||||
if (existing != null) {
|
||||
_changes[id] = existing.copyWith(state: ChangeState.syncError, syncError: errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TrackedChange<T>>> pendingChanges() async {
|
||||
return _changes.values.where((c) => c.state != ChangeState.synced).toList()
|
||||
..sort((a, b) => a.changedAt.compareTo(b.changedAt));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TrackedChange<T>?> getChange(String id) async => _changes[id];
|
||||
|
||||
@override
|
||||
Future<bool> get hasPendingChanges async => _changes.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _changes.clear();
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/// The outcome of a conflict resolution decision.
|
||||
enum ConflictResolution {
|
||||
/// Use the local version of the record.
|
||||
useLocal,
|
||||
|
||||
/// Use the remote version of the record.
|
||||
useRemote,
|
||||
|
||||
/// Merge both versions into a new record (implementation-defined).
|
||||
merge,
|
||||
|
||||
/// Escalate to the operator — neither version is automatically chosen.
|
||||
requiresOperatorReview,
|
||||
}
|
||||
|
||||
/// The result produced by a [ConflictResolver] for a single conflicting record.
|
||||
class ConflictResult<T> {
|
||||
/// Which version was chosen (or how the conflict was handled).
|
||||
final ConflictResolution resolution;
|
||||
|
||||
/// The resolved record, or `null` when [resolution] is
|
||||
/// [ConflictResolution.requiresOperatorReview].
|
||||
final T? resolvedValue;
|
||||
|
||||
/// Human-readable explanation of why this resolution was chosen.
|
||||
final String? reason;
|
||||
|
||||
const ConflictResult({required this.resolution, this.resolvedValue, this.reason});
|
||||
|
||||
/// Convenience constructor: local version wins.
|
||||
const ConflictResult.useLocal({required T local, this.reason})
|
||||
: resolution = ConflictResolution.useLocal,
|
||||
resolvedValue = local;
|
||||
|
||||
/// Convenience constructor: remote version wins.
|
||||
const ConflictResult.useRemote({required T remote, this.reason})
|
||||
: resolution = ConflictResolution.useRemote,
|
||||
resolvedValue = remote;
|
||||
|
||||
/// Convenience constructor: merged value produced.
|
||||
const ConflictResult.merged({required T merged, this.reason})
|
||||
: resolution = ConflictResolution.merge,
|
||||
resolvedValue = merged;
|
||||
|
||||
/// Convenience constructor: operator review required.
|
||||
const ConflictResult.requiresReview({this.reason})
|
||||
: resolution = ConflictResolution.requiresOperatorReview,
|
||||
resolvedValue = null;
|
||||
|
||||
@override
|
||||
String toString() => 'ConflictResult(resolution: $resolution, reason: $reason)';
|
||||
}
|
||||
|
||||
/// Contract for resolving conflicts between a locally-modified record and a
|
||||
/// remotely-modified record of the same entity.
|
||||
///
|
||||
/// [T] is the domain type being reconciled (e.g. `InventoryItem`, `Order`).
|
||||
///
|
||||
/// Implementations encode the business rules for which version wins when both
|
||||
/// the local app and a remote channel (WooCommerce, Square) have modified the
|
||||
/// same record since the last sync.
|
||||
abstract class ConflictResolver<T> {
|
||||
/// Resolves the conflict between [local] and [remote] versions of a record.
|
||||
///
|
||||
/// [localModifiedAt] and [remoteModifiedAt] are the last-modified timestamps
|
||||
/// for each version, used by time-based strategies.
|
||||
ConflictResult<T> resolve({
|
||||
required T local,
|
||||
required T remote,
|
||||
required DateTime localModifiedAt,
|
||||
required DateTime remoteModifiedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// A [ConflictResolver] that always prefers the most recently modified version.
|
||||
///
|
||||
/// When timestamps are equal the remote version is preferred, on the assumption
|
||||
/// that the remote is the authoritative source of truth.
|
||||
class LastWriteWinsResolver<T> implements ConflictResolver<T> {
|
||||
const LastWriteWinsResolver();
|
||||
|
||||
@override
|
||||
ConflictResult<T> resolve({
|
||||
required T local,
|
||||
required T remote,
|
||||
required DateTime localModifiedAt,
|
||||
required DateTime remoteModifiedAt,
|
||||
}) {
|
||||
if (localModifiedAt.isAfter(remoteModifiedAt)) {
|
||||
return ConflictResult.useLocal(
|
||||
local: local,
|
||||
reason: 'Local version is newer (last-write-wins)',
|
||||
);
|
||||
}
|
||||
return ConflictResult.useRemote(
|
||||
remote: remote,
|
||||
reason: 'Remote version is newer or equal (last-write-wins)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [ConflictResolver] that always prefers the local version.
|
||||
///
|
||||
/// Useful when the app is the authoritative source of truth and remote changes
|
||||
/// should never overwrite local edits.
|
||||
class LocalAlwaysWinsResolver<T> implements ConflictResolver<T> {
|
||||
const LocalAlwaysWinsResolver();
|
||||
|
||||
@override
|
||||
ConflictResult<T> resolve({
|
||||
required T local,
|
||||
required T remote,
|
||||
required DateTime localModifiedAt,
|
||||
required DateTime remoteModifiedAt,
|
||||
}) {
|
||||
return ConflictResult.useLocal(local: local, reason: 'Local always wins policy');
|
||||
}
|
||||
}
|
||||
|
||||
/// A [ConflictResolver] that always prefers the remote version.
|
||||
///
|
||||
/// Useful when the remote channel is the authoritative source of truth and
|
||||
/// local edits should be discarded on conflict.
|
||||
class RemoteAlwaysWinsResolver<T> implements ConflictResolver<T> {
|
||||
const RemoteAlwaysWinsResolver();
|
||||
|
||||
@override
|
||||
ConflictResult<T> resolve({
|
||||
required T local,
|
||||
required T remote,
|
||||
required DateTime localModifiedAt,
|
||||
required DateTime remoteModifiedAt,
|
||||
}) {
|
||||
return ConflictResult.useRemote(remote: remote, reason: 'Remote always wins policy');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/// Contract for bidirectional mapping between a local domain model and a
|
||||
/// remote data transfer object (DTO).
|
||||
///
|
||||
/// [TLocal] is the domain type used inside the app (e.g. `InventoryItem`).
|
||||
/// [TRemote] is the wire/DTO type returned by the remote API
|
||||
/// (e.g. a `Map<String, dynamic>` decoded from JSON, or a typed DTO class).
|
||||
///
|
||||
/// Implementations live in the data layer of each feature package and keep
|
||||
/// all serialisation/deserialisation logic out of the domain layer.
|
||||
abstract class DataMapper<TLocal, TRemote> {
|
||||
/// Converts a [remote] DTO into a local domain object.
|
||||
///
|
||||
/// Throws a [DataMappingException] if [remote] is missing required fields
|
||||
/// or contains values that cannot be mapped to the domain model.
|
||||
TLocal fromRemote(TRemote remote);
|
||||
|
||||
/// Converts a [local] domain object into a remote DTO suitable for sending
|
||||
/// to the API (e.g. as a JSON request body).
|
||||
TRemote toRemote(TLocal local);
|
||||
|
||||
/// Converts a list of [remote] DTOs into a list of local domain objects.
|
||||
///
|
||||
/// Delegates to [fromRemote] for each element. Implementations may override
|
||||
/// this for batch-optimised mapping.
|
||||
List<TLocal> fromRemoteList(List<TRemote> remotes) => remotes.map(fromRemote).toList();
|
||||
|
||||
/// Converts a list of [local] domain objects into a list of remote DTOs.
|
||||
///
|
||||
/// Delegates to [toRemote] for each element. Implementations may override
|
||||
/// this for batch-optimised mapping.
|
||||
List<TRemote> toRemoteList(List<TLocal> locals) => locals.map(toRemote).toList();
|
||||
}
|
||||
|
||||
/// Thrown when a [DataMapper] cannot convert a remote DTO to a local model.
|
||||
///
|
||||
/// Carries the [field] that caused the problem and a human-readable [message].
|
||||
class DataMappingException implements Exception {
|
||||
/// The field or path within the remote DTO that caused the mapping failure.
|
||||
final String field;
|
||||
|
||||
/// Human-readable description of the mapping failure.
|
||||
final String message;
|
||||
|
||||
/// The raw value that could not be mapped, if available.
|
||||
final Object? rawValue;
|
||||
|
||||
const DataMappingException({required this.field, required this.message, this.rawValue});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'DataMappingException(field: $field): $message'
|
||||
'${rawValue != null ? ' (raw: $rawValue)' : ''}';
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/// Contract for in-memory and persistent local caching.
|
||||
///
|
||||
/// [T] is the type of value stored in the cache. Implementations may back
|
||||
/// this with an in-memory map, SQLite, shared preferences, or any other
|
||||
/// storage mechanism appropriate for the platform.
|
||||
abstract class LocalCache<T> {
|
||||
/// Returns the cached value for [key], or `null` if the key is absent or
|
||||
/// the entry has expired.
|
||||
Future<T?> get(String key);
|
||||
|
||||
/// Stores [value] under [key].
|
||||
///
|
||||
/// If [ttl] is provided the entry expires after that duration and subsequent
|
||||
/// [get] calls will return `null`.
|
||||
Future<void> put(String key, T value, {Duration? ttl});
|
||||
|
||||
/// Removes the entry for [key]. No-op if the key is not present.
|
||||
Future<void> remove(String key);
|
||||
|
||||
/// Removes all entries from the cache.
|
||||
Future<void> clear();
|
||||
|
||||
/// Returns `true` if [key] is present and has not expired.
|
||||
Future<bool> containsKey(String key);
|
||||
|
||||
/// Returns all non-expired keys currently in the cache.
|
||||
Future<List<String>> keys();
|
||||
|
||||
/// Releases any resources held by this cache (file handles, DB connections).
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
/// A [LocalCache] backed by an in-memory [Map].
|
||||
///
|
||||
/// Entries are lost when the process exits. Suitable for short-lived caching
|
||||
/// within a single app session (e.g. caching API responses for the duration
|
||||
/// of a screen visit).
|
||||
class InMemoryCache<T> implements LocalCache<T> {
|
||||
final Map<String, _CacheEntry<T>> _store = {};
|
||||
|
||||
@override
|
||||
Future<T?> get(String key) async {
|
||||
final entry = _store[key];
|
||||
if (entry == null) return null;
|
||||
if (entry.isExpired) {
|
||||
_store.remove(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> put(String key, T value, {Duration? ttl}) async {
|
||||
_store[key] = _CacheEntry(
|
||||
value: value,
|
||||
expiresAt: ttl != null ? DateTime.now().add(ttl) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String key) async => _store.remove(key);
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _store.clear();
|
||||
|
||||
@override
|
||||
Future<bool> containsKey(String key) async {
|
||||
final entry = _store[key];
|
||||
if (entry == null) return false;
|
||||
if (entry.isExpired) {
|
||||
_store.remove(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> keys() async {
|
||||
// Prune expired entries before returning keys.
|
||||
_store.removeWhere((_, entry) => entry.isExpired);
|
||||
return _store.keys.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async => _store.clear();
|
||||
}
|
||||
|
||||
/// Internal entry wrapper that tracks an optional expiry time.
|
||||
class _CacheEntry<T> {
|
||||
final T value;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
const _CacheEntry({required this.value, this.expiresAt});
|
||||
|
||||
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
/// The processing status of a [SyncOperation].
|
||||
enum SyncOperationStatus {
|
||||
/// Waiting to be sent to the remote.
|
||||
pending,
|
||||
|
||||
/// Currently being transmitted.
|
||||
inProgress,
|
||||
|
||||
/// Successfully applied on the remote.
|
||||
completed,
|
||||
|
||||
/// Failed after all retry attempts; requires operator attention.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// A single unit of work that must be applied to a remote system.
|
||||
///
|
||||
/// Operations are created locally (e.g. while offline) and flushed to the
|
||||
/// remote when connectivity is restored.
|
||||
class SyncOperation {
|
||||
/// Unique identifier for this operation.
|
||||
final String id;
|
||||
|
||||
/// Logical type of the operation (e.g. `'createOrder'`, `'adjustInventory'`).
|
||||
final String type;
|
||||
|
||||
/// Serialisable payload describing the operation.
|
||||
final Map<String, dynamic> payload;
|
||||
|
||||
/// When this operation was enqueued.
|
||||
final DateTime enqueuedAt;
|
||||
|
||||
/// Current processing status.
|
||||
final SyncOperationStatus status;
|
||||
|
||||
/// Number of times this operation has been attempted.
|
||||
final int attemptCount;
|
||||
|
||||
/// Human-readable error from the last failed attempt, if any.
|
||||
final String? lastError;
|
||||
|
||||
const SyncOperation({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.payload,
|
||||
required this.enqueuedAt,
|
||||
this.status = SyncOperationStatus.pending,
|
||||
this.attemptCount = 0,
|
||||
this.lastError,
|
||||
});
|
||||
|
||||
/// Returns a copy of this operation with the given fields replaced.
|
||||
SyncOperation copyWith({SyncOperationStatus? status, int? attemptCount, String? lastError}) {
|
||||
return SyncOperation(
|
||||
id: id,
|
||||
type: type,
|
||||
payload: payload,
|
||||
enqueuedAt: enqueuedAt,
|
||||
status: status ?? this.status,
|
||||
attemptCount: attemptCount ?? this.attemptCount,
|
||||
lastError: lastError ?? this.lastError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SyncOperation(id: $id, type: $type, status: $status, attempts: $attemptCount)';
|
||||
}
|
||||
|
||||
/// Result returned after attempting to flush a [SyncOperation].
|
||||
class SyncFlushResult {
|
||||
/// The operation that was attempted.
|
||||
final SyncOperation operation;
|
||||
|
||||
/// Whether the flush succeeded.
|
||||
final bool success;
|
||||
|
||||
/// Error message if [success] is `false`.
|
||||
final String? errorMessage;
|
||||
|
||||
const SyncFlushResult({required this.operation, required this.success, this.errorMessage});
|
||||
|
||||
const SyncFlushResult.success({required this.operation}) : success = true, errorMessage = null;
|
||||
|
||||
const SyncFlushResult.failure({required this.operation, required this.errorMessage})
|
||||
: success = false;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SyncFlushResult(id: ${operation.id}, success: $success, error: $errorMessage)';
|
||||
}
|
||||
|
||||
/// Abstraction for queuing offline operations and flushing them when online.
|
||||
///
|
||||
/// Implementations persist the queue across app restarts so that operations
|
||||
/// captured while offline survive process termination.
|
||||
abstract class SyncQueue {
|
||||
/// Adds [operation] to the tail of the queue.
|
||||
Future<void> enqueue(SyncOperation operation);
|
||||
|
||||
/// Returns all operations currently in the queue, ordered by [SyncOperation.enqueuedAt].
|
||||
Future<List<SyncOperation>> pendingOperations();
|
||||
|
||||
/// Attempts to apply [operation] to the remote via [flush].
|
||||
///
|
||||
/// On success the operation is removed from the queue.
|
||||
/// On failure the operation's [SyncOperation.attemptCount] and
|
||||
/// [SyncOperation.lastError] are updated and it remains in the queue.
|
||||
Future<SyncFlushResult> process(
|
||||
SyncOperation operation,
|
||||
Future<void> Function(SyncOperation) flush,
|
||||
);
|
||||
|
||||
/// Removes [operation] from the queue regardless of its status.
|
||||
Future<void> remove(String operationId);
|
||||
|
||||
/// Removes all completed and failed operations, retaining only pending ones.
|
||||
Future<void> pruneCompleted();
|
||||
|
||||
/// Returns the number of operations currently in the queue.
|
||||
Future<int> get length;
|
||||
}
|
||||
|
||||
/// An in-memory [SyncQueue] suitable for testing and development.
|
||||
///
|
||||
/// Operations are not persisted across process restarts.
|
||||
class InMemorySyncQueue implements SyncQueue {
|
||||
final List<SyncOperation> _queue = [];
|
||||
|
||||
@override
|
||||
Future<void> enqueue(SyncOperation operation) async {
|
||||
_queue.add(operation);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncOperation>> pendingOperations() async {
|
||||
return _queue.where((op) => op.status == SyncOperationStatus.pending).toList()
|
||||
..sort((a, b) => a.enqueuedAt.compareTo(b.enqueuedAt));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncFlushResult> process(
|
||||
SyncOperation operation,
|
||||
Future<void> Function(SyncOperation) flush,
|
||||
) async {
|
||||
final index = _queue.indexWhere((op) => op.id == operation.id);
|
||||
if (index == -1) {
|
||||
return SyncFlushResult.failure(
|
||||
operation: operation,
|
||||
errorMessage: 'Operation not found in queue',
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as in-progress.
|
||||
_queue[index] = _queue[index].copyWith(status: SyncOperationStatus.inProgress);
|
||||
|
||||
try {
|
||||
await flush(operation);
|
||||
_queue[index] = _queue[index].copyWith(
|
||||
status: SyncOperationStatus.completed,
|
||||
attemptCount: operation.attemptCount + 1,
|
||||
);
|
||||
return SyncFlushResult.success(operation: _queue[index]);
|
||||
} catch (e) {
|
||||
_queue[index] = _queue[index].copyWith(
|
||||
status: SyncOperationStatus.failed,
|
||||
attemptCount: operation.attemptCount + 1,
|
||||
lastError: e.toString(),
|
||||
);
|
||||
return SyncFlushResult.failure(operation: _queue[index], errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String operationId) async {
|
||||
_queue.removeWhere((op) => op.id == operationId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pruneCompleted() async {
|
||||
_queue.removeWhere(
|
||||
(op) => op.status == SyncOperationStatus.completed || op.status == SyncOperationStatus.failed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> get length async => _queue.length;
|
||||
}
|
||||
|
|
@ -1,12 +1,627 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:data/data.dart';
|
||||
|
||||
// ── Test doubles ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Simple domain type used across all tests.
|
||||
class _Item {
|
||||
final String id;
|
||||
final String name;
|
||||
const _Item(this.id, this.name);
|
||||
@override
|
||||
String toString() => '_Item($id, $name)';
|
||||
}
|
||||
|
||||
/// Minimal [DataMapper] implementation for tests.
|
||||
class _ItemMapper extends DataMapper<_Item, Map<String, dynamic>> {
|
||||
@override
|
||||
_Item fromRemote(Map<String, dynamic> remote) {
|
||||
final id = remote['id'];
|
||||
final name = remote['name'];
|
||||
if (id == null || name == null) {
|
||||
throw DataMappingException(field: 'id/name', message: 'Required fields missing');
|
||||
}
|
||||
return _Item(id as String, name as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toRemote(_Item local) => {'id': local.id, 'name': local.name};
|
||||
}
|
||||
|
||||
/// A [ConflictResolver] that always merges by concatenating names.
|
||||
class _MergeResolver implements ConflictResolver<_Item> {
|
||||
const _MergeResolver();
|
||||
|
||||
@override
|
||||
ConflictResult<_Item> resolve({
|
||||
required _Item local,
|
||||
required _Item remote,
|
||||
required DateTime localModifiedAt,
|
||||
required DateTime remoteModifiedAt,
|
||||
}) {
|
||||
return ConflictResult.merged(
|
||||
merged: _Item(local.id, '${local.name}+${remote.name}'),
|
||||
reason: 'merged names',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// ── LocalCache / InMemoryCache ────────────────────────────────────────────
|
||||
|
||||
group('InMemoryCache', () {
|
||||
late InMemoryCache<String> cache;
|
||||
|
||||
setUp(() => cache = InMemoryCache<String>());
|
||||
tearDown(() async => cache.dispose());
|
||||
|
||||
test('put and get returns stored value', () async {
|
||||
await cache.put('k', 'hello');
|
||||
expect(await cache.get('k'), 'hello');
|
||||
});
|
||||
|
||||
test('get returns null for missing key', () async {
|
||||
expect(await cache.get('missing'), isNull);
|
||||
});
|
||||
|
||||
test('containsKey returns true for present key', () async {
|
||||
await cache.put('k', 'v');
|
||||
expect(await cache.containsKey('k'), isTrue);
|
||||
});
|
||||
|
||||
test('containsKey returns false for missing key', () async {
|
||||
expect(await cache.containsKey('missing'), isFalse);
|
||||
});
|
||||
|
||||
test('remove deletes the entry', () async {
|
||||
await cache.put('k', 'v');
|
||||
await cache.remove('k');
|
||||
expect(await cache.get('k'), isNull);
|
||||
});
|
||||
|
||||
test('clear removes all entries', () async {
|
||||
await cache.put('a', '1');
|
||||
await cache.put('b', '2');
|
||||
await cache.clear();
|
||||
expect(await cache.keys(), isEmpty);
|
||||
});
|
||||
|
||||
test('keys returns all non-expired keys', () async {
|
||||
await cache.put('x', '1');
|
||||
await cache.put('y', '2');
|
||||
final keys = await cache.keys();
|
||||
expect(keys, containsAll(['x', 'y']));
|
||||
});
|
||||
|
||||
test('entry expires after ttl', () async {
|
||||
await cache.put('k', 'v', ttl: const Duration(milliseconds: 1));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(await cache.get('k'), isNull);
|
||||
});
|
||||
|
||||
test('containsKey returns false for expired entry', () async {
|
||||
await cache.put('k', 'v', ttl: const Duration(milliseconds: 1));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(await cache.containsKey('k'), isFalse);
|
||||
});
|
||||
|
||||
test('entry without ttl does not expire', () async {
|
||||
await cache.put('k', 'persistent');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(await cache.get('k'), 'persistent');
|
||||
});
|
||||
|
||||
test('dispose clears all entries', () async {
|
||||
await cache.put('k', 'v');
|
||||
await cache.dispose();
|
||||
expect(await cache.keys(), isEmpty);
|
||||
});
|
||||
|
||||
test('overwriting a key replaces the value', () async {
|
||||
await cache.put('k', 'first');
|
||||
await cache.put('k', 'second');
|
||||
expect(await cache.get('k'), 'second');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SyncQueue / InMemorySyncQueue ─────────────────────────────────────────
|
||||
|
||||
group('SyncOperation', () {
|
||||
final op = SyncOperation(
|
||||
id: 'op-1',
|
||||
type: 'createOrder',
|
||||
payload: {'orderId': 42},
|
||||
enqueuedAt: DateTime(2026, 7, 11),
|
||||
);
|
||||
|
||||
test('stores all fields with defaults', () {
|
||||
expect(op.id, 'op-1');
|
||||
expect(op.type, 'createOrder');
|
||||
expect(op.payload['orderId'], 42);
|
||||
expect(op.status, SyncOperationStatus.pending);
|
||||
expect(op.attemptCount, 0);
|
||||
expect(op.lastError, isNull);
|
||||
});
|
||||
|
||||
test('copyWith replaces specified fields', () {
|
||||
final updated = op.copyWith(
|
||||
status: SyncOperationStatus.failed,
|
||||
attemptCount: 2,
|
||||
lastError: 'timeout',
|
||||
);
|
||||
expect(updated.status, SyncOperationStatus.failed);
|
||||
expect(updated.attemptCount, 2);
|
||||
expect(updated.lastError, 'timeout');
|
||||
expect(updated.id, op.id);
|
||||
});
|
||||
|
||||
test('toString includes id, type, status, attempts', () {
|
||||
expect(op.toString(), contains('op-1'));
|
||||
expect(op.toString(), contains('createOrder'));
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncFlushResult', () {
|
||||
final op = SyncOperation(
|
||||
id: 'op-1',
|
||||
type: 'adjustInventory',
|
||||
payload: {},
|
||||
enqueuedAt: DateTime(2026, 7, 11),
|
||||
);
|
||||
|
||||
test('success constructor sets success true and no error', () {
|
||||
final result = SyncFlushResult.success(operation: op);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('failure constructor sets success false and error message', () {
|
||||
final result = SyncFlushResult.failure(operation: op, errorMessage: 'network error');
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, 'network error');
|
||||
});
|
||||
|
||||
test('toString includes operation id and success flag', () {
|
||||
final result = SyncFlushResult.success(operation: op);
|
||||
expect(result.toString(), contains('op-1'));
|
||||
expect(result.toString(), contains('true'));
|
||||
});
|
||||
});
|
||||
|
||||
group('InMemorySyncQueue', () {
|
||||
late InMemorySyncQueue queue;
|
||||
|
||||
SyncOperation op0(String id) =>
|
||||
SyncOperation(id: id, type: 'test', payload: {}, enqueuedAt: DateTime(2026, 7, 11));
|
||||
|
||||
setUp(() => queue = InMemorySyncQueue());
|
||||
|
||||
test('enqueue adds operation and length increases', () async {
|
||||
await queue.enqueue(op0('a'));
|
||||
expect(await queue.length, 1);
|
||||
});
|
||||
|
||||
test('pendingOperations returns only pending ops', () async {
|
||||
await queue.enqueue(op0('a'));
|
||||
await queue.enqueue(op0('b'));
|
||||
final pending = await queue.pendingOperations();
|
||||
expect(pending.length, 2);
|
||||
expect(pending.every((op) => op.status == SyncOperationStatus.pending), isTrue);
|
||||
});
|
||||
|
||||
test('process succeeds and marks operation completed', () async {
|
||||
final op = op0('a');
|
||||
await queue.enqueue(op);
|
||||
final result = await queue.process(op, (_) async {});
|
||||
expect(result.success, isTrue);
|
||||
expect(result.operation.status, SyncOperationStatus.completed);
|
||||
expect(result.operation.attemptCount, 1);
|
||||
});
|
||||
|
||||
test('process failure marks operation failed with error', () async {
|
||||
final op = op0('a');
|
||||
await queue.enqueue(op);
|
||||
final result = await queue.process(op, (_) async => throw Exception('boom'));
|
||||
expect(result.success, isFalse);
|
||||
expect(result.operation.status, SyncOperationStatus.failed);
|
||||
expect(result.errorMessage, contains('boom'));
|
||||
expect(result.operation.attemptCount, 1);
|
||||
});
|
||||
|
||||
test('process returns failure for unknown operation id', () async {
|
||||
final op = op0('unknown');
|
||||
final result = await queue.process(op, (_) async {});
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, contains('not found'));
|
||||
});
|
||||
|
||||
test('remove deletes operation from queue', () async {
|
||||
await queue.enqueue(op0('a'));
|
||||
await queue.remove('a');
|
||||
expect(await queue.length, 0);
|
||||
});
|
||||
|
||||
test('pruneCompleted removes completed and failed ops', () async {
|
||||
final opA = op0('a');
|
||||
final opB = op0('b');
|
||||
final opC = op0('c');
|
||||
await queue.enqueue(opA);
|
||||
await queue.enqueue(opB);
|
||||
await queue.enqueue(opC);
|
||||
await queue.process(opA, (_) async {}); // completed
|
||||
await queue.process(opB, (_) async => throw Exception('err')); // failed
|
||||
// opC remains pending
|
||||
await queue.pruneCompleted();
|
||||
expect(await queue.length, 1);
|
||||
final pending = await queue.pendingOperations();
|
||||
expect(pending.first.id, 'c');
|
||||
});
|
||||
|
||||
test('pendingOperations excludes completed operations', () async {
|
||||
final op = op0('a');
|
||||
await queue.enqueue(op);
|
||||
await queue.process(op, (_) async {});
|
||||
final pending = await queue.pendingOperations();
|
||||
expect(pending, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ConflictResolver ──────────────────────────────────────────────────────
|
||||
|
||||
group('ConflictResult', () {
|
||||
const item = _Item('1', 'Widget');
|
||||
|
||||
test('useLocal constructor sets resolution and value', () {
|
||||
final result = ConflictResult.useLocal(local: item, reason: 'test');
|
||||
expect(result.resolution, ConflictResolution.useLocal);
|
||||
expect(result.resolvedValue, item);
|
||||
expect(result.reason, 'test');
|
||||
});
|
||||
|
||||
test('useRemote constructor sets resolution and value', () {
|
||||
final result = ConflictResult.useRemote(remote: item, reason: 'test');
|
||||
expect(result.resolution, ConflictResolution.useRemote);
|
||||
expect(result.resolvedValue, item);
|
||||
});
|
||||
|
||||
test('merged constructor sets resolution and value', () {
|
||||
final result = ConflictResult.merged(merged: item, reason: 'merged');
|
||||
expect(result.resolution, ConflictResolution.merge);
|
||||
expect(result.resolvedValue, item);
|
||||
});
|
||||
|
||||
test('requiresReview constructor sets resolution and null value', () {
|
||||
const result = ConflictResult<_Item>.requiresReview(reason: 'conflict');
|
||||
expect(result.resolution, ConflictResolution.requiresOperatorReview);
|
||||
expect(result.resolvedValue, isNull);
|
||||
});
|
||||
|
||||
test('toString includes resolution and reason', () {
|
||||
final result = ConflictResult.useLocal(local: item, reason: 'newer');
|
||||
expect(result.toString(), contains('useLocal'));
|
||||
expect(result.toString(), contains('newer'));
|
||||
});
|
||||
});
|
||||
|
||||
group('LastWriteWinsResolver', () {
|
||||
const resolver = LastWriteWinsResolver<_Item>();
|
||||
const local = _Item('1', 'Local');
|
||||
const remote = _Item('1', 'Remote');
|
||||
final older = DateTime(2026, 1, 1);
|
||||
final newer = DateTime(2026, 7, 11);
|
||||
|
||||
test('prefers local when local is newer', () {
|
||||
final result = resolver.resolve(
|
||||
local: local,
|
||||
remote: remote,
|
||||
localModifiedAt: newer,
|
||||
remoteModifiedAt: older,
|
||||
);
|
||||
expect(result.resolution, ConflictResolution.useLocal);
|
||||
expect(result.resolvedValue, local);
|
||||
});
|
||||
|
||||
test('prefers remote when remote is newer', () {
|
||||
final result = resolver.resolve(
|
||||
local: local,
|
||||
remote: remote,
|
||||
localModifiedAt: older,
|
||||
remoteModifiedAt: newer,
|
||||
);
|
||||
expect(result.resolution, ConflictResolution.useRemote);
|
||||
expect(result.resolvedValue, remote);
|
||||
});
|
||||
|
||||
test('prefers remote when timestamps are equal', () {
|
||||
final result = resolver.resolve(
|
||||
local: local,
|
||||
remote: remote,
|
||||
localModifiedAt: older,
|
||||
remoteModifiedAt: older,
|
||||
);
|
||||
expect(result.resolution, ConflictResolution.useRemote);
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalAlwaysWinsResolver', () {
|
||||
const resolver = LocalAlwaysWinsResolver<_Item>();
|
||||
const local = _Item('1', 'Local');
|
||||
const remote = _Item('1', 'Remote');
|
||||
final t = DateTime(2026, 7, 11);
|
||||
|
||||
test('always returns local regardless of timestamps', () {
|
||||
final result = resolver.resolve(
|
||||
local: local,
|
||||
remote: remote,
|
||||
localModifiedAt: t,
|
||||
remoteModifiedAt: t.add(const Duration(days: 1)),
|
||||
);
|
||||
expect(result.resolution, ConflictResolution.useLocal);
|
||||
expect(result.resolvedValue, local);
|
||||
});
|
||||
});
|
||||
|
||||
group('RemoteAlwaysWinsResolver', () {
|
||||
const resolver = RemoteAlwaysWinsResolver<_Item>();
|
||||
const local = _Item('1', 'Local');
|
||||
const remote = _Item('1', 'Remote');
|
||||
final t = DateTime(2026, 7, 11);
|
||||
|
||||
test('always returns remote regardless of timestamps', () {
|
||||
final result = resolver.resolve(
|
||||
local: local,
|
||||
remote: remote,
|
||||
localModifiedAt: t.add(const Duration(days: 1)),
|
||||
remoteModifiedAt: t,
|
||||
);
|
||||
expect(result.resolution, ConflictResolution.useRemote);
|
||||
expect(result.resolvedValue, remote);
|
||||
});
|
||||
});
|
||||
|
||||
group('Custom ConflictResolver (merge)', () {
|
||||
const resolver = _MergeResolver();
|
||||
const local = _Item('1', 'A');
|
||||
const remote = _Item('1', 'B');
|
||||
final t = DateTime(2026, 7, 11);
|
||||
|
||||
test('merge resolver produces merged value', () {
|
||||
final result = resolver.resolve(
|
||||
local: local,
|
||||
remote: remote,
|
||||
localModifiedAt: t,
|
||||
remoteModifiedAt: t,
|
||||
);
|
||||
expect(result.resolution, ConflictResolution.merge);
|
||||
expect((result.resolvedValue as _Item).name, 'A+B');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ChangeTracker / InMemoryChangeTracker ─────────────────────────────────
|
||||
|
||||
group('TrackedChange', () {
|
||||
final now = DateTime(2026, 7, 11);
|
||||
const item = _Item('1', 'Widget');
|
||||
|
||||
test('stores all fields', () {
|
||||
final change = TrackedChange<_Item>(
|
||||
id: '1',
|
||||
value: item,
|
||||
state: ChangeState.created,
|
||||
changedAt: now,
|
||||
);
|
||||
expect(change.id, '1');
|
||||
expect(change.value, item);
|
||||
expect(change.state, ChangeState.created);
|
||||
expect(change.changedAt, now);
|
||||
expect(change.syncError, isNull);
|
||||
});
|
||||
|
||||
test('copyWith replaces specified fields', () {
|
||||
final change = TrackedChange<_Item>(
|
||||
id: '1',
|
||||
value: item,
|
||||
state: ChangeState.modified,
|
||||
changedAt: now,
|
||||
);
|
||||
final updated = change.copyWith(state: ChangeState.syncError, syncError: 'err');
|
||||
expect(updated.state, ChangeState.syncError);
|
||||
expect(updated.syncError, 'err');
|
||||
expect(updated.id, '1');
|
||||
});
|
||||
|
||||
test('toString includes id, state, changedAt', () {
|
||||
final change = TrackedChange<_Item>(
|
||||
id: '1',
|
||||
value: item,
|
||||
state: ChangeState.created,
|
||||
changedAt: now,
|
||||
);
|
||||
expect(change.toString(), contains('1'));
|
||||
expect(change.toString(), contains('created'));
|
||||
});
|
||||
});
|
||||
|
||||
group('InMemoryChangeTracker', () {
|
||||
late InMemoryChangeTracker<_Item> tracker;
|
||||
const item = _Item('1', 'Widget');
|
||||
const updated = _Item('1', 'Widget Pro');
|
||||
|
||||
setUp(() => tracker = InMemoryChangeTracker<_Item>());
|
||||
|
||||
test('trackCreated records created state', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
final change = await tracker.getChange('1');
|
||||
expect(change?.state, ChangeState.created);
|
||||
expect(change?.value, item);
|
||||
});
|
||||
|
||||
test('trackModified records modified state', () async {
|
||||
await tracker.trackModified('1', updated);
|
||||
final change = await tracker.getChange('1');
|
||||
expect(change?.state, ChangeState.modified);
|
||||
});
|
||||
|
||||
test('trackModified on created record preserves created state', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
await tracker.trackModified('1', updated);
|
||||
final change = await tracker.getChange('1');
|
||||
expect(change?.state, ChangeState.created);
|
||||
});
|
||||
|
||||
test('trackDeleted records deleted state', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
await tracker.trackDeleted('1');
|
||||
final change = await tracker.getChange('1');
|
||||
expect(change?.state, ChangeState.deleted);
|
||||
expect(change?.value, isNull);
|
||||
});
|
||||
|
||||
test('markSynced removes the change', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
await tracker.markSynced('1');
|
||||
expect(await tracker.getChange('1'), isNull);
|
||||
});
|
||||
|
||||
test('markSyncError updates state and error message', () async {
|
||||
await tracker.trackModified('1', item);
|
||||
await tracker.markSyncError('1', 'network timeout');
|
||||
final change = await tracker.getChange('1');
|
||||
expect(change?.state, ChangeState.syncError);
|
||||
expect(change?.syncError, 'network timeout');
|
||||
});
|
||||
|
||||
test('pendingChanges returns all non-synced changes', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
await tracker.trackModified('2', updated);
|
||||
final pending = await tracker.pendingChanges();
|
||||
expect(pending.length, 2);
|
||||
});
|
||||
|
||||
test('hasPendingChanges returns true when changes exist', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
expect(await tracker.hasPendingChanges, isTrue);
|
||||
});
|
||||
|
||||
test('hasPendingChanges returns false when empty', () async {
|
||||
expect(await tracker.hasPendingChanges, isFalse);
|
||||
});
|
||||
|
||||
test('clear removes all tracked changes', () async {
|
||||
await tracker.trackCreated('1', item);
|
||||
await tracker.trackModified('2', updated);
|
||||
await tracker.clear();
|
||||
expect(await tracker.hasPendingChanges, isFalse);
|
||||
});
|
||||
|
||||
test('getChange returns null for untracked id', () async {
|
||||
expect(await tracker.getChange('unknown'), isNull);
|
||||
});
|
||||
|
||||
test('markSyncError on untracked id is a no-op', () async {
|
||||
// Should not throw.
|
||||
await tracker.markSyncError('unknown', 'error');
|
||||
expect(await tracker.hasPendingChanges, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DataMapper / DataMappingException ─────────────────────────────────────
|
||||
|
||||
group('DataMapper (_ItemMapper)', () {
|
||||
final mapper = _ItemMapper();
|
||||
|
||||
test('fromRemote maps valid remote DTO to domain object', () {
|
||||
final item = mapper.fromRemote({'id': 'i1', 'name': 'Candle'});
|
||||
expect(item.id, 'i1');
|
||||
expect(item.name, 'Candle');
|
||||
});
|
||||
|
||||
test('toRemote maps domain object to remote DTO', () {
|
||||
const item = _Item('i1', 'Candle');
|
||||
final dto = mapper.toRemote(item);
|
||||
expect(dto['id'], 'i1');
|
||||
expect(dto['name'], 'Candle');
|
||||
});
|
||||
|
||||
test('fromRemoteList maps a list of DTOs', () {
|
||||
final items = mapper.fromRemoteList([
|
||||
{'id': 'a', 'name': 'A'},
|
||||
{'id': 'b', 'name': 'B'},
|
||||
]);
|
||||
expect(items.length, 2);
|
||||
expect(items[0].id, 'a');
|
||||
expect(items[1].name, 'B');
|
||||
});
|
||||
|
||||
test('toRemoteList maps a list of domain objects', () {
|
||||
const items = [_Item('a', 'A'), _Item('b', 'B')];
|
||||
final dtos = mapper.toRemoteList(items);
|
||||
expect(dtos.length, 2);
|
||||
expect(dtos[0]['id'], 'a');
|
||||
expect(dtos[1]['name'], 'B');
|
||||
});
|
||||
|
||||
test('fromRemote throws DataMappingException for missing fields', () {
|
||||
expect(
|
||||
() => mapper.fromRemote({'id': null, 'name': null}),
|
||||
throwsA(isA<DataMappingException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('DataMappingException', () {
|
||||
test('stores field, message, and rawValue', () {
|
||||
const ex = DataMappingException(
|
||||
field: 'status',
|
||||
message: 'Unknown status value',
|
||||
rawValue: 'bogus',
|
||||
);
|
||||
expect(ex.field, 'status');
|
||||
expect(ex.message, 'Unknown status value');
|
||||
expect(ex.rawValue, 'bogus');
|
||||
});
|
||||
|
||||
test('rawValue defaults to null', () {
|
||||
const ex = DataMappingException(field: 'id', message: 'Missing id');
|
||||
expect(ex.rawValue, isNull);
|
||||
});
|
||||
|
||||
test('toString includes field and message', () {
|
||||
const ex = DataMappingException(field: 'price', message: 'Negative price');
|
||||
expect(ex.toString(), contains('price'));
|
||||
expect(ex.toString(), contains('Negative price'));
|
||||
});
|
||||
|
||||
test('toString includes rawValue when present', () {
|
||||
const ex = DataMappingException(field: 'qty', message: 'Bad qty', rawValue: -5);
|
||||
expect(ex.toString(), contains('-5'));
|
||||
});
|
||||
|
||||
test('is an Exception', () {
|
||||
const ex = DataMappingException(field: 'x', message: 'y');
|
||||
expect(ex, isA<Exception>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Barrel smoke test ─────────────────────────────────────────────────────
|
||||
|
||||
group('data barrel exports', () {
|
||||
test('all public types are accessible via package:data/data.dart', () {
|
||||
// LocalCache
|
||||
expect(InMemoryCache<String>.new, isNotNull);
|
||||
// SyncQueue
|
||||
expect(InMemorySyncQueue.new, isNotNull);
|
||||
expect(SyncOperationStatus.values, isNotEmpty);
|
||||
// ConflictResolver
|
||||
expect(LastWriteWinsResolver<String>.new, isNotNull);
|
||||
expect(LocalAlwaysWinsResolver<String>.new, isNotNull);
|
||||
expect(RemoteAlwaysWinsResolver<String>.new, isNotNull);
|
||||
expect(ConflictResolution.values, isNotEmpty);
|
||||
// ChangeTracker
|
||||
expect(InMemoryChangeTracker<String>.new, isNotNull);
|
||||
expect(ChangeState.values, isNotEmpty);
|
||||
// DataMapper
|
||||
expect(DataMappingException.new, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ TESTABLE=(
|
|||
packages/core
|
||||
packages/design_system
|
||||
packages/integrations
|
||||
packages/data
|
||||
packages/feature_wordpress
|
||||
packages/feature_inventory
|
||||
packages/feature_orders
|
||||
|
|
@ -32,6 +33,7 @@ ANALYZABLE=(
|
|||
packages/core
|
||||
packages/design_system
|
||||
packages/integrations
|
||||
packages/data
|
||||
packages/feature_wordpress
|
||||
packages/feature_inventory
|
||||
packages/feature_orders
|
||||
|
|
|
|||
Loading…
Reference in New Issue