From 04ded413d2f3b3c333ffbae071cbc5560b0b75ce Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:34:04 -0400 Subject: [PATCH] A coverage pass over the pure logic that fails silently when it breaks. Adds 70 phone tests and ~40 wear tests, and fixes two live bugs the tests surfaced. Fixes: runCatching, so a throw from the cache clear skipped the queue deletes and left the previous account's queued switches to replay under the new session. Each step now runs independently. This is a cross-account isolation guard, so - The importers hand-build their JSON submit bodies and escaped only quotes and backslashes. A pasted token with a newline (routine when copying from a chat client) produced a literal newline inside a JSON string and the import 422'd. Added a shared jsonEscape/jsonQuote covering the control range and routed the eight importers through it. Small refactors to make logic testable without an Android framework: - Extracted SyncWorker's op ordering and failure classification to top-level mergeQueuedOps / isPermanentHttpFailure. - Made wear's isPermanentSwitchError a top-level function and widened WearSwitchQueue.parseLine to internal. Coverage added: - Phone: FrontUpdate tri-state adapter, SystemTimezoneBody serialize-nulls, MemberRead display/initials, SyncWorker ordering + classification, TrustedDeviceCookieJar scoping, RevisionSafety re-auth gating, history pagination math, MemberDetailViewModel create-then-patch dedupe, RelationshipsEditorViewModel load-retry latch. - Wear: FrontHistory codec, WearSwitchQueue codec, isPermanentSwitchError parity with the phone, WearMember display/initials, markdown stripping. --- CHANGELOG.md | 9 +- .../sheaf/data/repository/AccountDataWiper.kt | 20 ++- .../lupine/sheaf/data/sync/SyncWorker.kt | 65 +++++++--- .../sheaf/ui/importcommon/JsonString.kt | 38 ++++++ .../sheaf/ui/importsp/ImportViewModel.kt | 3 +- .../PluralKitApiImportViewModel.kt | 8 +- .../pkimport/PluralKitFileImportViewModel.kt | 3 +- .../PluralSpaceImportViewModel.kt | 3 +- .../ui/prismimport/PrismImportViewModel.kt | 3 +- .../ui/tbimport/TupperboxImportViewModel.kt | 3 +- .../lupine/sheaf/MainDispatcherRule.kt | 21 +++ .../data/api/FrontUpdateJsonAdapterTest.kt | 103 +++++++++++++++ .../data/api/TrustedDeviceCookieJarTest.kt | 73 +++++++++++ .../sheaf/data/model/ModelContractsTest.kt | 93 ++++++++++++++ .../data/repository/AccountDataWiperTest.kt | 82 ++++++++++++ .../lupine/sheaf/data/sync/SyncQueueTest.kt | 92 +++++++++++++ .../sheaf/ui/components/RevisionSafetyTest.kt | 87 +++++++++++++ .../sheaf/ui/history/HistoryPaginationTest.kt | 45 +++++++ .../sheaf/ui/importcommon/ImportBodyTest.kt | 121 ++++++++++++++++++ .../ui/members/MemberDetailViewModelTest.kt | 103 +++++++++++++++ .../RelationshipsEditorViewModelTest.kt | 112 ++++++++++++++++ .../lupine/sheaf/wear/data/WearStore.kt | 18 ++- .../lupine/sheaf/wear/data/WearSwitchQueue.kt | 6 +- .../sheaf/wear/data/FrontHistoryCodecTest.kt | 70 ++++++++++ .../sheaf/wear/data/WearStoreLogicTest.kt | 58 +++++++++ .../wear/data/WearSwitchQueueCodecTest.kt | 48 +++++++ .../sheaf/wear/util/MarkdownStripTest.kt | 55 ++++++++ 27 files changed, 1298 insertions(+), 44 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/importcommon/JsonString.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/MainDispatcherRule.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/api/FrontUpdateJsonAdapterTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/repository/AccountDataWiperTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/data/sync/SyncQueueTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/RevisionSafetyTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/history/HistoryPaginationTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/MemberDetailViewModelTest.kt create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModelTest.kt create mode 100644 sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/FrontHistoryCodecTest.kt create mode 100644 sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearStoreLogicTest.kt create mode 100644 sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearSwitchQueueCodecTest.kt create mode 100644 sheaf/wear/src/test/java/systems/lupine/sheaf/wear/util/MarkdownStripTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab42c6..7ce63b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,14 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). Signing in or out now clears the local response cache and the offline action queue, so a new account can't briefly see the prior account's members, fronts or history, and actions queued while offline under one account can no longer - replay under another's credentials. + replay under another's credentials. The clear is now resilient: if wiping the + cache errors, the offline queue is still cleared (previously one failure + skipped the rest). + +- **Importing with a token that has a stray line break no longer fails.** Tokens + pasted from a chat client often pick up a trailing newline; the import request + now escapes it correctly instead of sending malformed JSON that the server + rejected. - **Wear: concurrent token refreshes no longer invalidate the session.** The watch app, its tiles, and its complications each build their own API client; diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/AccountDataWiper.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/AccountDataWiper.kt index a2aa40f..5e5c356 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/AccountDataWiper.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/repository/AccountDataWiper.kt @@ -21,11 +21,21 @@ class AccountDataWiper @Inject constructor( private val cache: LocalCache, private val pendingOps: PendingOperationsDao, ) { + /** + * Best-effort, but each step is attempted independently. A single + * runCatching around all three meant a throw from [LocalCache.clearAll] + * skipped both queue deletes, leaving the previous account's queued front + * switches to replay against the new account's credentials: exactly the + * thing this class exists to prevent, silently swallowed. + */ suspend fun wipe() { - runCatching { - cache.clearAll() - pendingOps.deleteAllSwitches() - pendingOps.deleteAllRemovals() - }.onFailure { Log.w("AccountDataWiper", "failed to wipe account data", it) } + step("cache") { cache.clearAll() } + step("pending switches") { pendingOps.deleteAllSwitches() } + step("pending removals") { pendingOps.deleteAllRemovals() } + } + + private suspend fun step(what: String, block: suspend () -> Unit) { + runCatching { block() } + .onFailure { Log.w("AccountDataWiper", "failed to wipe $what", it) } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt index d8b5b7c..043c66d 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/sync/SyncWorker.kt @@ -40,10 +40,7 @@ class SyncWorker @AssistedInject constructor( // mid-flight cancellation can still duplicate a front; a true // exactly-once guarantee needs a server-side Idempotency-Key on // createFront, tracked as a backend follow-up. - val ops: List = buildList { - dao.getAllRemovals().forEach { add(QueuedOp.Removal(it)) } - dao.getAllSwitches().forEach { add(QueuedOp.Switch(it)) } - }.sortedBy { it.createdAt } + val ops = mergeQueuedOps(dao.getAllRemovals(), dao.getAllSwitches()) for (op in ops) { val retry = when (op) { @@ -71,7 +68,7 @@ class SyncWorker @AssistedInject constructor( }.fold( onSuccess = { dao.deleteRemoval(removal); false }, onFailure = { e -> - if (isPermanent(e)) { + if (isPermanentHttpFailure(e)) { // Front already gone / ended on another device: drop it so // it can't wedge the queue behind an endless retry. Log.w(WORK_NAME, "dropping un-replayable removal (${reason(e)})") @@ -103,7 +100,7 @@ class SyncWorker @AssistedInject constructor( }.fold( onSuccess = { dao.deleteSwitch(switch); false }, onFailure = { e -> - if (isPermanent(e)) { + if (isPermanentHttpFailure(e)) { // Un-replayable: a member was deleted server-side (404) or // the payload is rejected (422). Drop so the rest isn't stuck. Log.w(WORK_NAME, "dropping un-replayable switch (${reason(e)})") @@ -113,21 +110,6 @@ class SyncWorker @AssistedInject constructor( ) } - private sealed class QueuedOp(val createdAt: Long) { - class Removal(val row: PendingFrontRemoval) : QueuedOp(row.createdAt) - class Switch(val row: PendingFrontSwitch) : QueuedOp(row.createdAt) - } - - // A failure is "permanent" when the server says the request itself is bad - // and replaying it can't help: a 4xx other than auth (401/403, handled by - // the token authenticator and recoverable on re-login), request timeout - // (408) and rate limiting (429), which are worth retrying. Network / IO - // errors and 5xx aren't HttpExceptions or are >=500, so they retry. - private fun isPermanent(t: Throwable): Boolean { - val code = (t as? HttpException)?.code() ?: return false - return code in 400..499 && code !in setOf(401, 403, 408, 429) - } - private fun reason(t: Throwable): String = (t as? HttpException)?.let { "HTTP ${it.code()}" } ?: (t::class.simpleName ?: "error") @@ -147,3 +129,44 @@ class SyncWorker @AssistedInject constructor( } } } + +/** + * One queued offline mutation, tagged with when the user actually made it. + * Top-level (rather than nested in the worker) so the ordering and the + * failure classification below can be unit-tested without a WorkManager. + */ +internal sealed class QueuedOp(val createdAt: Long) { + class Removal(val row: PendingFrontRemoval) : QueuedOp(row.createdAt) + class Switch(val row: PendingFrontSwitch) : QueuedOp(row.createdAt) +} + +/** + * Interleave removals and switches by the time the user made them, rather than + * replaying all removals and then all switches, which rebuilds the wrong + * timeline whenever the queue holds both. + */ +internal fun mergeQueuedOps( + removals: List, + switches: List, +): List = buildList { + removals.forEach { add(QueuedOp.Removal(it)) } + switches.forEach { add(QueuedOp.Switch(it)) } +}.sortedBy { it.createdAt } + +/** + * A failure is "permanent" when the server says the request itself is bad and + * replaying it can't help: a 4xx other than auth (401/403, handled by the token + * authenticator and recoverable on re-login), request timeout (408) and rate + * limiting (429), which are all worth retrying. Network / IO errors and 5xx + * aren't HttpExceptions, or are >= 500, so they retry. + * + * Getting this wrong is expensive in both directions: call a transient failure + * permanent and the user's offline switch is silently dropped; call a permanent + * one transient and it wedges the whole queue behind an endless retry. + * + * The wear app mirrors this in WearStore.isPermanentSwitchError. + */ +internal fun isPermanentHttpFailure(t: Throwable): Boolean { + val code = (t as? HttpException)?.code() ?: return false + return code in 400..499 && code !in setOf(401, 403, 408, 429) +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importcommon/JsonString.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importcommon/JsonString.kt new file mode 100644 index 0000000..af1fb17 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importcommon/JsonString.kt @@ -0,0 +1,38 @@ +package systems.lupine.sheaf.ui.importcommon + +/** + * Escape a string for embedding in a hand-built JSON body. + * + * The importers assemble their submit bodies as strings (the options blob is + * already-encoded JSON, so a typed data class would mean encoding it twice). + * That means every interpolated value has to be escaped here, and the escapers + * were doing quotes and backslashes only. A pasted PluralKit token with a + * newline in it (trivially easy: copy a token out of a chat client and pick up + * the trailing line break) produced a body with a literal newline inside a JSON + * string, which is invalid JSON, so the whole import 422'd with nothing useful + * to show the user. + * + * Handles the full set JSON requires: backslash, quote, the named control + * escapes, and \\u00xx for everything else below 0x20. + */ +internal fun jsonEscape(value: String): String { + val sb = StringBuilder(value.length + 8) + for (c in value) { + when (c) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\b' -> sb.append("\\b") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + '\u000C' -> sb.append("\\f") + else -> + if (c < ' ') sb.append("\\u%04x".format(c.code)) + else sb.append(c) + } + } + return sb.toString() +} + +/** [jsonEscape]d and wrapped in quotes, ready to drop into a JSON body. */ +internal fun jsonQuote(value: String): String = "\"${jsonEscape(value)}\"" diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportViewModel.kt index 3320d19..81951cf 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportViewModel.kt @@ -1,5 +1,6 @@ package systems.lupine.sheaf.ui.importsp +import systems.lupine.sheaf.ui.importcommon.jsonQuote import android.content.Context import android.net.Uri import android.provider.OpenableColumns @@ -225,7 +226,7 @@ private fun buildSpOptionsJson(opts: ImportOptions, memberIds: List?): S parts += "\"groups\":${opts.groups}" parts += "\"front_history\":${opts.frontHistory}" if (memberIds != null) { - val ids = memberIds.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + val ids = memberIds.joinToString(",") { jsonQuote(it) } parts += "\"member_ids\":[$ids]" } return parts.joinToString(",", prefix = "{", postfix = "}") diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportViewModel.kt index 5f0ee93..f1dace1 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportViewModel.kt @@ -1,5 +1,6 @@ package systems.lupine.sheaf.ui.pkapiimport +import systems.lupine.sheaf.ui.importcommon.jsonQuote import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -195,7 +196,7 @@ internal fun buildPkApiOptionsJson(opts: PKApiImportOptions, memberIds: List?) parts += "\"member_board_posts\":${opts.memberBoardPosts}" parts += "\"media_attachments\":${opts.mediaAttachments}" if (memberIds != null) { - val ids = memberIds.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + val ids = memberIds.joinToString(",") { jsonQuote(it) } parts += "\"member_ids\":[$ids]" } else { parts += "\"member_ids\":null" diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportViewModel.kt index 13369f5..5221e61 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportViewModel.kt @@ -1,5 +1,6 @@ package systems.lupine.sheaf.ui.tbimport +import systems.lupine.sheaf.ui.importcommon.jsonQuote import android.content.Context import android.net.Uri import android.provider.OpenableColumns @@ -209,7 +210,7 @@ internal fun buildTbOptionsJson(opts: TBImportOptions, memberIds: List?) val parts = mutableListOf() parts += "\"groups\":${opts.groups}" if (memberIds != null) { - val ids = memberIds.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + val ids = memberIds.joinToString(",") { jsonQuote(it) } parts += "\"member_ids\":[$ids]" } return parts.joinToString(",", prefix = "{", postfix = "}") diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/MainDispatcherRule.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/MainDispatcherRule.kt new file mode 100644 index 0000000..bf4b68c --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/MainDispatcherRule.kt @@ -0,0 +1,21 @@ +package systems.lupine.sheaf + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * viewModelScope dispatches on Dispatchers.Main, which does not exist in a JVM + * unit test. Swap it for a TestDispatcher the test controls, so a ViewModel's + * coroutines run when the test advances them. + */ +class MainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) = Dispatchers.setMain(dispatcher) + override fun finished(description: Description) = Dispatchers.resetMain() +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/FrontUpdateJsonAdapterTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/FrontUpdateJsonAdapterTest.kt new file mode 100644 index 0000000..5fadb80 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/FrontUpdateJsonAdapterTest.kt @@ -0,0 +1,103 @@ +package systems.lupine.sheaf.data.api + +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import okio.Buffer +import systems.lupine.sheaf.data.model.FrontUpdate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The fronts PATCH is a tri-state wire contract: omitted = leave alone, + * explicit null = clear, value = set. Moshi's codegen only does two of those, + * which is why this adapter is hand-written. If the "explicit null" half + * regresses, "mark still ongoing" silently degrades to "leave as-is": the front + * stays ended, the timeline is wrong, and nothing errors. + */ +class FrontUpdateJsonAdapterTest { + + private val adapter = FrontUpdateJsonAdapter() + + private fun encode(update: FrontUpdate): String { + val buffer = Buffer() + adapter.toJson(JsonWriter.of(buffer), update) + return buffer.readUtf8() + } + + private fun decode(json: String): FrontUpdate = + adapter.fromJson(JsonReader.of(Buffer().writeUtf8(json))) + + @Test fun `clearEndedAt emits an explicit null`() { + assertEquals("""{"ended_at":null}""", encode(FrontUpdate(clearEndedAt = true))) + } + + @Test fun `a null endedAt without clearEndedAt is omitted entirely`() { + // Omitted means "leave as-is". Emitting null here would silently end + // (or un-end) fronts on every unrelated patch. + assertEquals("{}", encode(FrontUpdate())) + } + + @Test fun `clearEndedAt wins over a supplied endedAt`() { + assertEquals( + """{"ended_at":null}""", + encode(FrontUpdate(endedAt = "2026-07-14T10:00:00Z", clearEndedAt = true)), + ) + } + + @Test fun `a value is emitted as a value`() { + assertEquals( + """{"ended_at":"2026-07-14T10:00:00Z"}""", + encode(FrontUpdate(endedAt = "2026-07-14T10:00:00Z")), + ) + } + + @Test fun `other null fields stay omitted`() { + val json = encode(FrontUpdate(memberIds = listOf("a", "b"))) + assertEquals("""{"member_ids":["a","b"]}""", json) + } + + @Test fun `every field together`() { + val json = encode( + FrontUpdate( + clearEndedAt = true, + memberIds = listOf("a"), + startedAt = "2026-07-14T09:00:00Z", + customStatus = "hi", + ) + ) + assertEquals( + """{"ended_at":null,"member_ids":["a"],"started_at":"2026-07-14T09:00:00Z","custom_status":"hi"}""", + json, + ) + } + + @Test fun `an empty member list is emitted, not omitted`() { + // Distinct from null: "no members" is a real (if odd) instruction. + assertEquals("""{"member_ids":[]}""", encode(FrontUpdate(memberIds = emptyList()))) + } + + @Test fun `reading an explicit null sets clearEndedAt`() { + val parsed = decode("""{"ended_at":null}""") + assertTrue(parsed.clearEndedAt) + assertNull(parsed.endedAt) + } + + @Test fun `reading a value leaves clearEndedAt false`() { + val parsed = decode("""{"ended_at":"2026-07-14T10:00:00Z"}""") + assertFalse(parsed.clearEndedAt) + assertEquals("2026-07-14T10:00:00Z", parsed.endedAt) + } + + @Test fun `unknown fields are skipped rather than fatal`() { + val parsed = decode("""{"unknown":{"nested":[1,2]},"started_at":"2026-07-14T09:00:00Z"}""") + assertEquals("2026-07-14T09:00:00Z", parsed.startedAt) + } + + @Test fun `round trip preserves the clear instruction`() { + val original = FrontUpdate(clearEndedAt = true, memberIds = listOf("a")) + assertEquals(original, decode(encode(original))) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt new file mode 100644 index 0000000..fdd56f3 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/TrustedDeviceCookieJarTest.kt @@ -0,0 +1,73 @@ +package systems.lupine.sheaf.data.api + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import okhttp3.Cookie +import okhttp3.HttpUrl.Companion.toHttpUrl +import systems.lupine.sheaf.data.repository.PreferencesRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Scoping test. The jar persists exactly one cookie and attaches it to exactly + * one path prefix; widening either is a disclosure (the trusted-device token + * riding along to, say, an avatar CDN request) or a surprise (browser session + * cookies shadowing our bearer tokens). + */ +class TrustedDeviceCookieJarTest { + + private val prefs = mockk(relaxed = true) + private val jar = TrustedDeviceCookieJar(prefs) + + private val authUrl = "https://app.sheaf.sh/v1/auth/login".toHttpUrl() + + private fun cookie(name: String, value: String): Cookie = + Cookie.Builder().name(name).value(value).domain("app.sheaf.sh").path("/").build() + + @Test fun `the trusted-device cookie is persisted`() { + coEvery { prefs.saveTrustedDeviceCookie(any(), any()) } returns Unit + + jar.saveFromResponse(authUrl, listOf(cookie("sheaf_trusted_device", "tdc-1"))) + + coVerify(exactly = 1) { prefs.saveTrustedDeviceCookie("tdc-1", any()) } + } + + @Test fun `session and refresh cookies are not persisted`() { + // These are browser shadow-state; mobile uses the bearer tokens from + // the JSON body. Persisting them would duplicate the session in a + // second place with different lifetime rules. + jar.saveFromResponse( + authUrl, + listOf(cookie("sheaf_session", "s"), cookie("sheaf_refresh", "r")), + ) + + coVerify(exactly = 0) { prefs.saveTrustedDeviceCookie(any(), any()) } + } + + @Test fun `the cookie is attached under the auth path`() { + every { prefs.trustedDeviceCookieBlocking() } returns "tdc-1" + + val cookies = jar.loadForRequest(authUrl) + + assertEquals(1, cookies.size) + assertEquals("sheaf_trusted_device", cookies[0].name) + assertEquals("tdc-1", cookies[0].value) + } + + @Test fun `the cookie is not attached anywhere else`() { + every { prefs.trustedDeviceCookieBlocking() } returns "tdc-1" + + assertTrue(jar.loadForRequest("https://app.sheaf.sh/v1/members".toHttpUrl()).isEmpty()) + assertTrue(jar.loadForRequest("https://cdn.sheaf.sh/files/avatar.png".toHttpUrl()).isEmpty()) + assertTrue(jar.loadForRequest("https://app.sheaf.sh/v1/fronts/current".toHttpUrl()).isEmpty()) + } + + @Test fun `nothing is attached when no cookie is stored`() { + every { prefs.trustedDeviceCookieBlocking() } returns null + + assertTrue(jar.loadForRequest(authUrl).isEmpty()) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt new file mode 100644 index 0000000..39d447f --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ModelContractsTest.kt @@ -0,0 +1,93 @@ +package systems.lupine.sheaf.data.model + +import com.squareup.moshi.Moshi +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Wire-contract details that a refactor can quietly break without any compiler + * or runtime complaint, because the server's response to a wrong body is + * "fine, nothing changed". + */ +class ModelContractsTest { + + private val moshi = Moshi.Builder().build() + + @Test fun `an explicit null timezone reaches the wire`() { + // "Automatic" is an explicit null, and the backend patches with + // exclude_unset: an omitted field means "leave unchanged". Without + // serializeNulls, Moshi drops the null and choosing Automatic becomes a + // no-op that silently keeps the old zone. + val json = moshi.adapter(SystemTimezoneBody::class.java) + .serializeNulls() + .toJson(SystemTimezoneBody(null)) + assertEquals("""{"timezone":null}""", json) + } + + @Test fun `a chosen timezone serialises normally`() { + val json = moshi.adapter(SystemTimezoneBody::class.java) + .serializeNulls() + .toJson(SystemTimezoneBody("Europe/London")) + assertEquals("""{"timezone":"Europe/London"}""", json) + } + + @Test fun `without serializeNulls the null would vanish`() { + // Pinning the trap itself, so the next person to touch this sees why + // the .serializeNulls() call at the call site is load-bearing. + val json = moshi.adapter(SystemTimezoneBody::class.java) + .toJson(SystemTimezoneBody(null)) + assertEquals("{}", json) + } + + // ── MemberRead derived display fields ─────────────────────────────────── + + private fun member(name: String, displayName: String? = null) = MemberRead( + id = "m1", + systemId = "s1", + name = name, + displayName = displayName, + description = null, + pronouns = null, + avatarUrl = null, + color = null, + birthday = null, + privacy = "private", + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-01T00:00:00Z", + ) + + @Test fun `display name wins over name when set`() { + assertEquals("Ash", member(name = "ashley", displayName = "Ash").displayNameOrName) + } + + @Test fun `a blank display name falls back to the name`() { + assertEquals("ashley", member(name = "ashley", displayName = " ").displayNameOrName) + } + + @Test fun `initials take the first two words`() { + assertEquals("AB", member("Alex Bell").initials) + assertEquals("AB", member("Alex Bell Carter").initials) + assertEquals("A", member("Alex").initials) + } + + @Test fun `initials come from the display name`() { + assertEquals("SR", member(name = "ashley", displayName = "Sam Rivers").initials) + } + + @Test fun `initials never come out empty`() { + // Rendered into every avatar placeholder on phone and widgets, so an + // empty string here is a blank circle rather than a crash. + assertEquals("?", member(" ").initials) + assertEquals("?", member("").initials) + } + + @Test fun `initials uppercase`() { + assertEquals("AB", member("alex bell").initials) + } + + @Test fun `archived is derived from the timestamp`() { + val active = member("Alex") + assertEquals(false, active.isArchived) + assertEquals(true, active.copy(archivedAt = "2026-01-02T00:00:00Z").isArchived) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/repository/AccountDataWiperTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/repository/AccountDataWiperTest.kt new file mode 100644 index 0000000..68ca8ae --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/repository/AccountDataWiperTest.kt @@ -0,0 +1,82 @@ +package systems.lupine.sheaf.data.repository + +import android.util.Log +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import systems.lupine.sheaf.data.db.LocalCache +import systems.lupine.sheaf.data.db.PendingOperationsDao +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * The wiper is what stops one account seeing another's cached data, and stops + * offline actions queued under one account replaying against another's + * credentials. Both halves have to happen even when the other fails. + */ +class AccountDataWiperTest { + + private val cache = mockk() + private val pendingOps = mockk() + private val wiper = AccountDataWiper(cache, pendingOps) + + @BeforeTest fun stubLog() { + mockkStatic(Log::class) + every { Log.w(any(), any(), any()) } returns 0 + } + + @AfterTest fun unstubLog() = unmockkStatic(Log::class) + + @Test fun `wipes the cache and both queues`() = runTest { + coEvery { cache.clearAll() } just Runs + coEvery { pendingOps.deleteAllSwitches() } just Runs + coEvery { pendingOps.deleteAllRemovals() } just Runs + + wiper.wipe() + + coVerify(exactly = 1) { cache.clearAll() } + coVerify(exactly = 1) { pendingOps.deleteAllSwitches() } + coVerify(exactly = 1) { pendingOps.deleteAllRemovals() } + } + + @Test fun `a failing cache wipe still clears the offline queue`() = runTest { + // The regression this guards: all three calls used to sit inside one + // runCatching, so a throw here skipped both queue deletes and left the + // previous account's queued switches to replay under the new session. + coEvery { cache.clearAll() } throws IllegalStateException("db locked") + coEvery { pendingOps.deleteAllSwitches() } just Runs + coEvery { pendingOps.deleteAllRemovals() } just Runs + + wiper.wipe() + + coVerify(exactly = 1) { pendingOps.deleteAllSwitches() } + coVerify(exactly = 1) { pendingOps.deleteAllRemovals() } + } + + @Test fun `a failing switch wipe still clears removals`() = runTest { + coEvery { cache.clearAll() } just Runs + coEvery { pendingOps.deleteAllSwitches() } throws IllegalStateException("db locked") + coEvery { pendingOps.deleteAllRemovals() } just Runs + + wiper.wipe() + + coVerify(exactly = 1) { pendingOps.deleteAllRemovals() } + } + + @Test fun `wipe never throws at the caller`() = runTest { + // Sign-in and sign-out both call this; a throw here must not strand the + // user mid-auth. + coEvery { cache.clearAll() } throws IllegalStateException("boom") + coEvery { pendingOps.deleteAllSwitches() } throws IllegalStateException("boom") + coEvery { pendingOps.deleteAllRemovals() } throws IllegalStateException("boom") + + wiper.wipe() + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/sync/SyncQueueTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/sync/SyncQueueTest.kt new file mode 100644 index 0000000..69ca1d4 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/sync/SyncQueueTest.kt @@ -0,0 +1,92 @@ +package systems.lupine.sheaf.data.sync + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response +import systems.lupine.sheaf.data.db.PendingFrontRemoval +import systems.lupine.sheaf.data.db.PendingFrontSwitch +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The offline queue's two decisions: what order to replay in, and whether a + * failure is worth retrying. Both are silently destructive when wrong. + */ +class SyncQueueTest { + + private fun http(code: Int): HttpException = + HttpException(Response.error(code, "".toResponseBody("text/plain".toMediaType()))) + + // ── Ordering ──────────────────────────────────────────────────────────── + + @Test fun `ops replay interleaved in the order the user made them`() { + val ops = mergeQueuedOps( + removals = listOf( + PendingFrontRemoval(id = 1, memberId = "m1", createdAt = 200), + PendingFrontRemoval(id = 2, memberId = "m2", createdAt = 400), + ), + switches = listOf( + PendingFrontSwitch(id = 1, memberIds = "a", createdAt = 100), + PendingFrontSwitch(id = 2, memberIds = "b", createdAt = 300), + ), + ) + // Not all-removals-then-all-switches: a switch made before a removal + // must be replayed before it, or the rebuilt timeline is wrong. + assertEquals(listOf(100L, 200L, 300L, 400L), ops.map { it.createdAt }) + assertTrue(ops[0] is QueuedOp.Switch) + assertTrue(ops[1] is QueuedOp.Removal) + assertTrue(ops[2] is QueuedOp.Switch) + assertTrue(ops[3] is QueuedOp.Removal) + } + + @Test fun `an empty queue produces no ops`() { + assertEquals(emptyList(), mergeQueuedOps(emptyList(), emptyList())) + } + + @Test fun `either list alone still comes out sorted`() { + val switchesOnly = mergeQueuedOps( + removals = emptyList(), + switches = listOf( + PendingFrontSwitch(id = 1, memberIds = "a", createdAt = 500), + PendingFrontSwitch(id = 2, memberIds = "b", createdAt = 100), + ), + ) + assertEquals(listOf(100L, 500L), switchesOnly.map { it.createdAt }) + } + + // ── Failure classification ────────────────────────────────────────────── + + @Test fun `a bad request is permanent - drop it rather than wedge the queue`() { + assertTrue(isPermanentHttpFailure(http(400))) + assertTrue(isPermanentHttpFailure(http(404))) + assertTrue(isPermanentHttpFailure(http(409))) + assertTrue(isPermanentHttpFailure(http(422))) + } + + @Test fun `auth failures are transient - the authenticator recovers them`() { + // Classing these permanent would silently delete the user's queued + // switch the moment their token expired. + assertFalse(isPermanentHttpFailure(http(401))) + assertFalse(isPermanentHttpFailure(http(403))) + } + + @Test fun `timeout and rate limiting are transient`() { + assertFalse(isPermanentHttpFailure(http(408))) + assertFalse(isPermanentHttpFailure(http(429))) + } + + @Test fun `server errors are transient`() { + assertFalse(isPermanentHttpFailure(http(500))) + assertFalse(isPermanentHttpFailure(http(502))) + assertFalse(isPermanentHttpFailure(http(503))) + } + + @Test fun `network failures are transient`() { + assertFalse(isPermanentHttpFailure(IOException("no route to host"))) + assertFalse(isPermanentHttpFailure(RuntimeException("boom"))) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/RevisionSafetyTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/RevisionSafetyTest.kt new file mode 100644 index 0000000..c53a286 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/components/RevisionSafetyTest.kt @@ -0,0 +1,87 @@ +package systems.lupine.sheaf.ui.components + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Decides whether unpinning a revision demands re-auth. Wrong in one direction + * it is a bypassable safety gate; wrong in the other it demands a TOTP code + * from someone who has no authenticator and cannot proceed at all. + */ +class RevisionSafetyTest { + + @Test fun `no grace period means no queue and no re-auth`() { + val safety = RevisionSafety( + authTier = "both", + totpEnabled = true, + appliesToRevisions = true, + gracePeriodDays = 0, + ) + assertFalse(safety.willQueueUnpin) + assertFalse(safety.needsPassword) + assertFalse(safety.needsTotp) + } + + @Test fun `safety that does not apply to revisions gates nothing`() { + val safety = RevisionSafety( + authTier = "both", + totpEnabled = true, + appliesToRevisions = false, + gracePeriodDays = 7, + ) + assertFalse(safety.willQueueUnpin) + assertFalse(safety.needsPassword) + assertFalse(safety.needsTotp) + } + + @Test fun `password tier asks for a password only`() { + val safety = RevisionSafety("password", totpEnabled = true, appliesToRevisions = true, gracePeriodDays = 7) + assertTrue(safety.willQueueUnpin) + assertTrue(safety.needsPassword) + assertFalse(safety.needsTotp) + } + + @Test fun `totp tier asks for a code only`() { + val safety = RevisionSafety("totp", totpEnabled = true, appliesToRevisions = true, gracePeriodDays = 7) + assertFalse(safety.needsPassword) + assertTrue(safety.needsTotp) + } + + @Test fun `both tier asks for both`() { + val safety = RevisionSafety("both", totpEnabled = true, appliesToRevisions = true, gracePeriodDays = 7) + assertTrue(safety.needsPassword) + assertTrue(safety.needsTotp) + } + + @Test fun `totp is not demanded from an account without an authenticator`() { + // Otherwise the dialog's confirm button can never enable and the user is + // locked out of their own unpin. + val totpTier = RevisionSafety("totp", totpEnabled = false, appliesToRevisions = true, gracePeriodDays = 7) + assertFalse(totpTier.needsTotp) + + val bothTier = RevisionSafety("both", totpEnabled = false, appliesToRevisions = true, gracePeriodDays = 7) + assertTrue(bothTier.needsPassword) + assertFalse(bothTier.needsTotp) + } + + @Test fun `tier none queues but asks for nothing`() { + val safety = RevisionSafety("none", totpEnabled = true, appliesToRevisions = true, gracePeriodDays = 3) + assertTrue(safety.willQueueUnpin) + assertFalse(safety.needsPassword) + assertFalse(safety.needsTotp) + } + + @Test fun `an unrecognised tier does not silently demand credentials`() { + val safety = RevisionSafety("magic", totpEnabled = true, appliesToRevisions = true, gracePeriodDays = 3) + assertFalse(safety.needsPassword) + assertFalse(safety.needsTotp) + } + + @Test fun `defaults gate nothing`() { + val safety = RevisionSafety() + assertFalse(safety.willQueueUnpin) + assertFalse(safety.needsPassword) + assertFalse(safety.needsTotp) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/history/HistoryPaginationTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/history/HistoryPaginationTest.kt new file mode 100644 index 0000000..b757e27 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/history/HistoryPaginationTest.kt @@ -0,0 +1,45 @@ +package systems.lupine.sheaf.ui.history + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Ceil-division page count, which feeds goToPage's coerceIn(1, totalPages). + * Off by one and the last page is unreachable, or there is a phantom empty one. + */ +class HistoryPaginationTest { + + private fun pages(total: Int?, size: Int) = + HistoryUiState(totalCount = total, pageSize = size).totalPages + + @Test fun `an exact multiple does not gain an empty page`() { + assertEquals(2, pages(total = 100, size = 50)) + } + + @Test fun `a partial page counts`() { + assertEquals(3, pages(total = 101, size = 50)) + assertEquals(1, pages(total = 1, size = 50)) + } + + @Test fun `no rows still means one page`() { + assertEquals(1, pages(total = 0, size = 50)) + } + + @Test fun `an unknown total means one page`() { + // Infinite mode never asks for a total; the paged UI must not render a + // zero-page control while the first response is in flight. + assertEquals(1, pages(total = null, size = 50)) + } + + @Test fun `a nonsense page size cannot divide by zero`() { + assertEquals(1, pages(total = 100, size = 0)) + assertEquals(1, pages(total = 100, size = -10)) + } + + @Test fun `each supported page size divides cleanly`() { + PAGE_SIZE_OPTIONS.forEach { size -> + assertEquals(1, pages(total = size, size = size), "one full page at size $size") + assertEquals(2, pages(total = size + 1, size = size), "one over at size $size") + } + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt new file mode 100644 index 0000000..d172377 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt @@ -0,0 +1,121 @@ +package systems.lupine.sheaf.ui.importcommon + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import systems.lupine.sheaf.data.model.ImportJobEvent +import systems.lupine.sheaf.data.model.ImportJobRead +import systems.lupine.sheaf.data.model.ImportJobStatus +import systems.lupine.sheaf.ui.pkapiimport.buildApiImportBodyJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * The importers hand-build their submit bodies (the options blob is + * already-encoded JSON, so a typed class would encode it twice), which means + * the escaping is ours to get right. The value being interpolated here is a + * token the user pasted in from somewhere else. + */ +class ImportBodyTest { + + private val mapAdapter = Moshi.Builder().build() + .adapter>( + Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java) + ) + + private fun parse(json: String): Map = + assertNotNull(mapAdapter.fromJson(json), "body did not parse as JSON: $json") + + @Test fun `a quote in the token cannot break out of the string`() { + val body = buildApiImportBodyJson( + token = """pk"token""", + idempotencyKey = "key-1", + options = """{"groups":true}""", + ) + assertEquals("""pk"token""", parse(body)["pk_token"]) + } + + @Test fun `a backslash in the token survives intact`() { + val body = buildApiImportBodyJson( + token = """pk\token""", + idempotencyKey = "key-1", + options = "{}", + ) + assertEquals("""pk\token""", parse(body)["pk_token"]) + } + + @Test fun `a newline in the token still yields valid JSON`() { + // Copying a token out of a chat client picks up a trailing line break + // more often than not. The old escaper handled quotes and backslashes + // only, so this produced a literal newline inside a JSON string: the + // whole import 422'd with nothing useful to show for it. + val body = buildApiImportBodyJson( + token = "pk\ntoken\t2", + idempotencyKey = "key-1", + options = "{}", + ) + assertEquals("pk\ntoken\t2", parse(body)["pk_token"]) + } + + @Test fun `a field-injection attempt stays inside the token value`() { + val hostile = """x","options":{"front_history":true},"junk":"y""" + val body = buildApiImportBodyJson( + token = hostile, + idempotencyKey = "key-1", + options = """{"front_history":false}""", + ) + val parsed = parse(body) + assertEquals(hostile, parsed["pk_token"]) + // The options we passed win; nothing was smuggled in through the token. + @Suppress("UNCHECKED_CAST") + val options = parsed["options"] as Map + assertEquals(false, options["front_history"]) + assertNull(parsed["junk"]) + } + + @Test fun `jsonEscape covers the control range`() { + assertEquals("""ab""", jsonEscape("ab")) + // named control escapes, and \uXXXX for the rest below 0x20 + assertEquals("""\b\n\r\t""", jsonEscape("\b\n\r\t")) + assertEquals("\\u0001", jsonEscape("\u0001")) + } + + @Test fun `jsonQuote wraps and escapes`() { + assertEquals(""""a\"b"""", jsonQuote("""a"b""")) + } + + // ── Terminal result decoding (shared by all eight importers) ──────────── + + private fun job(status: String, counts: Map = emptyMap(), events: List = emptyList()) = + ImportJobRead(id = "j1", source = "sheaf", status = status, counts = counts, events = events) + + @Test fun `a failed job has no result to show`() { + // Returning a result here would render a failed import as a success. + assertNull(job(ImportJobStatus.FAILED).terminalResult()) + assertNull(job(ImportJobStatus.RUNNING).terminalResult()) + } + + @Test fun `a complete job yields its counts and warnings`() { + val result = job( + status = ImportJobStatus.COMPLETE, + counts = mapOf("members_imported" to 12, "groups_imported" to 3), + events = listOf( + ImportJobEvent(level = "warning", stage = "members", message = "no avatar", recordRef = "Alex"), + ImportJobEvent(level = "info", stage = "members", message = "ignored"), + ), + ).terminalResult() + + assertNotNull(result) + assertEquals(listOf("Alex: no avatar"), result.warnings) + assertEquals(listOf("Members imported" to 12, "Groups imported" to 3), result.rows()) + } + + @Test fun `zero counts are dropped and the rest sort by size`() { + val result = ImportResult( + counts = mapOf("groups_imported" to 3, "members_imported" to 12, "fronts_imported" to 0), + warnings = emptyList(), + ) + assertEquals(listOf("Members imported" to 12, "Groups imported" to 3), result.rows()) + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/MemberDetailViewModelTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/MemberDetailViewModelTest.kt new file mode 100644 index 0000000..c583c69 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/members/MemberDetailViewModelTest.kt @@ -0,0 +1,103 @@ +package systems.lupine.sheaf.ui.members + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import com.squareup.moshi.Moshi +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import systems.lupine.sheaf.MainDispatcherRule +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.MemberCreate +import systems.lupine.sheaf.data.model.MemberRead +import systems.lupine.sheaf.ui.components.MarkdownImageDelegate +import kotlin.test.Test + +/** + * The duplicate-member guard. Creating a member is two server round trips (the + * member, then its custom-field values); if the second fails, the user is + * looking at an error on a screen that has already created someone. Pressing + * Save again must update that member, not mint a second one. + */ +class MemberDetailViewModelTest { + + @get:Rule val mainDispatcher = MainDispatcherRule() + + private val api = mockk(relaxed = true) + private val context = mockk(relaxed = true) + private val markdownImages = mockk(relaxed = true) + + private fun newMemberViewModel() = MemberDetailViewModel( + api = api, + moshi = Moshi.Builder().build(), + context = context, + markdownImages = markdownImages, + savedStateHandle = SavedStateHandle(mapOf("memberId" to "new")), + ) + + private fun member(id: String) = MemberRead( + id = id, + systemId = "s1", + name = "Alex", + displayName = null, + description = null, + pronouns = null, + avatarUrl = null, + color = null, + birthday = null, + privacy = "private", + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-01T00:00:00Z", + ) + + @Test fun `retrying a save after the first attempt failed does not create a second member`() = runTest { + val vm = newMemberViewModel() + advanceUntilIdle() + vm.updateForm { copy(name = "Alex") } + // Staged so the save has a second step to fail on. + vm.setCustomFieldValue("f1", "value") + + // First save: the member is created, then the custom-field flush blows up. + coEvery { api.createMember(any()) } returns member("m-created") + coEvery { api.setMemberFieldValues(any(), any()) } throws IllegalStateException("boom") + vm.save() + advanceUntilIdle() + + // Second save: must PATCH the member the first attempt already created. + coEvery { api.setMemberFieldValues(any(), any()) } returns emptyList() + coEvery { api.patchMemberRaw(any(), any()) } returns member("m-created") + vm.save() + advanceUntilIdle() + + coVerify(exactly = 1) { api.createMember(any()) } + coVerify(exactly = 1) { api.patchMemberRaw("m-created", any()) } + // And the field values land against that same member, not a new one. + coVerify { api.setMemberFieldValues("m-created", any()) } + } + + @Test fun `a clean save creates exactly one member`() = runTest { + val vm = newMemberViewModel() + advanceUntilIdle() + vm.updateForm { copy(name = "Alex") } + + coEvery { api.createMember(any()) } returns member("m-created") + vm.save() + advanceUntilIdle() + + coVerify(exactly = 1) { api.createMember(any()) } + coVerify(exactly = 0) { api.patchMemberRaw(any(), any()) } + } + + @Test fun `a blank name saves nothing`() = runTest { + val vm = newMemberViewModel() + advanceUntilIdle() + + vm.save() + advanceUntilIdle() + + coVerify(exactly = 0) { api.createMember(any()) } + } +} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModelTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModelTest.kt new file mode 100644 index 0000000..04ff044 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModelTest.kt @@ -0,0 +1,112 @@ +package systems.lupine.sheaf.ui.relationships + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import systems.lupine.sheaf.MainDispatcherRule +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.RelationshipFromViewpoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The load latch. It exists so the editor doesn't refetch on every + * recomposition, but latching a *failed* load meant one offline moment left a + * member's relationships permanently unloadable until you left the screen. + */ +class RelationshipsEditorViewModelTest { + + @get:Rule val mainDispatcher = MainDispatcherRule() + + private val api = mockk(relaxed = true) + + private fun edge(id: String) = RelationshipFromViewpoint( + id = id, + relationshipTypeId = "t1", + typeName = "Partner", + otherId = "m2", + label = "partner", + direction = "none", + mutual = false, + visibility = "private", + ) + + @Test fun `a successful load is latched and not repeated`() = runTest { + coEvery { api.getMemberRelationships("m1") } returns listOf(edge("e1")) + val vm = RelationshipsEditorViewModel(api) + + vm.load(REL_SCOPE_MEMBER, "m1") + advanceUntilIdle() + vm.load(REL_SCOPE_MEMBER, "m1") + advanceUntilIdle() + + coVerify(exactly = 1) { api.getMemberRelationships("m1") } + assertEquals(1, vm.state.value.relationships.size) + } + + @Test fun `a failed load can be retried`() = runTest { + coEvery { api.getMemberRelationships("m1") } throws IllegalStateException("offline") + val vm = RelationshipsEditorViewModel(api) + + vm.load(REL_SCOPE_MEMBER, "m1") + advanceUntilIdle() + assertNotNull(vm.state.value.error) + + // The retry has to actually reach the server: latching the failure meant + // this call was swallowed and the user was stuck on the error forever. + coEvery { api.getMemberRelationships("m1") } returns listOf(edge("e1")) + vm.retry() + advanceUntilIdle() + + coVerify(exactly = 2) { api.getMemberRelationships("m1") } + assertNull(vm.state.value.error) + assertEquals(1, vm.state.value.relationships.size) + } + + @Test fun `switching to another node loads that node`() = runTest { + coEvery { api.getMemberRelationships(any()) } returns emptyList() + val vm = RelationshipsEditorViewModel(api) + + vm.load(REL_SCOPE_MEMBER, "m1") + advanceUntilIdle() + vm.load(REL_SCOPE_MEMBER, "m2") + advanceUntilIdle() + + coVerify(exactly = 1) { api.getMemberRelationships("m1") } + coVerify(exactly = 1) { api.getMemberRelationships("m2") } + } + + @Test fun `the group scope hits the group endpoints`() = runTest { + coEvery { api.getGroupRelationships("g1") } returns emptyList() + val vm = RelationshipsEditorViewModel(api) + + vm.load(REL_SCOPE_GROUP, "g1") + advanceUntilIdle() + + coVerify(exactly = 1) { api.getGroupRelationships("g1") } + coVerify(exactly = 0) { api.getMemberRelationships(any()) } + } + + @Test fun `a reload failure after a successful save is surfaced, not swallowed`() = runTest { + coEvery { api.getMemberRelationships("m1") } returns emptyList() + val vm = RelationshipsEditorViewModel(api) + vm.load(REL_SCOPE_MEMBER, "m1") + advanceUntilIdle() + + coEvery { api.deleteMemberRelationship("e1") } returns Unit + coEvery { api.getMemberRelationships("m1") } throws IllegalStateException("offline") + vm.remove("e1") + advanceUntilIdle() + + // The delete worked; the list on screen is now stale. Say so rather than + // showing the old list as if nothing happened. + assertNotNull(vm.state.value.error) + assertTrue(vm.state.value.error!!.isNotBlank()) + } +} diff --git a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt index fdfbe7a..1150ecb 100644 --- a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt +++ b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearStore.kt @@ -176,12 +176,6 @@ class WearStore( return true } - // A 4xx (other than auth 401/403, timeout 408, rate-limit 429) means the - // request itself is bad and replaying it won't help. Mirrors the phone's - // SyncWorker.isPermanent. - private fun isPermanentSwitchError(code: Int): Boolean = - code in 400..499 && code !in setOf(401, 403, 408, 429) - suspend fun createMember(name: String, displayName: String?, pronouns: String?): WearMember { val member = apiClient.createMember(name, displayName, pronouns) members.value = members.value + member @@ -364,3 +358,15 @@ private fun newestSinceEpoch( runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() } }.maxOrNull() + +/** + * A 4xx (other than auth 401/403, timeout 408, rate-limit 429) means the request + * itself is bad and replaying it won't help: drop the queued switch instead of + * retrying it forever. Misclassify the other way and the user's switch is + * silently deleted and never lands. + * + * Top-level (not a WearStore member) so it is testable without a Context, and it + * mirrors the phone's SyncWorker isPermanentHttpFailure verdict for verdict. + */ +internal fun isPermanentSwitchError(code: Int): Boolean = + code in 400..499 && code !in setOf(401, 403, 408, 429) diff --git a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearSwitchQueue.kt b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearSwitchQueue.kt index bd7200e..995213b 100644 --- a/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearSwitchQueue.kt +++ b/sheaf/wear/src/main/java/systems/lupine/sheaf/wear/data/WearSwitchQueue.kt @@ -125,7 +125,11 @@ internal object WearSwitchQueue { internal fun encode(s: WearQueuedSwitch): String = "${s.uuid}|${s.createdAt}|${if (s.replaceFronts) 1 else 0}|${s.memberIds.joinToString(",")}" - private fun parseLine(line: String): WearQueuedSwitch? { + // internal so the encode/decode round trip can be unit-tested: a bad parse + // here silently drops a queued offline switch, or replays it with the wrong + // member set / replace flag / createdAt (which becomes the front's + // started_at on drain). + internal fun parseLine(line: String): WearQueuedSwitch? { val parts = line.split('|', limit = 4) if (parts.size != 4) return null val uuid = parts[0] diff --git a/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/FrontHistoryCodecTest.kt b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/FrontHistoryCodecTest.kt new file mode 100644 index 0000000..0345ad2 --- /dev/null +++ b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/FrontHistoryCodecTest.kt @@ -0,0 +1,70 @@ +package systems.lupine.sheaf.wear.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The front-history ring buffer is stored as hand-rolled JSON (Moshi is kept + * out of the cross-process tile read path). A parse bug here shows the history + * screen and the timeline tile an empty or garbled list, silently. + */ +class FrontHistoryCodecTest { + + private fun roundTrip(entries: List): List = + parseFrontHistoryJson(frontHistoryToJson(entries)) + + @Test fun `a single entry round trips`() { + val entries = listOf(FrontHistoryEntry(1_700_000_000_000, listOf("m1", "m2"), ongoing = true)) + assertEquals(entries, roundTrip(entries)) + } + + @Test fun `several entries round trip in order`() { + val entries = listOf( + FrontHistoryEntry(1_000, listOf("a")), + FrontHistoryEntry(2_000, listOf("b", "c"), ongoing = true), + FrontHistoryEntry(3_000, listOf("d")), + ) + assertEquals(entries, roundTrip(entries)) + } + + @Test fun `the ongoing flag survives both ways`() { + val ongoing = FrontHistoryEntry(1_000, listOf("a"), ongoing = true) + val ended = FrontHistoryEntry(2_000, listOf("a"), ongoing = false) + assertEquals(listOf(ongoing, ended), roundTrip(listOf(ongoing, ended))) + // The flag is only written when set, so its absence must decode as false. + assertTrue(frontHistoryToJson(listOf(ended)).contains("\"o\"").not()) + } + + @Test fun `an entry with no members survives`() { + // "Nobody is fronting" is a real state and must not be dropped. + val entries = listOf(FrontHistoryEntry(1_000, emptyList())) + assertEquals(entries, roundTrip(entries)) + } + + @Test fun `an empty history round trips`() { + assertEquals(emptyList(), roundTrip(emptyList())) + assertEquals("[]", frontHistoryToJson(emptyList())) + } + + @Test fun `garbage decodes to empty rather than throwing`() { + // Whatever is in prefs came from an older build or a corrupted write; + // the tile renderer must not crash on it. + assertEquals(emptyList(), parseFrontHistoryJson("")) + assertEquals(emptyList(), parseFrontHistoryJson("null")) + assertEquals(emptyList(), parseFrontHistoryJson("{\"t\":1}")) + assertEquals(emptyList(), parseFrontHistoryJson("not json at all")) + } + + @Test fun `a missing timestamp decodes as zero, not a crash`() { + val parsed = parseFrontHistoryJson("""[{"m":["a"]}]""") + assertEquals(1, parsed.size) + assertEquals(0L, parsed[0].timestamp) + assertEquals(listOf("a"), parsed[0].memberIds) + } + + @Test fun `many entries round trip`() { + val entries = (1..MAX_HISTORY).map { FrontHistoryEntry(it * 1_000L, listOf("m$it")) } + assertEquals(entries, roundTrip(entries)) + } +} diff --git a/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearStoreLogicTest.kt b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearStoreLogicTest.kt new file mode 100644 index 0000000..bf9b61d --- /dev/null +++ b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearStoreLogicTest.kt @@ -0,0 +1,58 @@ +package systems.lupine.sheaf.wear.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The watch's drop-vs-keep decision for a failed queued switch. It has to agree + * with the phone's SyncWorker.isPermanentHttpFailure, or the two surfaces treat + * the same server response differently: one drops the switch, the other retries + * it forever. + */ +class WearStoreLogicTest { + + @Test fun `a bad request is permanent`() { + assertTrue(isPermanentSwitchError(400)) + assertTrue(isPermanentSwitchError(404)) + assertTrue(isPermanentSwitchError(409)) + assertTrue(isPermanentSwitchError(422)) + } + + @Test fun `auth, timeout and rate-limit are transient`() { + assertFalse(isPermanentSwitchError(401)) + assertFalse(isPermanentSwitchError(403)) + assertFalse(isPermanentSwitchError(408)) + assertFalse(isPermanentSwitchError(429)) + } + + @Test fun `server errors and non-4xx are transient`() { + assertFalse(isPermanentSwitchError(500)) + assertFalse(isPermanentSwitchError(503)) + assertFalse(isPermanentSwitchError(399)) + assertFalse(isPermanentSwitchError(200)) + } + + @Test fun `member display fallback prefers display name`() { + assertEquals("Ash", wearMember(name = "ashley", displayName = "Ash").displayNameOrName) + assertEquals("ashley", wearMember(name = "ashley", displayName = " ").displayNameOrName) + } + + @Test fun `member initials never come out empty`() { + assertEquals("AB", wearMember("Alex Bell").initials) + assertEquals("A", wearMember("Alex").initials) + assertEquals("?", wearMember(" ").initials) + assertEquals("?", wearMember("").initials) + } + + private fun wearMember(name: String, displayName: String? = null) = WearMember( + id = "m1", + name = name, + displayName = displayName, + description = null, + pronouns = null, + avatarUrl = null, + color = null, + ) +} diff --git a/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearSwitchQueueCodecTest.kt b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearSwitchQueueCodecTest.kt new file mode 100644 index 0000000..e00bd6b --- /dev/null +++ b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/data/WearSwitchQueueCodecTest.kt @@ -0,0 +1,48 @@ +package systems.lupine.sheaf.wear.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The offline switch queue is line-delimited `uuid|createdAt|replace|ids` in + * SharedPreferences. A parse bug drops a queued switch, or replays it with the + * wrong member set / replace flag / createdAt (which becomes the front's + * started_at when the queue drains). + */ +class WearSwitchQueueCodecTest { + + private fun roundTrip(s: WearQueuedSwitch): WearQueuedSwitch? = + WearSwitchQueue.parseLine(WearSwitchQueue.encode(s)) + + @Test fun `a switch round trips`() { + val s = WearQueuedSwitch("u-1", listOf("m1", "m2"), replaceFronts = true, createdAt = 1_700_000_000_000) + assertEquals(s, roundTrip(s)) + } + + @Test fun `the replace flag survives both states`() { + val add = WearQueuedSwitch("u-1", listOf("m1"), replaceFronts = false, createdAt = 1) + val replace = WearQueuedSwitch("u-2", listOf("m1"), replaceFronts = true, createdAt = 2) + assertEquals(false, roundTrip(add)!!.replaceFronts) + assertEquals(true, roundTrip(replace)!!.replaceFronts) + } + + @Test fun `a multi-member switch keeps every member`() { + val s = WearQueuedSwitch("u-1", listOf("m1", "m2", "m3"), replaceFronts = true, createdAt = 5) + assertEquals(listOf("m1", "m2", "m3"), roundTrip(s)!!.memberIds) + } + + @Test fun `a malformed line is dropped, not half-parsed`() { + assertNull(WearSwitchQueue.parseLine("")) + assertNull(WearSwitchQueue.parseLine("u-1|123")) // too few fields + assertNull(WearSwitchQueue.parseLine("u-1|notanumber|1|m1")) // bad createdAt + assertNull(WearSwitchQueue.parseLine("|123|1|m1")) // blank uuid + assertNull(WearSwitchQueue.parseLine("u-1|123|1|")) // no members + } + + @Test fun `an unknown replace token is treated as false, not invalid`() { + // Documenting the current behaviour: only "1" means replace. + val parsed = WearSwitchQueue.parseLine("u-1|123|x|m1") + assertEquals(false, parsed!!.replaceFronts) + } +} diff --git a/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/util/MarkdownStripTest.kt b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/util/MarkdownStripTest.kt new file mode 100644 index 0000000..25ef9b0 --- /dev/null +++ b/sheaf/wear/src/test/java/systems/lupine/sheaf/wear/util/MarkdownStripTest.kt @@ -0,0 +1,55 @@ +package systems.lupine.sheaf.wear.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The watch can't render markdown, so it strips it. Getting the regexes wrong + * either leaks raw `**`/`[]()` syntax into member descriptions or eats content. + */ +class MarkdownStripTest { + + @Test fun `bold and italic are unwrapped`() { + assertEquals("bold", stripMarkdown("**bold**")) + assertEquals("bold", stripMarkdown("__bold__")) + assertEquals("italic", stripMarkdown("*italic*")) + assertEquals("italic", stripMarkdown("_italic_")) + } + + @Test fun `bold is stripped before italic so no stray star is left`() { + assertEquals("both", stripMarkdown("***both***")) + } + + @Test fun `links keep their text and drop the url`() { + assertEquals("my site", stripMarkdown("[my site](https://example.org)")) + } + + @Test fun `images are dropped entirely`() { + assertEquals("", stripMarkdown("![alt](https://example.org/a.png)").trim()) + } + + @Test fun `inline code is unwrapped`() { + assertEquals("code", stripMarkdown("`code`")) + } + + @Test fun `leading block markers are stripped`() { + assertEquals("Heading", stripMarkdown("# Heading")) + assertEquals("quote", stripMarkdown("> quote")) + assertEquals("item", stripMarkdown("- item")) + assertEquals("item", stripMarkdown("1. item")) + } + + @Test fun `plain text is untouched`() { + assertEquals("just a normal sentence", stripMarkdown("just a normal sentence")) + } + + @Test fun `empty input stays empty`() { + assertEquals("", stripMarkdown("")) + } + + @Test fun `a realistic multi-line description flattens cleanly`() { + val input = "**Name**: Alex\n- likes: [tea](https://tea.example)\n> a note" + val output = stripMarkdown(input) + assertEquals("Name: Alex\nlikes: tea\na note", output) + } +}