package com.sptmobile.voice import java.io.ByteArrayInputStream import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Test // [unit->REQ-VOICE-PIPE] /** The pure HTTP + multipart parse under the webhook receiver. */ class WebhookRequestTest { @Test fun read_parses_request_line_headers_and_body() { val raw = "POST / HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "X-Widget-Token: tok-1\r\n" + "Content-Length: 5\r\n" + "\r\n" + "hello" val parsed = WebhookRequest.read( ByteArrayInputStream(raw.toByteArray(Charsets.ISO_8859_1)), maxBody = 1024, ) assertEquals("POST", parsed.method) assertEquals("/", parsed.path) assertEquals("tok-1", parsed.headers["x-widget-token"]) assertEquals("hello", String(parsed.body)) } @Test fun read_rejects_body_over_cap_without_reading_it() { val raw = "POST / HTTP/1.1\r\nContent-Length: 999999\r\n\r\n" assertThrows(WebhookRequest.TooLarge::class.java) { WebhookRequest.read( ByteArrayInputStream(raw.toByteArray(Charsets.ISO_8859_1)), maxBody = 1024, ) } } @Test fun read_rejects_truncated_body() { val raw = "POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc" assertThrows(WebhookRequest.Malformed::class.java) { WebhookRequest.read( ByteArrayInputStream(raw.toByteArray(Charsets.ISO_8859_1)), maxBody = 1024, ) } } @Test fun fields_parses_the_pebble_shape() { val body = multipart( "b0undary", listOf( "transcription" to "note the meeting moved to noon", "recordedAt" to "1751791234567", "client" to "ring", ), ) val fields = WebhookRequest.fields( "multipart/form-data; boundary=b0undary", body, ) assertEquals("note the meeting moved to noon", fields["transcription"]) assertEquals("1751791234567", fields["recordedAt"]) assertEquals("ring", fields["client"]) } @Test fun fields_skips_file_parts_and_keeps_text_fields() { // The optional audio attachment (AAC m4a) must be tolerated + skipped // (DESIGN.md §Pebble webhook contract; v1 = transcription only). val boundary = "XyZ" val audio = byteArrayOf(0, 1, 2, 3, 13, 10, 45, 45) // binary incl CRLF + "--" val body = ("--$boundary\r\n" + "Content-Disposition: form-data; name=\"transcription\"\r\n\r\n" + "hi\r\n" + "--$boundary\r\n" + "Content-Disposition: form-data; name=\"audio\"; filename=\"a.m4a\"\r\n" + "Content-Type: audio/mp4\r\n\r\n").toByteArray(Charsets.ISO_8859_1) + audio + "\r\n--$boundary--\r\n".toByteArray(Charsets.ISO_8859_1) val fields = WebhookRequest.fields( "multipart/form-data; boundary=$boundary", body, ) assertEquals("hi", fields["transcription"]) assertFalse(fields.containsKey("audio")) } @Test fun fields_handles_quoted_boundary_and_utf8_values() { val body = multipart("q-b", listOf("transcription" to "café ≠ caffè")) val fields = WebhookRequest.fields( "multipart/form-data; boundary=\"q-b\"", body, ) assertEquals("café ≠ caffè", fields["transcription"]) } @Test fun fields_rejects_non_multipart_and_missing_boundary() { assertThrows(WebhookRequest.Malformed::class.java) { WebhookRequest.fields("application/json", "{}".toByteArray()) } assertThrows(WebhookRequest.Malformed::class.java) { WebhookRequest.fields("multipart/form-data", multipart("b", emptyList())) } assertThrows(WebhookRequest.Malformed::class.java) { WebhookRequest.fields(null, ByteArray(0)) } } companion object { /** Build a text-fields-only multipart body the way Pebble would. */ fun multipart(boundary: String, fields: List>): ByteArray { val out = StringBuilder() for ((name, value) in fields) { out.append("--").append(boundary).append("\r\n") out.append("Content-Disposition: form-data; name=\"").append(name) .append("\"\r\n\r\n") out.append(value).append("\r\n") } out.append("--").append(boundary).append("--\r\n") return out.toString().toByteArray(Charsets.UTF_8) } } }