diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a9266f26..9e16084453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Fixes + +- Fix `JsonObjectReader` and `MapObjectReader` hanging indefinitely when deserialization errors leave the reader in an inconsistent state ([#5293](https://github.com/getsentry/sentry-java/pull/5293)) + - Failed collection values are now skipped so parsing can continue + - Skipped collection values emit `WARNING` logs + - Unknown-key failures and unrecoverable recovery failures emit `ERROR` logs + ### Dependencies - Bump Native SDK from v0.13.3 to v0.13.6 ([#5277](https://github.com/getsentry/sentry-java/pull/5277)) diff --git a/sentry/src/main/java/io/sentry/JsonObjectReader.java b/sentry/src/main/java/io/sentry/JsonObjectReader.java index f9fe184184..82bf27e821 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectReader.java +++ b/sentry/src/main/java/io/sentry/JsonObjectReader.java @@ -4,8 +4,10 @@ import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.io.Reader; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Date; +import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -18,6 +20,8 @@ public final class JsonObjectReader implements ObjectReader { private final @NotNull JsonReader jsonReader; + private final @NotNull Deque recoveryStates = new ArrayDeque<>(); + private int depth = 0; public JsonObjectReader(Reader in) { this.jsonReader = new JsonReader(in); @@ -26,25 +30,25 @@ public JsonObjectReader(Reader in) { @Override public @Nullable String nextStringOrNull() throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - return jsonReader.nextString(); + return nextString(); } @Override public @Nullable Double nextDoubleOrNull() throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - return jsonReader.nextDouble(); + return nextDouble(); } @Override public @Nullable Float nextFloatOrNull() throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } return nextFloat(); @@ -52,42 +56,57 @@ public JsonObjectReader(Reader in) { @Override public float nextFloat() throws IOException { + markValueConsumed(); return (float) jsonReader.nextDouble(); } @Override public @Nullable Long nextLongOrNull() throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - return jsonReader.nextLong(); + return nextLong(); } @Override public @Nullable Integer nextIntegerOrNull() throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - return jsonReader.nextInt(); + return nextInt(); } @Override public @Nullable Boolean nextBooleanOrNull() throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - return jsonReader.nextBoolean(); + return nextBoolean(); } @Override public void nextUnknown(ILogger logger, Map unknown, String name) { + RecoveryState recoveryState = null; try { + recoveryState = beginRecovery(peek()); unknown.put(name, nextObjectOrNull()); } catch (Exception exception) { logger.log(SentryLevel.ERROR, exception, "Error deserializing unknown key: %s", name); + if (recoveryState != null) { + try { + recoverValue(recoveryState); + } catch (Exception recoveryException) { + logger.log( + SentryLevel.ERROR, + "Stream unrecoverable after unknown key deserialization failure.", + recoveryException); + } + } + } finally { + endRecovery(recoveryState); } } @@ -95,21 +114,31 @@ public void nextUnknown(ILogger logger, Map unknown, String name public @Nullable List nextListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - jsonReader.beginArray(); + beginArray(); List list = new ArrayList<>(); if (jsonReader.hasNext()) { do { + final RecoveryState recoveryState = beginRecovery(peek()); try { list.add(deserializer.deserialize(this, logger)); } catch (Exception e) { - logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); + if (!recoverAfterValueFailure( + logger, + e, + "Failed to deserialize object in list.", + "Stream unrecoverable, aborting list deserialization.", + recoveryState)) { + break; + } + } finally { + endRecovery(recoveryState); } } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT); } - jsonReader.endArray(); + endArray(); return list; } @@ -117,23 +146,33 @@ public void nextUnknown(ILogger logger, Map unknown, String name public @Nullable Map nextMapOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - jsonReader.beginObject(); + beginObject(); Map map = new HashMap<>(); if (jsonReader.hasNext()) { do { + final String key = jsonReader.nextName(); + final RecoveryState recoveryState = beginRecovery(peek()); try { - String key = jsonReader.nextName(); map.put(key, deserializer.deserialize(this, logger)); } catch (Exception e) { - logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); + if (!recoverAfterValueFailure( + logger, + e, + "Failed to deserialize object in map.", + "Stream unrecoverable, aborting map deserialization.", + recoveryState)) { + break; + } + } finally { + endRecovery(recoveryState); } } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT || jsonReader.peek() == JsonToken.NAME); } - jsonReader.endObject(); + endObject(); return map; } @@ -151,9 +190,23 @@ public void nextUnknown(ILogger logger, Map unknown, String name if (hasNext()) { do { final @NotNull String key = nextName(); - final @Nullable List list = nextListOrNull(logger, deserializer); - if (list != null) { - result.put(key, list); + final RecoveryState recoveryState = beginRecovery(peek()); + try { + final @Nullable List list = nextListOrNull(logger, deserializer); + if (list != null) { + result.put(key, list); + } + } catch (Exception e) { + if (!recoverAfterValueFailure( + logger, + e, + "Failed to deserialize list in map.", + "Stream unrecoverable, aborting map-of-lists deserialization.", + recoveryState)) { + break; + } + } finally { + endRecovery(recoveryState); } } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); } @@ -166,7 +219,7 @@ public void nextUnknown(ILogger logger, Map unknown, String name public @Nullable T nextOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws Exception { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } return deserializer.deserialize(this, logger); @@ -175,20 +228,20 @@ public void nextUnknown(ILogger logger, Map unknown, String name @Override public @Nullable Date nextDateOrNull(ILogger logger) throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } - return ObjectReader.dateOrNull(jsonReader.nextString(), logger); + return ObjectReader.dateOrNull(nextString(), logger); } @Override public @Nullable TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException { if (jsonReader.peek() == JsonToken.NULL) { - jsonReader.nextNull(); + nextNull(); return null; } try { - return TimeZone.getTimeZone(jsonReader.nextString()); + return TimeZone.getTimeZone(nextString()); } catch (Exception e) { logger.log(SentryLevel.ERROR, "Error when deserializing TimeZone", e); } @@ -219,21 +272,27 @@ public void nextUnknown(ILogger logger, Map unknown, String name @Override public void beginObject() throws IOException { jsonReader.beginObject(); + markValueConsumed(); + depth++; } @Override public void endObject() throws IOException { jsonReader.endObject(); + depth--; } @Override public void beginArray() throws IOException { jsonReader.beginArray(); + markValueConsumed(); + depth++; } @Override public void endArray() throws IOException { jsonReader.endArray(); + depth--; } @Override @@ -243,31 +302,37 @@ public boolean hasNext() throws IOException { @Override public int nextInt() throws IOException { + markValueConsumed(); return jsonReader.nextInt(); } @Override public long nextLong() throws IOException { + markValueConsumed(); return jsonReader.nextLong(); } @Override public String nextString() throws IOException { + markValueConsumed(); return jsonReader.nextString(); } @Override public boolean nextBoolean() throws IOException { + markValueConsumed(); return jsonReader.nextBoolean(); } @Override public double nextDouble() throws IOException { + markValueConsumed(); return jsonReader.nextDouble(); } @Override public void nextNull() throws IOException { + markValueConsumed(); jsonReader.nextNull(); } @@ -278,11 +343,80 @@ public void setLenient(boolean lenient) { @Override public void skipValue() throws IOException { + markValueConsumed(); jsonReader.skipValue(); } + private boolean recoverAfterValueFailure( + final @NotNull ILogger logger, + final @NotNull Exception error, + final @NotNull String warningMessage, + final @NotNull String unrecoverableMessage, + final @NotNull RecoveryState recoveryState) { + logger.log(SentryLevel.WARNING, warningMessage, error); + try { + recoverValue(recoveryState); + return true; + } catch (Exception recoveryException) { + logger.log(SentryLevel.ERROR, unrecoverableMessage, recoveryException); + return false; + } + } + + private @NotNull RecoveryState beginRecovery(final @NotNull JsonToken startToken) { + final RecoveryState recoveryState = new RecoveryState(depth, startToken); + recoveryStates.addLast(recoveryState); + return recoveryState; + } + + private void endRecovery(final @Nullable RecoveryState recoveryState) { + if (recoveryState == null) { + return; + } + if (!recoveryStates.isEmpty() && recoveryStates.peekLast() == recoveryState) { + recoveryStates.removeLast(); + } else { + recoveryStates.remove(recoveryState); + } + } + + private void markValueConsumed() { + final @Nullable RecoveryState recoveryState = recoveryStates.peekLast(); + if (recoveryState != null) { + recoveryState.valueConsumed = true; + } + } + + private void recoverValue(final @NotNull RecoveryState recoveryState) throws IOException { + while (depth > recoveryState.startDepth) { + final JsonToken token = peek(); + if (token == JsonToken.END_OBJECT) { + endObject(); + } else if (token == JsonToken.END_ARRAY) { + endArray(); + } else { + skipValue(); + } + } + + if (!recoveryState.valueConsumed && peek() == recoveryState.startToken) { + skipValue(); + } + } + @Override public void close() throws IOException { jsonReader.close(); } + + private static final class RecoveryState { + private final int startDepth; + private final @NotNull JsonToken startToken; + private boolean valueConsumed; + + private RecoveryState(final int startDepth, final @NotNull JsonToken startToken) { + this.startDepth = startDepth; + this.startToken = startToken; + } + } } diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index b04fbb9675..ab7afb54b6 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -31,10 +31,12 @@ public MapObjectReader(final Map root) { @Override public void nextUnknown( final @NotNull ILogger logger, final Map unknown, final String name) { + final int stackSizeBefore = stack.size(); try { unknown.put(name, nextObjectOrNull()); } catch (Exception exception) { logger.log(SentryLevel.ERROR, exception, "Error deserializing unknown key: %s", name); + recoverValue(stackSizeBefore); } } @@ -52,10 +54,12 @@ public List nextListOrNull( List list = new ArrayList<>(); if (hasNext()) { do { + final int stackSizeBefore = stack.size(); try { list.add(deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); + recoverValue(stackSizeBefore); } } while (peek() == JsonToken.BEGIN_OBJECT); } @@ -80,11 +84,13 @@ public Map nextMapOrNull( Map map = new HashMap<>(); if (hasNext()) { do { + final String key = nextName(); + final int stackSizeBefore = stack.size(); try { - String key = nextName(); map.put(key, deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); + recoverValue(stackSizeBefore); } } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); } @@ -109,9 +115,15 @@ public Map nextMapOrNull( if (hasNext()) { do { final @NotNull String key = nextName(); - final @Nullable List list = nextListOrNull(logger, deserializer); - if (list != null) { - result.put(key, list); + final int stackSizeBefore = stack.size(); + try { + final @Nullable List list = nextListOrNull(logger, deserializer); + if (list != null) { + result.put(key, list); + } + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize list in map.", e); + recoverValue(stackSizeBefore); } } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); } @@ -197,12 +209,13 @@ public String nextName() throws IOException { @Override public void beginObject() throws IOException { - final Map.Entry currentEntry = stack.removeLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry == null) { throw new IOException("No more entries"); } final Object value = currentEntry.getValue(); if (value instanceof Map) { + stack.removeLast(); // insert a dummy entry to indicate end of an object stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_OBJECT)); // extract map entries onto the stack @@ -223,12 +236,13 @@ public void endObject() throws IOException { @Override public void beginArray() throws IOException { - final Map.Entry currentEntry = stack.removeLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry == null) { throw new IOException("No more entries"); } final Object value = currentEntry.getValue(); if (value instanceof List) { + stack.removeLast(); // insert a dummy entry to indicate end of an object stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_ARRAY)); // extract map entries onto the stack @@ -377,7 +391,17 @@ public void nextNull() throws IOException { public void setLenient(final boolean lenient) {} @Override - public void skipValue() throws IOException {} + public void skipValue() throws IOException { + if (!stack.isEmpty()) { + stack.removeLast(); + } + } + + private void recoverValue(final int stackSizeBefore) { + while (!stack.isEmpty() && stack.size() >= stackSizeBefore) { + stack.removeLast(); + } + } @SuppressWarnings("TypeParameterUnusedInFormals") @Nullable diff --git a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt index 45c97122ee..895f3016c6 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt @@ -2,12 +2,15 @@ package io.sentry import java.io.StringReader import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions class JsonObjectReaderTest { class Fixture { @@ -18,6 +21,36 @@ class JsonObjectReaderTest { val fixture = Fixture() + private val throwingValueDeserializer = + JsonDeserializer { reader, _ -> + reader.beginObject() + reader.nextName() + val value = reader.nextString() + if (value == "fail") { + throw IllegalStateException("intentional") + } + reader.endObject() + value + } + + private val postParseThrowingValueDeserializer = + JsonDeserializer { reader, _ -> + reader.beginObject() + reader.nextName() + val value = reader.nextString() + reader.endObject() + if (value == "fail") { + throw IllegalStateException("intentional") + } + value + } + + private fun getValuesReader(jsonValue: String): JsonObjectReader = + fixture.getSut("{\"values\": $jsonValue}").apply { + beginObject() + nextName() + } + // nextStringOrNull @Test @@ -198,6 +231,226 @@ class JsonObjectReaderTest { verify(fixture.logger, never()).log(any(), any(), any()) } + @Test(timeout = 1000L) + fun `nextListOrNull skips a failing element`() { + val actual = + getValuesReader("[{\"value\": \"fail\"}]") + .nextListOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(emptyList(), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull skips an unconsumed failing element`() { + var callCount = 0 + val deserializer = + JsonDeserializer { reader, logger -> + if (callCount++ == 0) { + throw IllegalStateException("intentional") + } + throwingValueDeserializer.deserialize(reader, logger) + } + + val actual = + getValuesReader("[{\"value\": \"ignored\"}, {\"value\": \"two\"}]") + .nextListOrNull(fixture.logger, deserializer) + + assertEquals(listOf("two"), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements before a failing element`() { + val actual = + getValuesReader("[{\"value\": \"one\"}, {\"value\": \"fail\"}]") + .nextListOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(listOf("one"), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements after a failing element`() { + val actual = + getValuesReader("[{\"value\": \"fail\"}, {\"value\": \"two\"}]") + .nextListOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(listOf("two"), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements after a fully consumed failing element`() { + val actual = + getValuesReader("[{\"value\": \"fail\"}, {\"value\": \"two\"}]") + .nextListOrNull(fixture.logger, postParseThrowingValueDeserializer) + + assertEquals(listOf("two"), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements after skipValue consumes a failing element`() { + var callCount = 0 + val deserializer = + JsonDeserializer { reader, logger -> + if (callCount++ == 0) { + reader.skipValue() + throw IllegalStateException("intentional") + } + throwingValueDeserializer.deserialize(reader, logger) + } + + val actual = + getValuesReader("[{\"value\": \"ignored\"}, {\"value\": \"two\"}]") + .nextListOrNull(fixture.logger, deserializer) + + assertEquals(listOf("two"), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull skips a failing value`() { + val actual = + getValuesReader("{\"bad\": {\"value\": \"fail\"}}") + .nextMapOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(emptyMap(), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull keeps values before a failing value`() { + val actual = + getValuesReader("{\"good\": {\"value\": \"one\"}, \"bad\": {\"value\": \"fail\"}}") + .nextMapOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(mapOf("good" to "one"), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull keeps values after a failing value`() { + val actual = + getValuesReader("{\"bad\": {\"value\": \"fail\"}, \"good\": {\"value\": \"two\"}}") + .nextMapOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(mapOf("good" to "two"), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull skips a failing value`() { + val actual = + getValuesReader("{\"bad\": {\"value\": \"fail\"}}") + .nextMapOfListOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(emptyMap(), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull keeps values before a failing value`() { + val actual = + getValuesReader("{\"good\": [{\"value\": \"one\"}], \"bad\": {\"value\": \"fail\"}}") + .nextMapOfListOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(mapOf("good" to listOf("one")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull keeps values after a failing value`() { + val actual = + getValuesReader("{\"bad\": {\"value\": \"fail\"}, \"good\": [{\"value\": \"two\"}]}") + .nextMapOfListOrNull(fixture.logger, throwingValueDeserializer) + + assertEquals(mapOf("good" to listOf("two")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull keeps nested values after skipValue consumes a failing element`() { + var callCount = 0 + val deserializer = + JsonDeserializer { reader, logger -> + if (callCount++ == 0) { + reader.skipValue() + throw IllegalStateException("intentional") + } + throwingValueDeserializer.deserialize(reader, logger) + } + + val actual = + getValuesReader("{\"good\": [{\"value\": \"ignored\"}, {\"value\": \"two\"}]}") + .nextMapOfListOrNull(fixture.logger, deserializer) + + assertEquals(mapOf("good" to listOf("two")), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull logs and aborts when recovery fails`() { + assertFailsWith { + fixture + .getSut("[{\"value\": \"fail\"") + .nextListOrNull(fixture.logger, throwingValueDeserializer) + } + + verify(fixture.logger) + .log( + eq(SentryLevel.ERROR), + eq("Stream unrecoverable, aborting list deserialization."), + any(), + ) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull logs and aborts when recovery fails`() { + assertFailsWith { + fixture + .getSut("{\"bad\": {\"value\": \"fail\"") + .nextMapOrNull(fixture.logger, throwingValueDeserializer) + } + + verify(fixture.logger) + .log( + eq(SentryLevel.ERROR), + eq("Stream unrecoverable, aborting map deserialization."), + any(), + ) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull logs and aborts when recovery fails`() { + assertFailsWith { + fixture + .getSut("{\"bad\": [{\"value\": \"fail\"") + .nextMapOfListOrNull(fixture.logger, throwingValueDeserializer) + } + + verify(fixture.logger) + .log( + eq(SentryLevel.ERROR), + eq("Stream unrecoverable, aborting map-of-lists deserialization."), + any(), + ) + } + + @Test(timeout = 1000L) + fun `nextUnknown logs when recovery fails`() { + val unknown = mutableMapOf() + val reader = fixture.getSut("{\"key\": {\"value\": \"fail\"") + reader.beginObject() + val name = reader.nextName() + + reader.nextUnknown(fixture.logger, unknown, name) + + assertEquals(emptyMap(), unknown) + verify(fixture.logger) + .log( + eq(SentryLevel.ERROR), + any(), + eq("Error deserializing unknown key: %s"), + eq("key"), + ) + verify(fixture.logger) + .log( + eq(SentryLevel.ERROR), + eq("Stream unrecoverable after unknown key deserialization failure."), + any(), + ) + verifyNoMoreInteractions(fixture.logger) + } + // nextDateOrNull @Test diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt index 26991bd493..06270ffcce 100644 --- a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -13,8 +13,8 @@ import java.util.Currency import java.util.Date import java.util.Locale import java.util.TimeZone -import kotlin.test.Test import kotlin.test.assertEquals +import org.junit.Test class MapObjectReaderTest { enum class BasicEnum { @@ -39,9 +39,37 @@ class MapObjectReaderTest { } } + private val logger = NoOpLogger.getInstance() + + private fun getValuesReader(value: Any): MapObjectReader = + MapObjectReader(linkedMapOf("values" to value)).apply { + beginObject() + nextName() + } + + private fun serializableValue(value: String): Map = linkedMapOf("test" to value) + + private fun partialSerializableValue(kind: String, value: String): Map = + linkedMapOf("test" to value, "kind" to kind) + + private val partiallyFailingDeserializer = + JsonDeserializer { reader, _ -> + val basicSerializable = BasicSerializable() + reader.beginObject() + if (reader.nextName() == "kind") { + if (reader.nextString() == "fail") { + throw IllegalStateException("intentional") + } + } + if (reader.nextName() == "test") { + basicSerializable.test = reader.nextString() + } + reader.endObject() + basicSerializable + } + @Test fun `deserializes data correctly`() { - val logger = NoOpLogger.getInstance() val data = mutableMapOf() val writer = MapObjectWriter(data) @@ -145,4 +173,136 @@ class MapObjectReaderTest { reader.nextNull() reader.endObject() } + + @Test(timeout = 1000L) + fun `nextListOrNull skips a failing element`() { + val actual = + getValuesReader(listOf("fail")).nextListOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(emptyList(), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements before a failing element`() { + val actual = + getValuesReader(listOf(serializableValue("one"), "fail")) + .nextListOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(listOf(BasicSerializable("one")), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements after a failing element`() { + val actual = + getValuesReader(listOf("fail", serializableValue("two"))) + .nextListOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(listOf(BasicSerializable("two")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull skips a failing value`() { + val actual = + getValuesReader(linkedMapOf("bad" to "fail")) + .nextMapOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(emptyMap(), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull keeps values before a failing value`() { + val actual = + getValuesReader(linkedMapOf("bad" to "fail", "good" to serializableValue("one"))) + .nextMapOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(mapOf("good" to BasicSerializable("one")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull keeps values after a failing value`() { + val actual = + getValuesReader(linkedMapOf("good" to serializableValue("two"), "bad" to "fail")) + .nextMapOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(mapOf("good" to BasicSerializable("two")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull skips a failing value`() { + val actual = + getValuesReader(linkedMapOf("bad" to serializableValue("fail"))) + .nextMapOfListOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(emptyMap(), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull keeps values before a failing value`() { + val actual = + getValuesReader( + linkedMapOf( + "bad" to serializableValue("fail"), + "good" to listOf(serializableValue("one")), + ) + ) + .nextMapOfListOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(mapOf("good" to listOf(BasicSerializable("one"))), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull keeps values after a failing value`() { + val actual = + getValuesReader( + linkedMapOf( + "good" to listOf(serializableValue("two")), + "bad" to serializableValue("fail"), + ) + ) + .nextMapOfListOrNull(logger, BasicSerializable.Deserializer()) + + assertEquals(mapOf("good" to listOf(BasicSerializable("two"))), actual) + } + + @Test(timeout = 1000L) + fun `nextListOrNull keeps elements after a partially consumed failing element`() { + val actual = + getValuesReader( + listOf(partialSerializableValue("fail", "bad"), partialSerializableValue("ok", "two")) + ) + .nextListOrNull(logger, partiallyFailingDeserializer) + + assertEquals(listOf(BasicSerializable("two")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOrNull keeps values after a partially consumed failing value`() { + val actual = + getValuesReader( + linkedMapOf( + "bad" to partialSerializableValue("fail", "bad"), + "good" to partialSerializableValue("ok", "two"), + ) + ) + .nextMapOrNull(logger, partiallyFailingDeserializer) + + assertEquals(mapOf("good" to BasicSerializable("two")), actual) + } + + @Test(timeout = 1000L) + fun `nextMapOfListOrNull keeps values after a partially consumed failing element`() { + val actual = + getValuesReader( + linkedMapOf( + "bad" to listOf(partialSerializableValue("fail", "bad")), + "good" to listOf(partialSerializableValue("ok", "two")), + ) + ) + .nextMapOfListOrNull(logger, partiallyFailingDeserializer) + + assertEquals( + mapOf("bad" to emptyList(), "good" to listOf(BasicSerializable("two"))), + actual, + ) + } }