package com.sptmobile.voice import java.net.HttpURLConnection import java.net.Inet4Address import java.net.URL import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test // [unit->REQ-VOICE-PIPE] /** * The receiver end-to-end over a real loopback socket: exactly what Pebble * sees. Port 0 = ephemeral (tests never collide with a running app). */ class WebhookServerTest { private val spool = FakeVoiceSpool() private val server = WebhookServer( port = 0, handler = WebhookHandler(spool, { "tok-secret" }, now = { 42_000L }), ) private var port = 0 private fun ensureStarted() { if (port == 0) port = server.start() } @After fun tearDown() { server.stop() } // [unit->REQ-HAZARD-DICTATION-LOSS] /** * The loss-boundary contract: when the 200 arrives at the client, the * row is ALREADY durably spooled — handle() only builds the 200 from a * successful enqueue return, and the response bytes are written strictly * after. */ @Test fun valid_post_spools_before_the_200_lands() { ensureStarted() val (status, body) = post( token = "tok-secret", fields = listOf( "transcription" to "remember the milk", "recordedAt" to "1751791234567", "client" to "ring", ), ) assertEquals(200, status) // The 200 has landed: the row must already be there — no async gap. val rows = spool.all() assertEquals(1, rows.size) assertEquals("remember the milk", rows[0].transcription) assertEquals(1751791234567L, rows[0].recordedAtMs) assertTrue("200 body names the msg-id", body.contains(rows[0].msgId)) } // [unit->REQ-HAZARD-DICTATION-LOSS] /** A false 200 over a failed durable write would BE the dictation loss. */ @Test fun enqueue_failure_answers_500_never_200() { ensureStarted() spool.failEnqueue = true val (status, _) = post( token = "tok-secret", fields = listOf("transcription" to "lost?", "recordedAt" to "1"), ) assertEquals(500, status) assertTrue(spool.all().isEmpty()) } @Test fun bad_or_missing_token_is_401_and_nothing_spools() { ensureStarted() val (wrong, _) = post(token = "wrong", fields = listOf("transcription" to "x")) assertEquals(401, wrong) val (missing, _) = post(token = null, fields = listOf("transcription" to "x")) assertEquals(401, missing) assertTrue(spool.all().isEmpty()) } @Test fun audio_only_post_is_200_but_not_spooled() { ensureStarted() // v1 payload mode is transcription-only: nothing to forward, but // Pebble is answered so it does not re-post this recording forever. val (status, _) = post( token = "tok-secret", fields = listOf("recordedAt" to "5", "client" to "ring"), ) assertEquals(200, status) assertTrue(spool.all().isEmpty()) } @Test fun missing_recordedAt_degrades_to_receive_time() { ensureStarted() val (status, _) = post( token = "tok-secret", fields = listOf("transcription" to "undated"), ) assertEquals(200, status) assertEquals(42_000L, spool.all().single().recordedAtMs) // injected now() } // [unit->REQ-HAZARD-RECEIVER-BIND-V4] /** * The bind MUST be IPv4 loopback: Pebble connects to the literal * `127.0.0.1`, so an IPv6 `::1`-only bind (what `getLoopbackAddress()` * yields on dual-stack devices) refuses it with ECONNREFUSED. Guards the * regression that lost dictations on the razr+. */ @Test fun binds_ipv4_loopback_not_ipv6() { val s = WebhookServer(port = 0, handler = WebhookHandler(FakeVoiceSpool(), { "t" })) s.start() try { val addr = s.boundAddress assertTrue( "must bind IPv4 loopback (Pebble connects 127.0.0.1), was $addr", addr is Inet4Address && addr.isLoopbackAddress, ) } finally { s.stop() } } // [unit->REQ-HAZARD-RECEIVER-DOWN] /** bound tracks the socket lifetime — the liveness signal on the card. */ @Test fun bound_reflects_socket_lifetime() { val s = WebhookServer(port = 0, handler = WebhookHandler(FakeVoiceSpool(), { "t" })) assertFalse("not bound before start", s.bound) s.start() assertTrue("bound after start", s.bound) s.stop() assertFalse("unbound after stop", s.bound) } // [unit->REQ-HAZARD-RECEIVER-DOWN] /** * A bound receiver reports liveness: lastReceivedAt is null until a * request lands, then stamps — even a token-less 401 probe counts, since * reading it off the socket is proof the process is alive (the adb probe * relies on exactly this). */ @Test fun landed_request_marks_liveness_even_on_401() { val s = WebhookServer( port = 0, handler = WebhookHandler(spool, { "tok-secret" }), now = { 99_000L }, ) val p = s.start() try { assertNull("nothing received yet", s.lastReceivedAt.value) val conn = URL("http://127.0.0.1:$p/").openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.doOutput = true // no token → 401, but the request still lands and marks liveness. conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=b") conn.outputStream.use { it.write(WebhookRequestTest.multipart("b", listOf("transcription" to "x"))) } assertEquals(401, conn.responseCode) conn.disconnect() assertNotNull("a landed request stamps liveness", s.lastReceivedAt.value) assertEquals(99_000L, s.lastReceivedAt.value) } finally { s.stop() } } @Test fun get_is_405_and_malformed_multipart_is_400() { ensureStarted() val get = URL("http://127.0.0.1:$port/").openConnection() as HttpURLConnection assertEquals(405, get.responseCode) get.disconnect() val conn = URL("http://127.0.0.1:$port/").openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.doOutput = true conn.setRequestProperty("X-Widget-Token", "tok-secret") conn.setRequestProperty("Content-Type", "text/plain") conn.outputStream.use { it.write("not multipart".toByteArray()) } assertEquals(400, conn.responseCode) conn.disconnect() } /** POST the Pebble multipart shape; returns (status, response body). */ private fun post(token: String?, fields: List>): Pair { val boundary = "pebble-boundary" val body = WebhookRequestTest.multipart(boundary, fields) val conn = URL("http://127.0.0.1:$port/").openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.doOutput = true if (token != null) conn.setRequestProperty("X-Widget-Token", token) conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") conn.outputStream.use { it.write(body) } val status = conn.responseCode val text = (if (status < 400) conn.inputStream else conn.errorStream) ?.readBytes()?.toString(Charsets.UTF_8) ?: "" conn.disconnect() return status to text } }