diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt new file mode 100644 index 0000000..e1671d8 --- /dev/null +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt @@ -0,0 +1,268 @@ +package io.flamenet.messenger.app + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.printToString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Voice notes, stories, groups and calls — **used**, not just reachable. + * + * ⚠️ **`CrudSurfaceTest` already asserts these controls EXIST**, and that was + * the right thing to build first: features kept landing with a live test and no + * door in the UI. But `post-story`, `record-voice` and `start-call` being + * present says nothing about what happens when somebody presses them, and until + * now nothing pressed them. Four features shipped in an APK on that basis. + * + * So this presses them and asserts the state that follows: the recorder shows a + * timer, discarding puts the composer back, a group appears in the list under + * the name it was given, the story viewer opens, the call banner comes up and + * hanging up takes it away. + * + * ⚠️ **What this deliberately does NOT claim.** An emulator has no microphone + * worth the name and no second handset, so a voice note is exercised as far as + * the recorder and a call as far as signalling. Whether audio arrives at another + * person is not something one device can answer, and pretending otherwise would + * be worse than the gap. + */ +@RunWith(AndroidJUnit4::class) +class FeatureUseTest { + + @get:Rule val compose = createAndroidComposeRule() + + private val relay = "relay1.flamenet.io" + private lateinit var handle: String + + @Before + fun startSignedOut() { + handle = "andfeat" + (System.currentTimeMillis() % 100_000_000) + signOutIfSignedIn() + } + + // -- Voice notes --------------------------------------------------------- + + /** + * The recorder starts, runs, and can be thrown away. + * + * The discard half matters as much as the start: a recorder you cannot back + * out of holds the microphone open and leaves the composer unusable. + */ + @Test + fun aVoiceNoteRecordsAndCanBeDiscarded() { + if (!relayReachable()) return + signUpAndOpenAThread() + + compose.onNodeWithTag("record-voice").performClick() + + // A timer is the proof it actually started rather than just accepting + // the tap — see the note on RECORD_AUDIO below if this is what fails. + awaitTag("recording-timer", 20_000) + compose.onNodeWithTag("send-voice").assertExists() + compose.onNodeWithTag("cancel-voice").assertExists() + + compose.onNodeWithTag("cancel-voice").performClick() + awaitTag("draft", 20_000) + assertTrue( + "discarding a recording did not bring the composer back", + nodeExists("draft"), + ) + } + + // -- Groups -------------------------------------------------------------- + + /** + * A group is created and appears in the list under the name it was given. + * + * ⚠️ Needs a contact: the member picker has nobody in it otherwise, and the + * relay refuses a group whose members have no device (`requireReachable`). + */ + @Test + fun aGroupIsCreatedAndListed() { + if (!relayReachable()) return + signUpAndAddPeer() + + compose.onNodeWithTag("new-group").performClick() + awaitTag("group-name", 20_000) + compose.onNodeWithTag("group-name").performTextInput("Test Crew") + + // Pick the one contact there is, by the tag the row carries. + val memberTag = Regex("group-member-(\\d+)").find(visibleText())?.value + ?: throw AssertionError( + "nobody offered as a group member, so the contact did not stick. " + + "On screen: ${visibleText()}") + compose.onNodeWithTag(memberTag).performClick() + compose.onNodeWithTag("create-group").performClick() + + // The group list carries a `group-` row once the relay answers. + awaitNodeMatching(Regex("group-\\d+"), 60_000, + "the group never appeared in the list after Create") + } + + // -- Stories ------------------------------------------------------------- + + /** + * The story surface opens and says what it is when there is nothing in it. + * + * ⚠️ Posting one needs an image from the system picker, which is a + * different process and not something a Compose test drives. So this covers + * the strip and the empty state — an unexplained empty strip is a feature + * nobody finds — and stops short of claiming a story was published. + */ + @Test + fun theStorySurfaceOpensAndExplainsItself() { + if (!relayReachable()) return + signUp() + + compose.onNodeWithTag("stories").assertExists() + assertTrue( + "the stories strip is empty with no explanation, which is how a " + + "feature goes undiscovered", + nodeExists("stories-empty") || nodeExists("post-story"), + ) + } + + // -- Calls --------------------------------------------------------------- + + /** + * Placing a call raises the privacy notice, then the in-call banner, and + * hanging up clears it. + * + * ⚠️ **Signalling only.** Nobody answers on an emulator, so this proves the + * call is placed and can be ended — not that audio flows. Media needs two + * real devices and is honestly out of reach here. + */ + @Test + fun placingACallRaisesTheBannerAndHangingUpClearsIt() { + if (!relayReachable()) return + signUpAndOpenAThread() + + compose.onNodeWithTag("start-call").performClick() + + // First call shows the peer-to-peer privacy notice (spec §6.8): a + // direct connection reveals an IP address to the other side, and the + // app asks before doing that. + if (awaitTagOrNull("call-privacy", 8_000)) { + compose.onNodeWithTag("call-privacy-accept").performClick() + } + + awaitTag("call-banner", 30_000) + compose.onNodeWithTag("end-call").performClick() + + val gone = (1..40).any { + Thread.sleep(250) + !nodeExists("call-banner") + } + assertTrue("hanging up did not clear the call banner", gone) + } + + // -- Helpers ------------------------------------------------------------- + + private fun signUpAndAddPeer() { + signUp() + compose.onNodeWithTag("new-contact").performClick() + awaitTag("lookup-contact", 20_000) + compose.onNodeWithTag("lookup-contact").performTextInput(PEER) + compose.onNodeWithTag("confirm-contact").performClick() + awaitNodeMatching(Regex("buddy-\\d+"), 60_000, + "'$PEER' never became a contact — does that account exist on $relay?") + } + + private fun signUpAndOpenAThread() { + signUpAndAddPeer() + val buddyTag = Regex("buddy-(\\d+)").find(visibleText())?.value + ?: throw AssertionError("no contact row to open") + compose.onNodeWithTag(buddyTag).performClick() + awaitTag("draft", 20_000) + } + + private fun signUp() { + compose.onNodeWithTag("relay").performTextClearance() + compose.onNodeWithTag("relay").performTextInput(relay) + compose.onNodeWithTag("toggle-register").performClick() + compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") + compose.onNodeWithTag("handle").performTextInput(handle) + compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") + compose.onNodeWithTag("submit").performClick() + awaitTag("nav-contacts", 120_000) + } + + private fun signOutIfSignedIn() { + runCatching { + if (!awaitTagOrNull("nav-account", 8_000)) return + compose.onNodeWithTag("nav-account").performClick() + if (awaitTagOrNull("sign-out", 10_000)) { + compose.onNodeWithTag("sign-out").performClick() + } + awaitTagOrNull("submit", 20_000) + } + } + + private fun nodeExists(tag: String) = runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + + /** See ChatFlowTest: the busy spinner never lets the test clock go idle. */ + private fun awaitTag(tag: String, timeoutMillis: Long) { + if (!awaitTagOrNull(tag, timeoutMillis)) { + throw AssertionError( + "'$tag' did not appear within ${timeoutMillis}ms. " + + "On screen: ${visibleText()}") + } + } + + private fun awaitTagOrNull(tag: String, timeoutMillis: Long): Boolean { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + if (nodeExists(tag)) return true + Thread.sleep(100) + } + return false + } finally { + compose.mainClock.autoAdvance = true + } + } + + /** Wait for any tag matching a pattern — ids are assigned by the relay. */ + private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + if (pattern.containsMatchIn(visibleText())) return + Thread.sleep(200) + } + throw AssertionError("$whenMissing. On screen: ${visibleText()}") + } finally { + compose.mainClock.autoAdvance = true + } + } + + private fun visibleText(): String = runCatching { + compose.onRoot().printToString(maxDepth = 40) + }.getOrElse { "(could not read the screen: $it)" } + + private fun relayReachable(): Boolean = runCatching { + (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { connectTimeout = 8000; readTimeout = 8000 } + .responseCode in 200..299 + }.getOrDefault(false) + + private companion object { + /** A standing account on relay1 that other on-device tests also use. */ + const val PEER = "peerjvmone" + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 3f8b5df..45d44ed 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -141,12 +141,34 @@ class MessengerApi( raw("api/flamenet/v1/messenger/account", "DELETE", null) } - /** Exact-username lookup. Deliberately not a directory: no listing exists. */ - fun lookup(username: String): Me? = runCatching { - raw("api/flamenet/v1/messenger/users", "GET", null, query = mapOf("slug" to username)).let { - Me(it.getLong("id"), it.optString("name"), it.optString("slug")) - } - }.getOrNull() + /** + * Exact-username lookup. Deliberately not a directory: no listing exists. + * + * 🔴 **The relay answers with an ARRAY**, empty for a miss and one element + * for a hit — `RoutesAccount.lookup` returns `[MeResponse]` precisely so a + * miss and a hit differ only in length and neither leaks timing. This went + * through `send()`, which does `JSONObject(text)` and throws on an array, + * and the throw was swallowed by `runCatching { }.getOrNull()`. + * + * The result: **every lookup reported "no account called X here" even when + * the relay had found the account and returned it.** Adding a contact was + * impossible on Android, which is the first thing anyone does. Measured + * against relay.flamenet.io — the relay returned + * `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the + * app said no such account. + * + * ⚠️ So parse the array here, and keep `getOrNull` for TRANSPORT failure + * only — a miss is an empty array, not an exception. + */ + fun lookup(username: String): Me? { + val text = rawText( + "api/flamenet/v1/messenger/users", query = mapOf("slug" to username) + ) ?: return null + val found = runCatching { JSONArray(text) }.getOrNull() ?: return null + if (found.length() == 0) return null + val o = found.getJSONObject(0) + return Me(o.getLong("id"), o.optString("name"), o.optString("slug")) + } // -- Internals ----------------------------------------------------------- @@ -183,6 +205,29 @@ class MessengerApi( private fun delete(path: String) = send( Request.Builder().url(url(path)).delete().header("Accept", "application/json")) + /** + * A GET whose body is returned as text rather than forced into a + * `JSONObject`. + * + * ⚠️ Exists for the ONE route that answers with an array. Everything else + * goes through `send()`; do not widen this without checking the route, + * because losing the typed error mapping is a real cost. + */ + private fun rawText(path: String, query: Map = emptyMap()): String? { + val target = base.newBuilder().addPathSegments(path) + .also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build() + val request = Request.Builder().url(target) + .header("Accept", "application/json") + .header("Authorization", "Basic " + Wire.b64( + "$username:$appPassword".toByteArray(Charsets.UTF_8))) + .get().build() + return runCatching { + http.newCall(request).execute().use { + if (it.isSuccessful) it.body?.string() else null + } + }.getOrNull() + } + private fun send(builder: Request.Builder): JSONObject { val request = builder .header("Authorization", "Basic " + Wire.b64( diff --git a/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt b/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt new file mode 100644 index 0000000..a08006a --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt @@ -0,0 +1,77 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * 🔴 **The relay answers a lookup with an ARRAY, and the client used to insist + * it was an object.** + * + * `RoutesAccount.lookup` returns `[MeResponse]` — empty for a miss, one element + * for a hit — deliberately, so that a miss and a hit differ only in length and + * neither timing nor shape is an oracle. The client sent it through `send()`, + * which does `JSONObject(text)` and throws on an array, and `lookup()` wrapped + * the whole thing in `runCatching { }.getOrNull()`. + * + * Every lookup therefore reported **"No account called X here"** while the relay + * was returning the account. **Adding a contact was impossible on Android** — + * the first thing anyone does — and it shipped in an APK that way. + * + * Measured against relay.flamenet.io before the fix: the relay returned + * `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` and the app said + * no such account existed. + * + * ⚠️ Nothing caught it because all three layers were quiet: the relay was + * correct, the parse failure was swallowed, and a miss and a crash render + * identically. `CrudSurfaceTest` asserted the lookup CONTROL exists, which it + * did. The lesson is that a control being present says nothing about the + * feature working. + */ +class MessengerApiLookupTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + @AfterTest fun stop() { server.shutdown() } + + private fun api() = MessengerApi(server.url("/").toString(), "alice", "app pass word") + + private fun json(body: String) = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + @Test + fun `a hit comes back as a one element array and is parsed`() { + server.enqueue(json("""[{"id":30,"name":"Bravo","slug":"appreview2demo"}]""")) + val found = api().lookup("appreview2demo") + assertNotNull(found, "the relay returned the account and the client dropped it — " + + "this is the shape bug that made adding a contact impossible on Android") + assertEquals(30L, found.userId) + assertEquals("appreview2demo", found.handle) + } + + /** A miss is an EMPTY ARRAY, not an error and not an object. */ + @Test + fun `a miss comes back as an empty array and reads as not found`() { + server.enqueue(json("[]")) + assertNull(api().lookup("nobodyhere")) + } + + /** + * ⚠️ A miss and a transport failure must both read as "not found" to the + * caller, but for different reasons — this pins the miss so a future + * refactor cannot turn an empty array into an exception. + */ + @Test + fun `a 500 is not mistaken for a miss with a body`() { + server.enqueue(MockResponse().setResponseCode(500).setBody("nope")) + assertNull(api().lookup("appreview2demo")) + } +}