Compare commits
8 Commits
v0.3.4
...
4fe7a7e2a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe7a7e2a3 | |||
| b10dd444b3 | |||
| f3da6ea618 | |||
| ae170670d9 | |||
| 7fc497a1a4 | |||
| 4e3bb914ff | |||
| efe14d3828 | |||
| 43215b49a0 |
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||
|
||||
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||
|
||||
Deterministic ordering: directories before files, each group sorted
|
||||
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def build(paths):
|
||||
root = {}
|
||||
for p in paths:
|
||||
p = p.strip().replace("\\", "/")
|
||||
if not p:
|
||||
continue
|
||||
node = root
|
||||
for part in p.split("/"):
|
||||
node = node.setdefault(part, {})
|
||||
return root
|
||||
|
||||
|
||||
def render(node, prefix, lines):
|
||||
entries = list(node.items())
|
||||
# directories (non-empty children dict) before files, then case-insensitive name
|
||||
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||
for i, (name, child) in enumerate(entries):
|
||||
last = i == len(entries) - 1
|
||||
branch = "└── " if last else "├── "
|
||||
suffix = "/" if child else ""
|
||||
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||
if child:
|
||||
render(child, prefix + (" " if last else "│ "), lines)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||
except AttributeError:
|
||||
pass
|
||||
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
tree = build(sys.stdin.read().splitlines())
|
||||
lines = [f"{root_label}/"]
|
||||
render(tree, "", lines)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,11 +18,12 @@
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
#
|
||||
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
|
||||
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
|
||||
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
|
||||
# enrichment" note in sonar-project.properties for wiring in Android Lint /
|
||||
# coverage reports later.
|
||||
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
|
||||
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
|
||||
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
|
||||
# reports 0% and the coverage gate fails despite the test suite existing. That
|
||||
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
|
||||
# the runner container is bare, so base tools are apt-installed first.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
@@ -40,6 +41,16 @@ jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
|
||||
# them) and we install JDK 17 from the Ubuntu archive rather than
|
||||
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
|
||||
# pr-checks.yml — see its header note.
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip openjdk-17-jdk-headless
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -47,6 +58,31 @@ jobs:
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
|
||||
# variant (matches enableUnitTestCoverage) to keep peak memory down.
|
||||
- name: Unit tests + JaCoCo coverage
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
|
||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: sync-project-tree
|
||||
|
||||
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
|
||||
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||
# request against the docs repo. It never writes to the docs repo's `main`
|
||||
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sync-project-tree
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
DOCS_REPO: RunicGateway/docs
|
||||
SELF_REPO: RunicGateway/Android-app
|
||||
DOCS_PATH: android/PROJECT_TREE.md
|
||||
TREE_TITLE: Android App
|
||||
ROOT_LABEL: android-app
|
||||
PR_BRANCH: chore/sync-android-tree
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out this repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure python3 is available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||
|
||||
- name: Render PROJECT_TREE.md from tracked files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _sync
|
||||
{
|
||||
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||
printf 'git-ignored paths are excluded).\n\n'
|
||||
printf '```text\n'
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||
printf '```\n'
|
||||
} > _sync/PROJECT_TREE.md
|
||||
echo "----- generated ${DOCS_PATH} -----"
|
||||
cat _sync/PROJECT_TREE.md
|
||||
|
||||
- name: Open or update the docs PR if the tree changed
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||
# strip line breaks before they land in a URL or Authorization header.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||
|
||||
git clone --depth 1 "${REMOTE}" docs_repo
|
||||
cd docs_repo
|
||||
git config user.name "runic-docs-bot"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
|
||||
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||
git add "${DOCS_PATH}"
|
||||
if git diff --cached --quiet; then
|
||||
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||
git checkout -B "${PR_BRANCH}"
|
||||
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||
|
||||
# Open a PR only if one isn't already open for this branch (a force-push
|
||||
# to an existing open PR's head updates it in place).
|
||||
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=50" \
|
||||
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||
if [ "${OPEN}" = "0" ]; then
|
||||
curl -sSf -X POST "${API}/pulls" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg head "${PR_BRANCH}" \
|
||||
--arg base "main" \
|
||||
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||
>/dev/null
|
||||
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||
else
|
||||
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||
fi
|
||||
@@ -10,6 +10,11 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
jacoco
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.12"
|
||||
}
|
||||
|
||||
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||
@@ -78,6 +83,11 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
|
||||
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
|
||||
enableUnitTestCoverage = true
|
||||
}
|
||||
release {
|
||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||
// release APK). Keep rules live in proguard-rules.pro.
|
||||
@@ -170,3 +180,35 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
||||
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
|
||||
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
|
||||
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
|
||||
// JVM unit tests can't execute composable bodies without Robolectric.
|
||||
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
dependsOn("testDebugUnitTest")
|
||||
group = "verification"
|
||||
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
val coverageExcludes = listOf(
|
||||
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
|
||||
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
|
||||
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
|
||||
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
|
||||
)
|
||||
val buildDirFile = layout.buildDirectory.get().asFile
|
||||
classDirectories.setFrom(
|
||||
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
|
||||
)
|
||||
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
|
||||
executionData.setFrom(
|
||||
fileTree(buildDirFile) {
|
||||
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
|
||||
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val pendingStore: PendingSsoStore,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
) {
|
||||
|
||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
||||
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
|
||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
||||
return
|
||||
}
|
||||
// The user ticked "trust this device" on the TOTP form inside the Custom
|
||||
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
|
||||
// the token the exchange handed back is what lets a native PASSWORD login
|
||||
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
|
||||
// username exactly like the password path, so it is never replayed for a
|
||||
// different account on a shared device. Saved BEFORE onSignedIn so a
|
||||
// process death mid-callback can't lose it.
|
||||
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
_outcome.value = Outcome.Success
|
||||
return
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
|
||||
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
|
||||
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
|
||||
* can then be unit-tested against a fake stream instead of a real network connection.
|
||||
*/
|
||||
interface ShardStream {
|
||||
fun events(): Flow<ShardStreamEvent>
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
|
||||
baseClient: OkHttpClient,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val json: Json,
|
||||
) {
|
||||
) : ShardStream {
|
||||
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
||||
// so the read timeout must be disabled or the idle stream would be killed.
|
||||
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
|
||||
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||
* `{ kind, … }` payload the boards merge in place.
|
||||
*/
|
||||
fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
override fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
while (isActive) {
|
||||
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
@@ -35,7 +35,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ShardRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val stream: ShardStreamClient,
|
||||
private val stream: ShardStream,
|
||||
private val json: Json,
|
||||
) {
|
||||
// ── Snapshots ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.net.AuthInterceptor
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.TokenAuthenticator
|
||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
@@ -94,6 +96,12 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||
|
||||
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
|
||||
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideShardStream(client: ShardStreamClient): ShardStream = client
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
||||
@@ -84,7 +84,6 @@ fun AdminSupportScreen(
|
||||
|
||||
replyTo?.let { page ->
|
||||
RespondDialog(
|
||||
page = page,
|
||||
onDismiss = { replyTo = null },
|
||||
onSend = { message, close ->
|
||||
viewModel.respond(page.pageId, message, close)
|
||||
@@ -122,7 +121,6 @@ private fun SupportPageCard(
|
||||
|
||||
@Composable
|
||||
private fun RespondDialog(
|
||||
page: SupportPageDto,
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (message: String, close: Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
@@ -153,18 +153,11 @@ class LoginViewModel @Inject constructor(
|
||||
fun submit() {
|
||||
val s = _state.value
|
||||
if (s.submitting) return
|
||||
if (s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
||||
val validationError = validateForSubmit(s)
|
||||
if (validationError != null) {
|
||||
_state.update { it.copy(error = validationError) }
|
||||
return
|
||||
}
|
||||
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
|
||||
if (s.totpRequired) {
|
||||
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
|
||||
if (factor.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
@@ -178,7 +171,23 @@ class LoginViewModel @Inject constructor(
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = s.trustDevice,
|
||||
)
|
||||
when (result) {
|
||||
applyLoginResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
|
||||
private fun validateForSubmit(s: UiState): LoginError? {
|
||||
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
|
||||
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
|
||||
if (s.totpRequired) {
|
||||
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
|
||||
if (factor.isBlank()) return LoginError.BAD_CODE
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
|
||||
private fun applyLoginResult(result: LoginResult) = when (result) {
|
||||
is LoginResult.Success ->
|
||||
// The trusted-device cap (result.trustLimitReached) is an edge case:
|
||||
// login succeeded but the device wasn't remembered. It's surfaced +
|
||||
@@ -208,6 +217,4 @@ class LoginViewModel @Inject constructor(
|
||||
LoginResult.NetworkError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
|
||||
override fun clear() { pending = null }
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the encrypted trust-token store, scoped by username
|
||||
* the same way the production impl is. */
|
||||
private class FakeTrustTokenStore : TrustTokenStore {
|
||||
var owner: String? = null
|
||||
var token: String? = null
|
||||
override fun tokenFor(username: String): String? =
|
||||
if (owner.equals(username, ignoreCase = true)) token else null
|
||||
override fun save(username: String, token: String) { owner = username; this.token = token }
|
||||
override fun clear() { owner = null; token = null }
|
||||
}
|
||||
|
||||
/** Records the exchange it was called with and returns a scripted response. */
|
||||
private class FakeSsoApi(
|
||||
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
||||
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
|
||||
session: SessionManager,
|
||||
base: String? = "https://shard.example.com/",
|
||||
store: PendingSsoStore = FakePendingSsoStore(),
|
||||
trust: TrustTokenStore = FakeTrustTokenStore(),
|
||||
): SsoAuthManager {
|
||||
val holder = BaseUrlHolder()
|
||||
if (base != null) holder.set(base.toHttpUrl())
|
||||
return SsoAuthManager(api, session, holder, store)
|
||||
return SsoAuthManager(api, session, holder, store, trust)
|
||||
}
|
||||
|
||||
/** Build a start URL and pull the generated `state` back out of it. */
|
||||
@@ -120,6 +133,38 @@ class SsoAuthManagerTest {
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
}
|
||||
|
||||
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
|
||||
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
|
||||
// exchange additionally hands the APP its own token so a native password login
|
||||
// on this device skips the code too. Before this, SSO ignored trust entirely.
|
||||
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
val trust = FakeTrustTokenStore()
|
||||
val mgr = managerWith(api, session, trust = trust)
|
||||
|
||||
val state = startAndState(mgr)
|
||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
assertEquals("opaque-trust", trust.tokenFor("alice"))
|
||||
// Scoped to the account that minted it — never replayed for someone else.
|
||||
assertNull(trust.tokenFor("mallory"))
|
||||
}
|
||||
|
||||
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
val trust = FakeTrustTokenStore()
|
||||
val mgr = managerWith(api, session, trust = trust)
|
||||
|
||||
val state = startAndState(mgr)
|
||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
assertNull(trust.tokenFor("alice"))
|
||||
}
|
||||
|
||||
@Test fun `state mismatch fails without exchanging`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
|
||||
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
|
||||
* signal the player screens render as offline.
|
||||
*/
|
||||
class ApiResultExtrasTest {
|
||||
|
||||
@Test fun mapTransformsOkBody() {
|
||||
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
|
||||
assertEquals(ApiResult.Ok(3), mapped)
|
||||
}
|
||||
|
||||
@Test fun mapPassesFailuresThroughUnchanged() {
|
||||
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
|
||||
assertSame(http, http.map { it + 1 })
|
||||
|
||||
val cause = RuntimeException("offline")
|
||||
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
|
||||
val out = network.map { it + 1 }
|
||||
assertTrue(out is ApiResult.NetworkError)
|
||||
assertSame(cause, (out as ApiResult.NetworkError).cause)
|
||||
}
|
||||
|
||||
@Test fun isShardUnavailableOnlyForHttp503() {
|
||||
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
|
||||
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
|
||||
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
|
||||
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
|
||||
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
|
||||
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
|
||||
*/
|
||||
class AdminDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun dashboardDecodesNestedCountsAndActivity() {
|
||||
val dto = json.decodeFromString<AdminDashboardDto>(
|
||||
"""{
|
||||
"site_mode":"maintenance",
|
||||
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
|
||||
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
|
||||
"recent_activity":[
|
||||
{"id":9,"username":"mod","action":"post.publish",
|
||||
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
|
||||
]
|
||||
}""",
|
||||
)
|
||||
assertEquals("maintenance", dto.siteMode)
|
||||
assertEquals("admin", dto.lastChange.by)
|
||||
assertEquals(4, dto.counts.posts["news"])
|
||||
assertEquals(37, dto.counts.users)
|
||||
assertEquals(1, dto.recentActivity.size)
|
||||
assertEquals("post.publish", dto.recentActivity[0].action)
|
||||
// `detail` is provider-shaped JSON kept as a raw element.
|
||||
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
|
||||
}
|
||||
|
||||
@Test fun dashboardDefaultsWhenKeysAbsent() {
|
||||
val dto = json.decodeFromString<AdminDashboardDto>("{}")
|
||||
assertEquals("live", dto.siteMode)
|
||||
assertTrue(dto.counts.posts.isEmpty())
|
||||
assertTrue(dto.recentActivity.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
|
||||
val published = json.decodeFromString<AdminPostDto>(
|
||||
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
|
||||
)
|
||||
assertTrue(published.isPublished)
|
||||
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
|
||||
|
||||
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
|
||||
assertFalse(draft.isPublished)
|
||||
}
|
||||
|
||||
@Test fun adminWikiCategoryAndTagDecodeCounts() {
|
||||
val cat = json.decodeFromString<AdminWikiCategoryDto>(
|
||||
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
|
||||
)
|
||||
assertEquals(2, cat.sortOrder)
|
||||
assertEquals(5, cat.pageCount)
|
||||
assertEquals(4, cat.publishedCount)
|
||||
|
||||
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
|
||||
assertEquals("PvP", tag.label)
|
||||
assertEquals(11, tag.publishedCount)
|
||||
}
|
||||
|
||||
@Test fun supportPageDecodesSenderActor() {
|
||||
val dto = json.decodeFromString<SupportPageDto>(
|
||||
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
|
||||
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
|
||||
)
|
||||
assertEquals("0x1A2B", dto.pageId)
|
||||
assertEquals("Gwen", dto.sender?.name)
|
||||
assertEquals("gwen01", dto.sender?.account)
|
||||
assertEquals(false, dto.handled)
|
||||
}
|
||||
|
||||
@Test fun supportPageToleratesMissingSender() {
|
||||
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
|
||||
assertNull(dto.sender)
|
||||
assertNull(dto.type)
|
||||
}
|
||||
|
||||
@Test fun siteModeStateDecodesAudit() {
|
||||
val dto = json.decodeFromString<SiteModeStateDto>(
|
||||
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
|
||||
)
|
||||
assertEquals("maintenance", dto.siteMode)
|
||||
assertEquals("admin", dto.changedBy)
|
||||
}
|
||||
|
||||
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
|
||||
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
|
||||
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
|
||||
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
|
||||
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
|
||||
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
|
||||
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
|
||||
.contains("\"sort_order\":1"))
|
||||
|
||||
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
|
||||
assertTrue(post.contains("\"image_url\":\"/img.png\""))
|
||||
assertTrue(post.contains("\"category\":\"news\""))
|
||||
|
||||
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
|
||||
assertTrue(ban.contains("\"durationSec\":3600"))
|
||||
|
||||
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
|
||||
assertTrue(kick.contains("\"serial\":\"0xFF\""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
|
||||
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
|
||||
* the trusted-device outcome fields on the login response.
|
||||
*/
|
||||
class AuthRequestDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
|
||||
val body = json.encodeToString(
|
||||
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
|
||||
)
|
||||
assertTrue(body.contains("\"username\":\"gwen\""))
|
||||
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
|
||||
assertTrue(body.contains("\"trustDevice\":true"))
|
||||
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
|
||||
}
|
||||
|
||||
@Test fun loginRequestCarriesSecondFactorOnRetry() {
|
||||
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
|
||||
assertTrue(withCode.contains("\"code\":\"123456\""))
|
||||
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
|
||||
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
|
||||
}
|
||||
|
||||
@Test fun refreshAndLogoutBodiesEncode() {
|
||||
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
|
||||
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesTrustOutcome() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
|
||||
"user":{"id":1,"username":"gwen","role":"player"},
|
||||
"trustToken":"opaque"}""",
|
||||
)
|
||||
assertEquals("a", dto.accessToken)
|
||||
assertEquals("opaque", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertEquals("gwen", dto.user.username)
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesTrustLimitReached() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
|
||||
)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
|
||||
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
|
||||
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
|
||||
*/
|
||||
class ContentDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun postDetailDecodesBodyAndImage() {
|
||||
val dto = json.decodeFromString<PostDto>(
|
||||
"""{"id":10,"category":"news","title":"Update","slug":"update",
|
||||
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
|
||||
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals(10L, dto.id)
|
||||
assertEquals("<p>full</p>", dto.body)
|
||||
assertEquals("/i.png", dto.imageUrl)
|
||||
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
|
||||
}
|
||||
|
||||
@Test fun postListRowToleratesMissingBody() {
|
||||
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
|
||||
assertNull(dto.body)
|
||||
assertNull(dto.imageUrl)
|
||||
}
|
||||
|
||||
@Test fun pageDecodesBlocksWithRawProps() {
|
||||
val dto = json.decodeFromString<PageDto>(
|
||||
"""{
|
||||
"id":3,"slug":"about","title":"About","status":"published",
|
||||
"blocks":[
|
||||
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
|
||||
{"type":"divider","props":{}}
|
||||
],
|
||||
"publishedAt":"2026-07-01T00:00:00Z"
|
||||
}""",
|
||||
)
|
||||
assertEquals("about", dto.slug)
|
||||
assertEquals(2, dto.blocks.size)
|
||||
assertEquals("heading", dto.blocks[0].type)
|
||||
// props stay a raw JSON object the renderer reads by key.
|
||||
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
|
||||
assertTrue(dto.blocks[1].visible) // default true when absent
|
||||
}
|
||||
|
||||
@Test fun contactResponseSentAndFallbackVariants() {
|
||||
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
|
||||
assertTrue(sent.sent)
|
||||
assertNull(sent.fallback)
|
||||
|
||||
val fallback = json.decodeFromString<ContactResponse>(
|
||||
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
|
||||
)
|
||||
assertEquals("mailto", fallback.fallback)
|
||||
assertEquals("a@b.c", fallback.email)
|
||||
}
|
||||
|
||||
@Test fun contactRequestEncodesAllFields() {
|
||||
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
|
||||
assertTrue(body.contains("\"name\":\"Gwen\""))
|
||||
assertTrue(body.contains("\"email\":\"g@x.c\""))
|
||||
assertTrue(body.contains("\"message\":\"hi\""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the player self-service game-data DTOs
|
||||
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
|
||||
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
|
||||
*/
|
||||
class PlayerGameDataDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun linkRequestAndResultRoundTrip() {
|
||||
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
|
||||
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
|
||||
assertTrue(result.linked)
|
||||
assertEquals("acct1", result.account)
|
||||
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
|
||||
}
|
||||
|
||||
@Test fun linkedAccountDecodes() {
|
||||
val dto = json.decodeFromString<ShardLinkDto>(
|
||||
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("acct1", dto.account)
|
||||
assertEquals(42L, dto.userId)
|
||||
}
|
||||
|
||||
@Test fun rosterDecodesCharacters() {
|
||||
val dto = json.decodeFromString<RosterDto>(
|
||||
"""{"acct":"acct1","chars":[
|
||||
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
|
||||
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
|
||||
)
|
||||
assertEquals(2, dto.chars.size)
|
||||
assertTrue(dto.chars[0].online)
|
||||
assertEquals("0x24C", dto.chars[0].serial)
|
||||
}
|
||||
|
||||
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{
|
||||
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
|
||||
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
|
||||
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
|
||||
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
|
||||
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
|
||||
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
|
||||
"guild":{"name":"Knights","abbr":"KoT"},
|
||||
"governorOf":["Britain"]
|
||||
}""",
|
||||
)
|
||||
assertEquals("Gwen", dto.name)
|
||||
assertEquals(100, dto.stats!!.str)
|
||||
assertEquals(70, dto.stats!!.resist!!.phys)
|
||||
assertEquals(110.0, dto.skills.first().value!!, 0.0)
|
||||
assertEquals("OneHanded", dto.equipment.first().layer)
|
||||
assertEquals("Glorious", dto.titles!!.fameKarma)
|
||||
assertEquals("Knights", dto.guild!!.name)
|
||||
assertEquals(listOf("Britain"), dto.governorOf)
|
||||
}
|
||||
|
||||
@Test fun charSheetToleratesMinimalPayload() {
|
||||
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
|
||||
assertEquals(null, dto.stats)
|
||||
assertTrue(dto.skills.isEmpty())
|
||||
assertTrue(dto.equipment.isEmpty())
|
||||
assertFalse(dto.online)
|
||||
}
|
||||
|
||||
@Test fun vendorSnapshotAndListingsDecode() {
|
||||
val dto = json.decodeFromString<VendorSnapshotDto>(
|
||||
"""{"acct":"acct1","vendors":[
|
||||
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
|
||||
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
|
||||
)
|
||||
val vendor = dto.vendors.first()
|
||||
assertEquals("Wares", vendor.shopName)
|
||||
assertEquals(5000L, vendor.holdGold)
|
||||
val listing = vendor.listings.first()
|
||||
assertEquals(250L, listing.price)
|
||||
assertTrue(listing.forSale)
|
||||
}
|
||||
|
||||
@Test fun vendorSaleDecodes() {
|
||||
val dto = json.decodeFromString<VendorSaleDto>(
|
||||
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
|
||||
)
|
||||
assertEquals(1000L, dto.price)
|
||||
assertEquals(50, dto.commission)
|
||||
}
|
||||
|
||||
@Test fun playerHouseDecodesDecayFields() {
|
||||
val dto = json.decodeFromString<PlayerHouseDto>(
|
||||
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
|
||||
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("LikeNew", dto.stage)
|
||||
assertEquals("Keep", dto.name)
|
||||
assertFalse(dto.isIdoc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the public site/identity DTOs (`/public/status`,
|
||||
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
|
||||
* branding/registration/push blocks, and the additive-field tolerance.
|
||||
*/
|
||||
class PublicDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun statusDecodesVersionAndMaintenanceFlag() {
|
||||
val dto = json.decodeFromString<StatusDto>(
|
||||
"""{"mode":"MAINTENANCE","status_message":"back soon",
|
||||
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
|
||||
)
|
||||
assertTrue(dto.isMaintenance) // case-insensitive
|
||||
assertEquals("back soon", dto.statusMessage)
|
||||
assertEquals("1.2.3", dto.version.server)
|
||||
}
|
||||
|
||||
@Test fun liveStatusIsNotMaintenance() {
|
||||
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun statusDefaultsWhenEmpty() {
|
||||
val dto = json.decodeFromString<StatusDto>("{}")
|
||||
assertEquals("live", dto.mode)
|
||||
assertFalse(dto.isMaintenance)
|
||||
assertEquals("", dto.version.api)
|
||||
}
|
||||
|
||||
@Test fun settingsDecodesBrandRegistrationAndPush() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{
|
||||
"site_title":"UOMysticmoon","status_message":"welcome",
|
||||
"registration":{"password":true,"sso":false},
|
||||
"gameAccountSignup":true,
|
||||
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
|
||||
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
|
||||
"push":{"ntfyUrl":"https://ntfy.example.com"}
|
||||
}""",
|
||||
)
|
||||
assertEquals("UOMysticmoon", dto.siteTitle)
|
||||
assertTrue(dto.registration.password)
|
||||
assertFalse(dto.registration.sso)
|
||||
assertTrue(dto.gameAccountSignup)
|
||||
assertEquals("#7f99bd", dto.brand.accent)
|
||||
assertEquals("/logo.png", dto.brand.logo)
|
||||
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
|
||||
}
|
||||
|
||||
@Test fun settingsDefaultsOnOlderBackend() {
|
||||
// A backend that predates push/branding: nested blocks fall back to defaults.
|
||||
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
|
||||
assertFalse(dto.registration.password)
|
||||
assertEquals("", dto.brand.name)
|
||||
assertEquals(null, dto.push.ntfyUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
|
||||
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
|
||||
* permissive board payloads (champ/guild/governor/house/presence).
|
||||
*/
|
||||
class ShardBoardDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
|
||||
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
|
||||
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
|
||||
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
|
||||
}
|
||||
|
||||
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
|
||||
val online = json.decodeFromString<ShardStatusDto>(
|
||||
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
|
||||
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
|
||||
)
|
||||
assertTrue(online.isOnline)
|
||||
assertEquals(42, online.onlineCount)
|
||||
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
|
||||
|
||||
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
|
||||
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
|
||||
}
|
||||
|
||||
@Test fun champDecodesBossAndProgressFields() {
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
|
||||
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
|
||||
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
|
||||
)
|
||||
assertTrue(dto.active)
|
||||
assertTrue(dto.bossUp)
|
||||
assertEquals(16, dto.maxLevel)
|
||||
assertEquals(5000L, dto.hits)
|
||||
}
|
||||
|
||||
@Test fun guildDecodesLeaderActor() {
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
|
||||
"leader":{"name":"Arthur","webId":"9931"}}""",
|
||||
)
|
||||
assertEquals("Knights", dto.name)
|
||||
assertEquals("Arthur", dto.leader!!.label)
|
||||
assertEquals("9931", dto.leader!!.webId)
|
||||
}
|
||||
|
||||
@Test fun governorAndTermDecode() {
|
||||
val gov = json.decodeFromString<GovernorDto>(
|
||||
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
|
||||
)
|
||||
assertEquals("Britain", gov.city)
|
||||
assertEquals("Dawn", gov.governor!!.label)
|
||||
|
||||
val term = json.decodeFromString<GovernorTermDto>(
|
||||
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
|
||||
)
|
||||
assertEquals(50, term.votes)
|
||||
assertEquals(2000L, term.endedAt)
|
||||
}
|
||||
|
||||
@Test fun houseAndPresenceAndStaffDecode() {
|
||||
val house = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
|
||||
)
|
||||
assertTrue(house.isIdoc)
|
||||
assertEquals("Tower", house.name)
|
||||
|
||||
val presence = json.decodeFromString<PresenceDto>(
|
||||
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
|
||||
)
|
||||
assertEquals(30, presence.count)
|
||||
assertEquals(10, presence.byFacet["Felucca"])
|
||||
|
||||
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
|
||||
assertEquals("GM Bob", staff.name)
|
||||
}
|
||||
|
||||
@Test fun feedEventDecodesPayloadObject() {
|
||||
val ev = json.decodeFromString<FeedEventDto>(
|
||||
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("champ.spawn", ev.kind)
|
||||
assertTrue(ev.payload!!.containsKey("name"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
|
||||
* discovery is public (never secrets); the exchange body uses snake_case
|
||||
* `code_verifier` to match the backend.
|
||||
*/
|
||||
class SsoDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun providerDecodesWithOptionalFields() {
|
||||
val dto = json.decodeFromString<SsoProviderDto>(
|
||||
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
|
||||
)
|
||||
assertEquals("discord", dto.id)
|
||||
assertEquals("Discord", dto.name)
|
||||
assertEquals(2, dto.priority)
|
||||
}
|
||||
|
||||
@Test fun providerToleratesMissingOptionals() {
|
||||
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
|
||||
assertNull(dto.icon)
|
||||
assertNull(dto.priority)
|
||||
}
|
||||
|
||||
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
|
||||
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
|
||||
assertTrue(body.contains("\"code\":\"abc123\""))
|
||||
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
|
||||
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
|
||||
*/
|
||||
class WikiDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun summaryRowDecodesWithCategory() {
|
||||
val dto = json.decodeFromString<WikiSummaryDto>(
|
||||
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
|
||||
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("systems", dto.categorySlug)
|
||||
assertEquals("Systems", dto.categoryTitle)
|
||||
assertEquals("combat", dto.excerpt)
|
||||
}
|
||||
|
||||
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
|
||||
val dto = json.decodeFromString<WikiPageDto>(
|
||||
"""{
|
||||
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
|
||||
"category_slug":"systems","category_title":"Systems",
|
||||
"tags":[{"slug":"idoc","label":"IDOC"}],
|
||||
"backlinks":[{"slug":"pvp","title":"PvP"}],
|
||||
"missing_links":["nonexistent-page"]
|
||||
}""",
|
||||
)
|
||||
assertEquals("<p>text</p>", dto.body)
|
||||
assertEquals(1, dto.tags.size)
|
||||
assertEquals("IDOC", dto.tags[0].label)
|
||||
assertEquals("pvp", dto.backlinks[0].slug)
|
||||
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
|
||||
}
|
||||
|
||||
@Test fun pageDefaultsCollectionsWhenAbsent() {
|
||||
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
|
||||
assertTrue(dto.tags.isEmpty())
|
||||
assertTrue(dto.backlinks.isEmpty())
|
||||
assertTrue(dto.missingLinks.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun categoryAndTagDecodePublishedCounts() {
|
||||
val cat = json.decodeFromString<WikiCategoryDto>(
|
||||
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
|
||||
)
|
||||
assertEquals(12L, cat.publishedCount)
|
||||
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
|
||||
assertEquals(3L, tag.publishedCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.data.api.dto.BanRequest
|
||||
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||
import com.runicgateway.app.data.api.dto.KickRequest
|
||||
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.util.okUnit
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
|
||||
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
|
||||
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
|
||||
* branches. Set [error] to throw from every call (network / decode failure paths).
|
||||
*/
|
||||
class FakeAdminApi : AdminApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var dashboard: AdminDashboardDto = AdminDashboardDto()
|
||||
var siteMode: SiteModeStateDto = SiteModeStateDto()
|
||||
var posts: List<AdminPostDto> = emptyList()
|
||||
var createdPost: AdminPostDto = AdminPostDto(id = 0)
|
||||
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
|
||||
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
|
||||
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
|
||||
var wikiTags: List<AdminWikiTagDto> = emptyList()
|
||||
var supportPages: List<SupportPageDto> = emptyList()
|
||||
|
||||
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
|
||||
var unitResponse: Response<Unit> = okUnit()
|
||||
|
||||
/** Bodies seen by write calls, so a test can assert what was sent. */
|
||||
var lastPostCreate: PostCreateRequest? = null
|
||||
var lastBan: BanRequest? = null
|
||||
var lastRespond: Pair<String, PageRespondRequest>? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
|
||||
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
|
||||
|
||||
override suspend fun posts(): List<AdminPostDto> = reply(posts)
|
||||
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
|
||||
lastPostCreate = body
|
||||
return reply(createdPost)
|
||||
}
|
||||
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
|
||||
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
|
||||
|
||||
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
|
||||
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
|
||||
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
|
||||
|
||||
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun ban(body: BanRequest): Response<Unit> {
|
||||
lastBan = body
|
||||
return reply(unitResponse)
|
||||
}
|
||||
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
|
||||
|
||||
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
|
||||
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
|
||||
lastRespond = id to body
|
||||
return reply(unitResponse)
|
||||
}
|
||||
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.data.api.dto.RosterDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PlayerShardApi] for the player self-service repository /
|
||||
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
|
||||
* (drives the `503 shard offline` / `403 not-linked` / network paths).
|
||||
*/
|
||||
class FakePlayerShardApi : PlayerShardApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
|
||||
var accounts: List<ShardLinkDto> = emptyList()
|
||||
var roster: RosterDto = RosterDto()
|
||||
var char: CharProfileDto = CharProfileDto()
|
||||
var vendors: VendorSnapshotDto = VendorSnapshotDto()
|
||||
var sales: List<VendorSaleDto> = emptyList()
|
||||
var houses: List<PlayerHouseDto> = emptyList()
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
|
||||
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
|
||||
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
|
||||
override suspend fun roster(account: String): RosterDto = reply(roster)
|
||||
override suspend fun char(serial: String): CharProfileDto = reply(char)
|
||||
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
|
||||
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
|
||||
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
|
||||
* relevant `var` to the body a call should return; set [error] to make every call
|
||||
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
|
||||
* empty/neutral so a call an assertion doesn't care about never crashes.
|
||||
*/
|
||||
class FakePublicApi : PublicApi {
|
||||
|
||||
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
|
||||
var error: Throwable? = null
|
||||
|
||||
var status: StatusDto = StatusDto()
|
||||
var settings: SettingsDto = SettingsDto()
|
||||
var posts: List<PostDto> = emptyList()
|
||||
var post: PostDto = PostDto(id = 0)
|
||||
var page: PageDto = PageDto(id = 0)
|
||||
var wikiPages: List<WikiSummaryDto> = emptyList()
|
||||
var wikiCategories: List<WikiCategoryDto> = emptyList()
|
||||
var wikiTags: List<WikiTagDto> = emptyList()
|
||||
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
|
||||
var contactResponse: ContactResponse = ContactResponse(sent = true)
|
||||
var shardStatus: ShardStatusDto = ShardStatusDto()
|
||||
var shardFeed: List<FeedEventDto> = emptyList()
|
||||
var shardEconomy: List<EconomySampleDto> = emptyList()
|
||||
var shardOnline: List<OnlineStaffDto> = emptyList()
|
||||
var shardPresence: PresenceDto = PresenceDto()
|
||||
var champs: List<ChampDto> = emptyList()
|
||||
var guilds: List<GuildDto> = emptyList()
|
||||
var governors: List<GovernorDto> = emptyList()
|
||||
var governorHistory: List<GovernorTermDto> = emptyList()
|
||||
var houses: List<HouseDto> = emptyList()
|
||||
|
||||
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
|
||||
var lastContact: ContactRequest? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
|
||||
override suspend fun getStatus(): StatusDto = reply(status)
|
||||
override suspend fun getSettings(): SettingsDto = reply(settings)
|
||||
|
||||
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
|
||||
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
|
||||
override suspend fun getPage(slug: String): PageDto = reply(page)
|
||||
|
||||
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
|
||||
reply(wikiPages)
|
||||
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
|
||||
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
|
||||
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
|
||||
|
||||
override suspend fun postContact(body: ContactRequest): ContactResponse {
|
||||
lastContact = body
|
||||
return reply(contactResponse)
|
||||
}
|
||||
|
||||
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
|
||||
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
|
||||
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
|
||||
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
|
||||
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
|
||||
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
|
||||
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
|
||||
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
|
||||
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
|
||||
reply(governorHistory)
|
||||
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/**
|
||||
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
|
||||
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
|
||||
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
|
||||
* is exercised deterministically.
|
||||
*/
|
||||
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
|
||||
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.data.repository.WikiRepository
|
||||
import com.runicgateway.app.ui.home.HomeViewModel
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.news.NewsViewModel
|
||||
import com.runicgateway.app.ui.news.PostViewModel
|
||||
import com.runicgateway.app.ui.page.PageViewModel
|
||||
import com.runicgateway.app.ui.wiki.WikiPageViewModel
|
||||
import com.runicgateway.app.ui.wiki.WikiViewModel
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/** ViewModels over the public content APIs (news, CMS pages, wiki, home status). */
|
||||
class ContentViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val content = ContentRepository(api)
|
||||
private val wiki = WikiRepository(api)
|
||||
private val settings = SettingsRepository(api)
|
||||
|
||||
// ── News hub ──────────────────────────────────────────────────────────
|
||||
@Test fun newsLoadsSelectedCategory() {
|
||||
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
|
||||
val vm = NewsViewModel(content)
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun newsSelectCategoryReloads() {
|
||||
val vm = NewsViewModel(content)
|
||||
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
|
||||
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
|
||||
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun newsServerErrorIsUiError() {
|
||||
api.error = httpError(500)
|
||||
assertTrue(NewsViewModel(content).state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
|
||||
@Test fun postDetailLoadsForKnownCategory() {
|
||||
api.post = PostDto(id = 7, category = "news", title = "Update", body = "<p>x</p>")
|
||||
val handle = SavedStateHandle(
|
||||
mapOf(Routes.Args.CATEGORY to "news", Routes.Args.ID_OR_SLUG to "update"),
|
||||
)
|
||||
val vm = PostViewModel(content, handle)
|
||||
assertEquals("Update", (vm.state.value as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
@Test fun postDetailUnknownCategoryIsNotFoundWithoutApiCall() {
|
||||
val handle = SavedStateHandle(
|
||||
mapOf(Routes.Args.CATEGORY to "bogus", Routes.Args.ID_OR_SLUG to "x"),
|
||||
)
|
||||
val state = PostViewModel(content, handle).state.value
|
||||
assertTrue(state is UiState.Error)
|
||||
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── CMS page ────────────────────────────────────────────────────────────
|
||||
@Test fun pageLoadsBySlug() {
|
||||
api.page = PageDto(id = 3, slug = "about", title = "About")
|
||||
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "about")))
|
||||
assertEquals("About", (vm.state.value as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
@Test fun pageNotFoundIsUiError() {
|
||||
api.error = httpError(404)
|
||||
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "missing")))
|
||||
assertEquals(ErrorKind.NOT_FOUND, (vm.state.value as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── Wiki index + detail ─────────────────────────────────────────────────
|
||||
@Test fun wikiIndexLoadsAndTracksQuery() {
|
||||
api.wikiPages = listOf(WikiSummaryDto(id = 1, slug = "pvp", title = "PvP"))
|
||||
val vm = WikiViewModel(wiki)
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
vm.onQueryChange("housing")
|
||||
assertEquals("housing", vm.query.value)
|
||||
}
|
||||
|
||||
@Test fun wikiPageLoadsBySlug() {
|
||||
api.wikiPage = WikiPageDto(id = 9, slug = "housing", title = "Housing", body = "b")
|
||||
val vm = WikiPageViewModel(wiki, SavedStateHandle(mapOf(Routes.Args.SLUG to "housing")))
|
||||
assertEquals("Housing", (vm.state.value as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
// ── Home status ─────────────────────────────────────────────────────────
|
||||
@Test fun homeLoadsStatus() {
|
||||
api.status = StatusDto(mode = "maintenance")
|
||||
val vm = HomeViewModel(settings)
|
||||
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun homeNetworkErrorIsUiError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val state = HomeViewModel(settings).state.value
|
||||
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminContentViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminContentViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun loadsPostsAndWikiOnInit() {
|
||||
api.posts = listOf(AdminPostDto(id = 1, title = "A", published = 1))
|
||||
api.wikiCategories = listOf(AdminWikiCategoryDto(id = 2, slug = "lore", title = "Lore"))
|
||||
api.wikiTags = listOf(AdminWikiTagDto(id = 3, slug = "pvp", label = "PvP"))
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.posts is UiState.Success)
|
||||
assertEquals(1, (vm.state.value.posts as UiState.Success).data.size)
|
||||
assertEquals(1, vm.state.value.tags.size)
|
||||
}
|
||||
|
||||
@Test fun createPostRejectsBlankTitleWithoutCallingApi() {
|
||||
val vm = viewModel()
|
||||
vm.createPost(category = "news", title = " ", excerpt = "", body = "", published = false)
|
||||
assertFalse(vm.state.value.feedback!!.ok)
|
||||
assertEquals(R.string.admin_content_title_required, vm.state.value.feedback!!.messageRes)
|
||||
assertEquals(null, api.lastPostCreate) // never reached the API
|
||||
}
|
||||
|
||||
@Test fun createPostTrimsAndNullsBlanksThenReloads() {
|
||||
val vm = viewModel()
|
||||
vm.createPost(category = "news", title = " Hello ", excerpt = "", body = "b", published = true)
|
||||
val sent = api.lastPostCreate!!
|
||||
assertEquals("Hello", sent.title)
|
||||
assertEquals(null, sent.excerpt) // blank -> null
|
||||
assertEquals("b", sent.body)
|
||||
assertTrue(vm.state.value.feedback!!.ok)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun togglePublishForbiddenSurfacesForbiddenCopy() {
|
||||
api.unitResponse = errorUnit(403)
|
||||
api.error = httpError(403)
|
||||
val vm = viewModel()
|
||||
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
|
||||
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun createCategoryRejectsBlankFields() {
|
||||
val vm = viewModel()
|
||||
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
|
||||
assertEquals(R.string.admin_content_cat_fields_required, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun deletePostNetworkErrorShowsNetworkCopy() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val vm = viewModel()
|
||||
vm.deletePost(9)
|
||||
assertEquals(R.string.error_network, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AdminCountsDto
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminDashboardViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminDashboardViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun loadsDashboardOnInit() {
|
||||
api.dashboard = AdminDashboardDto(siteMode = "live", counts = AdminCountsDto(users = 12))
|
||||
val vm = viewModel()
|
||||
val state = vm.state.value.dashboard
|
||||
assertTrue(state is UiState.Success)
|
||||
assertEquals(12, (state as UiState.Success).data.counts.users)
|
||||
}
|
||||
|
||||
@Test fun loadSurfacesServerErrorAsUiError() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.dashboard is UiState.Error)
|
||||
}
|
||||
|
||||
@Test fun setSiteModeSuccessUpdatesModeAndClearsSwitching() {
|
||||
api.dashboard = AdminDashboardDto(siteMode = "live")
|
||||
api.siteMode = SiteModeStateDto(siteMode = "maintenance")
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("maintenance")
|
||||
val s = vm.state.value
|
||||
assertFalse(s.switching)
|
||||
assertTrue(s.feedback!!.ok)
|
||||
assertEquals(R.string.admin_site_mode_updated, s.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun setSiteModeForbiddenShowsForbiddenCopy() {
|
||||
api.dashboard = AdminDashboardDto()
|
||||
api.error = httpError(403)
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("maintenance")
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertFalse(fb.ok)
|
||||
assertEquals(R.string.admin_forbidden, fb.messageRes)
|
||||
}
|
||||
|
||||
@Test fun clearFeedbackResetsBanner() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("live")
|
||||
vm.clearFeedback()
|
||||
assertEquals(null, vm.state.value.feedback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminModerationViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminModerationViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun kickWithNoTargetShowsTargetRequired() {
|
||||
val vm = viewModel()
|
||||
vm.kick(account = "", serial = "")
|
||||
assertEquals(R.string.admin_mod_target_required, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun banForwardsFieldsAndSucceeds() {
|
||||
val vm = viewModel()
|
||||
vm.ban(account = "gwen", serial = "", durationSec = 3600, reason = "afk")
|
||||
assertEquals("gwen", api.lastBan!!.account)
|
||||
assertNull(api.lastBan!!.serial) // blank -> null
|
||||
assertEquals(3600L, api.lastBan!!.durationSec)
|
||||
assertEquals("afk", api.lastBan!!.reason)
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertTrue(fb.ok)
|
||||
assertEquals(R.string.admin_mod_banned, fb.messageRes)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun shardOfflineMapsTo503Copy() {
|
||||
api.unitResponse = errorUnit(503)
|
||||
val vm = viewModel()
|
||||
vm.kick(account = "x", serial = "")
|
||||
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun broadcastRejectsBlankText() {
|
||||
val vm = viewModel()
|
||||
vm.broadcast(text = " ", hue = null)
|
||||
assertEquals(R.string.admin_mod_text_required, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun unbanForbiddenMapsTo403Copy() {
|
||||
api.unitResponse = errorUnit(403)
|
||||
val vm = viewModel()
|
||||
vm.unban("gwen")
|
||||
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminSupportViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminSupportViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun loadsOpenPagesOnInit() {
|
||||
api.supportPages = listOf(SupportPageDto(pageId = "0x1", message = "help"))
|
||||
val vm = viewModel()
|
||||
val pages = vm.state.value.pages
|
||||
assertTrue(pages is UiState.Success)
|
||||
assertEquals("0x1", (pages as UiState.Success).data.first().pageId)
|
||||
}
|
||||
|
||||
@Test fun respondRejectsBlankMessage() {
|
||||
val vm = viewModel()
|
||||
vm.respond(id = "0x1", message = " ", close = true)
|
||||
assertEquals(R.string.admin_support_message_required, vm.state.value.feedback!!.messageRes)
|
||||
assertEquals(null, api.lastRespond)
|
||||
}
|
||||
|
||||
@Test fun respondTrimsMessageAndReloadsOnSuccess() {
|
||||
api.supportPages = listOf(SupportPageDto(pageId = "0x1"))
|
||||
val vm = viewModel()
|
||||
vm.respond(id = "0x1", message = " on it ", close = false)
|
||||
assertEquals("on it", api.lastRespond!!.second.message)
|
||||
assertTrue(vm.state.value.feedback!!.ok)
|
||||
}
|
||||
|
||||
@Test fun respondUnknownPageMapsTo404Copy() {
|
||||
api.unitResponse = errorUnit(404)
|
||||
val vm = viewModel()
|
||||
vm.respond(id = "0xZ", message = "hi", close = false)
|
||||
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun closeShardOfflineMapsTo503Copy() {
|
||||
api.unitResponse = errorUnit(503)
|
||||
val vm = viewModel()
|
||||
vm.close("0x1")
|
||||
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.contact
|
||||
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContactRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ContactViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private fun viewModel() = ContactViewModel(ContactRepository(api))
|
||||
|
||||
@Test fun blankFieldsProduceValidationError() {
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("Gwen")
|
||||
vm.send() // email + message still blank
|
||||
assertEquals(ContactViewModel.Result.ValidationError, vm.state.value.result)
|
||||
assertEquals(null, api.lastContact) // never hit the API
|
||||
}
|
||||
|
||||
@Test fun successfulSendClearsFieldsAndReportsSent() {
|
||||
api.contactResponse = ContactResponse(sent = true)
|
||||
val vm = viewModel()
|
||||
vm.onNameChange(" Gwen ")
|
||||
vm.onEmailChange(" g@x.c ")
|
||||
vm.onMessageChange(" hello ")
|
||||
vm.send()
|
||||
assertEquals(ContactViewModel.Result.Sent, vm.state.value.result)
|
||||
assertEquals("", vm.state.value.name) // fields cleared on success
|
||||
// Repository trims before sending.
|
||||
assertEquals("Gwen", api.lastContact!!.name)
|
||||
assertEquals("g@x.c", api.lastContact!!.email)
|
||||
}
|
||||
|
||||
@Test fun mailerFallbackSurfacesEmail() {
|
||||
api.contactResponse = ContactResponse(sent = false, fallback = "mailto", email = "team@shard.gg")
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
|
||||
vm.send()
|
||||
val result = vm.state.value.result
|
||||
assertTrue(result is ContactViewModel.Result.Fallback)
|
||||
assertEquals("team@shard.gg", (result as ContactViewModel.Result.Fallback).email)
|
||||
// Fields kept (not a clean send) so the user can retry.
|
||||
assertEquals("A", vm.state.value.name)
|
||||
}
|
||||
|
||||
@Test fun serverErrorSurfacesFailedWithKind() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
|
||||
vm.send()
|
||||
val result = vm.state.value.result
|
||||
assertTrue(result is ContactViewModel.Result.Failed)
|
||||
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.data.api.dto.RosterDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CharactersViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val playerApi = FakePlayerShardApi()
|
||||
private val publicApi = FakePublicApi()
|
||||
private fun viewModel() = CharactersViewModel(
|
||||
PlayerShardRepository(playerApi),
|
||||
SettingsRepository(publicApi),
|
||||
)
|
||||
|
||||
@Test fun loadsAccountsRostersAndSignupFlag() {
|
||||
playerApi.accounts = listOf(ShardLinkDto(account = "acct1"))
|
||||
playerApi.roster = RosterDto(acct = "acct1", chars = listOf(RosterCharDto(serial = "0x24C", name = "Gwen")))
|
||||
publicApi.settings = SettingsDto(gameAccountSignup = true)
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(listOf("acct1"), (vm.state.value.accounts as UiState.Success).data)
|
||||
val roster = vm.state.value.rosters["acct1"]
|
||||
assertTrue(roster is UiState.Success)
|
||||
assertEquals("Gwen", (roster as UiState.Success).data.first().name)
|
||||
assertTrue(vm.state.value.signupEnabled)
|
||||
}
|
||||
|
||||
@Test fun accountsErrorSurfacesError() {
|
||||
playerApi.error = httpError(503)
|
||||
assertTrue(viewModel().state.value.accounts is UiState.Error)
|
||||
}
|
||||
|
||||
@Test fun linkBlankCodeIsIgnored() {
|
||||
val vm = viewModel()
|
||||
vm.link(" ")
|
||||
assertEquals(null, vm.state.value.feedback)
|
||||
}
|
||||
|
||||
@Test fun linkSuccessShowsOkAndReloads() {
|
||||
playerApi.linkResult = ShardLinkResultDto(linked = true, account = "acct1")
|
||||
val vm = viewModel()
|
||||
vm.link("CODE1")
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertTrue(fb.ok)
|
||||
assertEquals(R.string.player_link_ok, fb.messageRes)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun linkBadCodeMapsTo400Copy() {
|
||||
playerApi.error = httpError(400)
|
||||
val vm = viewModel()
|
||||
vm.link("BAD")
|
||||
assertEquals(R.string.player_link_bad_code, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun createAccountRejectsShortPasswordWithoutApiCall() {
|
||||
val vm = viewModel()
|
||||
vm.createAccount(account = "acct1", password = "short") // < 8 chars
|
||||
assertEquals(null, vm.state.value.feedback) // guarded before any call/feedback
|
||||
}
|
||||
|
||||
@Test fun createAccountTakenMapsTo409Copy() {
|
||||
playerApi.error = httpError(409)
|
||||
val vm = viewModel()
|
||||
vm.createAccount(account = "acct1", password = "longenough")
|
||||
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.VendorDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/** ViewModels over the player self-service game-data API (own chars, vendors, houses). */
|
||||
class PlayerViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePlayerShardApi()
|
||||
private val repository = PlayerShardRepository(api)
|
||||
|
||||
// ── My houses ───────────────────────────────────────────────────────────
|
||||
@Test fun myHousesLoadsOwnHouses() {
|
||||
api.houses = listOf(PlayerHouseDto(serial = "0x40001", name = "Keep", isIdoc = false))
|
||||
val vm = MyHousesViewModel(repository)
|
||||
assertEquals("Keep", (vm.state.value as UiState.Success).data.first().name)
|
||||
}
|
||||
|
||||
@Test fun myHousesShardOfflineIsShardOfflineError() {
|
||||
api.error = httpError(503)
|
||||
val state = MyHousesViewModel(repository).state.value
|
||||
assertEquals(ErrorKind.SHARD_OFFLINE, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── Character sheet (SavedStateHandle serial) ─────────────────────────────
|
||||
@Test fun characterLoadsBySerial() {
|
||||
api.char = CharProfileDto(serial = "0x24C", name = "Gwen", online = true)
|
||||
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x24C")))
|
||||
assertEquals("Gwen", (vm.state.value as UiState.Success).data.name)
|
||||
}
|
||||
|
||||
@Test fun characterForbiddenIsNotFound() {
|
||||
api.error = httpError(403)
|
||||
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x1")))
|
||||
// 403 isn't a mapped status -> SERVER bucket (only 404/429/503 are special-cased).
|
||||
assertTrue(vm.state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Vendors (per-account snapshots + sales) ───────────────────────────────
|
||||
@Test fun vendorsLoadsAccountsThenPerAccountSnapshots() {
|
||||
api.accounts = listOf(ShardLinkDto(account = "acct1"))
|
||||
api.vendors = VendorSnapshotDto(acct = "acct1", vendors = listOf(VendorDto(serial = "0x9", shopName = "Wares")))
|
||||
api.sales = listOf(VendorSaleDto(itemType = "sword", price = 100))
|
||||
val vm = VendorsViewModel(repository)
|
||||
|
||||
val accounts = vm.state.value.accounts
|
||||
assertTrue(accounts is UiState.Success)
|
||||
assertEquals(listOf("acct1"), (accounts as UiState.Success).data)
|
||||
// Each account's vendors loaded into the per-account map.
|
||||
val perAccount = vm.state.value.vendors["acct1"]
|
||||
assertTrue(perAccount is UiState.Success)
|
||||
assertEquals("Wares", (perAccount as UiState.Success).data.first().shopName)
|
||||
assertTrue(vm.state.value.sales is UiState.Success)
|
||||
}
|
||||
|
||||
@Test fun vendorsAccountsErrorSurfacesError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val vm = VendorsViewModel(repository)
|
||||
assertTrue(vm.state.value.accounts is UiState.Error)
|
||||
assertTrue(vm.state.value.sales is UiState.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [FrameFields] does minimal typed reads from a raw SSE frame object for the
|
||||
* fields a `*.remove` / `*.decay` delta needs; it must return null (not crash) on
|
||||
* a missing or ill-typed field so a malformed frame is skipped.
|
||||
*/
|
||||
class FrameFieldsTest {
|
||||
|
||||
@Test fun longFieldParsesNumericPrimitive() {
|
||||
val obj = buildJsonObject { put("serial", "12345") }
|
||||
assertEquals(12345L, FrameFields.longField(obj, "serial"))
|
||||
}
|
||||
|
||||
@Test fun longFieldReturnsNullOnMissingOrNonNumeric() {
|
||||
val obj = buildJsonObject { put("serial", "0xNaN") }
|
||||
assertNull(FrameFields.longField(obj, "serial")) // not a Long
|
||||
assertNull(FrameFields.longField(obj, "absent")) // missing key
|
||||
}
|
||||
|
||||
@Test fun longFieldReturnsNullOnJsonNullOrObject() {
|
||||
val obj = JsonObject(mapOf("a" to JsonNull, "b" to JsonObject(emptyMap())))
|
||||
assertNull(FrameFields.longField(obj, "a"))
|
||||
assertNull(FrameFields.longField(obj, "b"))
|
||||
}
|
||||
|
||||
@Test fun stringFieldReadsPrimitiveContent() {
|
||||
val obj = JsonObject(mapOf("kind" to JsonPrimitive("champ.remove")))
|
||||
assertEquals("champ.remove", FrameFields.stringField(obj, "kind"))
|
||||
assertNull(FrameFields.stringField(obj, "missing"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.api.fake.FakeShardStream
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The live-board ViewModels (§6.2): a snapshot load kept live by merging SSE
|
||||
* frames. Tests use a [FakeShardStream] that emits a fixed script and completes,
|
||||
* so both the snapshot path and the frame-merge path are covered deterministically.
|
||||
*/
|
||||
class ShardBoardViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }
|
||||
|
||||
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
|
||||
|
||||
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
|
||||
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
|
||||
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
|
||||
val stream = FakeShardStream(
|
||||
listOf(
|
||||
ShardStreamEvent.Open,
|
||||
ShardStreamEvent.Frame(
|
||||
"champ.update",
|
||||
buildJsonObject { put("serial", "0x2"); put("category", "mini"); put("name", "Barracoon") },
|
||||
),
|
||||
ShardStreamEvent.Frame("champ.remove", buildJsonObject { put("serial", "0x1") }),
|
||||
),
|
||||
)
|
||||
val vm = ChampsViewModel(repo(stream))
|
||||
val rows = (vm.state.value as UiState.Success).data
|
||||
// 0x1 removed, 0x2 upserted.
|
||||
assertEquals(listOf("0x2"), rows.map { it.serial })
|
||||
assertTrue(vm.connected.value) // Open was seen
|
||||
}
|
||||
|
||||
@Test fun champsServerErrorIsUiError() {
|
||||
api.error = httpError(503)
|
||||
assertTrue(ChampsViewModel(repo()).state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Guilds ────────────────────────────────────────────────────────────
|
||||
@Test fun guildsSnapshotThenLiveUpdate() {
|
||||
api.guilds = listOf(GuildDto(id = 1, name = "Knights"))
|
||||
val stream = FakeShardStream(
|
||||
listOf(ShardStreamEvent.Frame("guild.update", buildJsonObject { put("id", 2); put("name", "Mages") })),
|
||||
)
|
||||
val vm = GuildsViewModel(repo(stream))
|
||||
val names = (vm.state.value as UiState.Success).data.map { it.name }
|
||||
assertTrue(names.contains("Knights"))
|
||||
assertTrue(names.contains("Mages"))
|
||||
}
|
||||
|
||||
// ── Governors (+ history) ─────────────────────────────────────────────
|
||||
@Test fun governorsSnapshotAndHistory() {
|
||||
api.governors = listOf(GovernorDto(city = "Britain"))
|
||||
api.governorHistory = listOf() // empty is fine
|
||||
val vm = GovernorsViewModel(repo())
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
vm.loadHistory("Britain")
|
||||
assertTrue(vm.history.value.containsKey("Britain"))
|
||||
}
|
||||
|
||||
// ── Houses (IDOC board) ───────────────────────────────────────────────
|
||||
@Test fun housesSnapshotLoads() {
|
||||
api.houses = listOf(HouseDto(serial = "0x40", name = "Tower", isIdoc = true))
|
||||
val vm = HousesViewModel(repo())
|
||||
assertEquals("Tower", (vm.state.value as UiState.Success).data.first().name)
|
||||
}
|
||||
|
||||
@Test fun housesNetworkErrorIsUiError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
assertTrue(HousesViewModel(repo()).state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Shard hub (status primary, presence/online best-effort, live feed) ─
|
||||
@Test fun shardHubLoadsStatusPresenceOnlineAndSeedsFeed() {
|
||||
api.shardStatus = ShardStatusDto(enabled = true, pluginConnected = true, onlineCount = 5)
|
||||
api.shardPresence = PresenceDto(count = 5)
|
||||
val stream = FakeShardStream(
|
||||
listOf(
|
||||
ShardStreamEvent.Closed,
|
||||
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
|
||||
),
|
||||
)
|
||||
val vm = ShardViewModel(repo(stream))
|
||||
val hub = (vm.state.value as UiState.Success).data
|
||||
assertTrue(hub.status.isOnline)
|
||||
// presence.online frame patched the count in place.
|
||||
assertEquals(9, hub.presence!!.count)
|
||||
assertFalse(vm.connected.value) // last lifecycle event was Closed
|
||||
}
|
||||
|
||||
@Test fun shardHubStatusErrorIsUiError() {
|
||||
api.error = httpError(503)
|
||||
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.util
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Test doubles emulate Retrofit's error contract: a suspend API method throws
|
||||
* [HttpException] on a non-2xx and an [java.io.IOException] on a transport failure,
|
||||
* exactly what `safeApiCall` folds into `ApiResult.HttpError` / `NetworkError`.
|
||||
*/
|
||||
|
||||
private val JSON = "application/json".toMediaTypeOrNull()
|
||||
|
||||
/** An [HttpException] carrying [code] — what a fake API throws to drive an HTTP-error path. */
|
||||
fun httpError(code: Int): HttpException =
|
||||
HttpException(Response.error<Any>(code, "{}".toResponseBody(JSON)))
|
||||
|
||||
/** A successful bodyless [Response] (for `@DELETE`/moderation endpoints returning `Response<Unit>`). */
|
||||
fun okUnit(): Response<Unit> = Response.success(Unit)
|
||||
|
||||
/** A non-2xx bodyless [Response] with [code]. */
|
||||
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.util
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
/**
|
||||
* Swaps `Dispatchers.Main` (which `viewModelScope` dispatches on) for a test
|
||||
* dispatcher for the duration of a test, so ViewModel coroutines run on the test
|
||||
* scheduler instead of a real Android main looper.
|
||||
*
|
||||
* Defaults to an [UnconfinedTestDispatcher] so work launched from a ViewModel's
|
||||
* `init {}` runs eagerly to its first real suspension — with fakes that never
|
||||
* truly suspend, that means the final state is settled by the time the constructor
|
||||
* returns, and a test can assert `state.value` directly without advancing time.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||||
) : TestWatcher() {
|
||||
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
|
||||
override fun finished(description: Description) = Dispatchers.resetMain()
|
||||
}
|
||||
@@ -20,13 +20,40 @@ sonar.exclusions=**/build/**,**/.gradle/**,**/generated/**
|
||||
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# ── Optional enrichment (enable once the reports are produced in CI) ──
|
||||
# For richer Kotlin/Android results, run the reporters in sonarqube.yml and point
|
||||
# SonarQube at their output:
|
||||
# ── Coverage (JaCoCo) ──
|
||||
# sonarqube.yml runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan;
|
||||
# that task (app/build.gradle.kts) writes this XML. Without it, Sonar reports 0%
|
||||
# coverage even though the JVM unit suite (app/src/test) exists.
|
||||
sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml
|
||||
|
||||
# Exclude from *coverage* (not from analysis — bugs/smells are still reported): code a
|
||||
# JVM unit test physically can't execute, so counting its lines would unfairly sink
|
||||
# new-code coverage. Two kinds: pure-@Composable UI (needs Robolectric/instrumented
|
||||
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
|
||||
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
|
||||
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
|
||||
sonar.coverage.exclusions=\
|
||||
app/src/main/java/**/ui/**/*Screen.kt,\
|
||||
app/src/main/java/**/ui/**/*Screen*.kt,\
|
||||
app/src/main/java/**/ui/theme/**,\
|
||||
app/src/main/java/**/ui/components/**,\
|
||||
app/src/main/java/**/ui/page/BlockRenderer.kt,\
|
||||
app/src/main/java/**/ui/shard/ShardComponents.kt,\
|
||||
app/src/main/java/**/ui/LocalAssetResolver.kt,\
|
||||
app/src/main/java/**/RunicApp.kt,\
|
||||
app/src/main/java/**/MainActivity.kt,\
|
||||
app/src/main/java/**/*Application.kt,\
|
||||
app/src/main/java/**/RunicGatewayApp.kt,\
|
||||
app/src/main/java/**/di/**,\
|
||||
app/src/main/java/**/core/push/PushService.kt,\
|
||||
app/src/main/java/**/core/push/PushManager.kt,\
|
||||
app/src/main/java/**/core/push/PushNotifier.kt,\
|
||||
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
|
||||
app/src/main/java/**/core/auth/Encrypted*.kt
|
||||
|
||||
# ── Optional enrichment (enable once produced in CI) ──
|
||||
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
|
||||
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.xml
|
||||
# • JaCoCo coverage (needs a coverage-enabled test run):
|
||||
# sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/.../*.xml
|
||||
# The alternative to the CLI scanner used here is the SonarQube Gradle plugin
|
||||
# (org.sonarqube), which auto-discovers these reports; the CLI + properties file
|
||||
# is used instead to keep this repo's setup identical to website/ and link/.
|
||||
|
||||
Reference in New Issue
Block a user