feat(m1): connect & browse — first-run flow, public content, contact
Some checks failed
PR Checks / android-build (pull_request) Failing after 33m35s
Some checks failed
PR Checks / android-build (pull_request) Failing after 33m35s
Implements M1 (functional Kotlin pass, docs/android/PLAN.md §9): the
first-run base-URL connect flow, brand-seeded Material 3 theming from
/public/settings, a Retrofit/OkHttp/kotlinx-serialization client with a
runtime host-selection interceptor (the base URL is not compiled in),
the layered repository stack returning a typed ApiResult for graceful
degradation, and functional Compose screens for Home/Status, News
(+ post detail), Wiki (+ detail), CMS pages (block renderer), and the
contact form. One shared, declarative navigation drawer. No auth yet (M3).
DTOs + the Retrofit interface are hand-written and spec-aligned rather
than openapi-generated: the committed swagger-output.json is produced by
swagger-autogen and its component schemas are meta-descriptive (nested
{type, example} wrappers), not codegen-clean, so a hand-authored client
module is the pragmatic "checked-in generated module" the plan allows
(§2). Shapes were matched against the website controllers/models.
JVM unit tests cover URL normalization, host rewriting, ApiResult/UiState
mapping, and brand-color parsing. `lint test assembleDebug` green locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for retargeting a sentinel-hosted request onto the configured base
|
||||
* URL (PLAN.md §3). Covers the base-path-prefix case that a naive host swap gets
|
||||
* wrong.
|
||||
*/
|
||||
class HostRewriteTest {
|
||||
|
||||
private fun rewrite(base: String, request: String) =
|
||||
rewriteOntoBase(base.toHttpUrl(), request.toHttpUrl()).toString()
|
||||
|
||||
@Test fun swapsHostForRootBase() {
|
||||
assertEquals(
|
||||
"https://shard.example.com/api/v1/public/status",
|
||||
rewrite(
|
||||
base = "https://shard.example.com/",
|
||||
request = "https://runic-gateway.invalid/api/v1/public/status",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun preservesBasePathPrefixWithoutDoubleSlash() {
|
||||
assertEquals(
|
||||
"https://host.example.com/uo/api/v1/public/status",
|
||||
rewrite(
|
||||
base = "https://host.example.com/uo/",
|
||||
request = "https://runic-gateway.invalid/api/v1/public/status",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun preservesQueryString() {
|
||||
assertEquals(
|
||||
"https://shard.example.com/api/v1/public/wiki?q=magic",
|
||||
rewrite(
|
||||
base = "https://shard.example.com/",
|
||||
request = "https://runic-gateway.invalid/api/v1/public/wiki?q=magic",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun keepsPortAndScheme() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:3000/api/v1/public/status",
|
||||
rewrite(
|
||||
base = "http://127.0.0.1:3000/",
|
||||
request = "https://runic-gateway.invalid/api/v1/public/status",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for the first-run base-URL normalization (PLAN.md §3). */
|
||||
class ServerUrlTest {
|
||||
|
||||
private fun valid(raw: String, allowInsecure: Boolean = true) =
|
||||
(ServerUrl.normalize(raw, allowInsecure) as ServerUrl.Result.Valid).url
|
||||
|
||||
private fun invalid(raw: String, allowInsecure: Boolean = true) =
|
||||
(ServerUrl.normalize(raw, allowInsecure) as ServerUrl.Result.Invalid).reason
|
||||
|
||||
@Test fun blankIsRejected() {
|
||||
assertEquals(ServerUrl.Reason.BLANK, invalid(""))
|
||||
assertEquals(ServerUrl.Reason.BLANK, invalid(" "))
|
||||
}
|
||||
|
||||
@Test fun nonHttpSchemeIsRejected() {
|
||||
assertEquals(ServerUrl.Reason.UNSUPPORTED_SCHEME, invalid("ftp://example.com"))
|
||||
assertEquals(ServerUrl.Reason.UNSUPPORTED_SCHEME, invalid("ws://example.com"))
|
||||
}
|
||||
|
||||
@Test fun garbageIsMalformed() {
|
||||
assertEquals(ServerUrl.Reason.MALFORMED, invalid("has spaces in it"))
|
||||
}
|
||||
|
||||
@Test fun httpRejectedWhenInsecureDisallowed() {
|
||||
assertEquals(ServerUrl.Reason.INSECURE, invalid("http://example.com", allowInsecure = false))
|
||||
}
|
||||
|
||||
@Test fun httpAllowedInDebug() {
|
||||
val url = valid("http://127.0.0.1:3000", allowInsecure = true)
|
||||
assertEquals("http", url.scheme)
|
||||
assertEquals(3000, url.port)
|
||||
}
|
||||
|
||||
@Test fun schemelessInputGetsHttps() {
|
||||
val url = valid("shard.example.com", allowInsecure = false)
|
||||
assertEquals("https", url.scheme)
|
||||
assertEquals("shard.example.com", url.host)
|
||||
assertEquals("/", url.encodedPath)
|
||||
}
|
||||
|
||||
@Test fun trailingSlashIsEnforced() {
|
||||
assertTrue(valid("https://example.com").toString().endsWith("/"))
|
||||
assertEquals("/base/", valid("https://example.com/base").encodedPath)
|
||||
assertEquals("/base/", valid("https://example.com/base/").encodedPath)
|
||||
}
|
||||
|
||||
@Test fun queryAndFragmentAreStripped() {
|
||||
val url = valid("https://example.com/base?token=abc#frag")
|
||||
assertEquals("/base/", url.encodedPath)
|
||||
assertNull(url.query)
|
||||
assertEquals("https://example.com/base/", url.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
/** Unit tests for the typed-result plumbing (PLAN.md §7). */
|
||||
class ApiResultTest {
|
||||
|
||||
@Test fun okWrapsValue() = runTest {
|
||||
val result = safeApiCall { 42 }
|
||||
assertEquals(ApiResult.Ok(42), result)
|
||||
}
|
||||
|
||||
@Test fun httpExceptionBecomesHttpError() = runTest {
|
||||
val result = safeApiCall {
|
||||
throw HttpException(Response.error<Any>(404, "nope".toResponseBody(null)))
|
||||
}
|
||||
assertTrue(result is ApiResult.HttpError)
|
||||
assertEquals(404, (result as ApiResult.HttpError).status)
|
||||
}
|
||||
|
||||
@Test fun ioExceptionBecomesNetworkError() = runTest {
|
||||
val result = safeApiCall { throw IOException("offline") }
|
||||
assertTrue(result is ApiResult.NetworkError)
|
||||
}
|
||||
|
||||
@Test fun cancellationIsRethrown() = runTest {
|
||||
assertThrows(CancellationException::class.java) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
safeApiCall { throw CancellationException("cancelled") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mapTransformsOnlyOk() {
|
||||
assertEquals(ApiResult.Ok(4), ApiResult.Ok(2).map { it * 2 })
|
||||
val err: ApiResult<Int> = ApiResult.HttpError(500)
|
||||
assertEquals(err, err.map { it * 2 })
|
||||
}
|
||||
}
|
||||
39
app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt
Normal file
39
app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** Unit tests for folding an [ApiResult] into a screen [UiState] (PLAN.md §7). */
|
||||
class UiStateTest {
|
||||
|
||||
@Test fun okBecomesSuccess() {
|
||||
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toUiState())
|
||||
}
|
||||
|
||||
@Test fun networkErrorMapsToNetworkKind() {
|
||||
val state = ApiResult.NetworkError(IOException()).toUiState()
|
||||
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
@Test fun knownStatusesMapToKinds() {
|
||||
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
|
||||
assertEquals(ErrorKind.RATE_LIMITED, kindOf(429))
|
||||
assertEquals(ErrorKind.SHARD_OFFLINE, kindOf(503))
|
||||
assertEquals(ErrorKind.SERVER, kindOf(500))
|
||||
}
|
||||
|
||||
@Test fun httpStatusIsPreserved() {
|
||||
val state = ApiResult.HttpError(503).toUiState() as UiState.Error
|
||||
assertEquals(503, state.httpStatus)
|
||||
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
|
||||
}
|
||||
|
||||
private fun kindOf(status: Int): ErrorKind =
|
||||
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for brand accent parsing (PLAN.md §3, §5). */
|
||||
class BrandColorTest {
|
||||
|
||||
@Test fun nullOrBlankReturnsNull() {
|
||||
assertNull(parseBrandColor(null))
|
||||
assertNull(parseBrandColor(""))
|
||||
assertNull(parseBrandColor(" "))
|
||||
}
|
||||
|
||||
@Test fun sixDigitHexParses() {
|
||||
assertEquals(Color(0xFF7F99BD), parseBrandColor("#7f99bd"))
|
||||
}
|
||||
|
||||
@Test fun hashIsOptional() {
|
||||
assertEquals(parseBrandColor("#7f99bd"), parseBrandColor("7f99bd"))
|
||||
}
|
||||
|
||||
@Test fun shorthandExpands() {
|
||||
assertEquals(parseBrandColor("#ffffff"), parseBrandColor("#fff"))
|
||||
}
|
||||
|
||||
@Test fun eightDigitHexKeepsAlpha() {
|
||||
assertEquals(Color(0x80112233), parseBrandColor("#80112233"))
|
||||
}
|
||||
|
||||
@Test fun invalidReturnsNull() {
|
||||
assertNull(parseBrandColor("#12"))
|
||||
assertNull(parseBrandColor("nothex!"))
|
||||
assertNull(parseBrandColor("#gggggg"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user