|
23 | 23 | import static org.junit.jupiter.api.Assertions.assertThrows; |
24 | 24 | import static org.junit.jupiter.api.Assertions.assertThrowsExactly; |
25 | 25 | import static org.junit.jupiter.api.Assertions.assertTrue; |
| 26 | +import static org.junit.jupiter.api.Assertions.fail; |
| 27 | +import static org.mockito.Mockito.mock; |
| 28 | +import static org.mockito.Mockito.times; |
| 29 | +import static org.mockito.Mockito.verify; |
| 30 | +import static org.mockito.Mockito.verifyNoMoreInteractions; |
| 31 | +import static org.mockito.Mockito.when; |
26 | 32 |
|
27 | 33 | import java.io.ByteArrayInputStream; |
28 | 34 | import java.io.IOException; |
29 | 35 | import java.io.InputStream; |
30 | 36 | import java.nio.charset.StandardCharsets; |
31 | 37 | import java.util.concurrent.atomic.AtomicBoolean; |
| 38 | +import java.util.stream.Stream; |
32 | 39 |
|
33 | 40 | import org.apache.commons.io.IOUtils; |
34 | 41 | import org.apache.commons.io.test.CustomIOException; |
35 | 42 | import org.apache.commons.lang3.mutable.MutableInt; |
36 | 43 | import org.junit.jupiter.api.Test; |
37 | 44 | import org.junit.jupiter.params.ParameterizedTest; |
| 45 | +import org.junit.jupiter.params.provider.Arguments; |
| 46 | +import org.junit.jupiter.params.provider.MethodSource; |
38 | 47 | import org.junit.jupiter.params.provider.ValueSource; |
39 | 48 |
|
40 | 49 | /** |
@@ -80,21 +89,74 @@ void testAfterReadConsumer() throws Exception { |
80 | 89 | // @formatter:on |
81 | 90 | } |
82 | 91 |
|
83 | | - @SuppressWarnings("resource") |
84 | | - @Test |
85 | | - void testAvailableAfterClose() throws Exception { |
| 92 | + static Stream<Arguments> testAvailableAfterClose() throws IOException { |
| 93 | + // Case 1: behaves like ByteArrayInputStream — close() is a no-op, available() still returns a value (e.g., 42). |
| 94 | + final InputStream noOpClose = mock(InputStream.class); |
| 95 | + when(noOpClose.available()).thenReturn(42, 42); |
| 96 | + |
| 97 | + // Case 2: returns 0 after close (Commons memory-backed streams that ignore close but report 0 when exhausted). |
| 98 | + final InputStream returnsZeroAfterClose = mock(InputStream.class); |
| 99 | + when(returnsZeroAfterClose.available()).thenReturn(42, 0); |
| 100 | + |
| 101 | + // Case 3: throws IOException after close (e.g., FileInputStream-like behavior). |
| 102 | + final InputStream throwsAfterClose = mock(InputStream.class); |
| 103 | + when(throwsAfterClose.available()).thenReturn(42).thenThrow(new IOException("Stream closed")); |
| 104 | + |
| 105 | + return Stream.of( |
| 106 | + Arguments.of("underlying stream still returns 42 after close", noOpClose, 42), |
| 107 | + Arguments.of("underlying stream returns 0 after close", returnsZeroAfterClose, 42), |
| 108 | + Arguments.of("underlying stream throws IOException after close", throwsAfterClose, 42)); |
| 109 | + } |
| 110 | + |
| 111 | + @ParameterizedTest(name = "{index} — {0}") |
| 112 | + @MethodSource |
| 113 | + void testAvailableAfterClose(String caseName, InputStream delegate, int expectedBeforeClose) |
| 114 | + throws Exception { |
86 | 115 | final InputStream shadow; |
87 | | - try (InputStream in = BoundedInputStream.builder().setCharSequence("Hi").get()) { |
88 | | - assertTrue(in.available() > 0); |
89 | | - shadow = in; |
90 | | - } |
91 | | - assertEquals(0, shadow.available()); |
| 116 | + try (InputStream in = BoundedInputStream.builder() |
| 117 | + .setInputStream(delegate) |
| 118 | + .setPropagateClose(true) |
| 119 | + .get()) { |
| 120 | + // Before close: pass-through behavior |
| 121 | + assertEquals(expectedBeforeClose, in.available(), caseName + " (before close)"); |
| 122 | + shadow = in; // keep reference to call after close |
| 123 | + } |
| 124 | + // Verify the underlying stream was closed |
| 125 | + verify(delegate, times(1)).close(); |
| 126 | + // After close: behavior depends on the underlying stream |
| 127 | + assertEquals(0, shadow.available(), caseName + " (after close)"); |
| 128 | + // Interactions: available called only once before close. |
| 129 | + verify(delegate, times(1)).available(); |
| 130 | + verifyNoMoreInteractions(delegate); |
92 | 131 | } |
93 | 132 |
|
94 | | - @Test |
95 | | - void testAvailableAfterOpen() throws Exception { |
96 | | - try (InputStream in = BoundedInputStream.builder().setCharSequence("Hi").get()) { |
97 | | - assertTrue(in.available() > 0); |
| 133 | + static Stream<Arguments> testAvailableUpperLimit() { |
| 134 | + final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8); |
| 135 | + return Stream.of( |
| 136 | + // Limited by maxCount |
| 137 | + Arguments.of(new ByteArrayInputStream(helloWorld), helloWorld.length - 1, helloWorld.length - 1, 0), |
| 138 | + // Limited by data length |
| 139 | + Arguments.of(new ByteArrayInputStream(helloWorld), helloWorld.length + 1, helloWorld.length, 0), |
| 140 | + // Limited by Integer.MAX_VALUE |
| 141 | + Arguments.of( |
| 142 | + new NullInputStream(Long.MAX_VALUE), Long.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)); |
| 143 | + } |
| 144 | + |
| 145 | + @ParameterizedTest |
| 146 | + @MethodSource |
| 147 | + void testAvailableUpperLimit(InputStream input, long maxCount, int expectedBeforeSkip, int expectedAfterSkip) |
| 148 | + throws Exception { |
| 149 | + try (BoundedInputStream bounded = BoundedInputStream.builder() |
| 150 | + .setInputStream(input) |
| 151 | + .setMaxCount(maxCount) |
| 152 | + .get()) { |
| 153 | + assertEquals( |
| 154 | + expectedBeforeSkip, bounded.available(), "available should be limited by maxCount and data length"); |
| 155 | + IOUtils.skip(bounded, expectedBeforeSkip); |
| 156 | + assertEquals( |
| 157 | + expectedAfterSkip, |
| 158 | + bounded.available(), |
| 159 | + "after skipping available should be limited by maxCount and data length"); |
98 | 160 | } |
99 | 161 | } |
100 | 162 |
|
@@ -444,15 +506,58 @@ void testPublicConstructors() throws IOException { |
444 | 506 | } |
445 | 507 | } |
446 | 508 |
|
447 | | - @SuppressWarnings("resource") |
448 | | - @Test |
449 | | - void testReadAfterClose() throws Exception { |
450 | | - final InputStream shadow; |
451 | | - try (InputStream in = BoundedInputStream.builder().setCharSequence("Hi").get()) { |
452 | | - assertTrue(in.available() > 0); |
453 | | - shadow = in; |
| 509 | + static Stream<Arguments> testReadAfterClose() throws IOException { |
| 510 | + // Case 1: no-op close (ByteArrayInputStream-like): read() still returns a value after close |
| 511 | + final InputStream noOpClose = mock(InputStream.class); |
| 512 | + when(noOpClose.read()).thenReturn(42); |
| 513 | + |
| 514 | + // Case 2: returns EOF (-1) after close |
| 515 | + final InputStream returnsEofAfterClose = mock(InputStream.class); |
| 516 | + when(returnsEofAfterClose.read()).thenReturn(IOUtils.EOF); |
| 517 | + |
| 518 | + // Case 3: throws IOException after close (FileInputStream-like) |
| 519 | + final InputStream throwsAfterClose = mock(InputStream.class); |
| 520 | + final IOException closed = new IOException("Stream closed"); |
| 521 | + when(throwsAfterClose.read()).thenThrow(closed); |
| 522 | + |
| 523 | + return Stream.of( |
| 524 | + Arguments.of("underlying stream still reads data after close", noOpClose, 42), |
| 525 | + Arguments.of("underlying stream returns EOF after close", returnsEofAfterClose, IOUtils.EOF), |
| 526 | + Arguments.of("underlying stream throws IOException after close", throwsAfterClose, closed)); |
| 527 | + } |
| 528 | + |
| 529 | + @ParameterizedTest(name = "{index} — {0}") |
| 530 | + @MethodSource("testReadAfterClose") |
| 531 | + void testReadAfterClose( |
| 532 | + String caseName, |
| 533 | + InputStream delegate, |
| 534 | + Object expectedAfterClose // Integer (value) or IOException (expected thrown) |
| 535 | + ) throws Exception { |
| 536 | + |
| 537 | + final InputStream bounded; |
| 538 | + try (InputStream in = BoundedInputStream.builder() |
| 539 | + .setInputStream(delegate) |
| 540 | + .setPropagateClose(true) |
| 541 | + .get()) { |
| 542 | + bounded = in; // call read() only after close |
| 543 | + } |
| 544 | + |
| 545 | + // Underlying stream should be closed exactly once |
| 546 | + verify(delegate, times(1)).close(); |
| 547 | + |
| 548 | + if (expectedAfterClose instanceof Integer) { |
| 549 | + assertEquals(expectedAfterClose, bounded.read(), caseName + " (after close)"); |
| 550 | + } else if (expectedAfterClose instanceof IOException) { |
| 551 | + final IOException actual = assertThrows(IOException.class, bounded::read, caseName + " (after close)"); |
| 552 | + // verify it's the exact instance we configured |
| 553 | + assertSame(expectedAfterClose, actual, caseName + " (exception instance)"); |
| 554 | + } else { |
| 555 | + fail("Unexpected expectedAfterClose type: " + expectedAfterClose); |
454 | 556 | } |
455 | | - assertEquals(IOUtils.EOF, shadow.read()); |
| 557 | + |
| 558 | + // We only performed one read() (after close) |
| 559 | + verify(delegate, times(1)).read(); |
| 560 | + verifyNoMoreInteractions(delegate); |
456 | 561 | } |
457 | 562 |
|
458 | 563 | @Test |
@@ -494,6 +599,46 @@ void testReadArray() throws Exception { |
494 | 599 | } |
495 | 600 | } |
496 | 601 |
|
| 602 | + static Stream<Arguments> testRemaining() { |
| 603 | + return Stream.of( |
| 604 | + // Unbounded: any negative maxCount is treated as "no limit". |
| 605 | + Arguments.of("unbounded (EOF constant)", IOUtils.EOF, Long.MAX_VALUE), |
| 606 | + Arguments.of("unbounded (arbitrary negative)", Long.MIN_VALUE, Long.MAX_VALUE), |
| 607 | + |
| 608 | + // Bounded: remaining equals the configured limit, regardless of underlying data size. |
| 609 | + Arguments.of("bounded (zero)", 0L, 0L), |
| 610 | + Arguments.of("bounded (small)", 1024L, 1024L), |
| 611 | + Arguments.of("bounded (Integer.MAX_VALUE)", Integer.MAX_VALUE, (long) Integer.MAX_VALUE), |
| 612 | + |
| 613 | + // Bounded but extremely large: still not 'unbounded'. |
| 614 | + Arguments.of("bounded (Long.MAX_VALUE)", Long.MAX_VALUE, Long.MAX_VALUE)); |
| 615 | + } |
| 616 | + |
| 617 | + @ParameterizedTest(name = "{index}: {0} -> initial remaining {2}") |
| 618 | + @MethodSource |
| 619 | + void testRemaining(final String caseName, final long maxCount, final long expectedInitialRemaining) |
| 620 | + throws Exception { |
| 621 | + final byte[] data = "Hello World".getBytes(StandardCharsets.UTF_8); // 11 bytes |
| 622 | + |
| 623 | + try (BoundedInputStream in = BoundedInputStream.builder() |
| 624 | + .setByteArray(data) |
| 625 | + .setMaxCount(maxCount) |
| 626 | + .get()) { |
| 627 | + // Initial remaining respects the imposed limit (or is Long.MAX_VALUE if unbounded). |
| 628 | + assertEquals(expectedInitialRemaining, in.getRemaining(), caseName + " (initial)"); |
| 629 | + |
| 630 | + // Skip more than the data length to exercise both bounded and unbounded paths. |
| 631 | + final long skipped = IOUtils.skip(in, 42); |
| 632 | + |
| 633 | + // For unbounded streams (EOF == -1), remaining stays the same. |
| 634 | + // For bounded, it decreases by 'skipped'. |
| 635 | + final long expectedAfterSkip = |
| 636 | + in.getMaxCount() == IOUtils.EOF ? expectedInitialRemaining : expectedInitialRemaining - skipped; |
| 637 | + |
| 638 | + assertEquals(expectedAfterSkip, in.getRemaining(), caseName + " (after skip)"); |
| 639 | + } |
| 640 | + } |
| 641 | + |
497 | 642 | @Test |
498 | 643 | void testReadSingle() throws Exception { |
499 | 644 | final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8); |
|
0 commit comments