Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/convert/OutputPublisherPublishTest.kt
T
JMR-devandClaude Opus 5 ad47ce6c96 S2 + S3 (#142, #143): the two OutputPublisher seams, and where the second one goes
#142 -- openOutputStream refuses two ways and only one was reachable. A
provider that has gone away throws from inside the call, which
`a destination the provider will not open...` already drives. A provider
that is present and declines returns null, and nothing could produce that
on demand. openDestination is the seam; the test asserts the failure names
the destination, which is what separates the `?: error(...)` from an NPE
inside `use`.

#143 -- the sweep's re-read. **The seam the ticket proposed does not reach
it.** Overriding the listing fires before the entries are snapshotted, so
StagingSweep.collectable is handed the new timestamp, the file is never
proposed for deletion, and the guard is never exercised. Measured: with an
entriesIn seam, deleting the guard outright left the test green.

The race is a file that *was* collectable when the snapshot was taken and
is not by the time the delete comes round, so the seam has to sit at the
snapshot. `snapshot(listing)` does, and deleting the guard now reddens the
test.

Three mutations after the move, three red:

  null stream returns silently   null-return test
  null stream via !! instead     null-return test
  sweep deletes unconditionally  race test

OutputPublisher.kt now has no never-executed lines at all. Two partial
branches are left and both are named exemptions rather than gaps:
L216's `getOrNull() ?: false` and L304's `getOrDefault(absoluteFile)` are
the failure arms of a runCatching whose body cannot be made to throw
through any public entry point -- the same shape as the `size >= 0`
exemption recorded in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:14:19 -05:00

303 lines
14 KiB
Kotlin

package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import java.io.File
import java.io.IOException
import java.io.OutputStream
/** What a destination volume says when it fills up mid-write. */
private const val NO_SPACE = "No space left on device"
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
private const val PARTIAL_BYTES = 512
/**
* A sink that behaves like a volume filling up.
*
* Two failure shapes, because `publish()` has to survive both: a write that throws partway,
* and a `close()` that throws while flushing -- the second arriving after `copyTo` has
* already returned successfully.
*/
private class UnreliableOutputStream(
private val sink: OutputStream,
private val failAfterBytes: Int = Int.MAX_VALUE,
private val failOnClose: Boolean = false,
) : OutputStream() {
private var written = 0
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(b: ByteArray, off: Int, len: Int) {
val room = failAfterBytes - written
if (room <= 0) throw IOException(NO_SPACE)
val accepted = minOf(room, len)
sink.write(b, off, accepted)
written += accepted
if (accepted < len) throw IOException(NO_SPACE)
}
override fun flush() = sink.flush()
override fun close() {
sink.close()
if (failOnClose) throw IOException(NO_SPACE)
}
}
/**
* `publish()` on the paths where something goes wrong.
*
* The defect: a copy that failed partway left the bytes it had managed at the name the user
* picked, while the UI said "Could not save the file". [OutputPublisherStagingTest] covers
* the staging side of the same class; this covers the destination side, and needs a provider
* rather than a bare file because the destination is a `content://` URI and the fix turns on
* what kind of URI it is.
*
* Every case here is a failure case except one, and that is the point -- these branches never
* run in a healthy test run and are exactly the ones a user meets on a bad day.
*/
@RunWith(RobolectricTestRunner::class)
class OutputPublisherPublishTest {
private lateinit var context: Context
private lateinit var publisher: OutputPublisher
private lateinit var staged: File
private val payload = ByteArray(8192) { (it % 251).toByte() }
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
private val documentUri: Uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
private val plainUri: Uri = Uri.parse("content://$PLAIN_AUTHORITY/document/holiday_plain.mp4")
private val deadUri: Uri = Uri.parse("content://org.libremediaconverter.nonexistent/document/gone.mp4")
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
FakeSafProvider.reset(File(context.cacheDir, "destinations").apply { mkdirs() })
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
registerProvider(context, FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
// SAF's CreateDocument contract hands back a document that already exists and is
// empty, so that is the state every destination starts in here.
FakeSafProvider.backingFile(documentUri).writeBytes(ByteArray(0))
FakeSafProvider.backingFile(plainUri).writeBytes(ByteArray(0))
publisher = OutputPublisher(context)
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(payload) }
}
@Test
fun `a copy that fails partway leaves nothing at the destination`() {
failMidCopy(documentUri, afterBytes = 512)
val failure = assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
assertEquals(NO_SPACE, failure.message)
assertEquals(listOf(documentUri), FakeSafProvider.deleteRequests)
assertFalse(
"a truncated file must not be left at the name the user picked",
FakeSafProvider.backingFile(documentUri).exists(),
)
}
@Test
fun `a close that fails while flushing counts as a failed copy`() {
// copyTo() has already returned by the time this throws. Guarding only the copy and
// not the close would leave the file behind on exactly the disk-full case.
shadowOf(context.contentResolver).registerOutputStreamSupplier(documentUri) {
UnreliableOutputStream(FakeSafProvider.backingFile(documentUri).outputStream(), failOnClose = true)
}
val failure = assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
assertEquals(NO_SPACE, failure.message)
assertFalse(
"bytes that were never flushed are not a saved file",
FakeSafProvider.backingFile(documentUri).exists(),
)
}
@Test
fun `a destination that already held bytes is not deleted`() {
// Not the CreateDocument case: a provider that handed back an existing document for
// a name the user re-picked. Truncating it is bad; removing it outright is worse, and
// publish() cannot tell from a Uri that the app created it.
val existing = FakeSafProvider.backingFile(documentUri).apply { writeBytes(ByteArray(4096)) }
failMidCopy(documentUri, afterBytes = 512)
assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
assertTrue("a document this app did not create must survive", existing.exists())
assertEquals(emptyList<Uri>(), FakeSafProvider.deleteRequests)
}
@Test
fun `a destination that is not a document is left alone`() {
failMidCopy(plainUri, afterBytes = 512)
assertThrows(IOException::class.java) { publisher.publish(staged, plainUri) }
assertEquals(
"deleteDocument has no business on a URI that is not a document",
emptyList<Uri>(),
FakeSafProvider.deleteRequests,
)
assertTrue(FakeSafProvider.backingFile(plainUri).exists())
}
@Test
fun `a cleanup that fails does not replace the failure the user needs to see`() {
FakeSafProvider.deleteFailure = SecurityException("provider refused the delete")
failMidCopy(documentUri, afterBytes = 512)
val failure = assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
assertEquals("the disk-full failure is what save() reports", NO_SPACE, failure.message)
assertEquals(
"the cleanup failure is attached rather than lost",
listOf("provider refused the delete"),
failure.suppressedExceptions.map { it.message },
)
}
@Test
fun `a destination the provider will not open does not stay behind as an empty file`() {
// No stream supplier is registered for this URI and the fake provider does not implement
// openFile, which is a provider that has gone away between the picker and the write.
//
// The document exists all the same: SAF's CreateDocument contract created it before
// publish() was ever called, so "nothing has been written yet" was never the same claim as
// "there is nothing of ours here". Leaving it means a zero-byte file at the name the user
// chose, while the screen says the save failed.
val destination = FakeSafProvider.backingFile(documentUri)
assertEquals("the fixture starts as the empty document SAF hands back", 0L, destination.length())
val failure = runCatching { publisher.publish(staged, documentUri) }.exceptionOrNull()
assertTrue("a destination that will not open must not appear to succeed, got $failure", failure != null)
assertEquals(listOf(documentUri), FakeSafProvider.deleteRequests)
assertFalse(
"a zero-byte file must not be left at the name the user picked",
destination.exists(),
)
}
@Test
fun `a destination that cannot be opened at all fails without any cleanup`() {
// The JVM twin of UnopenableUriTest's unwritable-destination case. The open sits inside
// the guarded region now, so what keeps this one untouched is the guard rather than the
// placement: nothing answers for that authority, so no size can be read, and "I could not
// tell" must never authorise a delete.
val failure = runCatching { publisher.publish(staged, deadUri) }.exceptionOrNull()
assertTrue("publishing to a dead provider must not appear to succeed, got $failure", failure != null)
assertEquals(emptyList<Uri>(), FakeSafProvider.deleteRequests)
}
@Test
fun `a destination whose size cannot be determined is never deleted`() {
// The three short-circuits in destinationIsKnownEmpty, and the reason its KDoc gives for
// each of them answering false:
//
// "this decides whether a delete is allowed and 'I could not tell' must never authorise
// one."
//
// The contrast is `a copy that fails partway leaves nothing at the destination` above: a
// provider that *does* say zero gets the delete. These say nothing, so they must not.
// Getting this backwards costs the user a file they already had, on a save that failed.
//
// Named exemption: of the three conjuncts, `size >= 0` cannot be falsified behaviourally.
// Measured -- getColumnIndex returns -1 for an absent column, and isNull(-1) throws
// CursorIndexOutOfBoundsException, which the surrounding runCatching already turns into
// `?: false`. So relaxing it to `size >= -1` leaves this test green: same answer, reached
// by the exception path instead. The guard should stay -- control flow through an exception
// is worse than a comparison, and another Cursor implementation need not throw -- but no
// assertion here pins it, and saying so beats implying the missing-column case covers it.
// `!row.isNull(size)` and `row.moveToFirst()` do both bite.
listOf(
RowShape.NO_SIZE_COLUMN to "a cursor with no SIZE column",
RowShape.NULL_SIZE to "a cursor whose SIZE cell is null",
RowShape.NO_ROWS to "a cursor holding no rows",
).forEach { (shape, description) ->
FakeSafProvider.deleteRequests.clear()
FakeSafProvider.backingFile(documentUri).writeBytes(ByteArray(0))
FakeSafProvider.rowShape = shape
failMidCopy(documentUri, afterBytes = PARTIAL_BYTES)
assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
assertEquals(
"$description must not authorise a delete",
emptyList<Uri>(),
FakeSafProvider.deleteRequests,
)
assertTrue(
"$description must leave the destination where it was",
FakeSafProvider.backingFile(documentUri).exists(),
)
}
}
@Test
fun `a provider that declines by returning null fails with the destination named`() {
// openOutputStream has two ways of refusing, and only one of them is otherwise reachable.
// `a destination the provider will not open...` above drives the throwing one -- a provider
// that has gone away. This is the other: a provider that is present, answers, and hands
// back null. Without the `?: error(...)` that becomes an NPE inside `use`, which reaches
// the user as "Conversion failed." with a null message.
val nullOpening = object : OutputPublisher(context) {
override fun openDestination(destination: Uri): OutputStream? = null
}
val failure = runCatching { nullOpening.publish(staged, documentUri) }.exceptionOrNull()
assertTrue("a null stream must not appear to succeed, got $failure", failure != null)
assertTrue(
"the failure must name the destination rather than being a bare NPE; got ${failure?.message}",
failure?.message?.contains("Could not open destination for writing") == true,
)
}
@Test
fun `a copy that succeeds delivers every byte and deletes nothing`() {
shadowOf(context.contentResolver).registerOutputStreamSupplier(documentUri) {
FakeSafProvider.backingFile(documentUri).outputStream()
}
publisher.publish(staged, documentUri)
assertArrayEquals(payload, FakeSafProvider.backingFile(documentUri).readBytes())
assertEquals(emptyList<Uri>(), FakeSafProvider.deleteRequests)
}
/**
* Arms the destination to accept [afterBytes] and then fail.
*
* A supplier rather than a ready-made stream: opening the backing file truncates it, and
* doing that here would erase the very content the "already held bytes" case is about
* before `publish()` ever got to read its size.
*/
private fun failMidCopy(destination: Uri, afterBytes: Int) {
shadowOf(context.contentResolver).registerOutputStreamSupplier(destination) {
UnreliableOutputStream(
FakeSafProvider.backingFile(destination).outputStream(),
failAfterBytes = afterBytes,
)
}
}
}