CollectingErrorsTest.java

package tools.jackson.databind.deser;

import java.io.File;
import java.io.InputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import tools.jackson.databind.*;
import tools.jackson.databind.exc.DeferredBindingException;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.assertj.core.api.Assertions.*;

/**
 * Tests for error-collecting deserialization feature (issue #1196).
 * Verifies opt-in per-call error collection via ObjectReader.problemCollectingReader().
 */
public class CollectingErrorsTest extends DatabindTestUtil
{
    private final ObjectMapper MAPPER = newJsonMapper();

    /*
    /**********************************************************************
    /* Test POJOs
    /**********************************************************************
     */

    static class Person {
        public String name;
        public int age;
        public boolean active;
    }

    static class Order {
        public int orderId;
        public List<Item> items;
    }

    static class Item {
        public String sku;
        public double price;
        public int quantity;
    }

    static class TypedData {
        public int intValue;
        public long longValue;
        public double doubleValue;
        public float floatValue;
        public boolean boolValue;
        public Integer boxedInt;
        public String stringValue;
    }

    static class JsonPointerTestBean {
        public String normalField;
        public String fieldWithSlash;
        public String fieldWithTilde;
        public String fieldWithBoth;
    }

    /*
    /**********************************************************************
    /* Helper methods
    /**********************************************************************
     */

    /**
     * Helper to reduce boilerplate: captures DeferredBindingException and returns it.
     * Use with AssertJ for cleaner assertions.
     */
    private DeferredBindingException expectDeferredBinding(ObjectReader reader, String json) {
        return catchThrowableOfType(
            DeferredBindingException.class,
            () -> reader.readValueCollectingProblems(json)
        );
    }

    /**
     * Overload for byte[] input
     */
    private DeferredBindingException expectDeferredBinding(ObjectReader reader, byte[] json) {
        return catchThrowableOfType(
            DeferredBindingException.class,
            () -> reader.readValueCollectingProblems(json)
        );
    }

    /**
     * Overload for File input
     */
    private DeferredBindingException expectDeferredBinding(ObjectReader reader, File json) {
        return catchThrowableOfType(
            DeferredBindingException.class,
            () -> reader.readValueCollectingProblems(json)
        );
    }

    /**
     * Overload for InputStream input
     */
    private DeferredBindingException expectDeferredBinding(ObjectReader reader, InputStream json) {
        return catchThrowableOfType(
            DeferredBindingException.class,
            () -> reader.readValueCollectingProblems(json)
        );
    }

    /**
     * Overload for Reader input
     */
    private DeferredBindingException expectDeferredBinding(ObjectReader reader, Reader json) {
        return catchThrowableOfType(
            DeferredBindingException.class,
            () -> reader.readValueCollectingProblems(json)
        );
    }

    /**
     * Helper to build JSON with specified number of invalid order items.
     * Used for testing limit behavior and hard failures.
     */
    private String buildInvalidOrderJson(int itemCount) {
        StringBuilder json = new StringBuilder("{\"items\":[");
        for (int i = 0; i < itemCount; i++) {
            if (i > 0) json.append(",");
            json.append("{\"price\":\"invalid").append(i).append("\"}");
        }
        json.append("]}");
        return json.toString();
    }

    /*
    /**********************************************************************
    /* Test: Default behavior (fail-fast unchanged)
    /**********************************************************************
     */

    @Nested
    @DisplayName("Default fail-fast behavior")
    class DefaultBehaviorTests {

        @Test
        @DisplayName("should fail fast by default when error encountered")
        void failFastDefault() {
            // setup
            String json = "{\"name\":\"John\",\"age\":\"not-a-number\"}";

            // when/then
            assertThatThrownBy(() -> MAPPER.readValue(json, Person.class))
                .isInstanceOf(DatabindException.class)
                .hasMessageContaining("not-a-number");
        }

        @Test
        @DisplayName("should fail fast when using regular readValue even after problemCollectingReader")
        void failFastAfterCollectErrors() {
            // setup
            String json = "{\"name\":\"John\",\"age\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when/then - using regular readValue, not readValueCollectingProblems
            assertThatThrownBy(() -> reader.readValue(json))
                .isInstanceOf(DatabindException.class);
        }
    }

    /*
    /**********************************************************************
    /* Test: Per-call bucket isolation
    /**********************************************************************
     */

    @Nested
    @DisplayName("Per-call bucket isolation")
    class BucketIsolationTests {

        @Test
        @DisplayName("should isolate errors between successive calls")
        void successiveCalls() {
            // setup
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();
            String json1 = "{\"name\":\"Alice\",\"age\":\"invalid1\"}";
            String json2 = "{\"name\":\"Bob\",\"age\":\"invalid2\"}";

            // when
            DeferredBindingException ex1 = expectDeferredBinding(reader, json1);
            DeferredBindingException ex2 = expectDeferredBinding(reader, json2);

            // then
            assertThat(ex1).isNotNull();
            assertThat(ex2).isNotNull();
            assertThat(ex1.getProblems()).hasSize(1);
            assertThat(ex2.getProblems()).hasSize(1);
            assertThat(ex1.getProblems().get(0).getRawValue()).isEqualTo("invalid1");
            assertThat(ex2.getProblems().get(0).getRawValue()).isEqualTo("invalid2");
        }

        @Test
        @DisplayName("should isolate errors in concurrent calls")
        void concurrentCalls() throws Exception {
            // setup
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();
            int threadCount = 10;
            ExecutorService executor = Executors.newFixedThreadPool(threadCount);
            CountDownLatch latch = new CountDownLatch(threadCount);
            AtomicInteger successCount = new AtomicInteger(0);
            List<DeferredBindingException> exceptions =
                Collections.synchronizedList(new ArrayList<>());
            List<Throwable> unexpectedErrors =
                Collections.synchronizedList(new ArrayList<>());

            // when
            try {
                for (int i = 0; i < threadCount; i++) {
                    final int index = i;
                    executor.submit(() -> {
                        try {
                            String json = String.format("{\"name\":\"User%d\",\"age\":\"invalid%d\"}",
                                index, index);
                            reader.readValueCollectingProblems(json);
                            unexpectedErrors.add(new AssertionError("Should have thrown DeferredBindingException"));
                        } catch (DeferredBindingException e) {
                            exceptions.add(e);
                            successCount.incrementAndGet();
                        } catch (Throwable t) {
                            unexpectedErrors.add(t);
                        } finally {
                            latch.countDown();
                        }
                    });
                }

                // Wait for all threads with assertion
                assertThat(latch.await(5, TimeUnit.SECONDS))
                    .as("All threads should complete within timeout")
                    .isTrue();

            } finally {
                executor.shutdown();
                try {
                    assertThat(executor.awaitTermination(2, TimeUnit.SECONDS))
                        .as("Executor should terminate within timeout")
                        .isTrue();
                } catch (InterruptedException e) {
                    executor.shutdownNow();
                    Thread.currentThread().interrupt();
                }
            }

            // then
            assertThat(unexpectedErrors)
                .as("No unexpected exceptions should occur")
                .isEmpty();
            assertThat(successCount.get()).isEqualTo(threadCount);

            // Synchronize iteration per JDK contract for Collections.synchronizedList()
            synchronized (exceptions) {
                assertThat(exceptions).hasSize(threadCount);

                // Verify each exception has exactly 1 problem and collect all raw values
                List<String> rawValues = new ArrayList<>();
                for (DeferredBindingException ex : exceptions) {
                    assertThat(ex.getProblems()).hasSize(1);
                    String rawValue = (String) ex.getProblems().get(0).getRawValue();
                    rawValues.add(rawValue);
                }

                // Verify we have exactly the unique values from each thread (no bucket sharing)
                assertThat(rawValues)
                    .as("Each thread should have its own isolated error bucket")
                    .containsExactlyInAnyOrder(
                        "invalid0", "invalid1", "invalid2", "invalid3", "invalid4",
                        "invalid5", "invalid6", "invalid7", "invalid8", "invalid9"
                    );
            }
        }
    }

    /*
    /**********************************************************************
    /* Test: JSON Pointer escaping (RFC 6901)
    /**********************************************************************
     */

    @Nested
    @DisplayName("JSON Pointer escaping (RFC 6901)")
    class JsonPointerEscapingTests {

        @Test
        @DisplayName("should escape tilde in property names")
        void escapeTilde() {
            // setup
            String json = "{\"field~name\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(JsonPointerTestBean.class)
                .problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            // Tilde should be escaped as ~0
            assertThat(ex.getProblems().get(0).getPath().toString())
                .isEqualTo("/field~0name");
        }

        @Test
        @DisplayName("should escape slash in property names")
        void escapeSlash() {
            // setup
            String json = "{\"field/name\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(JsonPointerTestBean.class)
                .problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            // Slash should be escaped as ~1
            assertThat(ex.getProblems().get(0).getPath().toString())
                .isEqualTo("/field~1name");
        }

        @Test
        @DisplayName("should escape both tilde and slash correctly")
        void escapeBoth() {
            // setup
            String json = "{\"field~/name\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(JsonPointerTestBean.class)
                .problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            // Must escape ~ first (to ~0), then / (to ~1)
            assertThat(ex.getProblems().get(0).getPath().toString())
                .isEqualTo("/field~0~1name");
        }

        @Test
        @DisplayName("should handle array indices in pointer")
        void arrayIndices() {
            // setup
            String json = "{\"orderId\":123,\"items\":[" +
                "{\"sku\":\"ABC\",\"price\":\"invalid\",\"quantity\":5}," +
                "{\"sku\":\"DEF\",\"price\":99.99,\"quantity\":\"bad\"}" +
                "]}";
            ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(2);
            assertThat(ex.getProblems().get(0).getPath().toString())
                .contains("/items/0/price");
            assertThat(ex.getProblems().get(1).getPath().toString())
                .contains("/items/1/quantity");
        }
    }

    /*
    /**********************************************************************
    /* Test: Limit reached behavior
    /**********************************************************************
     */

    @Nested
    @DisplayName("Limit reached behavior")
    class LimitReachedTests {

        @Test
        @DisplayName("should stop collecting when default limit reached")
        void defaultLimit() {
            // setup - create JSON with 101 errors (default limit is 100)
            String json = buildInvalidOrderJson(101);
            ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then - should get DeferredBindingException as primary when limit reached
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(100); // Stopped at limit
            assertThat(ex.isLimitReached()).isTrue();
            assertThat(ex.getMessage()).contains("limit reached");

            // Original DatabindException should be in suppressed for debugging
            Throwable[] suppressed = ex.getSuppressed();
            assertThat(suppressed).hasSizeGreaterThanOrEqualTo(1);
            assertThat(suppressed[0]).isInstanceOf(DatabindException.class);
        }

        @Test
        @DisplayName("should respect custom limit")
        void customLimit() {
            // setup
            String json = buildInvalidOrderJson(20);
            ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader(10);

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then - should get DeferredBindingException as primary when limit reached
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(10); // Custom limit
            assertThat(ex.isLimitReached()).isTrue();

            // Original DatabindException should be in suppressed for debugging
            Throwable[] suppressed = ex.getSuppressed();
            assertThat(suppressed).hasSizeGreaterThanOrEqualTo(1);
            assertThat(suppressed[0]).isInstanceOf(DatabindException.class);
        }

        @Test
        @DisplayName("should not set limit reached when under limit")
        void underLimit() {
            // setup
            String json = "{\"name\":\"John\",\"age\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(100);

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.isLimitReached()).isFalse();
            assertThat(ex.getMessage()).doesNotContain("limit reached");
        }
    }

    /*
    /**********************************************************************
    /* Test: Unknown property handling
    /**********************************************************************
     */

    @Nested
    @DisplayName("Unknown property handling")
    class UnknownPropertyTests {

        @Test
        @DisplayName("should collect unknown property errors when FAIL_ON_UNKNOWN_PROPERTIES enabled")
        void unknownProperty() {
            // setup
            String json = "{\"name\":\"Alice\",\"unknownField\":\"value\",\"age\":30}";
            ObjectReader reader = MAPPER.readerFor(Person.class)
                .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then - unknown property error is collected
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.getProblems().get(0).getMessage())
                .contains("Unknown property 'unknownField'");
        }

        @Test
        @DisplayName("should skip unknown property children")
        void skipUnknownChildren() {
            // setup
            String json = "{\"name\":\"Bob\",\"unknownObject\":{\"nested\":\"value\"},\"age\":25}";
            ObjectReader reader = MAPPER.readerFor(Person.class)
                .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.getProblems().get(0).getMessage())
                .contains("Unknown property 'unknownObject'");
        }
    }

    /*
    /**********************************************************************
    /* Test: Default value policy (primitives vs references)
    /**********************************************************************
     */

    @Nested
    @DisplayName("Default value policy")
    class DefaultValuePolicyTests {

        @Test
        @DisplayName("should collect error for primitive int coercion")
        void primitiveInt() {
            // setup
            String json = "{\"intValue\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then - error collected with default value used
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.getProblems().get(0).getRawValue()).isEqualTo("invalid");
        }

        @Test
        @DisplayName("should collect error for primitive long coercion")
        void primitiveLong() {
            // setup
            String json = "{\"longValue\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
        }

        @Test
        @DisplayName("should collect error for primitive double coercion")
        void primitiveDouble() {
            // setup
            String json = "{\"doubleValue\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
        }

        @Test
        @DisplayName("should collect error for primitive boolean coercion")
        void primitiveBoolean() {
            // setup
            String json = "{\"boolValue\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
        }

        @Test
        @DisplayName("should collect error for boxed Integer coercion")
        void boxedInteger() {
            // setup
            String json = "{\"boxedInt\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then - error collected for reference type
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
        }

        @Test
        @DisplayName("should handle multiple type coercion errors")
        void multipleTypeErrors() {
            // setup
            String json = "{\"intValue\":\"bad1\",\"longValue\":\"bad2\",\"doubleValue\":\"bad3\"}";
            ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(3);
            assertThat(ex.getProblems())
                .extracting(p -> p.getPath().toString())
                .containsExactlyInAnyOrder("/intValue", "/longValue", "/doubleValue");
        }
    }

    /*
    /**********************************************************************
    /* Test: Root-level problems
    /**********************************************************************
     */

    @Nested
    @DisplayName("Root-level problems")
    class RootLevelTests {

        @Test
        @DisplayName("should not collect root-level type mismatches")
        void rootLevelTypeMismatch() {
            // setup - root value is invalid for Person (non-recoverable)
            String json = "\"not-an-object\"";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when/then - root-level type mismatches are non-recoverable
            // They occur before property deserialization, so handler is never invoked
            assertThatThrownBy(() -> reader.readValueCollectingProblems(json))
                .isInstanceOf(DatabindException.class)
                .hasMessageContaining("Cannot construct instance")
                .satisfies(ex -> {
                    // Verify no problems were collected (root errors are non-recoverable)
                    assertThat(ex.getSuppressed()).isEmpty();
                });
        }

        @Test
        @DisplayName("should format property paths correctly without double slashes")
        void propertyPathFormatting() {
            // setup
            String json = "{\"age\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            String pointer = ex.getProblems().get(0).getPath().toString();
            assertThat(pointer).isEqualTo("/age");
            assertThat(pointer).doesNotContain("//"); // No double slashes
        }
    }

    /*
    /**********************************************************************
    /* Test: Hard failure with suppressed exceptions
    /**********************************************************************
     */

    @Nested
    @DisplayName("Hard failure with suppressed exceptions")
    class HardFailureTests {

        @Test
        @DisplayName("should attach collected problems as suppressed on hard failure")
        void suppressedProblems() {
            // setup - create JSON with 101 errors to trigger limit
            // When limit is reached, DeferredBindingException is thrown as primary,
            // with original DatabindException as suppressed
            String json = buildInvalidOrderJson(101);
            ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then - verify suppressed exception attachment mechanism
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(100);
            assertThat(ex.isLimitReached()).isTrue();

            // Original DatabindException should be in suppressed for debugging
            assertThat(ex.getSuppressed())
                .as("Original DatabindException should be attached as suppressed")
                .hasSizeGreaterThanOrEqualTo(1);
            assertThat(ex.getSuppressed()[0]).isInstanceOf(DatabindException.class);
        }
    }

    /*
    /**********************************************************************
    /* Test: Message formatting
    /**********************************************************************
     */

    @Nested
    @DisplayName("Message formatting")
    class MessageFormattingTests {

        @Test
        @DisplayName("should format single error message")
        void singleError() {
            // setup
            String json = "{\"age\":\"invalid\"}";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getMessage()).contains("1 deserialization problem");
        }

        @Test
        @DisplayName("should format multiple errors with first 5 shown")
        void multipleErrors() {
            // setup
            String json = buildInvalidOrderJson(10);
            ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, json);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getMessage())
                .contains("10 deserialization problems")
                .contains("showing first 5")
                .contains("... and 5 more");
        }
    }

    /*
    /**********************************************************************
    /* Test: Edge cases
    /**********************************************************************
     */

    @Nested
    @DisplayName("Edge cases")
    class EdgeCaseTests {

        @Test
        @DisplayName("should validate positive maxProblems")
        void validateMaxProblems() {
            // when/then
            assertThatThrownBy(() -> MAPPER.readerFor(Person.class).problemCollectingReader(0))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("maxProblems must be positive");

            assertThatThrownBy(() -> MAPPER.readerFor(Person.class).problemCollectingReader(-1))
                .isInstanceOf(IllegalArgumentException.class);
        }

        @Test
        @DisplayName("should handle empty JSON")
        void emptyJson() {
            // setup
            String json = "{}";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when
            Person result = reader.readValueCollectingProblems(json);

            // then
            assertThat(result).isNotNull();
            assertThat(result.name).isNull();
            assertThat(result.age).isEqualTo(0);
        }

        @Test
        @DisplayName("should handle null parser gracefully")
        void nullParser() {
            // setup
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when/then
            assertThatThrownBy(() -> reader.readValueCollectingProblems((String) null))
                .isInstanceOf(IllegalArgumentException.class);
        }

        @Test
        @DisplayName("should collect errors via byte[] overload")
        void collectFromByteArray() {
            // setup
            String jsonString = "{\"name\":\"Alice\",\"age\":\"invalid\"}";
            byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when
            DeferredBindingException ex = expectDeferredBinding(reader, jsonBytes);

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.getProblems().get(0).getPath().toString()).isEqualTo("/age");
        }

        @Test
        @DisplayName("should collect errors via File overload")
        void collectFromFile() throws Exception {
            // setup
            File tempFile = File.createTempFile("test", ".json");
            try {
                java.nio.file.Files.writeString(tempFile.toPath(),
                    "{\"name\":\"Bob\",\"age\":\"notANumber\"}");
                ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

                // when
                DeferredBindingException ex = expectDeferredBinding(reader, tempFile);

                // then
                assertThat(ex).isNotNull();
                assertThat(ex.getProblems()).hasSize(1);
                assertThat(ex.getProblems().get(0).getMessage())
                    .contains("notANumber");
            } finally {
                java.nio.file.Files.deleteIfExists(tempFile.toPath());
            }
        }

        @Test
        @DisplayName("should collect errors via InputStream overload")
        void collectFromInputStream() throws Exception {
            // setup
            String json = "{\"name\":\"Charlie\",\"age\":\"bad\"}";
            ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when
            DeferredBindingException ex;
            try (InputStream input = new java.io.ByteArrayInputStream(
                    json.getBytes(StandardCharsets.UTF_8))) {
                ex = expectDeferredBinding(reader, input);
            }

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.getProblems().get(0).getPath().toString()).isEqualTo("/age");
        }

        @Test
        @DisplayName("should collect errors via Reader overload")
        void collectFromReader() throws Exception {
            // setup
            String json = "{\"name\":\"Diana\",\"age\":\"invalid\"}";
            ObjectReader objectReader = MAPPER.readerFor(Person.class).problemCollectingReader();

            // when
            DeferredBindingException ex;
            try (Reader reader = new java.io.StringReader(json)) {
                ex = expectDeferredBinding(objectReader, reader);
            }

            // then
            assertThat(ex).isNotNull();
            assertThat(ex.getProblems()).hasSize(1);
            assertThat(ex.getProblems().get(0).getPath().toString()).isEqualTo("/age");
        }
    }
}