Android could never find anybody: the relay answers with an array
Adding a contact was impossible on Android. Every lookup reported "No account
called X here" while the relay was returning the account.
RoutesAccount.lookup answers with an ARRAY — empty for a miss, one element for a
hit — deliberately, so a miss and a hit differ only in length and neither timing
nor shape is an oracle. MessengerApi.lookup sent that through send(), which does
JSONObject(text) and throws on an array, and then swallowed the throw with
runCatching { }.getOrNull(). A parse failure and a genuine miss became the same
thing.
Measured against relay.flamenet.io, before and after, in the running app:
relay: [{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]
before: "No account called appreview2demo here."
after: the contact card, with Add contact enabled
⚠️ Three layers kept this quiet: the relay was correct, the failure was
swallowed, and a miss and a crash render identically. CrudSurfaceTest asserts
the lookup CONTROL exists — and it does. **A control being present says nothing
about the feature working**, which is the whole reason this was found by driving
the app rather than by reading the suite.
This is the ONLY array-returning route (grepped), so rawText() is deliberately
narrow: everything else keeps send()'s typed error mapping.
Guarded by MessengerApiLookupTest — hit, miss, and a 500 that must not be
mistaken for a miss. **Verified load-bearing**: restoring the old implementation
fails it. 182 Android unit tests, 0 failures.
Also adds FeatureUseTest, which USES voice notes, groups, stories and calls
rather than asserting their buttons exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
10ae4ec180
commit
2855711bc5
@@ -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<MainActivity>()
|
||||
|
||||
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-<id>` 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"
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = 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(
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user