12 Commits

Author SHA1 Message Date
4fe7a7e2a3 Merge pull request 'feat(auth): persist the trust token returned by the SSO exchange' (#29) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
SonarQube / analysis (push) Successful in 5m41s
Release APK / release (push) Successful in 9m29s
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:11:30 +00:00
b10dd444b3 feat(auth): persist the trust token returned by the SSO exchange
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m55s
Pairs with website feat/sso-trusted-device, which makes "trust this device" work
for SSO sign-ins. Two things reach this device when the user ticks the box:

  1. The rg_trust COOKIE in the Custom Tab. Custom Tabs share the system
     browser's cookie jar, so that alone makes the next SSO sign-in skip the
     TOTP step — no app change needed for that half.
  2. A trustToken in the /auth/mobile/sso/exchange response, which is what this
     commit stores. That covers the app's NATIVE password login on the same
     device, which reads the token back out of TrustTokenStore and replays it as
     X-Trust-Token.

MobileTokenResponse already carried trustToken (the native login path has always
persisted it) — SsoAuthManager simply dropped it on the floor. Save it scoped to
the signed-in username, exactly like AuthRepository.login does, so it is never
replayed for a different account on a shared device; and save it before
onSignedIn so a process death mid-callback can't lose it.

Tests: 2 new cases in SsoAuthManagerTest (token persisted + scoped to its owner;
absent token leaves the store untouched), with an in-memory FakeTrustTokenStore
matching the file's existing fake style. Full unit suite green: 266 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:33 -05:00
f3da6ea618 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#28) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
SonarQube / analysis (push) Successful in 3m16s
Release APK / release (push) Successful in 9m9s
Reviewed-on: #28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:24:49 +00:00
ae170670d9 ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 2m23s
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:22:06 -05:00
7fc497a1a4 Merge pull request 'test(coverage): raise unit coverage past the 50% gate (phases 0-2)' (#27) from test/coverage-phase-0-1-2 into main
Some checks failed
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:11:54 +00:00
4e3bb914ff test(coverage): raise unit coverage past the 50% gate (phases 0-2)
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m19s
Executes COVERAGE_PLAN.md phases 0-2 to clear the SonarQube new-code coverage
gate (was 16.4%, threshold 50%). Estimated new-code coverage after this change
is ~57%. 109 new tests across 19 files; full suite is 264 tests, all green.

Phase 0 — coverage exclusions (sonar-project.properties): drop code a JVM unit
test can't execute from the *coverage* denominator (still analysed for
bugs/smells) — pure-@Composable UI the `*Screen.kt` glob missed
(ui/components/**, BlockRenderer, ShardComponents), Android-framework glue
(push services, Keystore-backed Encrypted* stores, Hilt di/**).

Phase 1 — DTO serialization tests: AdminDto, PublicDto, WikiDto, PostDto/PageDto/
ContactDto, SsoDto, the shard board DTOs and player game-data DTOs, and the
mobile-auth request bodies — decode + encode + computed helpers
(isPublished/isMaintenance/ActorDto.label/ShardStatusDto.isOnline).

Phase 2 — ViewModel tests: a MainDispatcherRule harness + hand-written API fakes
(FakePublicApi/FakeAdminApi/FakePlayerShardApi/FakeShardStream) drive real
repositories into the ViewModels. Covers the admin (dashboard/content/moderation/
support), content (news/post/page/wiki/home/contact), player (characters/
vendors/character/my-houses) and shard-board (champs/guilds/governors/houses/
hub) ViewModels — load success/error, form validation, role/status-aware
feedback, and live-frame merging.

To make the shard boards testable, extract a small `ShardStream` interface from
`ShardStreamClient` (bound in NetworkModule) so `ShardRepository` depends on the
capability, not the OkHttp client — lets a fake stream replace the perpetual SSE
reconnect loop in tests. No production behaviour change.

Phases 3 (repositories) and 4 (core net/auth top-up) are follow-ups; the
deep-dependency auth family (Login/Account/TrustedDevices ViewModels,
AuthRepository) lands with them. See docs/android/COVERAGE_PLAN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 16:04:02 -05:00
efe14d3828 Merge pull request 'chore(sonar): wire JaCoCo coverage and clear actionable smells' (#26) from chore/sonar-coverage-and-cleanup into main
All checks were successful
SonarQube / analysis (push) Successful in 9m15s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:11:01 +00:00
43215b49a0 chore(sonar): wire JaCoCo coverage and clear actionable smells
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m44s
Fix the SonarQube coverage gate (0% on new code) — a reporting gap, not a
testing gap: the JVM unit suite already exists but the source-only scan
never received a coverage report.

- app/build.gradle.kts: apply jacoco, enable debug unit-test coverage, add a
  jacocoTestReport task (excludes generated/Hilt/Compose-singleton classes)
- sonar-project.properties: consume the JaCoCo XML; exclude pure-@Composable
  UI from coverage (JVM unit tests can't execute composable bodies)
- .gitea/workflows/sonarqube.yml: run JDK 17 + Android SDK +
  `testDebugUnitTest jacocoTestReport` before the scan

Also clear the three actionable code smells: remove an unused import
(AdminContentScreen), remove an unused parameter (AdminSupportScreen.
RespondDialog), and decompose LoginViewModel.submit() (cognitive complexity
20 -> under 15). The remaining 12 smells (snake_case DTO fields that mirror
the JSON wire contract; Compose/nav complexity) are marked Won't Fix in
SonarQube with rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 13:58:52 -05:00
a6446b04d8 Merge pull request 'fix(notifications): always serialize streams so clearing the last subscription saves' (#25) from fix/notifications-empty-subscriptions into main
All checks were successful
SonarQube / analysis (push) Successful in 1m24s
Release APK / release (push) Successful in 16m55s
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 16:48:06 +00:00
9c52a3dafa fix(notifications): always serialize streams so clearing the last subscription saves
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m34s
Turning off the final notification subscription (going from one opted-in
stream to zero) failed with "could not save" and the toggle stuck on. The
backend's PUT /auth/me/notifications/subscriptions validator requires the
`streams` field (body('streams').isArray()), but kotlinx.serialization omits a
property equal to its default (encodeDefaults=false). NotificationSubscriptionsDto
defaulted `streams` to emptyList(), so an empty set serialized to `{}` and the
backend rejected it 400 "Validation failed". Any non-empty set included the
field, so only the last toggle-off broke — regardless of which stream it was.

Remove the default from NotificationSubscriptionsDto.streams so kotlinx always
emits the field; an empty set now sends `{"streams":[]}` (200). The one call
site already passes streams explicitly and the server always returns the field,
so response decoding is unaffected. Add a regression test asserting the empty
DTO serializes to `{"streams":[]}` under the production Json config.

Verified on-device (AVD) against the live site and via the live API
(`{}` -> 400, `{"streams":[]}` -> 200).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 11:36:32 -05:00
f0a3b6c03e Merge pull request 'fix(nav): show the player game-data groups to staff' (#24) from fix/staff-player-menu into main
All checks were successful
SonarQube / analysis (push) Successful in 56s
Release APK / release (push) Successful in 9m55s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:32:57 +00:00
3aeb295342 fix(nav): show the player game-data groups to staff
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m8s
Staff are a superset of players (all player abilities plus their staff
tools), and the backend's player self-service surface is role-agnostic,
but MenuAccess.PLAYER gated "My characters/vendors/houses" on
role == player — so a signed-in admin/editor/moderator saw neither the
menu items nor, via the greyed personal streams, their own notification
options, even with linked characters.

Gate MenuAccess.PLAYER on isPlayer OR isStaff. The notifications screen
needs no change: once the backend returns the caller's linked accounts
(paired with RunicGateway/website), hasLinkedAccount resolves and the
personal streams enable themselves.

Tests: MenuAccessTest now asserts every staff role sees the player
game-data groups and a PLAYER entry, and an unrecognized role / anon
still cannot. Full unit suite passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:18:33 -05:00
43 changed files with 2283 additions and 71 deletions

View 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()

View File

@@ -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:

View 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

View File

@@ -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")
},
)
}

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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)

View File

@@ -60,8 +60,15 @@ data class NotificationStreamsDto(
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
* echoed back.
*
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
* backend validator requires the `streams` field (`body('streams').isArray()`).
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
* of `emptyList()` would drop the field when the user clears their LAST subscription,
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
*/
@Serializable
data class NotificationSubscriptionsDto(
val streams: List<String> = emptyList(),
val streams: List<String>,
)

View File

@@ -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 ────────────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
) {

View File

@@ -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,36 +171,50 @@ class LoginViewModel @Inject constructor(
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
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 +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired ->
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null,
)
}
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
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 +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired ->
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null,
)
}
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
}

View File

@@ -21,7 +21,12 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN,
/** Visible only to a player — the linked game-data groups (§6.3). */
/**
* The linked game-data groups (§6.3). Visible to any player **or** staff:
* staff are a superset of players (all player abilities plus their staff
* tools), and the backend's player self-service surface is role-agnostic, so
* a signed-in admin/editor/moderator sees + uses their own characters too.
*/
PLAYER,
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
@@ -70,7 +75,7 @@ fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry>
when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}

View File

@@ -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())

View File

@@ -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())
}
}

View File

@@ -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\""))
}
}

View File

@@ -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)
}
}

View File

@@ -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\""))
}
}

View File

@@ -3,6 +3,7 @@
*/
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
@@ -58,6 +59,15 @@ class NotificationsDtoTest {
assertEquals(listOf("news.post", "champ.start"), dto.streams)
}
@Test fun emptySubscriptionsStillSerializeStreamsField() {
// Regression: clearing the LAST subscription sends an empty set. The backend
// validator requires `streams`, so it must be present as `[]`, not omitted.
// Uses the production Json config (no encodeDefaults) to prove the field is
// always emitted because the DTO field has no default.
val body = json.encodeToString(NotificationSubscriptionsDto(emptyList()))
assertEquals("""{"streams":[]}""", body)
}
@Test fun settingsPushBlockDecodes() {
val dto = json.decodeFromString<SettingsDto>(
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"))
}
}

View File

@@ -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\""))
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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())
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -37,12 +37,16 @@ class MenuAccessTest {
assertTrue(visible.contains(Routes.HOME))
}
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
val visible = routes(signedIn(Role.EDITOR))
assertTrue(visible.contains(Routes.ACCOUNT))
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) })
@Test fun staffSeeThePlayerGameDataGroups() {
// Staff are a superset of players: every staff role sees the PLAYER-access
// game-data groups too (their own linked characters, via the role-agnostic
// /player self-service surface), on top of their staff entries.
val playerGroups = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
val visible = routes(signedIn(role))
assertTrue("$role should see Account", visible.contains(Routes.ACCOUNT))
assertTrue("$role should see the player game-data groups", playerGroups.all { visible.contains(it) })
}
}
@Test fun publicEntryCountIsStableAcrossSessions() {
@@ -58,10 +62,13 @@ class MenuAccessTest {
}
@Test fun playerAccessGatedFunction() {
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
// A PLAYER-gated entry is visible to a player AND to every staff role
// (staff superset), hidden only from an unrecognized role and anon.
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
for (role in listOf(Role.PLAYER, Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
assertTrue("$role should see a PLAYER entry", visibleEntries(entries, signedIn(role)).isNotEmpty())
}
assertTrue(visibleEntries(entries, signedIn(Role.UNKNOWN)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"))
}
}

View File

@@ -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)
}
}

View File

@@ -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))

View File

@@ -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()
}

View File

@@ -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:
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
# ── 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/.