Test the account routes and their error mapping

RelayAccounts was at 0% — every route a person meets before they can send
a single message, sign-up through password reset, including the error
mapping that decides what they are told when it goes wrong.

The error mapping earns as much attention as the happy path. These are
the only failures somebody sees before they have an account, and a 409
shown as "something went wrong" instead of "that name is taken" is the
difference between fixing it in five seconds and giving up. So there are
tests for the taken username keeping the relay's own words, a wrong
password arriving as Unauthorized rather than a generic failure, 429
carrying the relay's Retry-After and still being throttling without one,
a non-JSON error body surviving as readable text, and an unreachable
relay being Transport rather than a rejected password.

Two of my assertions were wrong and the code was right, which is worth
recording. Unauthorized and Unavailable deliberately keep the relay's
text in `body` and give `message` a fixed string, because the type is a
decision rather than a diagnostic — Unauthorized triggers exactly one
retry, and a caller matching on a string to decide that eventually
matches wrong. For 451 the fixed sentence is a product decision too: the
download site is not geo-gated, so somebody can read the page, install
the app, choose a password, tap sign up and be shown "relay returned
451", which mentions nothing about their country and cannot be retried
past. The test now pins that wording.

Also pins the compatibility details: an email goes in the `username`
field because that field has always meant "username or email" and a
client that only sends usernames rejects half the people who typed what
they were asked for; a self-hosted relay's 404 on the shared-session
route is the correct answer rather than a misconfiguration; and the
handle travels beside the token rather than inside it, so a relay cannot
correlate its users with another product's.

RelayAccounts 0% -> 100%, client 57.7% -> 63.2% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 09:47:10 -04:00
co-authored by Claude Opus 4.8
parent 7f7692318f
commit 4d8fc5b09c
@@ -0,0 +1,267 @@
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.assertFailsWith
import kotlin.test.assertTrue
/**
* Sign-up, sign-in and password reset against a relay that is its own identity
* provider.
*
* 🔴 **This whole class was at 0%** — every route a person meets before they
* can send a single message, including the error mapping that decides what they
* are told when it goes wrong. Nothing here had ever been executed by a test.
*
* The error mapping deserves the attention as much as the happy path does.
* These are the only failures a person sees before they have an account, and a
* 409 shown as "something went wrong" instead of "that name is taken" is the
* difference between fixing it in five seconds and giving up.
*/
class RelayAccountsTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
@AfterTest fun stop() { server.shutdown() }
private fun accounts() = RelayAccounts(server.url("/").toString().trimEnd('/'))
private fun json(body: String) = MockResponse()
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
private val ok = """{"user_id":7,"username":"ada","display_name":"Ada L","app_password":"abcd efgh"}"""
// -- Sign-up -------------------------------------------------------------
@Test
fun `signing up returns the credential the relay minted`() {
server.enqueue(json(ok))
val a = accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
assertEquals(7L, a.userId)
assertEquals("ada", a.username)
assertEquals("Ada L", a.displayName)
assertEquals("abcd efgh", a.appPassword)
}
@Test
fun `sign-up posts every field the relay needs`() {
server.enqueue(json(ok))
accounts().signUp("ada", "ada@example.com", "hunter2hunter2", displayName = "Ada L")
val req = server.takeRequest()
assertEquals("POST", req.method)
assertEquals("/account/signup", req.path)
val body = req.body.readUtf8()
assertTrue(body.contains("\"username\":\"ada\""))
assertTrue(body.contains("\"email\":\"ada@example.com\""))
assertTrue(body.contains("\"password\":\"hunter2hunter2\""))
assertTrue(body.contains("\"display_name\":\"Ada L\""))
}
/** Nobody should have to invent a display name to sign up. */
@Test
fun `the display name falls back to the username`() {
server.enqueue(json(ok))
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
assertTrue(server.takeRequest().body.readUtf8().contains("\"display_name\":\"ada\""))
}
/**
* The relay omits `display_name` for an account that never set one; falling
* over there would make such an account unusable rather than plain.
*/
@Test
fun `an account with no display name falls back to its username`() {
server.enqueue(json("""{"user_id":7,"username":"ada","app_password":"pw"}"""))
assertEquals("ada", accounts().signIn("ada", "pw").displayName)
}
// -- Sign-in -------------------------------------------------------------
/**
* ⚠️ The field is "username **or email**" and always has been. A client
* that only sends usernames rejects half the people who typed exactly what
* they were asked for.
*/
@Test
fun `an email address is sent in the username field`() {
server.enqueue(json(ok))
accounts().signIn("ada@example.com", "hunter2hunter2")
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("\"username\":\"ada@example.com\""))
}
@Test
fun `signing in returns the credential, not the password`() {
server.enqueue(json(ok))
val a = accounts().signIn("ada", "hunter2hunter2")
assertEquals("abcd efgh", a.appPassword)
}
// -- Shared session ------------------------------------------------------
/**
* ⚠️ The handle travels BESIDE the token, not inside it: the account
* service will not put a handle in a token, so a relay cannot correlate its
* users with another product's.
*/
@Test
fun `a shared session sends the handle alongside the token`() {
server.enqueue(json(ok))
accounts().sharedSession("tok-123", "ada", displayName = "Ada L")
val req = server.takeRequest()
assertEquals("/api/flamenet/v1/messenger/session", req.path)
val body = req.body.readUtf8()
assertTrue(body.contains("\"token\":\"tok-123\""))
assertTrue(body.contains("\"handle\":\"ada\""))
assertTrue(body.contains("\"display_name\":\"Ada L\""))
}
@Test
fun `a shared session omits an empty display name rather than sending blank`() {
server.enqueue(json(ok))
accounts().sharedSession("tok-123", "ada")
assertTrue(!server.takeRequest().body.readUtf8().contains("display_name"))
}
/**
* ⚠️ A self-hosted relay answers 404 here, and that is the CORRECT answer
* rather than a misconfiguration — it is its own identity provider. The
* caller has to be able to tell that apart from a broken relay.
*/
@Test
fun `a relay that is its own identity provider reports not-found distinctly`() {
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"message":"no"}"""))
assertFailsWith<RelayException.NotFound> { accounts().sharedSession("tok", "ada") }
}
// -- Password reset ------------------------------------------------------
/**
* ⚠️ The relay answers identically whatever the truth is — unknown handle,
* no address on file, an account that authenticates elsewhere. So this
* cannot report whether it worked, and a UI that claims it can is lying.
*/
@Test
fun `requesting a reset says nothing about whether the account exists`() {
server.enqueue(json("""{"ok":true}"""))
accounts().requestPasswordReset("ada") // returns Unit, by design
val req = server.takeRequest()
assertEquals("/account/reset/request", req.path)
assertTrue(req.body.readUtf8().contains("\"handle\":\"ada\""))
}
@Test
fun `confirming a reset sends the code and the new password`() {
server.enqueue(json("""{"ok":true}"""))
accounts().confirmPasswordReset("ada", "123456", "newpassword1")
val req = server.takeRequest()
assertEquals("/account/reset/confirm", req.path)
val body = req.body.readUtf8()
assertTrue(body.contains("\"code\":\"123456\""))
assertTrue(body.contains("\"password\":\"newpassword1\""))
}
// -- What people are told when it fails ----------------------------------
/**
* 🔴 The relay puts a human-readable reason in the body, and it is better
* than anything this layer could invent from a status code. Losing it means
* "that name is taken" becomes "something went wrong".
*/
@Test
fun `a taken username keeps the relay's own explanation`() {
server.enqueue(MockResponse().setResponseCode(409)
.setBody("""{"message":"That name is already taken."}"""))
val e = assertFailsWith<RelayException.Http> {
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
}
assertEquals(409, e.status)
assertTrue(e.message!!.contains("already taken"))
}
@Test
fun `a bad password is unauthorized rather than a generic failure`() {
server.enqueue(MockResponse().setResponseCode(401)
.setBody("""{"message":"Wrong password."}"""))
val e = assertFailsWith<RelayException.Unauthorized> {
accounts().signIn("ada", "wrong")
}
// ⚠️ The relay's text lives in `body`; `message` is fixed. The type is
// a DECISION — Unauthorized triggers exactly one retry — and a caller
// matching on a string to decide that eventually matches wrong.
assertTrue(e.body.contains("Wrong password"))
}
@Test
fun `throttling carries the retry-after the relay gave`() {
server.enqueue(MockResponse().setResponseCode(429).setHeader("Retry-After", "90"))
val e = assertFailsWith<RelayException.RateLimited> {
accounts().signIn("ada", "hunter2hunter2")
}
assertEquals(90, e.retryAfterSeconds)
}
@Test
fun `throttling without a retry-after is still throttling`() {
server.enqueue(MockResponse().setResponseCode(429))
val e = assertFailsWith<RelayException.RateLimited> {
accounts().signIn("ada", "hunter2hunter2")
}
assertEquals(null, e.retryAfterSeconds)
}
/**
* 🔴 The geo-gate, not the relay. It needs a sentence, not a number — a
* person blocked by region who is shown "error 451" has no idea what
* happened or that nothing is wrong with their account.
*/
@Test
fun `a region block is reported as unavailable with its explanation`() {
server.enqueue(MockResponse().setResponseCode(451)
.setBody("""{"message":"Not available in your region."}"""))
val e = assertFailsWith<RelayException.Unavailable> {
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
}
// 🔴 The message is deliberately OURS, not the relay's. The download
// site is not geo-gated, so somebody can read the page, install the
// app, pick a password, tap sign up and be told "relay returned 451" —
// which mentions nothing about their country and cannot be retried
// past, so the only sensible reading is that the app is broken.
assertTrue(e.message!!.contains("isn't available in your country"))
assertTrue(e.message!!.contains("isn't a problem with your account"))
assertTrue(e.body.contains("region"))
}
/** A body that is not JSON still has to produce something readable. */
@Test
fun `a non-JSON error body is passed through as the message`() {
server.enqueue(MockResponse().setResponseCode(500).setBody("upstream exploded"))
val e = assertFailsWith<RelayException.Http> {
accounts().signIn("ada", "hunter2hunter2")
}
assertEquals(500, e.status)
assertTrue(e.message!!.contains("upstream exploded"))
}
@Test
fun `a success that is not a JSON object is malformed, not a crash`() {
server.enqueue(json("""["not","an","object"]"""))
assertFailsWith<RelayException.Malformed> {
accounts().signIn("ada", "hunter2hunter2")
}
}
/** An unreachable relay is a transport failure, not a rejected password. */
@Test
fun `an unreachable relay is a transport failure`() {
server.shutdown()
assertFailsWith<RelayException.Transport> {
accounts().signIn("ada", "hunter2hunter2")
}
}
}