diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6413f..bbd8afd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: - run: flutter pub get - run: dart format --output=none --set-exit-if-changed lib test example/lib example/integration_test examples/static_app/lib examples/riverpod_app/lib examples/bloc_getit_app/lib - run: flutter analyze + - name: Validate Core contract and conformance fixtures + run: flutter test test/contract - run: flutter test - run: cd example && flutter pub get && flutter analyze # Integration tests need a single device (-d). On ubuntu-latest both linux diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..fdd0123 --- /dev/null +++ b/.pubignore @@ -0,0 +1,16 @@ +.dart_tool/ +.github/ +.idea/ +.vscode/ +*.iml +coverage/ +build/ +test/ +examples/ +plan +website/ +**/.flutter-plugins +**/.flutter-plugins-dependencies +**/.dart_tool/ +**/build/ +**/coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 20eb667..ad3c27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# 2.1.0 (2026-08-17) + +## Added + +- Published the versioned, language-neutral FlexTrack Core MVP specification + shared by the Flutter and Kotlin implementations. +- Added versioned JSON conformance fixtures, a Flutter runner and report, and + the Kotlin runner contract for cross-SDK behavior parity. + +## Fixed + +- Type-based routing now matches event subclasses and preserves the original + routing identity through `EnrichedEvent` transformers. Other routing + conditions still evaluate the transformed event. +- Replaced clock-modulo routing sampling with deterministic FNV-1a sampling + keyed by user id, session id, or event name. Essential events bypass + sampling, and published UTF-8 vectors keep future SDK implementations in + parity. +- Event instances now capture an immutable UUID v4 identifier and UTC + occurrence timestamp. Enrichment preserves both values. +- New clients now start with general and PII consent denied, matching the + documented privacy-safe default. Disabling consent checking on a routing + configuration now bypasses those checks as configured. +- `flexTrackVersion` now matches the package version declared in `pubspec.yaml`. + ## 2.0.0 ### Breaking changes @@ -73,8 +98,8 @@ This release promotes the package to **1.0.0** and focuses on **injectable analy ### Documentation * README: `FlexTrackClient`, `FlexTrackScope`, inspector section, table of contents. -* **`docs/flex-track-client.md`** — injectable client, Riverpod/Bloc, widget scope behavior. -* **`docs/assets/inspector.gif`** — demo of the inspector with the flagship app. +* **`doc/flex-track-client.md`** — injectable client, Riverpod/Bloc, widget scope behavior. +* **`doc/assets/inspector.gif`** — demo of the inspector with the flagship app. --- diff --git a/README.md b/README.md index 846aa2a..79751ea 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![FlexTrack Banner](docs/assets/banner.png) +![FlexTrack Banner](doc/assets/banner.png) # FlexTrack @@ -37,7 +37,7 @@ Instead of spreading analytics policy throughout the app, define it once and app ## Visual Demo -![Inspector Demo](docs/assets/inspector.gif) +![Inspector Demo](doc/assets/inspector.gif) ## Quick Example @@ -113,7 +113,7 @@ One call site, multiple tracker destinations, centralized policy. ```yaml # pubspec.yaml dependencies: - flex_track: ^1.0.0 + flex_track: ^2.1.0 ``` **Step 2 — implement your tracker** (the package ships no vendor SDKs; you write a thin adapter): @@ -207,6 +207,10 @@ FlexTrack exists to make that architecture explicit, maintainable, and debuggabl - `FlexTrackClient` for dependency injection patterns - Widget wrappers for click, impression, mount, and route-view tracking +The normative behavior shared by the Flutter and Kotlin SDKs is defined in the +[FlexTrack Core MVP specification](doc/core-mvp-specification.md) and verified +with [shared conformance fixtures](doc/conformance.md). + --- ## Examples @@ -301,7 +305,7 @@ class CheckoutCubit extends Cubit { For **strict** clean architecture, wrap `FlexTrackClient` behind your own `Analytics` interface in the domain module and implement the adapter in infrastructure. -More detail: [docs/flex-track-client.md](docs/flex-track-client.md). +More detail: [doc/flex-track-client.md](doc/flex-track-client.md). --- @@ -315,6 +319,12 @@ This package does not bundle Firebase, Mixpanel, Amplitude, or any other analyti Extend `BaseEvent` and implement the `name` and `properties` getters. Everything else is optional. +Every event receives an immutable UUID v4 `eventId` and UTC `timestamp` when +it is constructed. Those values remain unchanged through enrichment and +dispatch. For replay or restored offline events, pass the original metadata to +`super(eventId: storedId, timestamp: storedTimestamp)` from your event +constructor. + ```dart class PurchaseEvent extends BaseEvent { final double amount; @@ -474,6 +484,11 @@ FlexTrack.addTransformer((event) => EnrichedEvent(event, { `EnrichedEvent` is a `BaseEvent` wrapper. It forwards all metadata from the original event (`category`, `containsPII`, `requiresConsent`, etc.) and overrides `properties` to merge the original properties with the extra ones. Extra properties win on key collision. +Type-based routes remain anchored to the original event through any number of +`EnrichedEvent` wrappers. A `route()` rule therefore continues +to match enriched purchases and subclasses of `PurchaseEvent`, while +property-based routes can still match properties added by transformers. + ```dart // Extra properties override originals on the same key. EnrichedEvent(originalEvent, {'source': 'transformer'}) @@ -923,7 +938,17 @@ GDPRDefaults.applyStrict(routing, compliantTrackers: ['internal']); ## Sampling and performance -Sampling is applied per-rule. Each matching event independently has a random chance of being forwarded at the specified rate. +Sampling is applied per rule and is deterministic by default. FlexTrack hashes +the first non-empty value from `event.userId`, `event.sessionId`, and +`event.name` with FNV-1a over UTF-8 bytes. The resulting stable bucket is +compared with the rule's rate, so the same identity receives the same decision +across launches and SDK implementations. Essential events always bypass +sampling. + +When no user or session identity is available, all events with the same name +share a decision. Supply a stable user or session id when you need a +representative user-level sample. The cross-platform vectors are published in +`test/fixtures/sampling_vectors.json`. | Method | Rate | |--------|------| @@ -983,7 +1008,7 @@ FlexTrack Inspector (open in browser): http://127.0.0.1:7788 Open that address in a browser to inspect the live event list, tracker status, consent snapshot, and per-event JSON. -![FlexTrack Inspector dashboard with the flagship example app](docs/assets/inspector.gif) +![FlexTrack Inspector dashboard with the flagship example app](doc/assets/inspector.gif) ```dart import 'package:flex_track/flex_track_inspector.dart'; @@ -1053,7 +1078,7 @@ await FlexTrack.setup([ **Global singleton** (existing pattern): use `setupFlexTrackForTesting()` and `FlexTrack.reset()` in `tearDown`. -**Injectable client** (no global): create a `FlexTrackClient` with a `MockTracker`, pass it into your class under test, and call `await client.dispose()` in `tearDown`. See [docs/flex-track-client.md](docs/flex-track-client.md). +**Injectable client** (no global): create a `FlexTrackClient` with a `MockTracker`, pass it into your class under test, and call `await client.dispose()` in `tearDown`. See [doc/flex-track-client.md](doc/flex-track-client.md). ```dart import 'package:flutter_test/flutter_test.dart'; diff --git a/docs/README.md b/doc/README.md similarity index 74% rename from docs/README.md rename to doc/README.md index 292fa5d..0f270e8 100644 --- a/docs/README.md +++ b/doc/README.md @@ -7,4 +7,8 @@ Long-form documentation now lives in the **Docusaurus** site under [`website/doc Historical topic filenames (`trackers.md`, `routing-and-rules.md`, etc.) have corresponding pages in `website/docs/guides/` (when the Docusaurus site is present). +- **[Core MVP specification](core-mvp-specification.md)** — normative, + language-neutral contract shared by Flutter and Kotlin. +- **[Cross-SDK conformance](conformance.md)** — shared fixtures, reports, and + the Kotlin runner contract. - **[FlexTrackClient and DI](flex-track-client.md)** — injectable client, Riverpod and Bloc examples, tests without the global singleton. diff --git a/docs/assets/banner.png b/doc/assets/banner.png similarity index 100% rename from docs/assets/banner.png rename to doc/assets/banner.png diff --git a/docs/assets/ft_logo.png b/doc/assets/ft_logo.png similarity index 100% rename from docs/assets/ft_logo.png rename to doc/assets/ft_logo.png diff --git a/docs/assets/inspector.gif b/doc/assets/inspector.gif similarity index 100% rename from docs/assets/inspector.gif rename to doc/assets/inspector.gif diff --git a/doc/conformance.md b/doc/conformance.md new file mode 100644 index 0000000..8a8449d --- /dev/null +++ b/doc/conformance.md @@ -0,0 +1,65 @@ +# Cross-SDK conformance + +The files in +[`test/fixtures/conformance/`](https://github.com/alirezat66/flex_track/tree/main/test/fixtures/conformance) +are +the shared executable contract for Flutter and Kotlin Core MVP implementations. + +## Version 1.0.0 files + +- `core_mvp.schema.json` defines the fixture envelope. +- `core_mvp_cases.json` contains deterministic inputs and expected outputs. +- `flutter_report.json` is the machine-readable Flutter conformance report. +- `sampling_vectors.json` contains the complete Unicode FNV-1a vectors used by + both SDKs. + +Fixture case IDs are stable within a fixture major version. Adding a +backward-compatible case increments the fixture minor version. Changing an +existing input or expected result increments the fixture major version. + +## Kotlin runner contract + +The Android repository MUST copy or consume the fixture files without rewriting +their values. Its runner MUST: + +1. Reject an unsupported `specVersion` or fixture major version. +2. Validate the fixture envelope against `core_mvp.schema.json`. +3. Execute every case according to its `behavior` value. +4. Compare ordered arrays exactly; tracker ordering is observable. +5. Use UTF-8 and unsigned 32-bit FNV-1a for sampling cases. +6. Avoid wall-clock time, random identifiers, network calls, and Android device + state while evaluating fixtures. +7. Emit a JSON report with `specVersion`, `fixtureVersion`, `implementation`, + `total`, `passed`, `failed`, and ordered `caseIds`. +8. Exit unsuccessfully when schema validation or any case fails. + +Example Kotlin report: + +```json +{ + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "implementation": "kotlin", + "total": 8, + "passed": 8, + "failed": 0, + "caseIds": ["routing.priority-overlap"] +} +``` + +The abbreviated `caseIds` above is illustrative; a real passing report MUST +contain every fixture ID in fixture order. + +## Covered behavior + +The MVP suite covers priority overlap, same-tier merging, fallback, missing +general consent, missing PII consent, Unicode sampling, enrichment identity and +property precedence, and the debug dispatch decision. Later capabilities such +as offline queues and retry are intentionally excluded until their contracts +are versioned. + +Run the Flutter suite with: + +```bash +flutter test test/contract +``` diff --git a/doc/core-mvp-specification.md b/doc/core-mvp-specification.md new file mode 100644 index 0000000..fb509b5 --- /dev/null +++ b/doc/core-mvp-specification.md @@ -0,0 +1,218 @@ +# FlexTrack Core MVP Specification + +Status: Normative +Specification version: 1.0.0 +Target SDKs: Flutter 2.1.x and Kotlin 1.0.x + +## 1. Purpose and terminology + +This is the language-neutral contract for a conforming FlexTrack Core. It +defines observable behavior, not internal class structure. **MUST**, **MUST +NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are normative requirements. A +tracker is an adapter for one analytics destination; a dispatch is one attempt +to deliver one processed event to one tracker. + +Example: Kotlin MAY use data classes and Flutter MAY use abstract classes, but +both MUST make the same routing decision from the same inputs. + +## 2. MVP boundary + +Core MVP includes events and enrichment, tracker lifecycle, routing, consent, +PII gates, deterministic sampling, decision records, setup, track, flush, +tracker reset, and disposal. + +Durable/offline queues, persistence, retries/backoff, session management, +SDK-owned identity, and optimized batching are later capabilities. They MUST +NOT be required for Core MVP conformance. An adapter MAY implement them +privately, but Core 1.0 does not promise their semantics. + +Example: a tracker MAY buffer internally, but Core need not restore its buffer +after process death. + +## 3. Event model + +An event MUST expose: + +| Field | Type | Default or requirement | +|---|---|---| +| `eventId` | non-empty string | UUID v4 for a new occurrence | +| `name` | string | supplied by the event | +| `properties` | string-keyed object or null | null | +| `category` | string or null | null | +| `preferredGroup` | group or null | null | +| `containsPII` | boolean | false | +| `requiresConsent` | boolean | true | +| `isHighVolume` | boolean | false | +| `isEssential` | boolean | false | +| `timestamp` | instant | creation time in UTC | +| `userId` | string or null | null | +| `sessionId` | string or null | null | + +`eventId` and `timestamp` MUST be captured once and remain unchanged. +Reconstructed events MUST accept their original values. Generated IDs MUST be +RFC 4122 UUID v4 values. Serialized timestamps MUST be ISO 8601 with an +explicit UTC offset. Core MUST preserve property values without implicit string +conversion. + +Example: + +```json +{ + "eventId": "123e4567-e89b-42d3-a456-426614174000", + "name": "purchase", + "properties": {"amount": 29.99, "currency": "EUR"}, + "category": "business", + "containsPII": false, + "requiresConsent": true, + "isHighVolume": false, + "isEssential": false, + "timestamp": "2026-08-17T12:30:00.000Z", + "userId": null, + "sessionId": null +} +``` + +## 4. Enrichment + +Transformers MUST run in registration order before routing. Each output MUST be +the next input. Added properties MUST win on duplicate keys. Enrichment MUST +preserve ID, timestamp, name, category, group preference, privacy flags, +volume/essential flags, user ID, and session ID. Type matching MUST inspect the +original type through nested wrappers; other conditions MUST inspect the +transformed event. A transformer failure SHOULD be isolated and processing +SHOULD continue from the last valid event. + +Example: `{plan: free}` enriched with `{plan: pro, route: /pay}` becomes +`{plan: pro, route: /pay}` with the original ID and timestamp. + +## 5. Tracker interface and lifecycle + +Each tracker MUST have a unique non-empty stable ID, name, and enabled state. +Setup MUST reject an empty tracker list or duplicate IDs, then register and +initialize every tracker. Repeated client initialization MUST be idempotent. + +Core MUST call `track(event)` on every selected enabled tracker and record each +outcome independently. One failure MUST NOT prevent later tracker attempts. +Disabled selected trackers MUST produce failed tracker results. Unavailable IDs +MUST already have been removed during group resolution. + +`flush()` and tracker reset MUST delegate to all enabled registered trackers. +Disposal MUST flush enabled trackers when the client was initialized and release +client-owned debug resources. It does not guarantee durable delivery. + +Example: if `a` throws and `b` succeeds, Core still calls `b` and returns one +failed plus one successful result. + +## 6. Routing + +### 6.1 Conditions + +A rule matches only when every configured condition matches. MVP conditions are +original event type/subtype, name substring, name regex, category, property +presence and optional equality, PII, high-volume, essential, and environment. +An absent condition MUST NOT restrict matching. + +Example: category `business` plus `currency = EUR` matches a EUR purchase, not +a USD purchase or technical event. + +### 6.2 Priority tiers and merging + +Matching rules MUST be sorted by descending integer priority. Core MUST evaluate +until a tier produces targets. All successful rules at that priority MUST merge +tracker IDs as an ordered, de-duplicated set. Lower tiers MUST NOT run afterward. +A rule blocked by consent, sampled out, or resolving to no tracker does not +establish a winning tier. + +Example: priority-10 targets `[firebase]` and `[api, firebase]` merge to +`[firebase, api]`; priority 0 is ignored. If both are blocked, priority 0 runs. + +### 6.3 Groups and fallback + +A named group MUST resolve to configured IDs. `all` MUST resolve to every +available tracker. Unavailable IDs MUST be removed. An empty resolution MUST be +skipped with a warning. If no configured rule matches, Core MUST use a default +rule, otherwise an equivalent rule for `defaultGroup`; without either it MUST +return no targets. + +Example: `[firebase, missing]` with only `firebase` available resolves to +`[firebase]`. With no match and `defaultGroup = all`, all trackers are targeted. + +## 7. Consent and PII + +New clients MUST start with general and PII consent `false`. With consent +checking enabled, a non-essential rule MUST be skipped when its general consent, +the event's general consent, or its PII consent requirement is unmet. Essential +events MUST bypass both gates. Disabling configuration-level consent checking +MUST bypass all consent gates. Consent changes affect future processing only. + +Example: a purchase is rejected at startup, succeeds after general consent, +and a PII rule still waits for PII consent. An essential crash event is eligible +in every consent state. + +## 8. Deterministic sampling + +Essential events MUST bypass sampling. Rates `<= 0` MUST reject and rates `>= 1` +MUST accept. Otherwise choose the first non-empty `userId`, `sessionId`, then +`name`; hash its UTF-8 bytes with unsigned 32-bit FNV-1a; calculate +`bucket = hash / 4294967296`; accept exactly when `bucket < sampleRate`. +Implementations MUST pass +[`sampling_vectors.json`](https://github.com/alirezat66/flex_track/blob/main/test/fixtures/sampling_vectors.json). Locale +normalization and platform string hashes MUST NOT be used. + +Example: `hello` hashes to `1335831723`, bucket about `0.3110`; it is rejected +at 25% and accepted at 50%. + +## 9. Processing order + +`track(event)` MUST execute in this order: + +1. Stop unsuccessful with no targets when the processor is disabled. +2. Run transformers in registration order. +3. Match rules and sort them by descending priority. +4. Apply consent gates at each eligible tier. +5. Apply deterministic sampling. +6. Resolve groups against available trackers. +7. Merge successful rules in the first successful tier. +8. Attempt every selected tracker independently. +9. Return the processed event, routing decision, and tracker results. +10. In debug builds, emit one decision record after processing. + +Example: an enriched PII event gains `route=/profile`, matches a property rule, +then fails its PII gate. It causes no tracker call, while the result records the +enriched event and skipped rule. + +## 10. Results and debug decisions + +A result MUST contain the processed event, target IDs, applied rules, skipped +rules and reasons, warnings, per-tracker outcomes, and overall success. Success +MUST mean at least one delivery succeeded. `routed` MUST mean at least one target +was selected; `tracked` MUST mean at least one delivery succeeded. + +A tracker outcome MUST contain tracker ID, success, optional error, and attempt +timestamp. A debug decision MUST contain the processed event, selected IDs, and +successful IDs. Debug emission MAY be absent in release builds and MUST NOT +change delivery. + +Example: targets `[a, b]` with only `b` succeeding gives `routed=true`, +`tracked=true`, `successful=true`, successful IDs `[b]`, and one failure. + +## 11. Client operations + +Each client MUST own isolated trackers, routing, consent, and transformers. +Sequential helpers MAY process in input order; parallel helpers MAY deliver +concurrently but MUST return results in input order. Optimized batching remains +outside MVP. Enablement and consent changes affect future processing. Global +facades MAY exist but MUST delegate without changing client semantics. + +Example: a transformer added to client A MUST NOT affect client B. + +## 12. Versioning and compatibility + +The specification uses semantic versioning independently of SDK versions. +Patches clarify wording without behavior changes. Minors add backward-compatible +optional behavior. Majors may change required fields, evaluation order, or +decisions. Every SDK release MUST state its implemented spec version. SDKs on +the same spec major SHOULD interoperate on shared event and vector formats. + +Example: Flutter 2.1.2 and Kotlin 1.0.1 can both implement Core Spec 1.0.0. An +optional debug field can enter 1.1.0; changed priority semantics require 2.0.0. diff --git a/docs/flex-track-client.md b/doc/flex-track-client.md similarity index 100% rename from docs/flex-track-client.md rename to doc/flex-track-client.md diff --git a/docs/privacy-performance-debugging.md b/doc/privacy-performance-debugging.md similarity index 100% rename from docs/privacy-performance-debugging.md rename to doc/privacy-performance-debugging.md diff --git a/docs/routing-and-rules.md b/doc/routing-and-rules.md similarity index 100% rename from docs/routing-and-rules.md rename to doc/routing-and-rules.md diff --git a/docs/testing-and-troubleshooting.md b/doc/testing-and-troubleshooting.md similarity index 100% rename from docs/testing-and-troubleshooting.md rename to doc/testing-and-troubleshooting.md diff --git a/docs/trackers.md b/doc/trackers.md similarity index 100% rename from docs/trackers.md rename to doc/trackers.md diff --git a/docs/widgets.md b/doc/widgets.md similarity index 100% rename from docs/widgets.md rename to doc/widgets.md diff --git a/example/lib/events/app_events.dart b/example/lib/events/app_events.dart index ebaf130..3a1615d 100644 --- a/example/lib/events/app_events.dart +++ b/example/lib/events/app_events.dart @@ -39,14 +39,12 @@ class AppStartEvent extends BaseEvent { class PageViewEvent extends BaseEvent { final String pageName; final Map? parameters; - @override - final DateTime timestamp; PageViewEvent({ required this.pageName, this.parameters, - DateTime? timestamp, - }) : timestamp = timestamp ?? DateTime.now(); + super.timestamp, + }); @override String get name => 'page_view'; diff --git a/lib/flex_track.dart b/lib/flex_track.dart index f1ba26d..59edf8c 100644 --- a/lib/flex_track.dart +++ b/lib/flex_track.dart @@ -27,7 +27,7 @@ /// Use [FlexTrackClient.create] when you want a dedicated instance instead of /// the global [FlexTrack.setup] singleton. Wrap subtrees with [FlexTrackScope] /// so [FlexClickTrack] and related widgets use that client automatically. -/// See `docs/flex-track-client.md`. +/// See `doc/flex-track-client.md`. /// /// ## Advanced Setup /// @@ -143,7 +143,7 @@ export 'src/core/flex_track.dart' show FlexTrack; // ============= VERSION INFO ============= /// FlexTrack package version -const String flexTrackVersion = '1.0.0'; +const String flexTrackVersion = '2.1.0'; /// FlexTrack package description const String flexTrackDescription = diff --git a/lib/src/core/event_processor.dart b/lib/src/core/event_processor.dart index 4c58fa9..58191fb 100644 --- a/lib/src/core/event_processor.dart +++ b/lib/src/core/event_processor.dart @@ -12,8 +12,8 @@ class EventProcessor { final RoutingEngine _routingEngine; final List _transformers = []; - bool _hasGeneralConsent = true; - bool _hasPIIConsent = true; + bool _hasGeneralConsent = false; + bool _hasPIIConsent = false; bool _isEnabled = true; EventProcessor({ diff --git a/lib/src/models/event/base_event.dart b/lib/src/models/event/base_event.dart index f1cc6f5..977608a 100644 --- a/lib/src/models/event/base_event.dart +++ b/lib/src/models/event/base_event.dart @@ -1,7 +1,24 @@ +import 'dart:math'; + import 'package:flex_track/src/models/routing/event_category.dart'; import 'package:flex_track/src/models/routing/tracker_group.dart'; abstract class BaseEvent { + BaseEvent({String? eventId, DateTime? timestamp}) + : eventId = _resolveEventId(eventId), + timestamp = timestamp ?? DateTime.now().toUtc(); + + /// Stable identifier for this event occurrence. + /// + /// Supply an existing id when reconstructing an event for retry or replay. + /// Otherwise FlexTrack generates an RFC 4122 version 4 UUID. + final String eventId; + + /// Immutable time at which this event occurrence was created. + /// + /// Supply the original value when reconstructing historical events. + final DateTime timestamp; + /// Returns the name of the event. String get name; @@ -32,10 +49,6 @@ abstract class BaseEvent { /// Essential events may bypass consent requirements and sampling bool get isEssential => false; - /// Timestamp when the event was created - /// Defaults to current time, but can be overridden for historical events - DateTime get timestamp => DateTime.now(); - /// Optional user ID associated with this event /// Used for user-specific routing and privacy compliance String? get userId => null; @@ -47,6 +60,7 @@ abstract class BaseEvent { /// Useful for debugging and serialization Map toMap() { return { + 'eventId': eventId, 'name': name, 'properties': properties, 'category': category?.name, @@ -66,3 +80,26 @@ abstract class BaseEvent { return 'Event($name${category != null ? ', category: ${category!.name}' : ''})'; } } + +final Random _eventIdRandom = Random.secure(); + +String _resolveEventId(String? eventId) { + if (eventId != null) { + if (eventId.isEmpty) { + throw ArgumentError.value(eventId, 'eventId', 'Cannot be empty'); + } + return eventId; + } + + final bytes = List.generate(16, (_) => _eventIdRandom.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')); + final value = hex.join(); + + return '${value.substring(0, 8)}-' + '${value.substring(8, 12)}-' + '${value.substring(12, 16)}-' + '${value.substring(16, 20)}-' + '${value.substring(20)}'; +} diff --git a/lib/src/models/event/enriched_event.dart b/lib/src/models/event/enriched_event.dart index baa5519..935f45e 100644 --- a/lib/src/models/event/enriched_event.dart +++ b/lib/src/models/event/enriched_event.dart @@ -25,7 +25,8 @@ class EnrichedEvent extends BaseEvent { EnrichedEvent(BaseEvent original, Map extraProperties) : _original = original, - _extraProperties = Map.unmodifiable(extraProperties); + _extraProperties = Map.unmodifiable(extraProperties), + super(eventId: original.eventId, timestamp: original.timestamp); /// The original unwrapped event. BaseEvent get original => _original; @@ -60,9 +61,6 @@ class EnrichedEvent extends BaseEvent { @override bool get isEssential => _original.isEssential; - @override - DateTime get timestamp => _original.timestamp; - @override String? get userId => _original.userId; diff --git a/lib/src/models/routing/routing_config.dart b/lib/src/models/routing/routing_config.dart index 154b37a..2ed15ab 100644 --- a/lib/src/models/routing/routing_config.dart +++ b/lib/src/models/routing/routing_config.dart @@ -4,6 +4,7 @@ import 'package:flex_track/src/models/event/base_event.dart'; import 'routing_rule.dart'; import 'tracker_group.dart'; import 'event_category.dart'; +import '../../utils/sampling_utils.dart'; /// Complete routing configuration that contains all rules and settings class RoutingConfiguration { @@ -14,6 +15,7 @@ class RoutingConfiguration { final bool enableSampling; final bool enableConsentChecking; final bool isDebugMode; + final EventSampler sampler; const RoutingConfiguration({ required this.rules, @@ -23,6 +25,7 @@ class RoutingConfiguration { this.enableSampling = true, this.enableConsentChecking = true, this.isDebugMode = false, + this.sampler = const DeterministicEventSampler(), }); /// Creates an empty routing configuration @@ -120,13 +123,15 @@ class RoutingConfiguration { } // Check consent requirements - if (!rule.shouldApply(event, - hasGeneralConsent: hasGeneralConsent, hasPIIConsent: hasPIIConsent)) { + if (enableConsentChecking && + !rule.shouldApply(event, + hasGeneralConsent: hasGeneralConsent, + hasPIIConsent: hasPIIConsent)) { continue; } // Check sampling - if (enableSampling && !rule.shouldSample()) { + if (enableSampling && !rule.shouldSample(event, sampler: sampler)) { continue; } @@ -173,6 +178,7 @@ class RoutingConfiguration { bool? enableSampling, bool? enableConsentChecking, bool? isDebugMode, + EventSampler? sampler, }) { return RoutingConfiguration( rules: rules ?? this.rules, @@ -183,6 +189,7 @@ class RoutingConfiguration { enableConsentChecking: enableConsentChecking ?? this.enableConsentChecking, isDebugMode: isDebugMode ?? this.isDebugMode, + sampler: sampler ?? this.sampler, ); } @@ -251,6 +258,7 @@ class RoutingConfiguration { 'enableSampling': enableSampling, 'enableConsentChecking': enableConsentChecking, 'isDebugMode': isDebugMode, + 'sampler': sampler.runtimeType.toString(), 'rulesCount': rules.length, 'customGroupsCount': customGroups.length, 'customCategoriesCount': customCategories.length, diff --git a/lib/src/models/routing/routing_rule.dart b/lib/src/models/routing/routing_rule.dart index 8e4bfa1..9ebd332 100644 --- a/lib/src/models/routing/routing_rule.dart +++ b/lib/src/models/routing/routing_rule.dart @@ -1,12 +1,17 @@ import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/event/enriched_event.dart'; +import 'package:flex_track/src/utils/sampling_utils.dart'; import 'event_category.dart'; import 'tracker_group.dart'; +typedef EventTypeMatcher = bool Function(BaseEvent event); + /// Represents a routing rule that determines where events should be sent class RoutingRule { final String? id; final Type? eventType; + final EventTypeMatcher? eventTypeMatcher; final String? eventNamePattern; final RegExp? eventNameRegex; final EventCategory? category; @@ -28,6 +33,7 @@ class RoutingRule { const RoutingRule({ this.id, this.eventType, + this.eventTypeMatcher, this.eventNamePattern, this.eventNameRegex, this.category, @@ -55,7 +61,7 @@ class RoutingRule { if (productionOnly && isDebugMode) return false; // Check event type - if (eventType != null && event.runtimeType != eventType) { + if (!matchesEventType(event)) { return false; } @@ -105,6 +111,23 @@ class RoutingRule { return true; } + /// Whether [event] satisfies this rule's type condition. + /// + /// Routing builders provide a subtype-aware matcher for `route()`. The + /// event is unwrapped only for this type check so transformed properties and + /// metadata continue to participate in the remaining rule conditions. + bool matchesEventType(BaseEvent event) { + if (eventType == null) return true; + + BaseEvent routingEvent = event; + while (routingEvent is EnrichedEvent) { + routingEvent = routingEvent.original; + } + + return eventTypeMatcher?.call(routingEvent) ?? + routingEvent.runtimeType == eventType; + } + /// Returns true if this rule should be applied based on consent bool shouldApply( BaseEvent event, { @@ -127,18 +150,18 @@ class RoutingRule { } /// Returns true if this rule should be sampled for the given event - bool shouldSample() { - if (sampleRate >= 1.0) return true; - if (sampleRate <= 0.0) return false; - - // Use a simple random sampling - return (DateTime.now().millisecondsSinceEpoch % 1000) / 1000.0 < sampleRate; + bool shouldSample( + BaseEvent event, { + EventSampler sampler = const DeterministicEventSampler(), + }) { + return sampler.shouldSample(event, sampleRate); } /// Creates a copy of this rule with updated properties RoutingRule copyWith({ String? id, Type? eventType, + EventTypeMatcher? eventTypeMatcher, String? eventNamePattern, RegExp? eventNameRegex, EventCategory? category, @@ -160,6 +183,7 @@ class RoutingRule { return RoutingRule( id: id ?? this.id, eventType: eventType ?? this.eventType, + eventTypeMatcher: eventTypeMatcher ?? this.eventTypeMatcher, eventNamePattern: eventNamePattern ?? this.eventNamePattern, eventNameRegex: eventNameRegex ?? this.eventNameRegex, category: category ?? this.category, diff --git a/lib/src/routing/route_config_builder.dart b/lib/src/routing/route_config_builder.dart index 445a87c..9fd7f55 100644 --- a/lib/src/routing/route_config_builder.dart +++ b/lib/src/routing/route_config_builder.dart @@ -98,5 +98,6 @@ class RouteConfigBuilder { // ========== GETTERS FOR RULE BUILDER ========== Type? get eventType => _eventType; + bool matchesEventType(BaseEvent event) => event is T; RoutingBuilder get parent => _parent; } diff --git a/lib/src/routing/routing_engine.dart b/lib/src/routing/routing_engine.dart index 2f4d3c3..7c06df8 100644 --- a/lib/src/routing/routing_engine.dart +++ b/lib/src/routing/routing_engine.dart @@ -53,9 +53,10 @@ class RoutingEngine { } // Check if rule should be applied based on consent - if (!rule.shouldApply(event, - hasGeneralConsent: hasGeneralConsent, - hasPIIConsent: hasPIIConsent)) { + if (_configuration.enableConsentChecking && + !rule.shouldApply(event, + hasGeneralConsent: hasGeneralConsent, + hasPIIConsent: hasPIIConsent)) { skippedRules.add(SkippedRule( rule: rule, reason: 'Consent requirements not met', @@ -64,7 +65,8 @@ class RoutingEngine { } // Check sampling - if (_configuration.enableSampling && !rule.shouldSample()) { + if (_configuration.enableSampling && + !rule.shouldSample(event, sampler: _configuration.sampler)) { skippedRules.add(SkippedRule( rule: rule, reason: @@ -181,7 +183,7 @@ class RoutingEngine { String _getRuleNonMatchReason(RoutingRule rule, BaseEvent event) { final reasons = []; - if (rule.eventType != null && event.runtimeType != rule.eventType) { + if (!rule.matchesEventType(event)) { reasons.add( 'Event type mismatch: expected ${rule.eventType}, got ${event.runtimeType}'); } diff --git a/lib/src/routing/routing_rule_builder.dart b/lib/src/routing/routing_rule_builder.dart index 697fcb2..89e24a5 100644 --- a/lib/src/routing/routing_rule_builder.dart +++ b/lib/src/routing/routing_rule_builder.dart @@ -163,6 +163,8 @@ class RoutingRuleBuilder { final rule = RoutingRule( id: _id, eventType: _config.eventType, + eventTypeMatcher: + _config.eventType == null ? null : _config.matchesEventType, eventNamePattern: _config.eventNamePattern, eventNameRegex: _config.eventNameRegex, category: _config.category, diff --git a/lib/src/utils/sampling_utils.dart b/lib/src/utils/sampling_utils.dart index c14695c..3c54f43 100644 --- a/lib/src/utils/sampling_utils.dart +++ b/lib/src/utils/sampling_utils.dart @@ -1,5 +1,29 @@ +import 'dart:convert'; import 'dart:math' as math; +import '../models/event/base_event.dart'; + +/// Decides whether an event is retained for a routing rule's sample rate. +abstract interface class EventSampler { + bool shouldSample(BaseEvent event, double sampleRate); +} + +/// Cross-platform deterministic sampler using FNV-1a over UTF-8 bytes. +class DeterministicEventSampler implements EventSampler { + const DeterministicEventSampler(); + + @override + bool shouldSample(BaseEvent event, double sampleRate) { + if (event.isEssential) return true; + if (sampleRate >= 1.0) return true; + if (sampleRate <= 0.0) return false; + return SamplingUtils.shouldSampleDeterministic( + SamplingUtils.samplingKey(event), + sampleRate, + ); + } +} + /// Utility class for event sampling operations class SamplingUtils { static final math.Random _random = math.Random(); @@ -19,13 +43,38 @@ class SamplingUtils { if (sampleRate >= 1.0) return true; if (sampleRate <= 0.0) return false; - // Use hash of input for deterministic sampling - final hash = input.hashCode.abs(); - final normalizedHash = (hash % 10000) / 10000.0; + final normalizedHash = stableHash(input) / 0x100000000; return normalizedHash < sampleRate; } + /// Stable FNV-1a 32-bit hash over the UTF-8 bytes of [input]. + /// + /// Unlike Dart's [String.hashCode], this algorithm is explicitly defined + /// and can produce identical sampling decisions on every SDK platform. + static int stableHash(String input) { + var hash = 0x811c9dc5; + for (final byte in utf8.encode(input)) { + hash ^= byte; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash; + } + + /// Stable identity used by the default routing sampler. + /// + /// Empty identity values are ignored. Event name is the deterministic + /// fallback until an application supplies a user or session identity. + static String samplingKey(BaseEvent event) { + final userId = event.userId; + if (userId != null && userId.isNotEmpty) return userId; + + final sessionId = event.sessionId; + if (sessionId != null && sessionId.isNotEmpty) return sessionId; + + return event.name; + } + /// Check if an event should be sampled based on user ID /// Ensures consistent sampling per user static bool shouldSampleByUserId(String? userId, double sampleRate) { @@ -112,7 +161,7 @@ class SamplingUtils { static int getSamplingBucket(String identifier, int bucketCount) { if (bucketCount <= 0) return 0; - final hash = identifier.hashCode.abs(); + final hash = stableHash(identifier); return hash % bucketCount; } diff --git a/pubspec.yaml b/pubspec.yaml index 3bcdf39..417c9ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flex_track description: A flexible analytics tracking system for Flutter with intelligent routing, GDPR compliance, and multi-platform support -version: 2.0.0 +version: 2.1.0 homepage: https://flextrack.taghizadeh.dev/ repository: https://github.com/alirezat66/flex_track issue_tracker: https://github.com/alirezat66/flex_track/issues @@ -15,9 +15,9 @@ topics: screenshots: - description: FlexTrack brand logo - path: docs/assets/ft_logo.png + path: doc/assets/ft_logo.png - description: FlexTrack banner - path: docs/assets/banner.png + path: doc/assets/banner.png environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/contract/core_mvp_conformance_test.dart b/test/contract/core_mvp_conformance_test.dart new file mode 100644 index 0000000..5ac8e75 --- /dev/null +++ b/test/contract/core_mvp_conformance_test.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _fixturePath = 'test/fixtures/conformance/core_mvp_cases.json'; +const _schemaPath = 'test/fixtures/conformance/core_mvp.schema.json'; +const _reportPath = 'test/fixtures/conformance/flutter_report.json'; + +void main() { + final fixture = _readObject(_fixturePath); + final cases = (fixture['cases'] as List).cast>(); + + test('fixture document satisfies the shared schema contract', () { + final schema = _readObject(_schemaPath); + expect(schema[r'$schema'], 'https://json-schema.org/draft/2020-12/schema'); + expect(fixture[r'$schema'], 'core_mvp.schema.json'); + expect(fixture['specVersion'], '1.0.0'); + expect(fixture['fixtureVersion'], matches(r'^1\.[0-9]+\.[0-9]+$')); + expect(cases, isNotEmpty); + + final ids = {}; + for (final fixtureCase in cases) { + expect(fixtureCase.keys.toSet(), + equals({'id', 'behavior', 'input', 'expected'})); + expect(fixtureCase['id'], isA()); + expect((fixtureCase['id'] as String), isNotEmpty); + expect(ids.add(fixtureCase['id'] as String), isTrue, + reason: 'Fixture IDs must be unique'); + expect( + ['routing', 'consent', 'sampling', 'enrichment', 'debug'], + contains(fixtureCase['behavior']), + ); + expect(fixtureCase['input'], isA>()); + expect(fixtureCase['expected'], isA>()); + } + }); + + for (final fixtureCase in cases) { + test('conformance: ${fixtureCase['id']}', () async { + expect( + await _runCase(fixtureCase), + fixtureCase['expected'], + reason: fixtureCase['id'] as String, + ); + }); + } + + test('machine-readable Flutter report covers every passing case', () { + final report = _readObject(_reportPath); + final caseIds = cases.map((value) => value['id']).toList(); + + expect(report['specVersion'], fixture['specVersion']); + expect(report['fixtureVersion'], fixture['fixtureVersion']); + expect(report['implementation'], 'flutter'); + expect(report['total'], cases.length); + expect(report['passed'], cases.length); + expect(report['failed'], 0); + expect(report['caseIds'], caseIds); + }); +} + +Future> _runCase(Map fixtureCase) async { + final input = fixtureCase['input'] as Map; + switch (fixtureCase['behavior']) { + case 'routing': + return _runRouting(input); + case 'consent': + return _runConsent(input); + case 'sampling': + final identity = input['identity'] as String; + final rate = (input['sampleRate'] as num).toDouble(); + return { + 'hash': SamplingUtils.stableHash(identity), + 'accepted': SamplingUtils.shouldSampleDeterministic(identity, rate), + }; + case 'enrichment': + final original = _FixtureEvent.fromJson(input); + final enriched = EnrichedEvent( + original, + _objectProperties(input['extraProperties']), + ); + return { + 'eventId': enriched.eventId, + 'timestamp': enriched.timestamp.toIso8601String(), + 'name': enriched.name, + 'properties': enriched.properties, + }; + case 'debug': + final setup = _routingSetup(input); + final tracker = MockTracker(id: 'analytics', name: 'Analytics'); + final client = await FlexTrackClient.create( + [tracker], + routing: setup.engine.configuration, + ); + client.setGeneralConsent(true); + final recordFuture = client.eventDispatchStream.first; + await client.track(setup.event); + final record = await recordFuture; + await client.dispose(); + return { + 'targetTrackers': record.targetTrackers, + 'successfulTrackerIds': record.successfulTrackerIds, + }; + default: + throw StateError('Unsupported behavior: ${fixtureCase['behavior']}'); + } +} + +Map _runRouting(Map input) { + final setup = _routingSetup(input); + final result = setup.engine.routeEvent( + setup.event, + availableTrackers: setup.availableTrackers, + ); + return { + 'targets': result.targetTrackers, + 'appliedPriorities': + result.appliedRules.map((rule) => rule.priority).toList(), + }; +} + +Map _runConsent(Map input) { + final event = _FixtureEvent.fromJson(input['event'] as Map); + final rule = _ruleFromJson(input['rule'] as Map); + final result = RoutingEngine(RoutingConfiguration(rules: [rule])).routeEvent( + event, + hasGeneralConsent: input['generalConsent'] as bool, + hasPIIConsent: input['piiConsent'] as bool, + availableTrackers: {'analytics'}, + ); + return { + 'targets': result.targetTrackers, + 'skipReasons': result.skippedRules.map((value) => value.reason).toList(), + }; +} + +_RoutingSetup _routingSetup(Map input) { + final event = _FixtureEvent.fromJson(input['event'] as Map); + final rules = (input['rules'] as List) + .cast>() + .map(_ruleFromJson) + .toList(); + final defaultIds = (input['defaultGroup'] as List?)?.cast(); + final configuration = RoutingConfiguration( + rules: rules, + defaultGroup: + defaultIds == null ? null : TrackerGroup('fixture-default', defaultIds), + ); + return _RoutingSetup( + event, + RoutingEngine(configuration), + (input['availableTrackers'] as List).cast().toSet(), + ); +} + +RoutingRule _ruleFromJson(Map json) { + final targets = (json['targets'] as List).cast(); + return RoutingRule( + eventNamePattern: json['nameContains'] as String?, + category: _category(json['category'] as String?), + isDefault: json['default'] as bool? ?? false, + targetGroup: TrackerGroup('fixture', targets), + requireConsent: json['requireConsent'] as bool? ?? false, + requirePIIConsent: json['requirePIIConsent'] as bool? ?? false, + priority: json['priority'] as int? ?? 0, + ); +} + +EventCategory? _category(String? value) => + value == null ? null : EventCategory(value); + +Map _objectProperties(Object? value) => + (value as Map).cast(); + +Map _readObject(String path) => + (jsonDecode(File(path).readAsStringSync()) as Map).cast(); + +class _RoutingSetup { + const _RoutingSetup(this.event, this.engine, this.availableTrackers); + + final BaseEvent event; + final RoutingEngine engine; + final Set availableTrackers; +} + +class _FixtureEvent extends BaseEvent { + _FixtureEvent({ + required this.eventName, + this.eventProperties, + this.eventCategory, + this.eventContainsPII = false, + this.eventRequiresConsent = true, + super.eventId, + super.timestamp, + }); + + factory _FixtureEvent.fromJson(Map json) => _FixtureEvent( + eventName: json['name'] as String, + eventProperties: json['properties'] == null + ? null + : _objectProperties(json['properties']), + eventCategory: _category(json['category'] as String?), + eventContainsPII: json['containsPII'] as bool? ?? false, + eventRequiresConsent: json['requiresConsent'] as bool? ?? true, + eventId: json['eventId'] as String?, + timestamp: json['timestamp'] == null + ? null + : DateTime.parse(json['timestamp'] as String), + ); + + final String eventName; + final Map? eventProperties; + final EventCategory? eventCategory; + final bool eventContainsPII; + final bool eventRequiresConsent; + + @override + String get name => eventName; + + @override + Map? get properties => eventProperties; + + @override + EventCategory? get category => eventCategory; + + @override + bool get containsPII => eventContainsPII; + + @override + bool get requiresConsent => eventRequiresConsent; +} diff --git a/test/contract/core_mvp_specification_test.dart b/test/contract/core_mvp_specification_test.dart new file mode 100644 index 0000000..f11d21b --- /dev/null +++ b/test/contract/core_mvp_specification_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('core MVP specification keeps required contract sections', () { + final specification = + File('doc/core-mvp-specification.md').readAsStringSync(); + + expect(specification, contains('Specification version: 1.0.0')); + expect(specification, contains('## 2. MVP boundary')); + expect(specification, contains('## 9. Processing order')); + expect(specification, contains('## 12. Versioning and compatibility')); + expect(specification, contains('**MUST**')); + expect(specification, contains('Durable/offline queues')); + expect(specification, contains('sampling_vectors.json')); + }); +} diff --git a/test/core/event_processor_test.dart b/test/core/event_processor_test.dart index 72aac43..e19e22a 100644 --- a/test/core/event_processor_test.dart +++ b/test/core/event_processor_test.dart @@ -28,6 +28,7 @@ void main() { trackerRegistry: trackerRegistry, routingEngine: routingEngine, ); + eventProcessor.setConsent(general: true, pii: true); }); group('Enable/Disable Functionality', () { diff --git a/test/core/event_processor_transformer_test.dart b/test/core/event_processor_transformer_test.dart index e579066..cad1dcc 100644 --- a/test/core/event_processor_transformer_test.dart +++ b/test/core/event_processor_transformer_test.dart @@ -27,6 +27,7 @@ void main() { trackerRegistry: trackerRegistry, routingEngine: routingEngine, ); + eventProcessor.setConsent(general: true, pii: true); }); test('single transformer enriches event reaching the tracker', () async { diff --git a/test/core/flex_track_client_test.dart b/test/core/flex_track_client_test.dart index daf64df..ee950e2 100644 --- a/test/core/flex_track_client_test.dart +++ b/test/core/flex_track_client_test.dart @@ -39,6 +39,7 @@ void main() { await client.initialize(); expect(client.isInitialized, isTrue); + client.setGeneralConsent(true); await client.track(_TestEvent()); expect(mock.capturedEvents, hasLength(1)); @@ -53,6 +54,7 @@ void main() { final client = await FlexTrackClient.create([mock]); await client.initialize(); await client.initialize(); + client.setGeneralConsent(true); await client.track(_TestEvent()); expect(mock.capturedEvents, hasLength(1)); await client.dispose(); @@ -103,6 +105,7 @@ void main() { final mock = MockTracker(); await FlexTrack.setup([mock]); expect(FlexTrack.instance.client.trackerRegistry.get(mock.id), mock); + FlexTrack.setGeneralConsent(true); await FlexTrack.track(_TestEvent()); expect(mock.capturedEvents, hasLength(1)); await FlexTrack.reset(); @@ -111,6 +114,46 @@ void main() { }); group('consent and processor control', () { + test('new clients deny general and PII consent by default', () async { + final client = await FlexTrackClient.create([MockTracker()]); + + expect(client.getConsentStatus(), { + 'general': false, + 'pii': false, + }); + + await client.dispose(); + }); + + test('ordinary events remain blocked until consent is granted', () async { + final mock = MockTracker(); + final client = await FlexTrackClient.create([mock]); + + expect((await client.track(_TestEvent())).wasTracked, isFalse); + client.setGeneralConsent(true); + expect((await client.track(_TestEvent())).wasTracked, isTrue); + + await client.dispose(); + }); + + test('essential events bypass the default-deny consent state', () async { + final mock = MockTracker(); + final client = await FlexTrackClient.create([mock]); + + expect((await client.track(_EssentialTestEvent())).wasTracked, isTrue); + + await client.dispose(); + }); + + test('disabled consent checking bypasses the consent gate', () async { + final mock = MockTracker(); + final client = await _clientWithRelaxedRouting([mock]); + + expect((await client.track(_TestEvent())).wasTracked, isTrue); + + await client.dispose(); + }); + test( 'events that require consent are not delivered when general consent is denied', () async { @@ -397,6 +440,11 @@ class _NamedTestEvent extends BaseEvent { Map? get properties => const {}; } +class _EssentialTestEvent extends _TestEvent { + @override + bool get isEssential => true; +} + class _BrokenInitTracker extends NoOpTracker { _BrokenInitTracker() : super(id: 'broken', name: 'Broken init'); diff --git a/test/core/flex_track_client_transformer_test.dart b/test/core/flex_track_client_transformer_test.dart index 955b310..3a41fbf 100644 --- a/test/core/flex_track_client_transformer_test.dart +++ b/test/core/flex_track_client_transformer_test.dart @@ -8,6 +8,7 @@ Future<(FlexTrackClient, MockTracker)> _makeClient() async { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); diff --git a/test/core/flex_track_facade_test.dart b/test/core/flex_track_facade_test.dart index adc68f1..3e375f6 100644 --- a/test/core/flex_track_facade_test.dart +++ b/test/core/flex_track_facade_test.dart @@ -77,4 +77,10 @@ class _FacadeTestEvent extends BaseEvent { @override Map? get properties => const {}; + + @override + bool get requiresConsent => false; + + @override + bool get isEssential => true; } diff --git a/test/core/flex_track_test.dart b/test/core/flex_track_test.dart index 9ea0594..51313c6 100644 --- a/test/core/flex_track_test.dart +++ b/test/core/flex_track_test.dart @@ -12,6 +12,12 @@ class TestEvent extends BaseEvent { @override Map get properties => {'test_property': testProperty}; + + @override + bool get requiresConsent => false; + + @override + bool get isEssential => true; } class PurchaseTestEvent extends BaseEvent { @@ -128,6 +134,7 @@ void main() { test('should track multiple events', () async { await FlexTrack.setup([mockTracker1]); + FlexTrack.setConsent(general: true); final events = [ TestEvent(testProperty: 'value1'), @@ -182,6 +189,8 @@ void main() { return builder; // Return the builder }); + FlexTrack.setConsent(general: true); + // Clear any existing events mockTracker1.clearCapturedData(); mockTracker2.clearCapturedData(); @@ -213,6 +222,8 @@ void main() { return builder; }); + FlexTrack.setConsent(general: true); + // Clear trackers mockTracker1.clearCapturedData(); mockTracker2.clearCapturedData(); @@ -260,7 +271,7 @@ void main() { FlexTrack.setConsent(general: false, pii: false); // Regular event requiring consent should be blocked - await FlexTrack.track(TestEvent(testProperty: 'blocked')); + await FlexTrack.track(DebugTestEvent()); expect(mockTracker1.capturedEvents, hasLength(0)); // Essential event should go through regardless @@ -440,6 +451,8 @@ void main() { return builder; }); + FlexTrack.setConsent(general: true); + final businessEvent = PurchaseTestEvent(amount: 100.0); final debugInfo = FlexTrack.debugEvent(businessEvent); @@ -506,7 +519,7 @@ void main() { // Track multiple events for (int i = 0; i < 10; i++) { - await FlexTrack.track(TestEvent(testProperty: 'sample_test_$i')); + await FlexTrack.track(DebugTestEvent()); } // With 0% sampling, no events should be tracked diff --git a/test/fixtures/conformance/core_mvp.schema.json b/test/fixtures/conformance/core_mvp.schema.json new file mode 100644 index 0000000..3e80214 --- /dev/null +++ b/test/fixtures/conformance/core_mvp.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flextrack.taghizadeh.dev/schemas/core-mvp-1.0.0.json", + "title": "FlexTrack Core MVP conformance fixtures", + "type": "object", + "required": ["specVersion", "fixtureVersion", "cases"], + "properties": { + "specVersion": {"const": "1.0.0"}, + "fixtureVersion": {"type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$"}, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "behavior", "input", "expected"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "behavior": { + "enum": ["routing", "consent", "sampling", "enrichment", "debug"] + }, + "input": {"type": "object"}, + "expected": {"type": "object"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/test/fixtures/conformance/core_mvp_cases.json b/test/fixtures/conformance/core_mvp_cases.json new file mode 100644 index 0000000..4e841a0 --- /dev/null +++ b/test/fixtures/conformance/core_mvp_cases.json @@ -0,0 +1,99 @@ +{ + "$schema": "core_mvp.schema.json", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "cases": [ + { + "id": "routing.priority-overlap", + "behavior": "routing", + "input": { + "event": {"name": "purchase", "category": "business"}, + "availableTrackers": ["analytics", "archive"], + "rules": [ + {"category": "business", "priority": 10, "targets": ["analytics"]}, + {"default": true, "priority": 0, "targets": ["archive"]} + ] + }, + "expected": {"targets": ["analytics"], "appliedPriorities": [10]} + }, + { + "id": "routing.same-tier-merge", + "behavior": "routing", + "input": { + "event": {"name": "purchase"}, + "availableTrackers": ["analytics", "archive"], + "rules": [ + {"nameContains": "purchase", "priority": 5, "targets": ["analytics"]}, + {"nameContains": "purchase", "priority": 5, "targets": ["archive", "analytics"]} + ] + }, + "expected": {"targets": ["analytics", "archive"], "appliedPriorities": [5, 5]} + }, + { + "id": "routing.default-group-fallback", + "behavior": "routing", + "input": { + "event": {"name": "unmatched"}, + "availableTrackers": ["archive"], + "defaultGroup": ["archive"], + "rules": [{"nameContains": "purchase", "priority": 5, "targets": ["archive"]}] + }, + "expected": {"targets": ["archive"], "appliedPriorities": [0]} + }, + { + "id": "consent.general-missing", + "behavior": "consent", + "input": { + "event": {"name": "view", "requiresConsent": true}, + "generalConsent": false, + "piiConsent": false, + "rule": {"requireConsent": true, "targets": ["analytics"]} + }, + "expected": {"targets": [], "skipReasons": ["Consent requirements not met"]} + }, + { + "id": "consent.pii-missing", + "behavior": "consent", + "input": { + "event": {"name": "profile", "containsPII": true, "requiresConsent": true}, + "generalConsent": true, + "piiConsent": false, + "rule": {"requireConsent": true, "requirePIIConsent": true, "targets": ["analytics"]} + }, + "expected": {"targets": [], "skipReasons": ["Consent requirements not met"]} + }, + { + "id": "sampling.unicode-utf8", + "behavior": "sampling", + "input": {"identity": "नमस्ते", "sampleRate": 0.25}, + "expected": {"hash": 538106393, "accepted": true} + }, + { + "id": "enrichment.identity-and-properties", + "behavior": "enrichment", + "input": { + "eventId": "fixture-event-1", + "timestamp": "2026-08-17T12:30:00.000Z", + "name": "purchase", + "properties": {"plan": "free"}, + "extraProperties": {"plan": "pro", "route": "/pay"} + }, + "expected": { + "eventId": "fixture-event-1", + "timestamp": "2026-08-17T12:30:00.000Z", + "name": "purchase", + "properties": {"plan": "pro", "route": "/pay"} + } + }, + { + "id": "debug.routing-decision", + "behavior": "debug", + "input": { + "event": {"name": "purchase"}, + "availableTrackers": ["analytics"], + "rules": [{"nameContains": "purchase", "priority": 7, "targets": ["analytics"]}] + }, + "expected": {"targetTrackers": ["analytics"], "successfulTrackerIds": ["analytics"]} + } + ] +} diff --git a/test/fixtures/conformance/flutter_report.json b/test/fixtures/conformance/flutter_report.json new file mode 100644 index 0000000..3fca9c3 --- /dev/null +++ b/test/fixtures/conformance/flutter_report.json @@ -0,0 +1,18 @@ +{ + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "implementation": "flutter", + "total": 8, + "passed": 8, + "failed": 0, + "caseIds": [ + "routing.priority-overlap", + "routing.same-tier-merge", + "routing.default-group-fallback", + "consent.general-missing", + "consent.pii-missing", + "sampling.unicode-utf8", + "enrichment.identity-and-properties", + "debug.routing-decision" + ] +} diff --git a/test/fixtures/sampling_vectors.json b/test/fixtures/sampling_vectors.json new file mode 100644 index 0000000..78c4fe5 --- /dev/null +++ b/test/fixtures/sampling_vectors.json @@ -0,0 +1,26 @@ +{ + "algorithm": "fnv1a-32-utf8", + "bucketDivisor": 4294967296, + "vectors": [ + {"input": "", "hash": 2166136261, "at25": false, "at50": false}, + {"input": "a", "hash": 3826002220, "at25": false, "at50": false}, + {"input": "hello", "hash": 1335831723, "at25": false, "at50": true}, + {"input": "purchase", "hash": 2513801058, "at25": false, "at50": false}, + {"input": "user-123", "hash": 2358496403, "at25": false, "at50": false}, + {"input": "session-α", "hash": 2520526275, "at25": false, "at50": false}, + {"input": "你好", "hash": 2257816995, "at25": false, "at50": false}, + {"input": "🙂", "hash": 1470331467, "at25": false, "at50": true}, + {"input": "résumé", "hash": 3068721788, "at25": false, "at50": false}, + {"input": "مرحبا", "hash": 2831450846, "at25": false, "at50": false}, + {"input": "नमस्ते", "hash": 538106393, "at25": true, "at50": true}, + {"input": "Straße", "hash": 499330616, "at25": true, "at50": true}, + {"input": "null\u0000byte", "hash": 1921921222, "at25": false, "at50": true}, + {"input": "line\nbreak", "hash": 527786666, "at25": true, "at50": true}, + {"input": "emoji-🚀", "hash": 2040893807, "at25": false, "at50": true}, + {"input": "UPPER_lower-123", "hash": 1815750080, "at25": false, "at50": true}, + {"input": "café", "hash": 2821410889, "at25": false, "at50": false}, + {"input": "mañana", "hash": 798077619, "at25": true, "at50": true}, + {"input": "東京", "hash": 1759422319, "at25": false, "at50": true}, + {"input": "한국어", "hash": 18907935, "at25": true, "at50": true} + ] +} diff --git a/test/models/event/base_event_test.dart b/test/models/event/base_event_test.dart index 71b33f8..93c6a09 100644 --- a/test/models/event/base_event_test.dart +++ b/test/models/event/base_event_test.dart @@ -18,6 +18,44 @@ void main() { // ignore: deprecated_member_use_from_same_package expect(event.properties, event.properties); }); + + test('captures one immutable occurrence timestamp', () async { + final before = DateTime.now().toUtc(); + final event = _SampleEvent(); + final first = event.timestamp; + await Future.delayed(const Duration(milliseconds: 2)); + + expect(event.timestamp, same(first)); + expect(first.isBefore(before), isFalse); + }); + + test('generates a unique UUID event id', () { + final ids = List.generate(100, (_) => _SampleEvent().eventId); + + expect(ids.toSet(), hasLength(ids.length)); + for (final id in ids) { + expect( + id, + matches( + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ), + ), + ); + } + }); + + test('accepts explicit identity and occurrence time', () { + final timestamp = DateTime.utc(2024, 1, 2, 3, 4, 5); + final event = _MetadataEvent( + eventId: 'event-for-replay', + timestamp: timestamp, + ); + + expect(event.eventId, 'event-for-replay'); + expect(event.timestamp, same(timestamp)); + expect(event.toMap(), containsPair('eventId', 'event-for-replay')); + }); }); } @@ -28,3 +66,13 @@ class _SampleEvent extends BaseEvent { @override Map? get properties => const {'key': 'value'}; } + +class _MetadataEvent extends BaseEvent { + _MetadataEvent({super.eventId, super.timestamp}); + + @override + String get name => 'metadata'; + + @override + Map? get properties => null; +} diff --git a/test/models/event/enriched_event_test.dart b/test/models/event/enriched_event_test.dart index 45daf9d..88790f5 100644 --- a/test/models/event/enriched_event_test.dart +++ b/test/models/event/enriched_event_test.dart @@ -70,13 +70,23 @@ void main() { }); test('forwards timestamp from original', () { - // Capture once — BaseEvent.timestamp calls DateTime.now() each time + // Capture once to verify the enriched event forwards the same value. final fixedTime = DateTime(2024, 1, 1); final fixedEvent = _FixedTimestampEvent(fixedTime); final enriched = EnrichedEvent(fixedEvent, {}); expect(enriched.timestamp, equals(fixedTime)); }); + test('preserves event id and timestamp through nested enrichment', () { + final first = EnrichedEvent(original, {'layer': 1}); + final second = EnrichedEvent(first, {'layer': 2}); + + expect(first.eventId, original.eventId); + expect(second.eventId, original.eventId); + expect(first.timestamp, same(original.timestamp)); + expect(second.timestamp, same(original.timestamp)); + }); + test('forwards userId from original', () { final enriched = EnrichedEvent(original, {}); expect(enriched.userId, original.userId); diff --git a/test/routing/presets/smart_defaults_test.dart b/test/routing/presets/smart_defaults_test.dart index 0a1eb18..78b8027 100644 --- a/test/routing/presets/smart_defaults_test.dart +++ b/test/routing/presets/smart_defaults_test.dart @@ -432,6 +432,8 @@ void main() { return builder; }); + FlexTrack.setConsent(general: true); + final technicalEvent = TestEvent('debug_test', EventCategory.technical); // In debug mode, technical events should go to development trackers @@ -602,6 +604,9 @@ class TestEvent extends BaseEvent { @override EventCategory? get category => eventCategory; + + @override + bool get requiresConsent => false; } class HighVolumeTestEvent extends BaseEvent { @@ -617,6 +622,9 @@ class HighVolumeTestEvent extends BaseEvent { @override bool get isHighVolume => true; + + @override + bool get isEssential => true; } class EssentialTestEvent extends BaseEvent { diff --git a/test/routing/routing_identity_test.dart b/test/routing/routing_identity_test.dart new file mode 100644 index 0000000..fdd0e93 --- /dev/null +++ b/test/routing/routing_identity_test.dart @@ -0,0 +1,98 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class PurchaseEvent extends BaseEvent { + @override + String get name => 'purchase'; + + @override + Map? get properties => null; +} + +class SubscriptionPurchaseEvent extends PurchaseEvent {} + +void main() { + group('routing identity', () { + late RoutingEngine engine; + + setUp(() { + final configuration = (RoutingBuilder() + ..route().to(['billing']).withPriority(10).and() + ..routeDefault().to(['other']).and()) + .build(); + + engine = RoutingEngine(configuration); + }); + + test('a type route matches subclasses', () { + final result = engine.routeEvent( + SubscriptionPurchaseEvent(), + availableTrackers: {'billing', 'other'}, + ); + + expect(result.targetTrackers, ['billing']); + }); + + test('an enriched event preserves its original type route', () { + final result = engine.routeEvent( + EnrichedEvent(PurchaseEvent(), {'app_version': '2.1.0'}), + availableTrackers: {'billing', 'other'}, + ); + + expect(result.targetTrackers, ['billing']); + }); + + test('nested enrichment preserves the deepest original type route', () { + final result = engine.routeEvent( + EnrichedEvent( + EnrichedEvent(SubscriptionPurchaseEvent(), {'session': 'one'}), + {'app_version': '2.1.0'}, + ), + availableTrackers: {'billing', 'other'}, + ); + + expect(result.targetTrackers, ['billing']); + }); + + test('transformed properties remain visible to property routes', () { + final propertyConfiguration = (RoutingBuilder() + ..routeWithProperty('app_version') + .to(['versioned']) + .withPriority(10) + .and() + ..routeDefault().to(['other']).and()) + .build(); + final propertyEngine = RoutingEngine(propertyConfiguration); + + final result = propertyEngine.routeEvent( + EnrichedEvent(PurchaseEvent(), {'app_version': '2.1.0'}), + availableTrackers: {'versioned', 'other'}, + ); + + expect(result.targetTrackers, ['versioned']); + }); + + test('debug output uses the same routing identity semantics', () { + final event = EnrichedEvent(SubscriptionPurchaseEvent(), const {}); + + final debug = engine.debugEvent( + event, + availableTrackers: {'billing', 'other'}, + ); + + expect(debug.routingResult.targetTrackers, ['billing']); + expect( + debug.matchingRules.where( + (rule) => rule.eventType == PurchaseEvent, + ), + hasLength(1), + ); + expect( + debug.nonMatchingRules.where( + (entry) => entry.rule.eventType == PurchaseEvent, + ), + isEmpty, + ); + }); + }); +} diff --git a/test/routing/sampling_correctness_test.dart b/test/routing/sampling_correctness_test.dart new file mode 100644 index 0000000..a8e950c --- /dev/null +++ b/test/routing/sampling_correctness_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class SamplingEvent extends BaseEvent { + SamplingEvent({this.eventUserId, this.eventSessionId}); + + final String? eventUserId; + final String? eventSessionId; + + @override + String get name => 'purchase'; + + @override + Map? get properties => null; + + @override + String? get userId => eventUserId; + + @override + String? get sessionId => eventSessionId; +} + +class RecordingSampler implements EventSampler { + BaseEvent? event; + double? rate; + + @override + bool shouldSample(BaseEvent event, double sampleRate) { + this.event = event; + rate = sampleRate; + return false; + } +} + +void main() { + group('cross-platform deterministic sampling', () { + final fixture = jsonDecode( + File('test/fixtures/sampling_vectors.json').readAsStringSync(), + ) as Map; + final vectors = fixture['vectors'] as List; + + test('uses the documented FNV-1a UTF-8 vectors', () { + for (final value in vectors.cast>()) { + final input = value['input'] as String; + + expect( + SamplingUtils.stableHash(input), + value['hash'], + reason: 'hash mismatch for ${jsonEncode(input)}', + ); + expect( + SamplingUtils.shouldSampleDeterministic(input, 0.25), + value['at25'], + reason: '25% decision mismatch for ${jsonEncode(input)}', + ); + expect( + SamplingUtils.shouldSampleDeterministic(input, 0.50), + value['at50'], + reason: '50% decision mismatch for ${jsonEncode(input)}', + ); + } + }); + + test('uses user, session, then event name as the stable key', () { + expect(SamplingUtils.samplingKey(SamplingEvent(eventUserId: 'user-1')), + 'user-1'); + expect( + SamplingUtils.samplingKey( + SamplingEvent(eventUserId: '', eventSessionId: 'session-1'), + ), + 'session-1', + ); + expect(SamplingUtils.samplingKey(SamplingEvent()), 'purchase'); + }); + + test('makes repeated routing decisions independent of wall-clock time', () { + final rule = RoutingRule( + targetGroup: TrackerGroup.all, + sampleRate: 0.5, + ); + final event = SamplingEvent(eventUserId: 'stable-user'); + + final decisions = List.generate(1000, (_) => rule.shouldSample(event)); + + expect(decisions.toSet(), hasLength(1)); + }); + + test('always keeps essential events and boundary rate one', () { + final essential = _EssentialSamplingEvent(); + + expect( + const RoutingRule( + targetGroup: TrackerGroup.all, + sampleRate: 0, + ).shouldSample(essential), + isTrue, + ); + expect( + const RoutingRule( + targetGroup: TrackerGroup.all, + sampleRate: 1, + ).shouldSample(SamplingEvent()), + isTrue, + ); + }); + + test('allows the sampler to be injected through routing configuration', () { + final sampler = RecordingSampler(); + final event = SamplingEvent(eventUserId: 'user-1'); + final configuration = RoutingConfiguration( + rules: const [ + RoutingRule(targetGroup: TrackerGroup.all, sampleRate: 0.5), + ], + sampler: sampler, + ); + + final result = RoutingEngine(configuration).routeEvent( + event, + availableTrackers: {'console'}, + ); + + expect(result.targetTrackers, isEmpty); + expect(sampler.event, same(event)); + expect(sampler.rate, 0.5); + }); + }); +} + +class _EssentialSamplingEvent extends SamplingEvent { + @override + bool get isEssential => true; +} diff --git a/test/test_utils/mock_events.dart b/test/test_utils/mock_events.dart index e6fbfeb..2be069a 100644 --- a/test/test_utils/mock_events.dart +++ b/test/test_utils/mock_events.dart @@ -15,7 +15,7 @@ class CustomEvent extends BaseEvent { EventCategory? category, bool containsPII = false, bool isHighVolume = false, - bool isEssential = false, + bool isEssential = true, }) : _properties = properties, _category = category, _containsPII = containsPII, @@ -28,7 +28,7 @@ class CustomEvent extends BaseEvent { EventCategory? category, bool containsPII = false, bool isHighVolume = false, - bool isEssential = false, + bool isEssential = true, }) { return CustomEvent( name, @@ -59,6 +59,10 @@ class CustomEvent extends BaseEvent { @override bool get isEssential => _isEssential; + + // Most tests using this fixture exercise routing or dispatch, not consent. + @override + bool get requiresConsent => false; } class PurchaseEvent extends CustomEvent { diff --git a/test/version_test.dart b/test/version_test.dart new file mode 100644 index 0000000..822b1d3 --- /dev/null +++ b/test/version_test.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('exported package version matches pubspec', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + final match = + RegExp(r'^version:\s*(\S+)\s*$', multiLine: true).firstMatch(pubspec); + + expect(match, isNotNull); + expect(flexTrackVersion, match!.group(1)); + }); +} diff --git a/test/widgets/flex_click_track_test.dart b/test/widgets/flex_click_track_test.dart index 50c1e45..b2c9cad 100644 --- a/test/widgets/flex_click_track_test.dart +++ b/test/widgets/flex_click_track_test.dart @@ -186,6 +186,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -215,6 +216,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/flex_impression_track_test.dart b/test/widgets/flex_impression_track_test.dart index c734ae3..5caf7fe 100644 --- a/test/widgets/flex_impression_track_test.dart +++ b/test/widgets/flex_impression_track_test.dart @@ -210,6 +210,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -246,6 +247,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/flex_mount_track_test.dart b/test/widgets/flex_mount_track_test.dart index ca72130..ce279fc 100644 --- a/test/widgets/flex_mount_track_test.dart +++ b/test/widgets/flex_mount_track_test.dart @@ -106,6 +106,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -133,6 +134,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/flex_route_track_test.dart b/test/widgets/flex_route_track_test.dart index b96a12c..fc87e1c 100644 --- a/test/widgets/flex_route_track_test.dart +++ b/test/widgets/flex_route_track_test.dart @@ -289,6 +289,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -316,6 +317,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/transformer_widget_test.dart b/test/widgets/transformer_widget_test.dart index 2346a14..b91f1bb 100644 --- a/test/widgets/transformer_widget_test.dart +++ b/test/widgets/transformer_widget_test.dart @@ -13,6 +13,7 @@ void main() { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); @@ -53,6 +54,7 @@ void main() { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); @@ -92,6 +94,7 @@ void main() { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), );