diff --git a/contract/PARITY.md b/contract/PARITY.md new file mode 100644 index 0000000..bb09729 --- /dev/null +++ b/contract/PARITY.md @@ -0,0 +1,45 @@ +# Flutter–Kotlin test parity + +Reference: Flutter `v2.2.0` at +`f38c0618374272ec256bc2809fa70ad86c555631`. + +## Shared behavioral contract + +| Area | Shared input | Kotlin coverage | +| --- | --- | --- | +| Routing, consent, sampling, enrichment, debug decisions | `core_mvp_cases.json` | `CoreMvpConformanceTest` | +| Offline queueing, partial delivery, selective retry, FIFO, idempotency, lifecycle | `runtime_mvp_cases.json` | `RuntimeMvpConformanceTest` | + +Both implementations must consume the same vendored JSON cases. A behavior +change requires updating the fixture version and both conformance reports. + +## Platform-equivalent Kotlin coverage + +- Transformer execution occurs once across retry. +- Concurrent flush calls cannot redeliver the same queued event. +- Offline flush performs no tracker delivery. +- Queue snapshots cannot mutate internal queue state. +- Partial tracker startup is rolled back and can be retried. +- Shutdown is idempotent. +- Route logs explain consent and unavailable-destination decisions. +- Property values require explicit verbose opt-in. +- Logcat is disabled for every level in non-debuggable builds. +- File queue tests cover recreation, identity/metadata, malformed data, + invalid shape, concurrent enqueue, duplicate IDs, replacement order, and + invalid read limits. +- Compose delivery tests cover rendered queue state, consent/network actions, + track/flush actions, and loading-state duplicate prevention. + +## Intentional platform differences + +Flutter widget tests (`FlexClickTrack`, `FlexImpressionTrack`, +`FlexMountTrack`, and `FlexRouteTrack`) map to Compose UI interaction and +navigation tests, not line-for-line ports. Flutter's HTTP/WebSocket Inspector +maps to Android's debug-only structured Logcat output. Dart exception-type and +environment-detector tests do not apply directly to the Kotlin API. + +## Execution boundary + +JVM unit tests run without a device. `FileEventQueueInstrumentedTest` and +`DeliveryScreenTest` compile into Android test APKs and must also run on an +emulator or physical device in CI before release. diff --git a/contract/README.md b/contract/README.md index 1a5cf08..c20387c 100644 --- a/contract/README.md +++ b/contract/README.md @@ -4,19 +4,22 @@ This directory contains the language-neutral inputs used to keep the Flutter and Kotlin SDKs behaviorally compatible. - Core specification: `1.0.0` +- Runtime specification: `1.0.0` - Fixture version: `1.0.0` -- Flutter reference: `flex_track` `v2.1.0` -- Flutter reference commit: `78d7f46c2479b9680268ab794443375e3959d441` +- Flutter reference: `flex_track` `v2.2.0` +- Flutter reference commit: `f38c0618374272ec256bc2809fa70ad86c555631` Canonical sources: -- [Core MVP specification](https://github.com/alirezat66/flex_track/blob/v2.1.0/doc/core-mvp-specification.md) -- [Conformance runner contract](https://github.com/alirezat66/flex_track/blob/v2.1.0/doc/conformance.md) -- [Flutter fixtures](https://github.com/alirezat66/flex_track/tree/v2.1.0/test/fixtures/conformance) +- [Core MVP specification](https://github.com/alirezat66/flex_track/blob/v2.2.0/doc/core-mvp-specification.md) +- [Conformance runner contract](https://github.com/alirezat66/flex_track/blob/v2.2.0/doc/conformance.md) +- [Flutter fixtures](https://github.com/alirezat66/flex_track/tree/v2.2.0/test/fixtures/conformance) +- [Runtime fixtures](https://github.com/alirezat66/flex_track/tree/v2.2.0/test/fixtures/conformance) The JSON files in this directory are vendored so Kotlin CI never depends on network availability or a moving Flutter branch. Updates require an explicit fixture version change and source-reference update. -Queues, persistence, retry/backoff, session management, SDK-owned identity, -and optimized batching are intentionally outside Core MVP 1.0.0. +Queueing and selective retry are specified separately by Runtime MVP 1.0.0. +See [PARITY.md](PARITY.md) for platform-equivalent coverage and intentional +Flutter/Android differences. diff --git a/contract/runtime_mvp.schema.json b/contract/runtime_mvp.schema.json new file mode 100644 index 0000000..a00ac28 --- /dev/null +++ b/contract/runtime_mvp.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flextrack.taghizadeh.dev/schemas/runtime-mvp-1.0.0.json", + "title": "FlexTrack Runtime MVP fixtures", + "type": "object", + "required": ["specVersion", "fixtureVersion", "cases"], + "additionalProperties": false, + "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"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "behavior": { + "enum": ["offline", "partialFailure", "flush", "queue", "lifecycle"] + }, + "input": {"type": "object"}, + "expected": {"type": "object"} + } + } + } + } +} diff --git a/contract/runtime_mvp_cases.json b/contract/runtime_mvp_cases.json new file mode 100644 index 0000000..babcca2 --- /dev/null +++ b/contract/runtime_mvp_cases.json @@ -0,0 +1,73 @@ +{ + "$schema": "runtime_mvp.schema.json", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "cases": [ + { + "id": "offline.queue-all-targets", + "behavior": "offline", + "input": {"targets": ["analytics", "archive"]}, + "expected": {"attempted": [], "queued": ["analytics", "archive"], "queueSize": 1} + }, + { + "id": "offline.no-target-no-queue", + "behavior": "offline", + "input": {"targets": []}, + "expected": {"attempted": [], "queued": [], "queueSize": 0} + }, + { + "id": "delivery.partial-failure", + "behavior": "partialFailure", + "input": {"targets": ["analytics", "archive"], "failing": ["archive"]}, + "expected": {"successful": ["analytics"], "queued": ["archive"], "queueSize": 1} + }, + { + "id": "delivery.all-success", + "behavior": "partialFailure", + "input": {"targets": ["analytics", "archive"], "failing": []}, + "expected": {"successful": ["analytics", "archive"], "queued": [], "queueSize": 0} + }, + { + "id": "flush.selective-retry-success", + "behavior": "flush", + "input": {"pending": ["archive"], "failing": [], "online": true}, + "expected": {"attemptedEvents": 1, "deliveredEvents": 1, "remainingEvents": 0, "pending": []} + }, + { + "id": "flush.retain-only-failures", + "behavior": "flush", + "input": {"pending": ["analytics", "archive"], "failing": ["archive"], "online": true}, + "expected": {"attemptedEvents": 1, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["archive"], "attempts": 1} + }, + { + "id": "flush.offline-noop", + "behavior": "flush", + "input": {"pending": ["analytics"], "failing": [], "online": false}, + "expected": {"attemptedEvents": 0, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["analytics"], "attempts": 0} + }, + { + "id": "queue.duplicate-id-idempotent", + "behavior": "queue", + "input": {"operation": "duplicate", "eventIds": ["event-1", "event-1"]}, + "expected": {"eventIds": ["event-1"], "queueSize": 1} + }, + { + "id": "queue.fifo-limit", + "behavior": "queue", + "input": {"operation": "read", "eventIds": ["event-1", "event-2", "event-3"], "limit": 2}, + "expected": {"eventIds": ["event-1", "event-2"], "queueSize": 3} + }, + { + "id": "queue.replace-preserves-position", + "behavior": "queue", + "input": {"operation": "replace", "eventIds": ["event-1", "event-2"]}, + "expected": {"eventIds": ["event-1", "event-2"], "queueSize": 2, "attempts": [1, 0]} + }, + { + "id": "lifecycle.initialize-idempotent", + "behavior": "lifecycle", + "input": {"initializeCalls": 2}, + "expected": {"trackerInitializeCalls": 1} + } + ] +} diff --git a/flextrack/build.gradle.kts b/flextrack/build.gradle.kts index 002b4fd..9e05208 100644 --- a/flextrack/build.gradle.kts +++ b/flextrack/build.gradle.kts @@ -16,6 +16,7 @@ android { defaultConfig { minSdk = 21 consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -53,6 +54,9 @@ dependencies { testImplementation(libs.junit.jupiter) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.json) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.test) } publishing { diff --git a/flextrack/src/androidTest/kotlin/dev/flextrack/runtime/FileEventQueueInstrumentedTest.kt b/flextrack/src/androidTest/kotlin/dev/flextrack/runtime/FileEventQueueInstrumentedTest.kt new file mode 100644 index 0000000..774808a --- /dev/null +++ b/flextrack/src/androidTest/kotlin/dev/flextrack/runtime/FileEventQueueInstrumentedTest.kt @@ -0,0 +1,134 @@ +package dev.flextrack.runtime + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.flextrack.event.FlexEvent +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.time.Instant +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class FileEventQueueInstrumentedTest { + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + private val files = mutableListOf() + + @After + fun cleanUp() { + files.forEach { file -> + file.delete() + File("${file.path}.bak").delete() + File("${file.path}.new").delete() + } + } + + @Test + fun survivesRecreationAndPreservesIdentityAndMetadata() = runTest { + val name = queueName() + val original = TestEvent("stable-id") + FileEventQueue(context, name).enqueue( + QueuedEvent(original.eventId, original, listOf("a", "b")), + ) + + val restored = FileEventQueue(context, name).read(10).single() + + assertEquals(original.eventId, restored.id) + assertEquals(original.timestamp, restored.event.timestamp) + assertEquals(original.name, restored.event.name) + assertEquals(original.properties, restored.event.properties) + assertEquals(listOf("a", "b"), restored.trackerIds) + } + + @Test + fun malformedJsonFailsWithoutDeletingPersistedBytes() = runTest { + val name = queueName() + val file = trackedFile(name).apply { writeText("{broken") } + + expectFailure { FileEventQueue(context, name).read(10) } + assertEquals("{broken", file.readText()) + } + + @Test + fun invalidPersistedShapeFailsVisibly() = runTest { + val name = queueName() + trackedFile(name).writeText("{}") + + expectFailure { FileEventQueue(context, name).size() } + } + + @Test + fun concurrentEnqueuesAreSerializedWithoutLoss() = runTest { + val queue = FileEventQueue(context, queueName()) + + (0 until 50).map { index -> + async { + val id = "event-$index" + queue.enqueue(QueuedEvent(id, TestEvent(id), listOf("analytics"))) + } + }.awaitAll() + + assertEquals(50, queue.size()) + assertEquals((0 until 50).map { "event-$it" }, queue.read(50).map { it.id }) + } + + @Test + fun duplicateIdIsIdempotentAndReplacePreservesPositionAcrossRecreation() = runTest { + val name = queueName() + val queue = FileEventQueue(context, name) + queue.enqueue(QueuedEvent("one", TestEvent("one"), listOf("a"))) + queue.enqueue(QueuedEvent("two", TestEvent("two"), listOf("a"))) + queue.enqueue(QueuedEvent("one", TestEvent("one"), listOf("b"))) + queue.replace(queue.read(10).first().copy(attempts = 1)) + + val restored = FileEventQueue(context, name).read(10) + + assertEquals(listOf("one", "two"), restored.map { it.id }) + assertEquals(listOf(1, 0), restored.map { it.attempts }) + assertEquals(listOf("a"), restored.first().trackerIds) + } + + @Test + fun nonPositiveReadDoesNotMutateStorage() = runTest { + val name = queueName() + val queue = FileEventQueue(context, name) + queue.enqueue(QueuedEvent("one", TestEvent("one"), listOf("a"))) + + expectFailure { queue.read(0) } + + assertEquals(1, FileEventQueue(context, name).size()) + } + + private fun queueName(): String = "flextrack-test-${UUID.randomUUID()}.json".also(::trackedFile) + + private fun trackedFile(name: String): File = File(context.filesDir, name).also { + if (it !in files) files += it + } + + private suspend inline fun expectFailure( + crossinline block: suspend () -> Unit, + ) { + val failure = runCatching { block() }.exceptionOrNull() + assertTrue("Expected ${T::class.java.name}, got $failure", failure is T) + } + + private class TestEvent(id: String) : FlexEvent( + id, + Instant.parse("2026-08-17T12:30:00Z"), + ) { + override val name: String = "purchase" + override val properties: Map = mapOf( + "plan" to "pro", + "nested" to mapOf("enabled" to true), + "items" to listOf(1, "two"), + ) + override val requiresConsent: Boolean = false + } +} diff --git a/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt b/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt index 4c8fd52..b215bb8 100644 --- a/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt +++ b/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt @@ -9,6 +9,9 @@ public enum class FlexTrackLogLevel { OFF, BASIC, VERBOSE } /** Logging boundary. Implementations must never throw into analytics delivery. */ public fun interface FlexTrackLogger { public fun log(message: String) + + /** Explicit opt-in for payload values. Keep false for production loggers. */ + public val includesPropertyValues: Boolean get() = false } public object NoOpFlexTrackLogger : FlexTrackLogger { @@ -22,8 +25,13 @@ public class AndroidLogcatLogger( private val tag: String = "FlexTrack", ) : FlexTrackLogger { private val enabled: Boolean = - context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 && - level != FlexTrackLogLevel.OFF + shouldEnableLogcat( + isDebuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0, + level = level, + ) + + override val includesPropertyValues: Boolean + get() = enabled && level == FlexTrackLogLevel.VERBOSE override fun log(message: String) { if (!enabled) return @@ -35,6 +43,7 @@ internal fun FlexTrackLogger.safeLog(message: () -> String) { runCatching { log(message()) } } -/** Only the debuggable Android logger can opt into event values. */ -internal fun FlexTrackLogger.includesPropertyValues(): Boolean = - this is AndroidLogcatLogger && level == FlexTrackLogLevel.VERBOSE +internal fun shouldEnableLogcat( + isDebuggable: Boolean, + level: FlexTrackLogLevel, +): Boolean = isDebuggable && level != FlexTrackLogLevel.OFF diff --git a/flextrack/src/main/kotlin/dev/flextrack/runtime/EventQueue.kt b/flextrack/src/main/kotlin/dev/flextrack/runtime/EventQueue.kt index 9e1df22..0153261 100644 --- a/flextrack/src/main/kotlin/dev/flextrack/runtime/EventQueue.kt +++ b/flextrack/src/main/kotlin/dev/flextrack/runtime/EventQueue.kt @@ -4,6 +4,7 @@ import dev.flextrack.event.FlexEvent import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.time.Instant +import java.util.Collections public data class QueuedEvent( val id: String, @@ -28,20 +29,27 @@ public class InMemoryEventQueue : EventQueue { private val items: LinkedHashMap = linkedMapOf() override suspend fun enqueue(item: QueuedEvent): Unit = mutex.withLock { - items.putIfAbsent(item.id, item) + items.putIfAbsent(item.id, item.withImmutableTrackerIds()) Unit } override suspend fun read(limit: Int): List = mutex.withLock { require(limit > 0) { "limit must be positive" } - items.values.take(limit) + immutableSnapshot(items.values.take(limit)) } override suspend fun replace(item: QueuedEvent): Unit = mutex.withLock { - if (item.id in items) items[item.id] = item + if (item.id in items) items[item.id] = item.withImmutableTrackerIds() } override suspend fun remove(id: String): Unit = mutex.withLock { items.remove(id); Unit } override suspend fun size(): Int = mutex.withLock { items.size } } + +internal fun immutableSnapshot(items: List): List = + Collections.unmodifiableList(items.map(QueuedEvent::withImmutableTrackerIds)) + +private fun QueuedEvent.withImmutableTrackerIds(): QueuedEvent = copy( + trackerIds = Collections.unmodifiableList(trackerIds.toList()), +) diff --git a/flextrack/src/main/kotlin/dev/flextrack/runtime/FileEventQueue.kt b/flextrack/src/main/kotlin/dev/flextrack/runtime/FileEventQueue.kt index 54a8f82..8ebce93 100644 --- a/flextrack/src/main/kotlin/dev/flextrack/runtime/FileEventQueue.kt +++ b/flextrack/src/main/kotlin/dev/flextrack/runtime/FileEventQueue.kt @@ -34,7 +34,9 @@ public class FileEventQueue( override suspend fun read(limit: Int): List { require(limit > 0) { "limit must be positive" } - return mutex.withLock { withContext(Dispatchers.IO) { load().take(limit) } } + return mutex.withLock { + withContext(Dispatchers.IO) { immutableSnapshot(load().take(limit)) } + } } override suspend fun replace(item: QueuedEvent): Unit = mutate { items -> diff --git a/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt b/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt index 3afc7ff..93535cb 100644 --- a/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt +++ b/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt @@ -4,7 +4,6 @@ import dev.flextrack.event.FlexEvent import dev.flextrack.event.TransformerPipeline import dev.flextrack.logging.FlexTrackLogger import dev.flextrack.logging.NoOpFlexTrackLogger -import dev.flextrack.logging.includesPropertyValues import dev.flextrack.logging.safeLog import dev.flextrack.routing.ConsentState import dev.flextrack.routing.RoutingEngine @@ -12,6 +11,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** Runtime entry point for transforming, routing, delivering, and retrying events. */ public class FlexTrackClient( @@ -23,6 +24,8 @@ public class FlexTrackClient( private val onlineProvider: () -> Boolean = { true }, private val logger: FlexTrackLogger = NoOpFlexTrackLogger, ) { + private val flushMutex: Mutex = Mutex() + public suspend fun start() { registry.start() val trackerCount = registry.snapshot().size @@ -48,7 +51,7 @@ public class FlexTrackClient( "🟣 ROUTE ${transformed.name} targets=${targets.renderIds()} " + "properties=${propertyKeys.size} keys=${propertyKeys.renderIds()}" } - if (logger.includesPropertyValues()) { + if (logger.includesPropertyValues) { logger.safeLog { "🔎 PAYLOAD ${transformed.name} eventId=${transformed.eventId} " + "values=${transformed.properties.orEmpty()}" @@ -91,12 +94,12 @@ public class FlexTrackClient( ) } - public suspend fun flush(limit: Int = 100): FlushResult { + public suspend fun flush(limit: Int = 100): FlushResult = flushMutex.withLock { require(limit > 0) { "limit must be positive" } if (!onlineProvider()) { val remaining = queue.size() logger.safeLog { "⚪ OFFLINE flush skipped queue=$remaining" } - return FlushResult(0, 0, remaining) + return@withLock FlushResult(0, 0, remaining) } val items = queue.read(limit) @@ -120,7 +123,7 @@ public class FlexTrackClient( logger.safeLog { "🔵 FLUSH attempted=${result.attemptedEvents} delivered=${result.deliveredEvents} remaining=${result.remainingEvents}" } - return result + result } private suspend fun deliver( diff --git a/flextrack/src/test/kotlin/dev/flextrack/conformance/RuntimeMvpConformanceTest.kt b/flextrack/src/test/kotlin/dev/flextrack/conformance/RuntimeMvpConformanceTest.kt new file mode 100644 index 0000000..170e238 --- /dev/null +++ b/flextrack/src/test/kotlin/dev/flextrack/conformance/RuntimeMvpConformanceTest.kt @@ -0,0 +1,202 @@ +package dev.flextrack.conformance + +import dev.flextrack.event.FlexEvent +import dev.flextrack.routing.RoutingConfiguration +import dev.flextrack.routing.RoutingEngine +import dev.flextrack.routing.RoutingRule +import dev.flextrack.routing.TrackerGroup +import dev.flextrack.runtime.EventQueue +import dev.flextrack.runtime.FlexTrackClient +import dev.flextrack.runtime.InMemoryEventQueue +import dev.flextrack.runtime.QueuedEvent +import dev.flextrack.runtime.Tracker +import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import java.nio.file.Path +import java.time.Instant +import java.util.stream.Stream + +class RuntimeMvpConformanceTest { + private val contractRoot: Path by lazy { + val workingDirectory = Path.of(System.getProperty("user.dir")) + workingDirectory.resolve("contract").takeIf { it.toFile().isDirectory } + ?: workingDirectory.parent.resolve("contract") + } + + private val document: JSONObject by lazy { + JSONObject(contractRoot.resolve("runtime_mvp_cases.json").toFile().readText()) + } + + @Test + fun `runtime fixture envelope and case identities are valid`() { + val schema = JSONObject(contractRoot.resolve("runtime_mvp.schema.json").toFile().readText()) + val cases = document.getJSONArray("cases").objects() + + assertEquals("https://json-schema.org/draft/2020-12/schema", schema.getString("\$schema")) + assertEquals("runtime_mvp.schema.json", document.getString("\$schema")) + assertEquals("1.0.0", document.getString("specVersion")) + assertTrue(document.getString("fixtureVersion").matches(Regex("^1\\.[0-9]+\\.[0-9]+$"))) + assertEquals(cases.size, cases.map { it.getString("id") }.toSet().size) + cases.forEach { fixture -> + assertEquals(setOf("id", "behavior", "input", "expected"), fixture.keySet()) + assertTrue( + fixture.getString("behavior") in + setOf("offline", "partialFailure", "flush", "queue", "lifecycle"), + ) + } + } + + @TestFactory + fun `shared Flutter and Kotlin fixtures conform to Runtime MVP`(): Stream = + document.getJSONArray("cases").objects().map { fixture -> + DynamicTest.dynamicTest(fixture.getString("id")) { + runTest { + val actual = runFixture(fixture) + val expected = fixture.getJSONObject("expected") + assertTrue( + expected.similar(JSONObject(actual)), + "${fixture.getString("id")} expected=$expected actual=${JSONObject(actual)}", + ) + } + } + }.stream() + + private suspend fun runFixture(fixture: JSONObject): Map { + val input = fixture.getJSONObject("input") + return when (fixture.getString("behavior")) { + "offline" -> offline(input) + "partialFailure" -> partialFailure(input) + "flush" -> flush(input) + "queue" -> queue(input) + "lifecycle" -> lifecycle(input) + else -> error("Unsupported fixture behavior") + } + } + + private suspend fun offline(input: JSONObject): Map { + val setup = setup(input.getJSONArray("targets").strings(), online = false) + val result = setup.client.track(FixtureEvent("event-1")) + return mapOf( + "attempted" to setup.trackers.flatMap { it.events }, + "queued" to result.queuedTrackerIds, + "queueSize" to setup.queue.size(), + ) + } + + private suspend fun partialFailure(input: JSONObject): Map { + val setup = setup( + targets = input.getJSONArray("targets").strings(), + failing = input.getJSONArray("failing").strings().toSet(), + ) + val result = setup.client.track(FixtureEvent("event-1")) + return mapOf( + "successful" to result.successfulTrackerIds, + "queued" to result.queuedTrackerIds, + "queueSize" to setup.queue.size(), + ) + } + + private suspend fun flush(input: JSONObject): Map { + val pending = input.getJSONArray("pending").strings() + val setup = setup( + targets = pending, + failing = input.getJSONArray("failing").strings().toSet(), + online = input.getBoolean("online"), + ) + setup.queue.enqueue(QueuedEvent("event-1", FixtureEvent("event-1"), pending)) + val result = setup.client.flush() + val remaining = setup.queue.read(10) + return buildMap { + put("attemptedEvents", result.attemptedEvents) + put("deliveredEvents", result.deliveredEvents) + put("remainingEvents", result.remainingEvents) + put("pending", remaining.singleOrNull()?.trackerIds.orEmpty()) + if (remaining.isNotEmpty() || !input.getBoolean("online")) { + put("attempts", remaining.singleOrNull()?.attempts ?: 0) + } + } + } + + private suspend fun queue(input: JSONObject): Map { + val queue = InMemoryEventQueue() + input.getJSONArray("eventIds").strings().forEach { id -> + queue.enqueue(QueuedEvent(id, FixtureEvent(id), listOf("a"))) + } + if (input.getString("operation") == "replace") { + val first = queue.read(10).first() + queue.replace(first.copy(attempts = 1)) + } + val values = queue.read(input.optInt("limit", 10)) + return buildMap { + put("eventIds", values.map(QueuedEvent::id)) + put("queueSize", queue.size()) + if (input.getString("operation") == "replace") { + put("attempts", values.map(QueuedEvent::attempts)) + } + } + } + + private suspend fun lifecycle(input: JSONObject): Map { + val tracker = FixtureTracker("analytics") + val client = FlexTrackClient(RoutingEngine(RoutingConfiguration(emptyList()))) + client.register(tracker) + repeat(input.getInt("initializeCalls")) { client.start() } + return mapOf("trackerInitializeCalls" to tracker.startCalls) + } + + private suspend fun setup( + targets: List, + failing: Set = emptySet(), + online: Boolean = true, + ): Setup { + val queue = InMemoryEventQueue() + val trackers = targets.map { FixtureTracker(it, it in failing) } + val rules = if (targets.isEmpty()) emptyList() else listOf( + RoutingRule( + targetGroup = TrackerGroup("fixture", targets), + requireConsent = false, + ), + ) + val client = FlexTrackClient( + routingEngine = RoutingEngine(RoutingConfiguration(rules)), + queue = queue, + onlineProvider = { online }, + ) + (trackers.ifEmpty { listOf(FixtureTracker("unused")) }).forEach { client.register(it) } + client.start() + return Setup(client, queue, trackers) + } + + private class FixtureEvent(id: String) : FlexEvent(id, Instant.parse("2026-08-17T00:00:00Z")) { + override val name: String = "purchase" + override val properties: Map = mapOf("plan" to "pro") + override val requiresConsent: Boolean = false + } + + private class FixtureTracker(override val id: String, private val failing: Boolean = false) : Tracker { + val events = mutableListOf() + var startCalls: Int = 0 + + override suspend fun start() { startCalls++ } + + override suspend fun track(event: FlexEvent) { + events += id + if (failing) error("failure") + } + } + + private data class Setup( + val client: FlexTrackClient, + val queue: EventQueue, + val trackers: List, + ) +} + +private fun JSONArray.strings(): List = List(length()) { getString(it) } +private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } diff --git a/flextrack/src/test/kotlin/dev/flextrack/logging/LoggerPolicyTest.kt b/flextrack/src/test/kotlin/dev/flextrack/logging/LoggerPolicyTest.kt new file mode 100644 index 0000000..f04db59 --- /dev/null +++ b/flextrack/src/test/kotlin/dev/flextrack/logging/LoggerPolicyTest.kt @@ -0,0 +1,21 @@ +package dev.flextrack.logging + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LoggerPolicyTest { + @Test + fun `release builds never enable Logcat at any level`() { + FlexTrackLogLevel.entries.forEach { level -> + assertFalse(shouldEnableLogcat(isDebuggable = false, level)) + } + } + + @Test + fun `debug builds honor off basic and verbose levels`() { + assertFalse(shouldEnableLogcat(true, FlexTrackLogLevel.OFF)) + assertTrue(shouldEnableLogcat(true, FlexTrackLogLevel.BASIC)) + assertTrue(shouldEnableLogcat(true, FlexTrackLogLevel.VERBOSE)) + } +} diff --git a/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt b/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt index 5576f23..c78e310 100644 --- a/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt +++ b/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt @@ -77,6 +77,37 @@ class FlexTrackClientTest { assertEquals(1, second.shutdowns) } + @Test + fun `partial start failure rolls back and retry starts every tracker`() = runTest { + val registry = TrackerRegistry() + val healthy = RecordingTracker("healthy") + val flaky = RecordingTracker("flaky", startFailures = 1) + registry.register(healthy) + registry.register(flaky) + + assertTrue(runCatching { registry.start() }.exceptionOrNull() is IllegalStateException) + assertEquals(1, healthy.starts) + assertEquals(1, healthy.shutdowns) + + registry.start() + + assertEquals(2, healthy.starts) + assertEquals(2, flaky.starts) + } + + @Test + fun `shutdown is idempotent after successful start`() = runTest { + val registry = TrackerRegistry() + val tracker = RecordingTracker("analytics") + registry.register(tracker) + registry.start() + + registry.shutdown() + registry.shutdown() + + assertEquals(1, tracker.shutdowns) + } + @Test fun `consent denial prevents delivery and queueing`() = runTest { val tracker = RecordingTracker("analytics") @@ -130,6 +161,29 @@ class FlexTrackClientTest { assertTrue(messages.any { "SKIPPED purchase" in it && "Consent requirements not met" in it }) } + @Test + fun `property values require explicit verbose logger opt in`() = runTest { + val basicMessages = mutableListOf() + val verboseMessages = mutableListOf() + val basic = client( + queue = InMemoryEventQueue(), + logger = FlexTrackLogger(basicMessages::add), + ) + val verboseLogger = object : FlexTrackLogger { + override val includesPropertyValues: Boolean = true + override fun log(message: String) { verboseMessages += message } + } + val verbose = client(queue = InMemoryEventQueue(), logger = verboseLogger) + basic.register(RecordingTracker("analytics")) + verbose.register(RecordingTracker("analytics")) + + basic.track(TestEvent()) + verbose.track(TestEvent()) + + assertTrue(basicMessages.none { "secret-value" in it }) + assertTrue(verboseMessages.any { "PAYLOAD purchase" in it && "secret-value" in it }) + } + @Test fun `offline flush logs skip and never delivers`() = runTest { val messages = mutableListOf() @@ -196,12 +250,19 @@ class FlexTrackClientTest { private class RecordingTracker( override val id: String, var fail: Boolean = false, + var startFailures: Int = 0, ) : Tracker { val events = mutableListOf() var starts = 0 var shutdowns = 0 - override suspend fun start() { starts++ } + override suspend fun start() { + starts++ + if (startFailures > 0) { + startFailures-- + error("start failed") + } + } override suspend fun track(event: FlexEvent) { if (fail) error("delivery failed") events += event diff --git a/flextrack/src/test/kotlin/dev/flextrack/runtime/RuntimeDeliveryParityTest.kt b/flextrack/src/test/kotlin/dev/flextrack/runtime/RuntimeDeliveryParityTest.kt new file mode 100644 index 0000000..60b0c2b --- /dev/null +++ b/flextrack/src/test/kotlin/dev/flextrack/runtime/RuntimeDeliveryParityTest.kt @@ -0,0 +1,151 @@ +package dev.flextrack.runtime + +import dev.flextrack.event.EnrichedEvent +import dev.flextrack.event.EventTransformer +import dev.flextrack.event.FlexEvent +import dev.flextrack.event.TransformerPipeline +import dev.flextrack.routing.RoutingConfiguration +import dev.flextrack.routing.RoutingEngine +import dev.flextrack.routing.RoutingRule +import dev.flextrack.routing.TrackerGroup +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.time.Instant + +class RuntimeDeliveryParityTest { + @Test + fun `retry never reruns transformers or redelivers successful targets`() = runTest { + var transforms = 0 + val transformers = TransformerPipeline().apply { + add(EventTransformer { event -> + transforms++ + EnrichedEvent(event, mapOf("transform" to "once")) + }) + } + val success = RecordingTracker("success") + val retry = RecordingTracker("retry", failuresRemaining = 1) + val client = client(listOf(success, retry), transformers = transformers) + + val first = client.track(TestEvent("event-1")) + client.flush() + + assertEquals(listOf("retry"), first.queuedTrackerIds) + assertEquals(1, transforms) + assertEquals(1, success.events.size) + assertEquals(2, retry.events.size) + assertEquals("once", retry.events.last().properties?.get("transform")) + assertEquals(0, client.queue.size()) + } + + @Test + fun `concurrent flush calls cannot duplicate a queued delivery`() = runTest { + val deliveryStarted = CompletableDeferred() + val releaseDelivery = CompletableDeferred() + val tracker = RecordingTracker("analytics") { + deliveryStarted.complete(Unit) + releaseDelivery.await() + } + val queue = InMemoryEventQueue().apply { + enqueue(QueuedEvent("event-1", TestEvent("event-1"), listOf("analytics"))) + } + val client = client(listOf(tracker), queue = queue) + + val first = async { client.flush() } + deliveryStarted.await() + val second = async { client.flush() } + releaseDelivery.complete(Unit) + + assertEquals(1, first.await().deliveredEvents) + assertEquals(0, second.await().attemptedEvents) + assertEquals(1, tracker.events.size) + } + + @Test + fun `offline flush never invokes a tracker`() = runTest { + val tracker = RecordingTracker("analytics") + val queue = InMemoryEventQueue().apply { + enqueue(QueuedEvent("event-1", TestEvent("event-1"), listOf("analytics"))) + } + val client = client(listOf(tracker), queue = queue, online = false) + + val result = client.flush() + + assertEquals(0, result.attemptedEvents) + assertEquals(1, result.remainingEvents) + assertTrue(tracker.events.isEmpty()) + } + + @Test + fun `queue snapshots cannot mutate internal state`() = runTest { + val sourceTargets = mutableListOf("analytics") + val queue = InMemoryEventQueue() + queue.enqueue(QueuedEvent("event-1", TestEvent("event-1"), sourceTargets)) + sourceTargets.clear() + val snapshot = queue.read(10) + + assertEquals(listOf("analytics"), snapshot.single().trackerIds) + assertThrows(UnsupportedOperationException::class.java) { + (snapshot as MutableList).clear() + } + assertThrows(UnsupportedOperationException::class.java) { + (snapshot.single().trackerIds as MutableList).clear() + } + assertEquals(1, queue.size()) + assertEquals(listOf("analytics"), queue.read(10).single().trackerIds) + } + + private suspend fun client( + trackers: List, + queue: EventQueue = InMemoryEventQueue(), + transformers: TransformerPipeline = TransformerPipeline(), + online: Boolean = true, + ): FlexTrackClient { + val ids = trackers.map(Tracker::id) + val client = FlexTrackClient( + routingEngine = RoutingEngine( + RoutingConfiguration( + listOf( + RoutingRule( + targetGroup = TrackerGroup("all", ids), + requireConsent = false, + ), + ), + ), + ), + queue = queue, + transformers = transformers, + onlineProvider = { online }, + ) + trackers.forEach { client.register(it) } + client.start() + return client + } + + private class TestEvent(id: String) : FlexEvent(id, Instant.parse("2026-08-17T00:00:00Z")) { + override val name: String = "purchase" + override val properties: Map = mapOf("plan" to "pro") + override val requiresConsent: Boolean = false + } + + private class RecordingTracker( + override val id: String, + var failuresRemaining: Int = 0, + private val beforeResult: suspend () -> Unit = {}, + ) : Tracker { + val events = mutableListOf() + + override suspend fun track(event: FlexEvent) { + events += event + beforeResult() + if (failuresRemaining > 0) { + failuresRemaining-- + error("failure") + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7fabc5..29f42f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,8 +4,9 @@ kotlin = "2.0.21" coreKtx = "1.10.1" junit = "4.13.2" junitJupiter = "5.11.4" -junitVersion = "1.1.5" -espressoCore = "3.5.1" +junitVersion = "1.3.0" +androidxTestRunner = "1.7.0" +espressoCore = "3.7.0" appcompat = "1.6.1" material = "1.10.0" desugarJdkLibs = "2.1.5" @@ -25,6 +26,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = junit = { group = "junit", name = "junit", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } diff --git a/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt b/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt index aa661b6..670bd9e 100644 --- a/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt +++ b/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt @@ -1,39 +1,36 @@ package dev.flextrack.sample +import androidx.activity.compose.setContent import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import dev.flextrack.runtime.FlushResult import dev.flextrack.sample.ui.DeliveryUiState import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertEquals class DeliveryScreenTest { @get:Rule - val compose = createComposeRule() + val compose = createAndroidComposeRule() @Test fun rendersOfflineQueueAndSelectiveRetryResult() { - compose.setContent { - DeliveryScreen( - state = DeliveryUiState( - loading = false, - isOnline = false, - queueSize = 2, - deliveredIds = listOf("sample_reliable"), - failedIds = listOf("sample_retry"), - queuedIds = listOf("sample_retry"), - flushResult = FlushResult(1, 0, 2), - ), - onBack = {}, - onOnlineChanged = {}, - onConsentChanged = {}, - onRetryHealthyChanged = {}, - onTrack = {}, - onFlush = {}, - ) - } + setScreen( + DeliveryUiState( + loading = false, + isOnline = false, + queueSize = 2, + deliveredIds = listOf("sample_reliable"), + failedIds = listOf("sample_retry"), + queuedIds = listOf("sample_retry"), + flushResult = FlushResult(1, 0, 2), + ), + ) compose.onNodeWithText("Offline Delivery Lab").assertIsDisplayed() compose.onNodeWithTag("queue-count").assertIsDisplayed() @@ -41,4 +38,62 @@ class DeliveryScreenTest { compose.onNodeWithText("Delivered: sample_reliable").assertIsDisplayed() compose.onNodeWithText("Queued: sample_retry").assertIsDisplayed() } + + @Test + fun controlsExposeNetworkConsentTrackAndFlushActions() { + var online: Boolean? = null + var consent: Boolean? = null + var tracks = 0 + var flushes = 0 + setScreen( + state = DeliveryUiState( + loading = false, + isOnline = false, + hasConsent = false, + ), + onOnlineChanged = { online = it }, + onConsentChanged = { consent = it }, + onTrack = { tracks++ }, + onFlush = { flushes++ }, + ) + + compose.onNodeWithTag("network-toggle").performClick() + compose.onNodeWithTag("consent-toggle").performClick() + compose.onNodeWithTag("track-event").assertIsEnabled().performClick() + compose.onNodeWithTag("flush-queue").assertIsEnabled().performClick() + + assertEquals(true, online) + assertEquals(true, consent) + assertEquals(1, tracks) + assertEquals(1, flushes) + } + + @Test + fun loadingPreventsDuplicateTrackAndFlushActions() { + setScreen(DeliveryUiState(loading = true)) + + compose.onNodeWithTag("track-event").assertIsNotEnabled() + compose.onNodeWithTag("flush-queue").assertIsNotEnabled() + } + + private fun setScreen( + state: DeliveryUiState, + onOnlineChanged: (Boolean) -> Unit = {}, + onConsentChanged: (Boolean) -> Unit = {}, + onTrack: () -> Unit = {}, + onFlush: () -> Unit = {}, + ) { + compose.activity.setContent { + DeliveryScreen( + state = state, + onBack = {}, + onOnlineChanged = onOnlineChanged, + onConsentChanged = onConsentChanged, + onRetryHealthyChanged = {}, + onTrack = onTrack, + onFlush = onFlush, + ) + } + compose.waitForIdle() + } }