package com.segment.analytics; import static android.Manifest.permission.ACCESS_NETWORK_STATE; import static android.content.Context.CONNECTIVITY_SERVICE; import static android.content.pm.PackageManager.PERMISSION_DENIED; import static com.segment.analytics.Analytics.LogLevel.NONE; import static com.segment.analytics.SegmentIntegration.MAX_QUEUE_SIZE; import static com.segment.analytics.SegmentIntegration.UTF_8; import static com.segment.analytics.TestUtils.SynchronousExecutor; import static com.segment.analytics.TestUtils.TRACK_PAYLOAD; import static com.segment.analytics.TestUtils.TRACK_PAYLOAD_JSON; import static com.segment.analytics.TestUtils.mockApplication; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyMap; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; import com.segment.analytics.Client.Connection; import com.segment.analytics.PayloadQueue.PersistentQueue; import com.segment.analytics.integrations.Logger; import com.segment.analytics.integrations.TrackPayload; import com.segment.analytics.internal.Utils; import java.io.File; import java.io.IOError; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ExecutorService; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowLog; @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE) public class SegmentIntegrationTest { @Rule public TemporaryFolder folder = new TemporaryFolder(); QueueFile queueFile; private static Client.Connection mockConnection() { return mockConnection(mock(HttpURLConnection.class)); } private static Client.Connection mockConnection(HttpURLConnection connection) { return new Client.Connection(connection, mock(InputStream.class), mock(OutputStream.class)) { @Override public void close() throws IOException { super.close(); } }; } @Before public void setUp() throws IOException { queueFile = new QueueFile(new File(folder.getRoot(), "queue-file")); } @After public void tearDown() { assertThat(ShadowLog.getLogs()).isEmpty(); } @Test public void enqueueAddsToQueueFile() throws IOException { PayloadQueue payloadQueue = new PersistentQueue(queueFile); SegmentIntegration segmentIntegration = new SegmentBuilder().payloadQueue(payloadQueue).build(); segmentIntegration.performEnqueue(TRACK_PAYLOAD); assertThat(payloadQueue.size()).isEqualTo(1); } @Test public void enqueueWritesIntegrations() throws IOException { final HashMap<String, Boolean> integrations = new LinkedHashMap<>(); integrations.put("All", false); // should overwrite existing values in the map. integrations.put("Segment.io", false); // should ignore Segment setting in payload. integrations.put("foo", true); // should add new values. PayloadQueue payloadQueue = mock(PayloadQueue.class); SegmentIntegration segmentIntegration = new SegmentBuilder() // .payloadQueue(payloadQueue) .integrations(integrations) .build(); TrackPayload trackPayload = new TrackPayload.Builder() .messageId("a161304c-498c-4830-9291-fcfb8498877b") .timestamp(Utils.parseISO8601Date("2014-12-15T13:32:44-0700")) .event("foo") .userId("userId") .build(); segmentIntegration.performEnqueue(trackPayload); String expected = "{" + "\"channel\":\"mobile\"," + "\"type\":\"track\"," + "\"messageId\":\"a161304c-498c-4830-9291-fcfb8498877b\"," + "\"timestamp\":\"2014-12-15T20:32:44.000Z\"," + "\"context\":{}," + "\"integrations\":{\"All\":false,\"foo\":true}," + "\"userId\":\"userId\"," + "\"anonymousId\":null," + "\"event\":\"foo\"," + "\"properties\":{}" + "}"; ArgumentCaptor<byte[]> captor = ArgumentCaptor.forClass(byte[].class); verify(payloadQueue).add(captor.capture()); String got = new String(captor.getValue(), UTF_8); assertThat(got).isEqualTo(expected); } @Test public void enqueueLimitsQueueSize() throws IOException { PayloadQueue payloadQueue = mock(PayloadQueue.class); // We want to trigger a remove, but not a flush. when(payloadQueue.size()).thenReturn(0, MAX_QUEUE_SIZE, MAX_QUEUE_SIZE, 0); SegmentIntegration segmentIntegration = new SegmentBuilder().payloadQueue(payloadQueue).build(); segmentIntegration.performEnqueue(TRACK_PAYLOAD); verify(payloadQueue).remove(1); // Oldest entry is removed. verify(payloadQueue).add(any(byte[].class)); // Newest entry is added. } @Test public void exceptionIgnoredIfFailedToRemove() throws IOException { PayloadQueue payloadQueue = mock(PayloadQueue.class); doThrow(new IOException("no remove for you.")).when(payloadQueue).remove(1); when(payloadQueue.size()).thenReturn(MAX_QUEUE_SIZE); // trigger a remove SegmentIntegration segmentIntegration = new SegmentBuilder().payloadQueue(payloadQueue).build(); try { segmentIntegration.performEnqueue(TRACK_PAYLOAD); } catch (IOError unexpected) { fail("did not expect QueueFile to throw an error."); } verify(payloadQueue, never()).add(any(byte[].class)); } @Test public void enqueueMaxTriggersFlush() throws IOException { PayloadQueue payloadQueue = new PayloadQueue.PersistentQueue(queueFile); Client client = mock(Client.class); Client.Connection connection = mockConnection(); when(client.upload()).thenReturn(connection); SegmentIntegration segmentIntegration = new SegmentBuilder() // .client(client) .flushSize(5) .payloadQueue(payloadQueue) .build(); for (int i = 0; i < 4; i++) { segmentIntegration.performEnqueue(TRACK_PAYLOAD); } verifyZeroInteractions(client); // Only the last enqueue should trigger an upload. segmentIntegration.performEnqueue(TRACK_PAYLOAD); verify(client).upload(); } @Test public void flushRemovesItemsFromQueue() throws IOException { PayloadQueue payloadQueue = new PayloadQueue.PersistentQueue(queueFile); Client client = mock(Client.class); when(client.upload()).thenReturn(mockConnection()); SegmentIntegration segmentIntegration = new SegmentBuilder() // .client(client) .payloadQueue(payloadQueue) .build(); byte[] bytes = TRACK_PAYLOAD_JSON.getBytes(); for (int i = 0; i < 4; i++) { queueFile.add(bytes); } segmentIntegration.submitFlush(); assertThat(queueFile.size()).isEqualTo(0); } @Test public void flushSubmitsToExecutor() throws IOException { ExecutorService executor = spy(new SynchronousExecutor()); PayloadQueue payloadQueue = mock(PayloadQueue.class); when(payloadQueue.size()).thenReturn(1); SegmentIntegration dispatcher = new SegmentBuilder() // .payloadQueue(payloadQueue) .networkExecutor(executor) .build(); dispatcher.submitFlush(); verify(executor).submit(any(Runnable.class)); } @Test public void flushWhenDisconnectedSkipsUpload() throws IOException { NetworkInfo networkInfo = mock(NetworkInfo.class); when(networkInfo.isConnectedOrConnecting()).thenReturn(false); ConnectivityManager connectivityManager = mock(ConnectivityManager.class); when(connectivityManager.getActiveNetworkInfo()).thenReturn(networkInfo); Context context = mockApplication(); when(context.getSystemService(CONNECTIVITY_SERVICE)).thenReturn(connectivityManager); Client client = mock(Client.class); SegmentIntegration segmentIntegration = new SegmentBuilder().context(context).client(client).build(); segmentIntegration.submitFlush(); verify(client, never()).upload(); } @Test public void flushWhenQueueSizeIsLessThanOneSkipsUpload() throws IOException { PayloadQueue payloadQueue = mock(PayloadQueue.class); when(payloadQueue.size()).thenReturn(0); Context context = mockApplication(); Client client = mock(Client.class); SegmentIntegration segmentIntegration = new SegmentBuilder() // .payloadQueue(payloadQueue) .context(context) .client(client) .build(); segmentIntegration.submitFlush(); verifyZeroInteractions(context); verify(client, never()).upload(); } @Test public void flushDisconnectsConnection() throws IOException { Client client = mock(Client.class); PayloadQueue payloadQueue = new PayloadQueue.PersistentQueue(queueFile); queueFile.add(TRACK_PAYLOAD_JSON.getBytes()); HttpURLConnection urlConnection = mock(HttpURLConnection.class); Client.Connection connection = mockConnection(urlConnection); when(client.upload()).thenReturn(connection); SegmentIntegration segmentIntegration = new SegmentBuilder() // .client(client) // .payloadQueue(payloadQueue) // .build(); segmentIntegration.submitFlush(); verify(urlConnection, times(2)).disconnect(); } @Test public void removesRejectedPayloads() throws IOException { // todo: rewrite using mockwebserver. PayloadQueue payloadQueue = new PayloadQueue.PersistentQueue(queueFile); Client client = mock(Client.class); when(client.upload()) .thenReturn( new Connection( mock(HttpURLConnection.class), mock(InputStream.class), mock(OutputStream.class)) { @Override public void close() throws IOException { throw new Client.HTTPException(400, "Bad Request", "bad request"); } }); SegmentIntegration segmentIntegration = new SegmentBuilder() // .client(client) .payloadQueue(payloadQueue) .build(); for (int i = 0; i < 4; i++) { payloadQueue.add(TRACK_PAYLOAD_JSON.getBytes()); } segmentIntegration.submitFlush(); assertThat(queueFile.size()).isEqualTo(0); } @Test public void ignoresServerError() throws IOException { // todo: rewrite using mockwebserver. PayloadQueue payloadQueue = new PayloadQueue.PersistentQueue(queueFile); Client client = mock(Client.class); when(client.upload()) .thenReturn( new Connection( mock(HttpURLConnection.class), mock(InputStream.class), mock(OutputStream.class)) { @Override public void close() throws IOException { throw new Client.HTTPException( 500, "Internal Server Error", "internal server error"); } }); SegmentIntegration segmentIntegration = new SegmentBuilder() // .client(client) .payloadQueue(payloadQueue) .build(); for (int i = 0; i < 4; i++) { payloadQueue.add(TRACK_PAYLOAD_JSON.getBytes()); } segmentIntegration.submitFlush(); assertThat(queueFile.size()).isEqualTo(4); } @Test public void serializationErrorSkipsAddingPayload() throws IOException { PayloadQueue payloadQueue = mock(PayloadQueue.class); Cartographer cartographer = mock(Cartographer.class); TrackPayload payload = new TrackPayload.Builder().event("event").userId("userId").build(); SegmentIntegration segmentIntegration = new SegmentBuilder() // .cartographer(cartographer) .payloadQueue(payloadQueue) .build(); // Serialized json is null. when(cartographer.toJson(anyMap())).thenReturn(null); segmentIntegration.performEnqueue(payload); verify(payloadQueue, never()).add((byte[]) any()); // Serialized json is empty. when(cartographer.toJson(anyMap())).thenReturn(""); segmentIntegration.performEnqueue(payload); verify(payloadQueue, never()).add((byte[]) any()); // Serialized json is too large (> 15kb). StringBuilder stringBuilder = new StringBuilder(); for (int i = 0; i < SegmentIntegration.MAX_PAYLOAD_SIZE + 1; i++) { stringBuilder.append("a"); } when(cartographer.toJson(anyMap())).thenReturn(stringBuilder.toString()); segmentIntegration.performEnqueue(payload); verify(payloadQueue, never()).add((byte[]) any()); } @Test public void shutdown() throws IOException { PayloadQueue payloadQueue = mock(PayloadQueue.class); SegmentIntegration segmentIntegration = new SegmentBuilder().payloadQueue(payloadQueue).build(); segmentIntegration.shutdown(); verify(payloadQueue).close(); } @Test public void payloadVisitorReadsOnly475KB() throws IOException { SegmentIntegration.PayloadWriter payloadWriter = new SegmentIntegration.PayloadWriter( mock(SegmentIntegration.BatchPayloadWriter.class), Crypto.none()); byte[] bytes = ("{\n" + " \"context\": {\n" + " \"library\": \"analytics-android\",\n" + " \"libraryVersion\": \"0.4.4\",\n" + " \"telephony\": {\n" + " \"radio\": \"gsm\",\n" + " \"carrier\": \"FI elisa\"\n" + " },\n" + " \"wifi\": {\n" + " \"connected\": false,\n" + " \"available\": false\n" + " },\n" + " \"providers\": {\n" + " \"Tapstream\": false,\n" + " \"Amplitude\": false,\n" + " \"Localytics\": false,\n" + " \"Flurry\": false,\n" + " \"Countly\": false,\n" + " \"Bugsnag\": false,\n" + " \"Quantcast\": false,\n" + " \"Crittercism\": false,\n" + " \"Google Analytics\": false,\n" + " \"Omniture\": false,\n" + " \"Mixpanel\": false\n" + " },\n" + " \"location\": {\n" + " \"speed\": 0,\n" + " \"longitude\": 24.937207,\n" + " \"latitude\": 60.2495497\n" + " },\n" + " \"locale\": {\n" + " \"carrier\": \"FI elisa\",\n" + " \"language\": \"English\",\n" + " \"country\": \"United States\"\n" + " },\n" + " \"device\": {\n" + " \"userId\": \"123\",\n" + " \"brand\": \"samsung\",\n" + " \"release\": \"4.2.2\",\n" + " \"manufacturer\": \"samsung\",\n" + " \"sdk\": 17\n" + " },\n" + " \"display\": {\n" + " \"density\": 1.5,\n" + " \"width\": 800,\n" + " \"height\": 480\n" + " },\n" + " \"build\": {\n" + " \"name\": \"1.0\",\n" + " \"code\": 1\n" + " },\n" + " \"ip\": \"80.186.195.102\",\n" + " \"inferredIp\": true\n" + " }\n" + " }") .getBytes(); // length 1432 // Fill the payload with (1432 * 500) = ~716kb of data for (int i = 0; i < 500; i++) { queueFile.add(bytes); } queueFile.forEach(payloadWriter); // Verify only (331 * 1432) = 473992 < 475KB bytes are read assertThat(payloadWriter.payloadCount).isEqualTo(331); } private static class SegmentBuilder { Client client; Stats stats; PayloadQueue payloadQueue; Context context; Cartographer cartographer; Map<String, Boolean> integrations; int flushInterval = Utils.DEFAULT_FLUSH_INTERVAL; int flushSize = Utils.DEFAULT_FLUSH_QUEUE_SIZE; Logger logger = Logger.with(NONE); ExecutorService networkExecutor; SegmentBuilder() { initMocks(this); context = mockApplication(); when(context.checkCallingOrSelfPermission(ACCESS_NETWORK_STATE)) // .thenReturn(PERMISSION_DENIED); cartographer = Cartographer.INSTANCE; } public SegmentBuilder client(Client client) { this.client = client; return this; } public SegmentBuilder stats(Stats stats) { this.stats = stats; return this; } public SegmentBuilder payloadQueue(PayloadQueue payloadQueue) { this.payloadQueue = payloadQueue; return this; } public SegmentBuilder context(Context context) { this.context = context; return this; } public SegmentBuilder cartographer(Cartographer cartographer) { this.cartographer = cartographer; return this; } public SegmentBuilder integrations(Map<String, Boolean> integrations) { this.integrations = integrations; return this; } public SegmentBuilder flushInterval(int flushInterval) { this.flushInterval = flushInterval; return this; } public SegmentBuilder flushSize(int flushSize) { this.flushSize = flushSize; return this; } public SegmentBuilder log(Logger logger) { this.logger = logger; return this; } public SegmentBuilder networkExecutor(ExecutorService networkExecutor) { this.networkExecutor = networkExecutor; return this; } SegmentIntegration build() { if (context == null) { context = mockApplication(); when(context.checkCallingOrSelfPermission(ACCESS_NETWORK_STATE)) // .thenReturn(PERMISSION_DENIED); } if (client == null) { client = mock(Client.class); } if (cartographer == null) { cartographer = Cartographer.INSTANCE; } if (payloadQueue == null) { payloadQueue = mock(PayloadQueue.class); } if (stats == null) { stats = mock(Stats.class); } if (integrations == null) { integrations = Collections.emptyMap(); } if (networkExecutor == null) { networkExecutor = new SynchronousExecutor(); } return new SegmentIntegration( context, client, cartographer, networkExecutor, payloadQueue, stats, integrations, flushInterval, flushSize, logger, Crypto.none()); } } }