diff --git a/flextrack/build.gradle.kts b/flextrack/build.gradle.kts index 8779489..6e7cee4 100644 --- a/flextrack/build.gradle.kts +++ b/flextrack/build.gradle.kts @@ -49,7 +49,10 @@ android { dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) + implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit.jupiter) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.json) } publishing { diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/DispatchResult.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/DispatchResult.kt new file mode 100644 index 0000000..13ad865 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/DispatchResult.kt @@ -0,0 +1,22 @@ +package dev.taghizadeh.flextrack.runtime + +import dev.taghizadeh.flextrack.event.FlexEvent +import dev.taghizadeh.flextrack.routing.RoutingResult + +public data class TrackerFailure(val trackerId: String, val cause: Throwable) + +public data class DispatchResult( + val event: FlexEvent, + val routing: RoutingResult, + val successfulTrackerIds: List, + val failures: List, + val queuedTrackerIds: List, +) { + public val wasQueued: Boolean get() = queuedTrackerIds.isNotEmpty() +} + +public data class FlushResult( + val attemptedEvents: Int, + val deliveredEvents: Int, + val remainingEvents: Int, +) diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/EventQueue.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/EventQueue.kt new file mode 100644 index 0000000..d82ba55 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/EventQueue.kt @@ -0,0 +1,47 @@ +package dev.taghizadeh.flextrack.runtime + +import dev.taghizadeh.flextrack.event.FlexEvent +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Instant + +public data class QueuedEvent( + val id: String, + val event: FlexEvent, + val trackerIds: List, + val attempts: Int = 0, + val queuedAt: Instant = Instant.now(), +) + +/** Storage boundary for events waiting for another delivery attempt. */ +public interface EventQueue { + public suspend fun enqueue(item: QueuedEvent) + public suspend fun read(limit: Int): List + public suspend fun replace(item: QueuedEvent) + public suspend fun remove(id: String) + public suspend fun size(): Int +} + +/** Process-local queue useful for tests and apps that do not require persistence. */ +public class InMemoryEventQueue : EventQueue { + private val mutex: Mutex = Mutex() + private val items: LinkedHashMap = linkedMapOf() + + override suspend fun enqueue(item: QueuedEvent): Unit = mutex.withLock { + items.putIfAbsent(item.id, item) + Unit + } + + override suspend fun read(limit: Int): List = mutex.withLock { + require(limit > 0) { "limit must be positive" } + items.values.take(limit) + } + + override suspend fun replace(item: QueuedEvent): Unit = mutex.withLock { + if (item.id in items) items[item.id] = item + } + + override suspend fun remove(id: String): Unit = mutex.withLock { items.remove(id); Unit } + + override suspend fun size(): Int = mutex.withLock { items.size } +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/FileEventQueue.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/FileEventQueue.kt new file mode 100644 index 0000000..0ff6d14 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/FileEventQueue.kt @@ -0,0 +1,152 @@ +package dev.taghizadeh.flextrack.runtime + +import android.content.Context +import android.util.AtomicFile +import dev.taghizadeh.flextrack.event.FlexEvent +import dev.taghizadeh.flextrack.routing.EventCategory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.time.Instant + +/** Durable JSON queue stored in the application's private files directory. */ +public class FileEventQueue( + context: Context, + fileName: String = "flextrack-event-queue.json", +) : EventQueue { + private val file: File = File(context.applicationContext.filesDir, fileName) + private val atomicFile: AtomicFile = AtomicFile(file) + private val mutex: Mutex = Mutex() + + init { + require(fileName.isNotBlank() && !fileName.contains(File.separatorChar)) { + "fileName must be a simple non-blank file name" + } + } + + override suspend fun enqueue(item: QueuedEvent): Unit = mutate { items -> + if (items.none { it.id == item.id }) items += item + } + + override suspend fun read(limit: Int): List { + require(limit > 0) { "limit must be positive" } + return mutex.withLock { withContext(Dispatchers.IO) { load().take(limit) } } + } + + override suspend fun replace(item: QueuedEvent): Unit = mutate { items -> + val index = items.indexOfFirst { it.id == item.id } + if (index >= 0) items[index] = item + } + + override suspend fun remove(id: String): Unit = mutate { items -> + items.removeAll { it.id == id } + } + + override suspend fun size(): Int = mutex.withLock { + withContext(Dispatchers.IO) { load().size } + } + + private suspend fun mutate(block: (MutableList) -> Unit) { + mutex.withLock { + withContext(Dispatchers.IO) { + val items = load() + block(items) + persist(items) + } + } + } + + private fun load(): MutableList { + if (!file.exists() || file.length() == 0L) return mutableListOf() + val array = JSONArray(atomicFile.readFully().toString(Charsets.UTF_8)) + return MutableList(array.length()) { index -> array.getJSONObject(index).toQueuedEvent() } + } + + private fun persist(items: List) { + file.parentFile?.mkdirs() + val output = atomicFile.startWrite() + try { + output.write(JSONArray(items.map(QueuedEvent::toJson)).toString().toByteArray(Charsets.UTF_8)) + atomicFile.finishWrite(output) + } catch (failure: Throwable) { + atomicFile.failWrite(output) + throw failure + } + } +} + +private fun QueuedEvent.toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("trackerIds", JSONArray(trackerIds)) + put("attempts", attempts) + put("queuedAt", queuedAt.toString()) + put("event", event.toJson()) +} + +private fun FlexEvent.toJson(): JSONObject = JSONObject().apply { + put("eventId", eventId) + put("timestamp", timestamp.toString()) + put("name", name) + put("properties", properties?.let(::JSONObject) ?: JSONObject.NULL) + put("category", category?.name ?: JSONObject.NULL) + put("containsPII", containsPII) + put("requiresConsent", requiresConsent) + put("isHighVolume", isHighVolume) + put("isEssential", isEssential) + put("userId", userId ?: JSONObject.NULL) + put("sessionId", sessionId ?: JSONObject.NULL) +} + +private fun JSONObject.toQueuedEvent(): QueuedEvent { + val trackerArray = getJSONArray("trackerIds") + return QueuedEvent( + id = getString("id"), + event = getJSONObject("event").toEvent(), + trackerIds = List(trackerArray.length()) { trackerArray.getString(it) }, + attempts = getInt("attempts"), + queuedAt = Instant.parse(getString("queuedAt")), + ) +} + +private fun JSONObject.toEvent(): FlexEvent { + val source = this + return object : FlexEvent( + eventId = source.getString("eventId"), + timestamp = Instant.parse(source.getString("timestamp")), + ) { + override val name: String = source.getString("name") + override val properties: Map? = source.optJSONObject("properties")?.toMap() + override val category: EventCategory? = source.nullableString("category")?.let(::EventCategory) + override val containsPII: Boolean = source.getBoolean("containsPII") + override val requiresConsent: Boolean = source.getBoolean("requiresConsent") + override val isHighVolume: Boolean = source.getBoolean("isHighVolume") + override val isEssential: Boolean = source.getBoolean("isEssential") + override val userId: String? = source.nullableString("userId") + override val sessionId: String? = source.nullableString("sessionId") + } +} + +private fun JSONObject.nullableString(key: String): String? = + if (isNull(key)) null else getString(key) + +private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { key -> + when (val value = get(key)) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } +} + +private fun JSONArray.toList(): List = List(length()) { index -> + when (val value = get(index)) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/FlexTrackClient.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/FlexTrackClient.kt new file mode 100644 index 0000000..40f36b5 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/FlexTrackClient.kt @@ -0,0 +1,99 @@ +package dev.taghizadeh.flextrack.runtime + +import dev.taghizadeh.flextrack.event.FlexEvent +import dev.taghizadeh.flextrack.event.TransformerPipeline +import dev.taghizadeh.flextrack.routing.ConsentState +import dev.taghizadeh.flextrack.routing.RoutingEngine +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope + +/** Runtime entry point for transforming, routing, delivering, and retrying events. */ +public class FlexTrackClient( + public val routingEngine: RoutingEngine, + public val registry: TrackerRegistry = TrackerRegistry(), + public val queue: EventQueue = InMemoryEventQueue(), + public val transformers: TransformerPipeline = TransformerPipeline(), + private val consentProvider: () -> ConsentState = { ConsentState() }, + private val onlineProvider: () -> Boolean = { true }, +) { + public suspend fun start(): Unit = registry.start() + + public suspend fun shutdown(): Unit = registry.shutdown() + + public suspend fun register(tracker: Tracker): Unit = registry.register(tracker) + + public suspend fun unregister(trackerId: String): Tracker? = registry.unregister(trackerId) + + public suspend fun track(event: FlexEvent): DispatchResult { + val transformed = transformers.transform(event) + val trackers = registry.snapshot() + val routing = routingEngine.route(transformed, consentProvider(), trackers.keys) + val targets = routing.targetTrackers + + if (!onlineProvider() && targets.isNotEmpty()) { + queue.enqueue(QueuedEvent(transformed.eventId, transformed, targets)) + return DispatchResult(transformed, routing, emptyList(), emptyList(), targets) + } + + val outcomes = deliver(transformed, targets, trackers) + val failures = outcomes.mapNotNull { it.failure } + val failedIds = failures.map(TrackerFailure::trackerId) + if (failedIds.isNotEmpty()) { + queue.enqueue(QueuedEvent(transformed.eventId, transformed, failedIds)) + } + return DispatchResult( + transformed, + routing, + outcomes.filter { it.failure == null }.map(Outcome::trackerId), + failures, + failedIds, + ) + } + + public suspend fun flush(limit: Int = 100): FlushResult { + require(limit > 0) { "limit must be positive" } + if (!onlineProvider()) return FlushResult(0, 0, queue.size()) + + val items = queue.read(limit) + val trackers = registry.snapshot() + var delivered = 0 + for (item in items) { + val failedIds = deliver(item.event, item.trackerIds, trackers) + .mapNotNull { it.failure?.trackerId } + if (failedIds.isEmpty()) { + queue.remove(item.id) + delivered++ + } else { + queue.replace(item.copy(trackerIds = failedIds, attempts = item.attempts + 1)) + } + } + return FlushResult(items.size, delivered, queue.size()) + } + + private suspend fun deliver( + event: FlexEvent, + trackerIds: List, + trackers: Map, + ): List = coroutineScope { + trackerIds.map { id -> + async { + val tracker = trackers[id] + if (tracker == null) { + Outcome(id, TrackerFailure(id, IllegalStateException("tracker '$id' is unavailable"))) + } else { + try { + tracker.track(event) + Outcome(id) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + Outcome(id, TrackerFailure(id, failure)) + } + } + } + }.awaitAll() + } + + private data class Outcome(val trackerId: String, val failure: TrackerFailure? = null) +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/Tracker.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/Tracker.kt new file mode 100644 index 0000000..89b23a7 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/Tracker.kt @@ -0,0 +1,14 @@ +package dev.taghizadeh.flextrack.runtime + +import dev.taghizadeh.flextrack.event.FlexEvent + +/** A destination adapter managed by [FlexTrackClient]. */ +public interface Tracker { + public val id: String + + public suspend fun start(): Unit = Unit + + public suspend fun track(event: FlexEvent) + + public suspend fun shutdown(): Unit = Unit +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/TrackerRegistry.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/TrackerRegistry.kt new file mode 100644 index 0000000..42453d7 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/runtime/TrackerRegistry.kt @@ -0,0 +1,59 @@ +package dev.taghizadeh.flextrack.runtime + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.CancellationException + +/** Thread-safe registry that owns tracker lifecycle. */ +public class TrackerRegistry { + private val mutex: Mutex = Mutex() + private val trackers: LinkedHashMap = linkedMapOf() + private var started: Boolean = false + + public suspend fun register(tracker: Tracker) { + require(tracker.id.isNotBlank()) { "tracker id cannot be blank" } + mutex.withLock { + require(tracker.id !in trackers) { "tracker '${tracker.id}' is already registered" } + if (started) tracker.start() + trackers[tracker.id] = tracker + } + } + + public suspend fun unregister(id: String): Tracker? = mutex.withLock { + trackers.remove(id)?.also { if (started) it.shutdown() } + } + + public suspend fun start() { + mutex.withLock { + if (started) return + val initialized = mutableListOf() + try { + for (tracker in trackers.values) { + tracker.start() + initialized += tracker + } + started = true + } catch (failure: Throwable) { + initialized.asReversed().forEach { runCatching { it.shutdown() } } + if (failure is CancellationException) throw failure + throw failure + } + } + } + + public suspend fun shutdown() { + mutex.withLock { + if (!started) return + trackers.values.toList().asReversed().forEach { tracker -> + try { + tracker.shutdown() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + } + } + started = false + } + } + + internal suspend fun snapshot(): Map = mutex.withLock { trackers.toMap() } +} diff --git a/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/conformance/CoreMvpConformanceTest.kt b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/conformance/CoreMvpConformanceTest.kt new file mode 100644 index 0000000..6d8b080 --- /dev/null +++ b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/conformance/CoreMvpConformanceTest.kt @@ -0,0 +1,176 @@ +package dev.taghizadeh.flextrack.conformance + +import dev.taghizadeh.flextrack.event.EnrichedEvent +import dev.taghizadeh.flextrack.event.FlexEvent +import dev.taghizadeh.flextrack.routing.ConsentState +import dev.taghizadeh.flextrack.routing.EventCategory +import dev.taghizadeh.flextrack.routing.RoutingConfiguration +import dev.taghizadeh.flextrack.routing.RoutingEngine +import dev.taghizadeh.flextrack.routing.RoutingRule +import dev.taghizadeh.flextrack.routing.TrackerGroup +import dev.taghizadeh.flextrack.sampling.DeterministicSampler +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import java.nio.file.Path +import java.time.Instant +import java.util.stream.Stream + +class CoreMvpConformanceTest { + @TestFactory + fun `shared Flutter and Kotlin fixtures conform to Core MVP`(): Stream { + val workingDirectory = Path.of(System.getProperty("user.dir")) + val root = workingDirectory.resolve("contract").takeIf { it.toFile().isDirectory } + ?: workingDirectory.parent.resolve("contract") + val document = JSONObject(root.resolve("core_mvp_cases.json").toFile().readText()) + assertEquals("1.0.0", document.getString("specVersion")) + val cases = document.getJSONArray("cases") + return (0 until cases.length()).map { cases.getJSONObject(it) }.map { fixture -> + DynamicTest.dynamicTest(fixture.getString("id")) { verify(fixture) } + }.stream() + } + + private fun verify(fixture: JSONObject) { + when (fixture.getString("behavior")) { + "routing" -> verifyRouting(fixture) + "consent" -> verifyConsent(fixture) + "sampling" -> verifySampling(fixture) + "enrichment" -> verifyEnrichment(fixture) + "debug" -> verifyDebug(fixture) + else -> error("Unsupported fixture behavior") + } + } + + private fun verifyRouting(fixture: JSONObject) { + val input = fixture.getJSONObject("input") + val expected = fixture.getJSONObject("expected") + val event = input.getJSONObject("event").toEvent(requiresConsent = false) + val rules = input.getJSONArray("rules").objects().map(::toRule) + val defaultGroup = input.optJSONArray("defaultGroup")?.strings()?.let { + TrackerGroup("default", it) + } + val result = RoutingEngine(RoutingConfiguration(rules, defaultGroup = defaultGroup)).route( + event, + consent = ConsentState(general = true), + availableTrackers = input.getJSONArray("availableTrackers").strings().toSet(), + ) + assertEquals(expected.getJSONArray("targets").strings(), result.targetTrackers) + assertEquals( + expected.getJSONArray("appliedPriorities").ints(), + result.appliedRules.map(RoutingRule::priority), + ) + } + + private fun verifyConsent(fixture: JSONObject) { + val input = fixture.getJSONObject("input") + val ruleInput = input.getJSONObject("rule") + val rule = RoutingRule( + targetGroup = TrackerGroup("fixture", ruleInput.getJSONArray("targets").strings()), + requireConsent = ruleInput.optBoolean("requireConsent", true), + requirePIIConsent = ruleInput.optBoolean("requirePIIConsent", false), + ) + val result = RoutingEngine(RoutingConfiguration(listOf(rule))).route( + input.getJSONObject("event").toEvent(), + ConsentState(input.getBoolean("generalConsent"), input.getBoolean("piiConsent")), + rule.targetGroup.trackerIds.toSet(), + ) + assertEquals( + fixture.getJSONObject("expected").getJSONArray("skipReasons").strings(), + result.skippedRules.map { it.reason }, + ) + } + + private fun verifySampling(fixture: JSONObject) { + val input = fixture.getJSONObject("input") + val identity = input.getString("identity") + val event = FixtureEvent(nameValue = "fixture", userIdValue = identity) + assertEquals( + fixture.getJSONObject("expected").getLong("hash"), + DeterministicSampler.stableHash(identity), + ) + assertEquals( + fixture.getJSONObject("expected").getBoolean("accepted"), + DeterministicSampler.shouldSample(event, input.getDouble("sampleRate")), + ) + } + + private fun verifyEnrichment(fixture: JSONObject) { + val input = fixture.getJSONObject("input") + val event = FixtureEvent( + eventIdValue = input.getString("eventId"), + timestampValue = Instant.parse(input.getString("timestamp")), + nameValue = input.getString("name"), + propertiesValue = input.getJSONObject("properties").toMap(), + ) + val enriched = EnrichedEvent(event, input.getJSONObject("extraProperties").toMap()) + val expected = fixture.getJSONObject("expected") + assertEquals(expected.getString("eventId"), enriched.eventId) + assertEquals(Instant.parse(expected.getString("timestamp")), enriched.timestamp) + assertEquals(expected.getJSONObject("properties").toMap(), enriched.properties) + } + + private fun verifyDebug(fixture: JSONObject) { + val input = fixture.getJSONObject("input") + val engine = RoutingEngine( + RoutingConfiguration(input.getJSONArray("rules").objects().map(::toRule)), + ) + val debug = engine.debug( + input.getJSONObject("event").toEvent(requiresConsent = false), + ConsentState(general = true), + input.getJSONArray("availableTrackers").strings().toSet(), + ) + assertEquals( + fixture.getJSONObject("expected").getJSONArray("targetTrackers").strings(), + debug.routingResult.targetTrackers, + ) + } + + private fun toRule(value: JSONObject): RoutingRule { + val targets = value.getJSONArray("targets").strings() + return RoutingRule( + eventNameContains = value.optString("nameContains").ifBlank { null }, + category = value.optString("category").ifBlank { null }?.let(::EventCategory), + isDefault = value.optBoolean("default", false), + targetGroup = TrackerGroup("fixture-${targets.joinToString()}", targets), + priority = value.optInt("priority", 0), + requireConsent = false, + ) + } + + private fun JSONObject.toEvent(requiresConsent: Boolean? = null): FlexEvent = FixtureEvent( + nameValue = getString("name"), + categoryValue = optString("category").ifBlank { null }?.let(::EventCategory), + containsPIIValue = optBoolean("containsPII", false), + requiresConsentValue = requiresConsent ?: optBoolean("requiresConsent", true), + ) + + private class FixtureEvent( + eventIdValue: String = "fixture-event", + timestampValue: Instant = Instant.EPOCH, + private val nameValue: String, + private val propertiesValue: Map? = emptyMap(), + private val categoryValue: EventCategory? = null, + private val containsPIIValue: Boolean = false, + private val requiresConsentValue: Boolean = false, + private val userIdValue: String? = null, + ) : FlexEvent(eventIdValue, timestampValue) { + override val name: String = nameValue + override val properties: Map? = propertiesValue + override val category: EventCategory? = categoryValue + override val containsPII: Boolean = containsPIIValue + override val requiresConsent: Boolean = requiresConsentValue + override val userId: String? = userIdValue + } +} + +private fun org.json.JSONArray.strings(): List = + List(length()) { getString(it) } + +private fun org.json.JSONArray.ints(): List = + List(length()) { getInt(it) } + +private fun org.json.JSONArray.objects(): List = + List(length()) { getJSONObject(it) } + +private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { get(it) } diff --git a/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/runtime/FlexTrackClientTest.kt b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/runtime/FlexTrackClientTest.kt new file mode 100644 index 0000000..f1457a4 --- /dev/null +++ b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/runtime/FlexTrackClientTest.kt @@ -0,0 +1,135 @@ +package dev.taghizadeh.flextrack.runtime + +import dev.taghizadeh.flextrack.event.FlexEvent +import dev.taghizadeh.flextrack.routing.ConsentState +import dev.taghizadeh.flextrack.routing.RoutingConfiguration +import dev.taghizadeh.flextrack.routing.RoutingEngine +import dev.taghizadeh.flextrack.routing.RoutingRule +import dev.taghizadeh.flextrack.routing.TrackerGroup +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class FlexTrackClientTest { + @Test + fun `delivers to matching trackers and isolates failures`() = runTest { + val successful = RecordingTracker("analytics") + val failing = RecordingTracker("archive", fail = true) + val queue = InMemoryEventQueue() + val client = client(queue = queue) + client.register(successful) + client.register(failing) + + val result = client.track(TestEvent()) + + assertEquals(listOf("analytics"), result.successfulTrackerIds) + assertEquals(listOf("archive"), result.failures.map(TrackerFailure::trackerId)) + assertEquals(1, successful.events.size) + assertEquals(1, queue.size()) + } + + @Test + fun `queues all destinations while offline`() = runTest { + val tracker = RecordingTracker("analytics") + val queue = InMemoryEventQueue() + val client = client(queue = queue, online = { false }) + client.register(tracker) + + val result = client.track(TestEvent()) + + assertTrue(result.wasQueued) + assertTrue(tracker.events.isEmpty()) + assertEquals(1, queue.size()) + } + + @Test + fun `flush retries only destinations that still fail`() = runTest { + val tracker = RecordingTracker("analytics", fail = true) + val queue = InMemoryEventQueue() + val client = client(queue = queue) + client.register(tracker) + client.track(TestEvent()) + tracker.fail = false + + val result = client.flush() + + assertEquals(1, result.deliveredEvents) + assertEquals(0, result.remainingEvents) + } + + @Test + fun `registry starts late registrations and shuts down once`() = runTest { + val registry = TrackerRegistry() + val first = RecordingTracker("first") + val second = RecordingTracker("second") + registry.register(first) + registry.start() + registry.start() + registry.register(second) + registry.shutdown() + + assertEquals(1, first.starts) + assertEquals(1, second.starts) + assertEquals(1, first.shutdowns) + assertEquals(1, second.shutdowns) + } + + @Test + fun `consent denial prevents delivery and queueing`() = runTest { + val tracker = RecordingTracker("analytics") + val queue = InMemoryEventQueue() + val client = client(queue = queue, consent = { ConsentState() }) + client.register(tracker) + + val result = client.track(TestEvent(requiresConsentValue = true)) + + assertFalse(result.wasQueued) + assertTrue(result.routing.targetTrackers.isEmpty()) + assertTrue(tracker.events.isEmpty()) + } + + private fun client( + queue: EventQueue, + online: () -> Boolean = { true }, + consent: () -> ConsentState = { ConsentState(general = true) }, + ): FlexTrackClient = FlexTrackClient( + routingEngine = RoutingEngine( + RoutingConfiguration( + rules = listOf( + RoutingRule( + targetGroup = TrackerGroup("all", listOf("analytics", "archive")), + ), + ), + ), + ), + queue = queue, + onlineProvider = online, + consentProvider = consent, + ) + + private class TestEvent( + private val requiresConsentValue: Boolean = false, + ) : FlexEvent() { + override val name: String = "purchase" + override val properties: Map = mapOf("plan" to "pro") + override val requiresConsent: Boolean = requiresConsentValue + } + + private class RecordingTracker( + override val id: String, + var fail: Boolean = false, + ) : Tracker { + val events = mutableListOf() + var starts = 0 + var shutdowns = 0 + + override suspend fun start() { starts++ } + override suspend fun track(event: FlexEvent) { + if (fail) error("delivery failed") + events += event + } + override suspend fun shutdown() { shutdowns++ } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d24d57..56905ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,8 @@ espressoCore = "3.5.1" appcompat = "1.6.1" material = "1.10.0" desugarJdkLibs = "2.1.5" +coroutines = "1.9.0" +json = "20250107" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -19,6 +21,9 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +json = { module = "org.json:json", version.ref = "json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }