diff --git a/core/common/src/main/kotlin/app/muxtv/common/diagnostics/BackgroundWorkFailure.kt b/core/common/src/main/kotlin/app/muxtv/common/diagnostics/BackgroundWorkFailure.kt new file mode 100644 index 000000000..fdbb192c8 --- /dev/null +++ b/core/common/src/main/kotlin/app/muxtv/common/diagnostics/BackgroundWorkFailure.kt @@ -0,0 +1,24 @@ +package app.muxtv.common.diagnostics + +internal enum class BackgroundWorkFailureKind { + INITIALIZATION, + SCHEDULING, + WORKER_INITIALIZATION, + WORKER_EXECUTION, +} + +internal enum class BackgroundWorkerCategory { + SOURCE_REFRESH, + EPG_REFRESH, + UNKNOWN, +} + +internal data class BackgroundWorkFailureObservation( + val kind: BackgroundWorkFailureKind, + val timestampEpochMillis: Long, + val workerCategory: BackgroundWorkerCategory, +) { + init { + require(timestampEpochMillis >= 0L) { "Timestamp must be non-negative" } + } +} diff --git a/core/common/src/test/kotlin/app/muxtv/common/diagnostics/BackgroundWorkFailureTest.kt b/core/common/src/test/kotlin/app/muxtv/common/diagnostics/BackgroundWorkFailureTest.kt new file mode 100644 index 000000000..22f3955db --- /dev/null +++ b/core/common/src/test/kotlin/app/muxtv/common/diagnostics/BackgroundWorkFailureTest.kt @@ -0,0 +1,68 @@ +package app.muxtv.common.diagnostics + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class BackgroundWorkFailureTest { + @Test + fun `failure kinds match stable WorkManager callbacks`() { + assertThat(BackgroundWorkFailureKind.entries) + .containsExactly( + BackgroundWorkFailureKind.INITIALIZATION, + BackgroundWorkFailureKind.SCHEDULING, + BackgroundWorkFailureKind.WORKER_INITIALIZATION, + BackgroundWorkFailureKind.WORKER_EXECUTION, + ) + .inOrder() + } + + @Test + fun `worker category is a closed secret-safe vocabulary`() { + assertThat(BackgroundWorkerCategory.entries) + .containsExactly( + BackgroundWorkerCategory.SOURCE_REFRESH, + BackgroundWorkerCategory.EPG_REFRESH, + BackgroundWorkerCategory.UNKNOWN, + ) + .inOrder() + } + + @Test + fun `observation exposes no arbitrary string or throwable payload`() { + val fieldTypes = BackgroundWorkFailureObservation::class.java.declaredFields.map { it.type } + + assertThat(fieldTypes).doesNotContain(String::class.java) + assertThat(fieldTypes).doesNotContain(Throwable::class.java) + } + + @Test + fun `observation equality is deterministic`() { + val first = + BackgroundWorkFailureObservation( + kind = BackgroundWorkFailureKind.WORKER_EXECUTION, + timestampEpochMillis = 1234L, + workerCategory = BackgroundWorkerCategory.SOURCE_REFRESH, + ) + val second = first.copy() + + assertThat(second).isEqualTo(first) + assertThat(second.hashCode()).isEqualTo(first.hashCode()) + } + + @Test + fun `observation rejects negative wall clock timestamp`() { + var thrown: Throwable? = null + + try { + BackgroundWorkFailureObservation( + kind = BackgroundWorkFailureKind.INITIALIZATION, + timestampEpochMillis = -1L, + workerCategory = BackgroundWorkerCategory.UNKNOWN, + ) + } catch (failure: Throwable) { + thrown = failure + } + + assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) + } +}