2323
2424package com .wepay .kafka .connect .bigquery .write .row ;
2525
26+ import static org .junit .jupiter .api .Assertions .assertDoesNotThrow ;
2627import static org .junit .jupiter .api .Assertions .assertThrows ;
2728import static org .junit .jupiter .api .Assertions .assertEquals ;
2829import static org .junit .jupiter .api .Assertions .assertTrue ;
2930import static org .junit .jupiter .api .Assumptions .assumeTrue ;
3031import static org .mockito .ArgumentMatchers .any ;
3132import static org .mockito .ArgumentMatchers .anyList ;
33+ import static org .mockito .ArgumentMatchers .eq ;
34+ import static org .mockito .Mockito .doThrow ;
3235import static org .mockito .Mockito .mock ;
3336import static org .mockito .Mockito .times ;
3437import static org .mockito .Mockito .verify ;
3841import static org .mockito .Mockito .atMost ;
3942import static org .mockito .Mockito .verifyNoMoreInteractions ;
4043
44+ import com .google .cloud .bigquery .TableInfo ;
4145import com .google .gson .Gson ;
4246import com .google .gson .JsonIOException ;
4347import com .google .cloud .bigquery .BigQuery ;
6165import com .wepay .kafka .connect .bigquery .write .storage .StorageApiBatchModeHandler ;
6266import com .wepay .kafka .connect .bigquery .write .storage .StorageWriteApiDefaultStream ;
6367import com .wepay .kafka .connect .bigquery .exception .BigQueryConnectException ;
68+
69+
6470import java .nio .ByteBuffer ;
6571import java .util .LinkedHashMap ;
6672import java .util .Collections ;
8389import org .junit .jupiter .api .Test ;
8490
8591import io .debezium .data .VariableScaleDecimal ;
92+ import org .mockito .stubbing .OngoingStubbing ;
8693
8794public class GcsToBqWriterTest {
8895
@@ -91,11 +98,16 @@ public class GcsToBqWriterTest {
9198 private static StorageWriteApiDefaultStream mockedStorageWriteApiDefaultStream = mock (StorageWriteApiDefaultStream .class );
9299 private static StorageApiBatchModeHandler mockedBatchHandler = mock (StorageApiBatchModeHandler .class );
93100
101+ private static final TableId tableId = TableId .of ("ds" , "tbl" );
102+ private static final Table table = mock (Table .class );
103+
94104 private final Time time = new MockTime ();
95105
96106 @ BeforeAll
97107 public static void initializePropertiesFactory () {
98108 propertiesFactory = new SinkPropertiesFactory ();
109+ when (table .getTableId ()).thenReturn (tableId );
110+
99111 }
100112
101113 @ Test
@@ -230,7 +242,7 @@ public void happyPathNoRetry() throws Exception {
230242 storage , bigQuery , schemaManager , retries , retryWaitMs , autoCreate , false , mockTime );
231243
232244 long t0 = mockTime .milliseconds ();
233- writer .writeRows (oneRow (), TableId . of ( "ds" , "tbl" ) , "bucket" , "blob" );
245+ writer .writeRows (oneRow (), tableId , "bucket" , "blob" );
234246 long elapsed = mockTime .milliseconds () - t0 ;
235247
236248 // One lookup, one upload; no retries, no sleeps → elapsed should be 0
@@ -260,7 +272,7 @@ public void schemaUpdatedWhenEnabled() throws Exception {
260272 new GcsToBqWriter (
261273 storage , bigQuery , schemaManager , retries , retryWaitMs , autoCreate , true , mockTime );
262274
263- writer .writeRows (oneRow (), TableId . of ( "ds" , "tbl" ) , "bucket" , "blob" );
275+ writer .writeRows (oneRow (), tableId , "bucket" , "blob" );
264276
265277 verify (schemaManager , times (1 )).updateSchema (any (TableId .class ), anyList ());
266278 verify (storage , times (1 )).create (any (BlobInfo .class ), any (byte [].class ));
@@ -291,7 +303,7 @@ public void backoffIsCapped() throws Exception {
291303 storage , bigQuery , schemaManager , retries , retryWaitMs , autoCreate , true , mockTime );
292304
293305 long t0 = mockTime .milliseconds ();
294- writer .writeRows (oneRow (), TableId . of ( "ds" , "tbl" ) , "bucket" , "blob" );
306+ writer .writeRows (oneRow (), tableId , "bucket" , "blob" );
295307 long elapsed = mockTime .milliseconds () - t0 ;
296308
297309 long minExpected = 20_000 ; // Budget = retries(4) * retryWaitMs(5000) = 20s
@@ -338,7 +350,7 @@ public void budgetCutsBeforeAllRetries() throws Exception {
338350 // with a BigQueryConnectException (null table interpreted as lookup failure).
339351 assertThrows (
340352 BigQueryConnectException .class ,
341- () -> writer .writeRows (oneRow (), TableId . of ( "ds" , "tbl" ) , "bucket" , "blob" ));
353+ () -> writer .writeRows (oneRow (), tableId , "bucket" , "blob" ));
342354
343355 // We expect multiple getTable() attempts until budget expires.
344356 // Because jitter can vary up to 1s per sleep and sleeps are clamped by remaining budget,
@@ -352,6 +364,183 @@ public void budgetCutsBeforeAllRetries() throws Exception {
352364 verify (storage , never ()).create (any (BlobInfo .class ), any (byte [].class ));
353365 }
354366
367+
368+ /**
369+ * A mocked SchemaManager.
370+ * @param createTable value for {@code createTable()} call. null == exception, else value.
371+ * @param schemaUpdate value for {@code updateSchema()} call. null == exception, false = BQException, true = success
372+ * @return A mock schema manager
373+ */
374+ private SchemaManager mockSchemaManager (Boolean createTable , Boolean schemaUpdate ) {
375+ SchemaManager schemaManager = mock (SchemaManager .class );
376+
377+ if (createTable == null ) {
378+ doThrow (new IllegalArgumentException ("SchemaManager create table failed" )).when (schemaManager ).createTable (eq (tableId ), anyList ());
379+ } else {
380+ when (schemaManager .createTable (eq (tableId ), anyList ())).thenReturn (createTable );
381+ }
382+
383+ if (schemaUpdate == null ) {
384+ doThrow (new UnsupportedOperationException ("SchemaManager threw exception" )).when (schemaManager ).updateSchema (any (), anyList ());
385+ } else if (!schemaUpdate ) {
386+ doThrow (new BigQueryConnectException ("SchemaManager schema update failed" )).when (schemaManager ).updateSchema (any (), anyList ());
387+ }
388+ return schemaManager ;
389+ }
390+
391+ /**
392+ * Create a mocked big query.
393+ * @param falseCount the number of times to report the files is not found.
394+ * @param hasTable if true a table is returned on the falseCount + 1 request.
395+ * @return a mocked BigQuery.
396+ */
397+ private BigQuery mockBigQuery (int falseCount , boolean hasTable ) {
398+ BigQuery bigQuery = mock (BigQuery .class );
399+ OngoingStubbing <Table > stub = when (bigQuery .getTable (eq (tableId )));
400+ for (int i = 0 ; i < falseCount ; i ++) {
401+ stub = stub .thenReturn (null );
402+ }
403+ if (hasTable ) {
404+ stub .thenReturn (table );
405+ }
406+
407+ return bigQuery ;
408+ }
409+
410+
411+ /**
412+ * A mocked Storage.
413+ * @param retryError null = no error, true = retryable error, false = non-retryable error.
414+ * @return a mocked storage that succeeds or fails beased on retryError flag.
415+ */
416+ private Storage mockStorage (Boolean retryError ) {
417+ Storage storage = mock (Storage .class );
418+ if (retryError != null ) {
419+ StorageException storageException = retryError ? new StorageException (500 , "it failed" ) : new StorageException (400 , "it failed" );
420+ when (storage .create (any (BlobInfo .class ), any (byte [].class ))).thenThrow (storageException );
421+ }
422+ return storage ;
423+ }
424+
425+
426+ @ Test
427+ void writeRowsCreateTableTest () {
428+ final int retries = 4 ;
429+ final long retryWaitMs = 100L ;
430+ final boolean attemptSchemaUpdate = false ;
431+
432+ Time mockTime = new MockTime (); // virtual clock; sleep() advances time but doesn’t block
433+
434+ // BigQuery does not have the table, schema manager should not be called, any call the schema manager will result in an exception.
435+ String msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , false ), mockSchemaManager (null , null ),
436+ retries , retryWaitMs , false , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
437+ "no table, schema manager exception"
438+ ).getMessage ();
439+ assertEquals ("Failed to lookup table " + tableId , msg );
440+
441+ // BigQuery does not have the table. Schema manager will return true
442+ msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , false ), mockSchemaManager (true , null ),
443+ retries , retryWaitMs , true , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
444+ "no table, schema manager reports success" ).getMessage ();
445+ assertEquals ("Failed to lookup table " + tableId , msg );
446+
447+ // BigQuery does not have the table. Schema manager will return false
448+ msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , false ), mockSchemaManager (false , null ),
449+ retries , retryWaitMs , true , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
450+ "no table, schema manager reports failure" ).getMessage ();
451+ assertEquals ("Failed to lookup table " + tableId , msg );
452+
453+ // BigQuery does not have the table, schema manager will throw an exception.
454+ msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , false ), mockSchemaManager (null , null ),
455+ retries , retryWaitMs , true , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
456+ "no table, schema manager exception"
457+ ).getMessage ();
458+ assertEquals ("Operation failed during executeWithRetry: SchemaManager create table failed" , msg );
459+
460+ // BigQuery does not have the table on the first call, but will on second call. Schema manager will return false
461+ assertDoesNotThrow (() ->
462+ new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , true ), mockSchemaManager (false , null ),
463+ retries , retryWaitMs , true , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
464+ "not table then table, schema manager did not create table" );
465+
466+ assertDoesNotThrow (() -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , true ), mockSchemaManager (true , null ),
467+ retries , retryWaitMs , true , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
468+ "not table then table, schema manager did create table" );
469+
470+ // BigQuery does not have the table on the first call, but will on second call. Schema manager should not be called, any call the schema manager will result in an exception.
471+ assertDoesNotThrow (() -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (1 , true ), mockSchemaManager (null , null ),
472+ retries , retryWaitMs , false , attemptSchemaUpdate , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
473+ "not table then table, schema manager exception" );
474+
475+ }
476+
477+ @ Test
478+ void writeRowsUpdateSchemaTest () {
479+ final int retries = 4 ;
480+ final long retryWaitMs = 100L ;
481+
482+ Time mockTime = new MockTime (); // virtual clock; sleep() advances time but doesn’t block
483+
484+ // schema update throws exception.
485+ String msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (0 , true ),
486+ mockSchemaManager (null , null ),
487+ retries , retryWaitMs , false , true , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
488+ "schema manager update failed"
489+ ).getMessage ();
490+ assertEquals ("Operation failed during executeWithRetry: SchemaManager threw exception" , msg );
491+
492+ // schema update returns false
493+ msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (0 , true ),
494+ mockSchemaManager (null , false ),
495+ retries , retryWaitMs , false , true , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
496+ "schema manager update failed"
497+ ).getMessage ();
498+ assertEquals ("Operation failed during executeWithRetry: SchemaManager schema update failed" , msg );
499+
500+ final SchemaManager schemaManager = mock (SchemaManager .class );
501+ doThrow (new BigQueryException (500 , "it failed" )).when (schemaManager ).updateSchema (any (), anyList ());
502+ msg = assertThrows (BigQueryConnectException .class ,() -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (0 , true ), schemaManager ,
503+ retries , retryWaitMs , false , true , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
504+ "schema manager update faild with timeout" ).getMessage ();
505+ assertTrue (msg .startsWith ("Timeout expired after " ));
506+
507+ // schema update returns true
508+ assertDoesNotThrow (() -> new GcsToBqWriter (mockStorage (null ), mockBigQuery (0 , true ), mockSchemaManager (null , true ),
509+ retries , retryWaitMs , false , true , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
510+ "schema manager update succeeded"
511+ );
512+ }
513+
514+ @ Test
515+ void writeRowsUploadData () {
516+ final int retries = 4 ;
517+ final long retryWaitMs = 100L ;
518+
519+ Time mockTime = new MockTime (); // virtual clock; sleep() advances time but doesn’t block
520+
521+ BigQuery bq = mockBigQuery (0 , true );
522+
523+ // storage succeeds.
524+ assertDoesNotThrow (() -> new GcsToBqWriter (mockStorage (null ), bq , null ,
525+ retries , retryWaitMs , false , false , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
526+ "upload succeeded"
527+ );
528+
529+ // storage throws retryable error
530+ String msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (true ), mockBigQuery (0 , true ), mockSchemaManager (null , true ),
531+ retries , retryWaitMs , false , false , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
532+ "upload failed -- retry"
533+ ).getMessage ();
534+ assertTrue (msg .startsWith ("Timeout expired after" ));
535+
536+ // storage throws non-retryable error.
537+ msg = assertThrows (BigQueryConnectException .class , () -> new GcsToBqWriter (mockStorage (false ), mockBigQuery (0 , true ), mockSchemaManager (null , true ),
538+ retries , retryWaitMs , false , false , mockTime ).writeRows (oneRow (), tableId , "bucket" , "blob" ),
539+ "upload failed -- no retry"
540+ ).getMessage ();
541+ assertEquals ("Non-retryable exception on attempt 1." , msg );
542+ }
543+
355544 @ Nested
356545 @ DisplayName ("JSON serialization (Gson / ByteBuffer)" )
357546 class JsonSerializationTests {
0 commit comments