diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index ee286ef8..6fff5e6c 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -30,33 +30,18 @@ protected void onCreate(Bundle savedInstanceState) { // Set debugging to true so we don't actually send things to Parse.ly ParselyTracker.sharedInstance().setDebug(true); - final TextView queueView = (TextView) findViewById(R.id.queue_size); - queueView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize())); - - final TextView storedView = (TextView) findViewById(R.id.stored_size); - storedView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount())); - final TextView intervalView = (TextView) findViewById(R.id.interval); - storedView.setText(String.format("Flush interval: %d", ParselyTracker.sharedInstance().getFlushInterval())); updateEngagementStrings(); - final TextView views[] = new TextView[3]; - views[0] = queueView; - views[1] = storedView; - views[2] = intervalView; + final TextView views[] = new TextView[1]; + views[0] = intervalView; final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { TextView[] v = (TextView[]) msg.obj; - TextView qView = v[0]; - qView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize())); - - TextView sView = v[1]; - sView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount())); - - TextView iView = v[2]; + TextView iView = v[0]; if (ParselyTracker.sharedInstance().flushTimerIsActive()) { iView.setText(String.format("Flush Interval: %d", ParselyTracker.sharedInstance().getFlushInterval())); } else { diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index ce23ecdd..e86a9223 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -68,26 +68,11 @@ android:onClick="trackReset" android:text="@string/button_reset_video" /> - - - - - \ No newline at end of file + diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 48bf7503..ead1058c 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -214,13 +214,12 @@ class FunctionalTests { activity: Activity, flushInterval: Duration = defaultFlushInterval ): ParselyTracker { + val field: Field = ParselyTracker::class.java.getDeclaredField("ROOT_URL") + field.isAccessible = true + field.set(this, url) return ParselyTracker.sharedInstance( siteId, flushInterval.inWholeSeconds.toInt(), activity.application - ).apply { - val f: Field = this::class.java.getDeclaredField("ROOT_URL") - f.isAccessible = true - f.set(this, url) - } + ) } private companion object { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index 121b6bf9..5026c8d8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -13,26 +13,35 @@ import kotlinx.coroutines.launch * Handles stopping and starting the flush timer. The flush timer * controls how often we send events to Parse.ly servers. */ -internal class FlushManager( - private val parselyTracker: ParselyTracker, - val intervalMillis: Long, +internal interface FlushManager { + fun start() + fun stop() + val isRunning: Boolean + val intervalMillis: Long +} + +internal class ParselyFlushManager( + private val onFlush: () -> Unit, + override val intervalMillis: Long, private val coroutineScope: CoroutineScope -) { +) : FlushManager { private var job: Job? = null - fun start() { + override fun start() { if (job?.isActive == true) return job = coroutineScope.launch { while (isActive) { delay(intervalMillis) - parselyTracker.flushEvents() + onFlush.invoke() } } } - fun stop() = job?.cancel() + override fun stop() { + job?.cancel() + } - val isRunning: Boolean + override val isRunning: Boolean get() = job?.isActive ?: false } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt new file mode 100644 index 00000000..4a989b95 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -0,0 +1,51 @@ +package com.parsely.parselyandroid + +import com.parsely.parselyandroid.JsonSerializer.toParselyEventsPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class FlushQueue( + private val flushManager: FlushManager, + private val repository: QueueRepository, + private val restClient: RestClient, + private val scope: CoroutineScope +) { + + private val mutex = Mutex() + + operator fun invoke(skipSendingEvents: Boolean) { + scope.launch { + mutex.withLock { + val eventsToSend = repository.getStoredQueue() + + if (eventsToSend.isEmpty()) { + flushManager.stop() + return@launch + } + + if (skipSendingEvents) { + ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") + repository.remove(eventsToSend) + return@launch + } + ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + val jsonPayload = toParselyEventsPayload(eventsToSend) + ParselyTracker.PLog("POST Data %s", jsonPayload) + ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) + restClient.send(jsonPayload) + .fold( + onSuccess = { + ParselyTracker.PLog("Pixel request success") + repository.remove(eventsToSend) + }, + onFailure = { + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(it.toString()) + } + ) + } + } + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index c92c0c7a..619e993d 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.sync.withLock internal class InMemoryBuffer( private val coroutineScope: CoroutineScope, - private val localStorageRepository: LocalStorageRepository, + private val localStorageRepository: QueueRepository, private val onEventAddedListener: () -> Unit, ) { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt new file mode 100644 index 00000000..dde232ce --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt @@ -0,0 +1,32 @@ +package com.parsely.parselyandroid + +import com.fasterxml.jackson.databind.ObjectMapper +import java.io.IOException +import java.io.StringWriter + +internal object JsonSerializer { + + fun toParselyEventsPayload(eventsToSend: List?>): String { + val batchMap: MutableMap = HashMap() + batchMap["events"] = eventsToSend + return toJson(batchMap).orEmpty() + } + /** + * Encode an event Map as JSON. + * + * @param map The Map object to encode as JSON. + * @return The JSON-encoded value of `map`. + */ + private fun toJson(map: Map): String? { + val mapper = ObjectMapper() + var ret: String? = null + try { + val strWriter = StringWriter() + mapper.writeValue(strWriter, map) + ret = strWriter.toString() + } catch (e: IOException) { + e.printStackTrace() + } + return ret + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index d3873326..1f1f28fc 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -5,11 +5,16 @@ import java.io.EOFException import java.io.FileNotFoundException import java.io.ObjectInputStream import java.io.ObjectOutputStream -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -internal open class LocalStorageRepository(private val context: Context) { +internal interface QueueRepository { + suspend fun remove(toRemove: List?>) + suspend fun getStoredQueue(): ArrayList?> + suspend fun insertEvents(toInsert: List?>) +} + +internal class LocalStorageRepository(private val context: Context) : QueueRepository { private val mutex = Mutex() @@ -33,23 +38,7 @@ internal open class LocalStorageRepository(private val context: Context) { } } - /** - * Delete the stored queue from persistent storage. - */ - fun purgeStoredQueue() { - persistObject(ArrayList>()) - } - - fun remove(toRemove: List>) { - persistObject(getStoredQueue() - toRemove.toSet()) - } - - /** - * Get the stored event queue from persistent storage. - * - * @return The stored queue of events. - */ - open fun getStoredQueue(): ArrayList?> { + private fun getInternalStoredQueue(): ArrayList?> { var storedQueue: ArrayList?> = ArrayList() try { val fis = context.applicationContext.openFileInput(STORAGE_KEY) @@ -71,19 +60,26 @@ internal open class LocalStorageRepository(private val context: Context) { return storedQueue } + override suspend fun remove(toRemove: List?>) = mutex.withLock { + val storedEvents = getInternalStoredQueue() + persistObject(storedEvents - toRemove.toSet()) + } + /** - * Delete an event from the stored queue. + * Get the stored event queue from persistent storage. + * + * @return The stored queue of events. */ - open fun expelStoredEvent() { - val storedQueue = getStoredQueue() - storedQueue.removeAt(0) + override suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { + getInternalStoredQueue() } /** * Save the event queue to persistent storage. */ - open suspend fun insertEvents(toInsert: List?>) = mutex.withLock { - persistObject(ArrayList((toInsert + getStoredQueue()).distinct())) + override suspend fun insertEvents(toInsert: List?>) = mutex.withLock { + val storedEvents = getInternalStoredQueue() + persistObject(ArrayList((toInsert + storedEvents).distinct())) } companion object { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 8d0634bd..c1c1e422 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -13,50 +13,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -@file:Suppress("DEPRECATION") package com.parsely.parselyandroid -import android.os.AsyncTask import java.net.HttpURLConnection import java.net.URL -internal class ParselyAPIConnection(private val tracker: ParselyTracker) : AsyncTask() { - private var exception: Exception? = null +internal interface RestClient { + suspend fun send(payload: String): Result +} - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg data: String?): Void? { +internal class ParselyAPIConnection(private val url: String) : RestClient { + override suspend fun send(payload: String): Result { var connection: HttpURLConnection? = null try { - if (data.size == 1) { // non-batched (since no post data is included) - connection = URL(data[0]).openConnection() as HttpURLConnection - connection.inputStream - } else if (data.size == 2) { // batched (post data included) - connection = URL(data[0]).openConnection() as HttpURLConnection - connection.doOutput = true // Triggers POST (aka silliest interface ever) - connection.setRequestProperty("Content-Type", "application/json") - val output = connection.outputStream - output.write(data[1]?.toByteArray()) - output.close() - connection.inputStream - } + connection = URL(url).openConnection() as HttpURLConnection + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + val output = connection.outputStream + output.write(payload.toByteArray()) + output.close() + connection.inputStream } catch (ex: Exception) { - exception = ex + return Result.failure(ex) + } finally { + connection?.disconnect() } - return null - } - @Deprecated("Deprecated in Java") - override fun onPostExecute(result: Void?) { - if (exception != null) { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(exception.toString()) - } else { - ParselyTracker.PLog("Pixel request success") - - // only purge the queue if the request was successful - tracker.purgeEventsQueue() - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - tracker.stopFlushTimer() - } + return Result.success(Unit) } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 6a72bbb0..6b341e87 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -19,7 +19,6 @@ import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.os.AsyncTask; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -27,18 +26,13 @@ import androidx.lifecycle.LifecycleEventObserver; import androidx.lifecycle.ProcessLifecycleOwner; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.io.IOException; -import java.io.StringWriter; -import java.util.ArrayList; import java.util.Formatter; -import java.util.HashMap; import java.util.Map; import java.util.Timer; import java.util.UUID; import kotlin.Unit; +import kotlin.jvm.functions.Function0; /** * Tracks Parse.ly app views in Android apps @@ -51,8 +45,8 @@ public class ParselyTracker { private static final int DEFAULT_FLUSH_INTERVAL_SECS = 60; private static final int DEFAULT_ENGAGEMENT_INTERVAL_MILLIS = 10500; @SuppressWarnings("StringOperationCanBeSimplified") -// private static final String ROOT_URL = "http://10.0.2.2:5001/".intern(); // emulator localhost - private static final String ROOT_URL = "https://p1.parsely.com/".intern(); +// static final String ROOT_URL = "http://10.0.2.2:5001/".intern(); // emulator localhost + static final String ROOT_URL = "https://p1.parsely.com/".intern(); private boolean isDebug; private final Context context; private final Timer timer; @@ -68,6 +62,8 @@ public class ParselyTracker { private final LocalStorageRepository localStorageRepository; @NonNull private final InMemoryBuffer inMemoryBuffer; + @NonNull + private final FlushQueue flushQueue; /** * Create a new ParselyTracker instance. @@ -76,7 +72,13 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); localStorageRepository = new LocalStorageRepository(context); - flushManager = new FlushManager(this, flushInterval * 1000L, + flushManager = new ParselyFlushManager(new Function0() { + @Override + public Unit invoke() { + flushEvents(); + return Unit.INSTANCE; + } + }, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, () -> { if (!flushTimerIsActive()) { @@ -85,14 +87,13 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { } return Unit.INSTANCE; }); + flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation timer = new Timer(); isDebug = false; - if (localStorageRepository.getStoredQueue().size() > 0) { - startFlushTimer(); - } + flushManager.start(); ProcessLifecycleOwner.get().getLifecycle().addObserver( (LifecycleEventObserver) (lifecycleOwner, event) -> { @@ -428,34 +429,6 @@ public void flushEventQueue() { // no-op } - /** - * Send the batched event request to Parsely. - *

- * Creates a POST request containing the JSON encoding of the event queue. - * Sends this request to Parse.ly servers. - * - * @param events The list of event dictionaries to serialize - */ - private void sendBatchRequest(ArrayList> events) { - if (events == null || events.size() == 0) { - return; - } - PLog("Sending request with %d events", events.size()); - - // Put in a Map for the proxy server - Map batchMap = new HashMap<>(); - batchMap.put("events", events); - - if (isDebug) { - PLog("Debug mode on. Not sending to Parse.ly"); - purgeEventsQueue(); - } else { - new ParselyAPIConnection(this).execute(ROOT_URL + "mobileproxy", JsonEncode(batchMap)); - PLog("Requested %s", ROOT_URL); - } - PLog("POST Data %s", JsonEncode(batchMap)); - } - /** * Returns whether the network is accessible and Parsely is reachable. * @@ -468,29 +441,6 @@ private boolean isReachable() { return netInfo != null && netInfo.isConnectedOrConnecting(); } - void purgeEventsQueue() { - localStorageRepository.purgeStoredQueue(); - } - - /** - * Encode an event Map as JSON. - * - * @param map The Map object to encode as JSON. - * @return The JSON-encoded value of `map`. - */ - private String JsonEncode(Map map) { - ObjectMapper mapper = new ObjectMapper(); - String ret = null; - try { - StringWriter strWriter = new StringWriter(); - mapper.writeValue(strWriter, map); - ret = strWriter.toString(); - } catch (IOException e) { - e.printStackTrace(); - } - return ret; - } - /** * Start the timer to flush events to Parsely. *

@@ -511,60 +461,16 @@ public boolean flushTimerIsActive() { return flushManager.isRunning(); } - /** - * Stop the event queue flush timer. - */ - public void stopFlushTimer() { - flushManager.stop(); - } - @NonNull private String generatePixelId() { return UUID.randomUUID().toString(); } - /** - * Get the number of events waiting to be flushed to Parsely. - * - * @return The number of events waiting to be flushed to Parsely. - */ - public int queueSize() { - return localStorageRepository.getStoredQueue().size(); - } - - /** - * Get the number of events stored in persistent storage. - * - * @return The number of events stored in persistent storage. - */ - public int storedEventsCount() { - ArrayList> ar = localStorageRepository.getStoredQueue(); - return ar.size(); - } - - private class FlushQueue extends AsyncTask { - @Override - protected synchronized Void doInBackground(Void... params) { - ArrayList> storedQueue = localStorageRepository.getStoredQueue(); - PLog("%d events in stored queue", storedEventsCount()); - // in case both queues have been flushed and app quits, don't crash - if (storedQueue.isEmpty()) { - stopFlushTimer(); - return null; - } - if (!isReachable()) { - PLog("Network unreachable. Not flushing."); - return null; - } - - PLog("Flushing queue"); - sendBatchRequest(storedQueue); - return null; - } - } - void flushEvents() { - new FlushQueue().execute(); + if (!isReachable()) { + PLog("Network unreachable. Not flushing."); + return; + } + flushQueue.invoke(isDebug); } - } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt index cf2ef157..02842a2c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -1,6 +1,5 @@ package com.parsely.parselyandroid -import androidx.test.core.app.ApplicationProvider import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy @@ -8,42 +7,42 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) class FlushManagerTest { - private lateinit var sut: FlushManager - private val tracker = FakeTracker() - @Test fun `when timer starts and interval time passes, then flush queue`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(1) + assertThat(flushEventsCounter).isEqualTo(1) } @Test fun `when timer starts and three interval time passes, then flush queue 3 times`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(3 * DEFAULT_INTERVAL_MILLIS) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(3) + assertThat(flushEventsCounter).isEqualTo(3) } @Test fun `when timer starts and is stopped after 2 intervals passes, then flush queue 2 times`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(2 * DEFAULT_INTERVAL_MILLIS) @@ -52,13 +51,15 @@ class FlushManagerTest { advanceTimeBy(DEFAULT_INTERVAL_MILLIS) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(2) + assertThat(flushEventsCounter).isEqualTo(2) } @Test fun `when timer starts, is stopped before end of interval and then time of interval passes, then do not flush queue`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) @@ -67,13 +68,15 @@ class FlushManagerTest { advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(0) + assertThat(flushEventsCounter).isEqualTo(0) } @Test fun `when timer starts, and another timer starts after some time, then flush queue according to the first start`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) @@ -82,25 +85,15 @@ class FlushManagerTest { advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(1) + assertThat(flushEventsCounter).isEqualTo(1) advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(1) + assertThat(flushEventsCounter).isEqualTo(1) } private companion object { val DEFAULT_INTERVAL_MILLIS: Long = 30.seconds.inWholeMilliseconds } - - class FakeTracker : ParselyTracker( - "", 0, ApplicationProvider.getApplicationContext() - ) { - var flushEventsCounter = 0 - - override fun flushEvents() { - flushEventsCounter++ - } - } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt new file mode 100644 index 00000000..e49605b6 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -0,0 +1,219 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class FlushQueueTest { + + @Test + fun `given empty local storage, when sending events, then do nothing`() = + runTest { + // given + val sut = FlushQueue( + FakeFlushManager(), + FakeRepository(), + FakeRestClient(), + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(FakeRepository().getStoredQueue()).isEmpty() + } + + @Test + fun `given non-empty local storage, when flushing queue with not skipping sending events, then events are sent and removed from local storage`() = + runTest { + // given + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.success(Unit) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isEmpty() + } + + @Test + fun `given non-empty local storage, when flushing queue with skipping sending events, then events are not sent and removed from local storage`() = + runTest { + // given + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + FakeRestClient(), + this + ) + + // when + sut.invoke(true) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isEmpty() + } + + @Test + fun `given non-empty local storage, when flushing queue with not skipping sending events fails, then events are not removed from local storage`() = + runTest { + // given + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.failure(Exception()) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isNotEmpty + } + + @Test + fun `given non-empty local storage, when flushing queue with not skipping sending events fails, then flush manager is not stopped`() = + runTest { + // given + val flushManager = FakeFlushManager() + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.failure(Exception()) + } + val sut = FlushQueue( + flushManager, + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isFalse + } + + @Test + fun `given non-empty local storage, when storage is not empty after successful flushing queue with not skipping sending events, then flush manager is not stopped`() = + runTest { + // given + val flushManager = FakeFlushManager() + val repository = object : FakeRepository() { + override suspend fun getStoredQueue(): ArrayList?> { + return ArrayList(listOf(mapOf("test" to 123))) + } + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.success(Unit) + } + val sut = FlushQueue( + flushManager, + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isFalse + } + + @Test + fun `given empty local storage, when invoked, then flush manager is stopped`() = runTest { + // given + val flushManager = FakeFlushManager() + val sut = FlushQueue( + flushManager, + FakeRepository(), + FakeRestClient(), + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isTrue() + } + + private class FakeFlushManager : FlushManager { + var stopped = false + override fun start() { + TODO("Not implemented") + } + + override fun stop() { + stopped = true + } + + override val isRunning + get() = TODO("Not implemented") + override val intervalMillis + get() = TODO("Not implemented") + } + + private open class FakeRepository : QueueRepository { + private var storage = emptyList?>() + + override suspend fun insertEvents(toInsert: List?>) { + storage = storage + toInsert + } + + override suspend fun remove(toRemove: List?>) { + storage = storage - toRemove.toSet() + } + + override suspend fun getStoredQueue(): ArrayList?> { + return ArrayList(storage) + } + } + + private class FakeRestClient : RestClient { + + var nextResult: Result? = null + + override suspend fun send(payload: String): Result { + return nextResult!! + } + } +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt index 66bc9614..e4f354ff 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -1,6 +1,5 @@ package com.parsely.parselyandroid -import androidx.test.core.app.ApplicationProvider import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel @@ -78,8 +77,7 @@ internal class InMemoryBufferTest { assertThat(repository.getStoredQueue()).containsOnlyOnceElementsOf(events) } - class FakeLocalStorageRepository : - LocalStorageRepository(ApplicationProvider.getApplicationContext()) { + class FakeLocalStorageRepository : QueueRepository { private val events = mutableListOf?>() @@ -87,7 +85,11 @@ internal class InMemoryBufferTest { events.addAll(toInsert) } - override fun getStoredQueue(): ArrayList?> { + override suspend fun remove(toRemove: List?>) { + TODO("Not implemented") + } + + override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(events) } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index f92b0d59..47a60057 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -24,19 +24,6 @@ class LocalStorageRepositoryTest { sut = LocalStorageRepository(context) } - @Test - fun `when expelling stored event, then assert that it has no effect`() = runTest { - // given - sut.insertEvents(((1..100).map { mapOf("index" to it) })) - runCurrent() - - // when - sut.expelStoredEvent() - - // then - assertThat(sut.getStoredQueue()).hasSize(100) - } - @Test fun `given the list of events, when persisting the list, then querying the list returns the same result`() = runTest { // given @@ -51,7 +38,7 @@ class LocalStorageRepositoryTest { } @Test - fun `given no locally stored list, when requesting stored queue, then return an empty list`() { + fun `given no locally stored list, when requesting stored queue, then return an empty list`() = runTest { assertThat(sut.getStoredQueue()).isEmpty() } @@ -92,7 +79,7 @@ class LocalStorageRepositoryTest { } @Test - fun `given stored file with serialized events, when querying the queue, then list has expected events`() { + fun `given stored file with serialized events, when querying the queue, then list has expected events`() = runTest { // given val file = File(context.filesDir.path + "/parsely-events.ser") File(ClassLoader.getSystemResource("valid-java-parsely-events.ser")?.path!!).copyTo(file) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 815abc93..497e5d5c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -1,29 +1,29 @@ package com.parsely.parselyandroid -import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okio.IOException import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.LooperMode -import org.robolectric.shadows.ShadowLooper.shadowMainLooper +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -@LooperMode(LooperMode.Mode.PAUSED) class ParselyAPIConnectionTest { private lateinit var sut: ParselyAPIConnection private val mockServer = MockWebServer() private val url = mockServer.url("").toString() - private val tracker = FakeTracker() @Before fun setUp() { - sut = ParselyAPIConnection(tracker) + sut = ParselyAPIConnection(url) } @After @@ -32,88 +32,43 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when making connection without any events, then make GET request`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - - // when - sut.execute(url).get() - shadowMainLooper().idle(); - - // then - val request = mockServer.takeRequest() - assertThat(request).satisfies({ - assertThat(it.method).isEqualTo("GET") - assertThat(it.failure).isNull() - }) - } - - @Test - fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - - // when - sut.execute(url, pixelPayload).get() - shadowMainLooper().idle(); - - // then - assertThat(mockServer.takeRequest()).satisfies({ - assertThat(it.method).isEqualTo("POST") - assertThat(it.headers["Content-Type"]).isEqualTo("application/json") - assertThat(it.body.readUtf8()).isEqualTo(pixelPayload) - }) - } - - @Test - fun `given successful response, when request is made, then purge events queue and stop flush timer`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - tracker.events.add(mapOf("idsite" to "example.com")) - - // when - sut.execute(url).get() - shadowMainLooper().idle(); - - // then - assertThat(tracker.events).isEmpty() - assertThat(tracker.flushTimerStopped).isTrue - } + fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() = + runTest { + // given + mockServer.enqueue(MockResponse().setResponseCode(200)) + + // when + val result = sut.send(pixelPayload) + runCurrent() + + // then + assertThat(mockServer.takeRequest()).satisfies({ + assertThat(it.method).isEqualTo("POST") + assertThat(it.headers["Content-Type"]).isEqualTo("application/json") + assertThat(it.body.readUtf8()).isEqualTo(pixelPayload) + }) + assertThat(result.isSuccess).isTrue + } @Test - fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(400)) - val sampleEvents = mapOf("idsite" to "example.com") - tracker.events.add(sampleEvents) - - // when - sut.execute(url).get() - shadowMainLooper().idle(); - - // then - assertThat(tracker.events).containsExactly(sampleEvents) - assertThat(tracker.flushTimerStopped).isFalse - } + fun `given unsuccessful response, when request is made, then return failure with exception`() = + runTest { + // given + mockServer.enqueue(MockResponse().setResponseCode(400)) + + // when + val result = sut.send(pixelPayload) + runCurrent() + + // then + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + } companion object { val pixelPayload: String = - this::class.java.getResource("pixel_payload.json")?.readText().orEmpty() - } - - private class FakeTracker : ParselyTracker( - "siteId", 10, ApplicationProvider.getApplicationContext() - ) { - - var flushTimerStopped = false - val events = mutableListOf>() - - override fun purgeEventsQueue() { - events.clear() - } - - override fun stopFlushTimer() { - flushTimerStopped = true - } + ClassLoader.getSystemResource("pixel_payload.json").readText().apply { + assert(isNotBlank()) + } } }