// Copyright 2016 Google Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // //////////////////////////////////////////////////////////////////////////////// package com.google.pubsub.kafka.sink; import static org.junit.Assert.assertEquals; import static org.mockito.Matchers.any; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; 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.when; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.protobuf.ByteString; import com.google.pubsub.kafka.common.ConnectorUtils; import com.google.pubsub.v1.PublishRequest; import com.google.pubsub.v1.PublishResponse; import com.google.pubsub.v1.PubsubMessage; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.apache.kafka.clients.consumer.OffsetAndMetadata; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.connect.data.Schema; import org.apache.kafka.connect.data.SchemaBuilder; import org.apache.kafka.connect.data.Struct; import org.apache.kafka.connect.errors.DataException; import org.apache.kafka.connect.sink.SinkRecord; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; /** Tests for {@link CloudPubSubSinkTask}. */ public class CloudPubSubSinkTaskTest { private static final String CPS_TOPIC = "the"; private static final String CPS_PROJECT = "quick"; private static final String CPS_MIN_BATCH_SIZE1 = "2"; private static final String CPS_MIN_BATCH_SIZE2 = "9"; private static final String KAFKA_TOPIC = "brown"; private static final ByteString KAFKA_MESSAGE1 = ByteString.copyFromUtf8("fox"); private static final ByteString KAFKA_MESSAGE2 = ByteString.copyFromUtf8("jumped"); private static final String FIELD_STRING1 = "Roll"; private static final String FIELD_STRING2 = "War"; private static final String KAFKA_MESSAGE_KEY = "over"; private static final Schema STRING_SCHEMA = SchemaBuilder.string().build(); private static final Schema BYTE_STRING_SCHEMA = SchemaBuilder.bytes().name(ConnectorUtils.SCHEMA_NAME).build(); private CloudPubSubSinkTask task; private Map<String, String> props; private CloudPubSubPublisher publisher; @Before public void setup() { publisher = mock(CloudPubSubPublisher.class, RETURNS_DEEP_STUBS); task = new CloudPubSubSinkTask(publisher); props = new HashMap<>(); props.put(ConnectorUtils.CPS_TOPIC_CONFIG, CPS_TOPIC); props.put(ConnectorUtils.CPS_PROJECT_CONFIG, CPS_PROJECT); props.put(CloudPubSubSinkConnector.MAX_BUFFER_SIZE_CONFIG, CPS_MIN_BATCH_SIZE2); } /** Tests that an exception is thrown when the schema of the value is not BYTES. */ @Test public void testPutPrimitives() { task.start(props); SinkRecord record8 = new SinkRecord(null, -1, null, null, SchemaBuilder.int8(), (byte) 5, -1); SinkRecord record16 = new SinkRecord(null, -1, null, null, SchemaBuilder.int16(), (short) 5, -1); SinkRecord record32 = new SinkRecord(null, -1, null, null, SchemaBuilder.int32(), (int) 5, -1); SinkRecord record64 = new SinkRecord(null, -1, null, null, SchemaBuilder.int64(), (long) 5, -1); SinkRecord recordFloat32 = new SinkRecord(null, -1, null, null, SchemaBuilder.float32(), (float) 8, -1); SinkRecord recordFloat64 = new SinkRecord(null, -1, null, null, SchemaBuilder.float64(), (double) 8, -1); SinkRecord recordBool = new SinkRecord(null, -1, null, null, SchemaBuilder.bool(), true, -1); SinkRecord recordString = new SinkRecord(null, -1, null, null, SchemaBuilder.string(), "Test put.", -1); List<SinkRecord> list = new ArrayList<>(); list.add(record8); list.add(record16); list.add(record32); list.add(record64); list.add(recordFloat32); list.add(recordFloat64); list.add(recordBool); list.add(recordString); task.put(list); } @Test public void testStructSchema() { task.start(props); Schema schema = SchemaBuilder.struct().field(FIELD_STRING1, SchemaBuilder.string()) .field(FIELD_STRING2, SchemaBuilder.string()).build(); Struct val = new Struct(schema); val.put(FIELD_STRING1, "tide"); val.put(FIELD_STRING2, "eagle"); SinkRecord record = new SinkRecord(null, -1, null, null, schema, val, -1); List<SinkRecord> list = new ArrayList<>(); list.add(record); task.put(list); schema = SchemaBuilder.struct().field(FIELD_STRING1, SchemaBuilder.struct()).build(); record = new SinkRecord(null, -1, null, null, schema, new Struct(schema), -1); list.add(record); try { task.put(list); } catch (DataException e) { } // Expected, pass. } @Test public void testMapSchema() { task.start(props); Schema schema = SchemaBuilder.map(SchemaBuilder.string(), SchemaBuilder.string()).build(); Map<String, String> val = new HashMap<>(); val.put(FIELD_STRING1, "tide"); val.put(FIELD_STRING2, "eagle"); SinkRecord record = new SinkRecord(null, -1, null, null, schema, val, -1); List<SinkRecord> list = new ArrayList<>(); list.add(record); task.put(list); schema = SchemaBuilder.map(SchemaBuilder.string(), SchemaBuilder.bytes()).build(); record = new SinkRecord(null, -1, null, null, schema, val, -1); list.add(record); try { task.put(list); } catch (DataException e) { } // Expected, pass. } @Test public void testArraySchema() { task.start(props); Schema schema = SchemaBuilder.array(SchemaBuilder.string()).build(); String[] val = {"Roll", "tide"}; SinkRecord record = new SinkRecord(null, -1, null, null, schema, val, -1); List<SinkRecord> list = new ArrayList<>(); list.add(record); task.put(list); schema = SchemaBuilder.array(SchemaBuilder.struct()).build(); record = new SinkRecord(null, -1, null, null, schema, null, -1); list.add(record); try { task.put(list); } catch (DataException e) { } // Expected, pass. } @Test public void testNullSchema() { task.start(props); String val = "I have no schema"; SinkRecord record = new SinkRecord(null, -1, null, null, null, val, -1); List<SinkRecord> list = new ArrayList<>(); list.add(record); task.put(list); } /** * Tests that if there are not enough messages buffered, publisher.publish() is not invoked. */ @Test public void testPutWhereNoPublishesAreInvoked() { task.start(props); List<SinkRecord> records = getSampleRecords(); task.put(records); verify(publisher, never()).publish(any(PublishRequest.class)); } /** * Tests that if there are enough messages buffered, that the PublishRequest sent to the publisher * is correct. */ @Test public void testPutWherePublishesAreInvoked() { props.put(CloudPubSubSinkConnector.MAX_BUFFER_SIZE_CONFIG, CPS_MIN_BATCH_SIZE1); task.start(props); List<SinkRecord> records = getSampleRecords(); task.put(records); ArgumentCaptor<PublishRequest> captor = ArgumentCaptor.forClass(PublishRequest.class); verify(publisher, times(1)).publish(captor.capture()); PublishRequest requestArg = captor.getValue(); assertEquals(requestArg.getMessagesList(), getPubsubMessagesFromSampleRecords()); } /** * Tests that a call to flush() processes the Futures that were generated during this same call * to flush() (i.e buffered messages were not published until the call to flush()). */ @Test public void testFlushWithNoPublishInPut() throws Exception { task.start(props); Map<TopicPartition, OffsetAndMetadata> partitionOffsets = new HashMap<>(); partitionOffsets.put(new TopicPartition(KAFKA_TOPIC, 0), null); List<SinkRecord> records = getSampleRecords(); ListenableFuture<PublishResponse> goodFuture = spy(Futures.immediateFuture(PublishResponse.getDefaultInstance())); when(publisher.publish(any(PublishRequest.class))).thenReturn(goodFuture); task.put(records); task.flush(partitionOffsets); verify(publisher, times(1)).publish(any(PublishRequest.class)); verify(goodFuture, times(1)).get(); } /** * Tests that a call to flush() processes the Futures that were generated during a previous * call to put() (i.e enough messages were buffered in put() to trigger a publish). */ @Test public void testFlushWithPublishInPut() throws Exception { props.put(CloudPubSubSinkConnector.MAX_BUFFER_SIZE_CONFIG, CPS_MIN_BATCH_SIZE1); task.start(props); List<SinkRecord> records = getSampleRecords(); ListenableFuture<PublishResponse> goodFuture = spy(Futures.immediateFuture(PublishResponse.getDefaultInstance())); when(publisher.publish(any(PublishRequest.class))).thenReturn(goodFuture); task.put(records); Map<TopicPartition, OffsetAndMetadata> partitionOffsets = new HashMap<>(); partitionOffsets.put(new TopicPartition(KAFKA_TOPIC, 0), null); task.flush(partitionOffsets); verify(goodFuture, times(1)).get(); ArgumentCaptor<PublishRequest> captor = ArgumentCaptor.forClass(PublishRequest.class); verify(publisher, times(1)).publish(captor.capture()); PublishRequest requestArg = captor.getValue(); assertEquals(requestArg.getMessagesList(), getPubsubMessagesFromSampleRecords()); } /** * Tests that if a Future that is being processed in flush() failed with an exception, that an * exception is thrown. */ @Test(expected = RuntimeException.class) public void testFlushExceptionCase() throws Exception { task.start(props); Map<TopicPartition, OffsetAndMetadata> partitionOffsets = new HashMap<>(); partitionOffsets.put(new TopicPartition(KAFKA_TOPIC, 0), null); List<SinkRecord> records = getSampleRecords(); ListenableFuture<PublishResponse> badFuture = spy(Futures.<PublishResponse>immediateFailedFuture(new Exception())); when(publisher.publish(any(PublishRequest.class))).thenReturn(badFuture); task.put(records); task.flush(partitionOffsets); verify(publisher, times(1)).publish(any(PublishRequest.class)); verify(badFuture, times(1)).get(); } /** Get some sample SinkRecords's to use in the tests. */ private List<SinkRecord> getSampleRecords() { List<SinkRecord> records = new ArrayList<>(); records.add( new SinkRecord( KAFKA_TOPIC, 0, STRING_SCHEMA, KAFKA_MESSAGE_KEY, BYTE_STRING_SCHEMA, KAFKA_MESSAGE1, -1)); records.add( new SinkRecord( KAFKA_TOPIC, 0, STRING_SCHEMA, KAFKA_MESSAGE_KEY, BYTE_STRING_SCHEMA, KAFKA_MESSAGE2, -1)); return records; } /** * Get some PubsubMessage's which correspond to the SinkRecord's created in {@link * #getSampleRecords()}. */ private List<PubsubMessage> getPubsubMessagesFromSampleRecords() { List<PubsubMessage> messages = new ArrayList<>(); Map<String, String> attributes = new HashMap<>(); attributes.put(ConnectorUtils.CPS_MESSAGE_KEY_ATTRIBUTE, KAFKA_MESSAGE_KEY); messages.add( PubsubMessage.newBuilder().putAllAttributes(attributes).setData(KAFKA_MESSAGE1).build()); messages.add( PubsubMessage.newBuilder().putAllAttributes(attributes).setData(KAFKA_MESSAGE2).build()); return messages; } }