From 904197777bddbdcf8455e1b426aec17a1c1434d6 Mon Sep 17 00:00:00 2001 From: whtis Date: Thu, 4 Jun 2026 01:23:39 +0800 Subject: [PATCH] test(network): cover MemoRelationAdapter for v0.24/v0.25/v0.26 parsing `MemoRelationAdapter` is the seam between the Memos server's three different on-the-wire shapes for memo relations: - v0.24 sometimes ships `memo` / `relatedMemo` as bare strings - v0.24/v0.25 ship `type` as a numeric proto enum (1 = REFERENCE, 2 = COMMENT) - v0.26 ships `memo` / `relatedMemo` as objects with name/uid/snippet and `type` as a string A regression in this adapter silently breaks comment threads, reference chips, and the memo detail page across every supported server version, but until now it had zero test coverage. Adds 10 unit tests pinning every parsing branch (string / object / numeric / null memo fields; string / numeric / unknown / null type fields; snake_case alias; unknown-field skip; round-trip toJson), plus the JUnit test-implementation wiring needed for `:core:network` to host them. No production code changes. --- core/network/build.gradle.kts | 2 + .../network/dto/MemoRelationAdapterTest.kt | 195 ++++++++++++++++++ gradle/libs.versions.toml | 6 + 3 files changed, 203 insertions(+) create mode 100644 core/network/src/test/java/com/whtis/memosly/core/network/dto/MemoRelationAdapterTest.kt diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 228119b..51a148a 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -21,4 +21,6 @@ dependencies { ksp(libs.moshi.codegen) implementation(libs.kotlinx.coroutines.android) + + testImplementation(libs.junit) } diff --git a/core/network/src/test/java/com/whtis/memosly/core/network/dto/MemoRelationAdapterTest.kt b/core/network/src/test/java/com/whtis/memosly/core/network/dto/MemoRelationAdapterTest.kt new file mode 100644 index 0000000..1abdc64 --- /dev/null +++ b/core/network/src/test/java/com/whtis/memosly/core/network/dto/MemoRelationAdapterTest.kt @@ -0,0 +1,195 @@ +package com.whtis.memosly.core.network.dto + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Covers the three on-the-wire shapes a Memos server can produce for + * `MemoRelation`: + * + * - v0.24 (sometimes): `memo` / `relatedMemo` are bare strings ("memos/123") + * - v0.24/v0.25: `type` is a numeric proto enum (1 = REFERENCE, 2 = COMMENT) + * - v0.26: `memo` / `relatedMemo` are objects with name/uid/snippet; `type` is a string + * + * A regression in this adapter silently breaks comment threads, reference + * chips, and the memo detail page across every supported server version, + * so the parsing branches are pinned with explicit fixtures. + */ +class MemoRelationAdapterTest { + + private val moshi = Moshi.Builder() + .add(MemoRelationAdapter()) + .add(KotlinJsonAdapterFactory()) + .build() + + private val adapter = moshi.adapter(MemoRelationDto::class.java) + + @Test + fun `v026 object shape with string type parses fully`() { + val json = """ + { + "memo": {"name": "memos/abc", "uid": "abc", "snippet": "hello"}, + "relatedMemo": {"name": "memos/xyz", "uid": "xyz", "snippet": "world"}, + "type": "COMMENT" + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("memos/abc", dto.memo.name) + assertEquals("abc", dto.memo.uid) + assertEquals("hello", dto.memo.snippet) + assertEquals("memos/xyz", dto.relatedMemo.name) + assertEquals("xyz", dto.relatedMemo.uid) + assertEquals("world", dto.relatedMemo.snippet) + assertEquals("COMMENT", dto.type) + } + + @Test + fun `v024 string shape lifts bare names into RelatedMemoInfoDto`() { + val json = """ + { + "memo": "memos/abc", + "relatedMemo": "memos/xyz", + "type": "REFERENCE" + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("memos/abc", dto.memo.name) + assertEquals("", dto.memo.uid) + assertEquals("", dto.memo.snippet) + assertEquals("memos/xyz", dto.relatedMemo.name) + assertEquals("REFERENCE", dto.type) + } + + @Test + fun `numeric type 1 maps to REFERENCE`() { + val json = """ + { + "memo": {"name": "memos/abc"}, + "relatedMemo": {"name": "memos/xyz"}, + "type": 1 + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("REFERENCE", dto.type) + } + + @Test + fun `numeric type 2 maps to COMMENT`() { + val json = """ + { + "memo": {"name": "memos/abc"}, + "relatedMemo": {"name": "memos/xyz"}, + "type": 2 + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("COMMENT", dto.type) + } + + @Test + fun `unknown numeric type falls back to TYPE_UNSPECIFIED`() { + val json = """ + { + "memo": {"name": "memos/abc"}, + "relatedMemo": {"name": "memos/xyz"}, + "type": 99 + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("TYPE_UNSPECIFIED", dto.type) + } + + @Test + fun `numeric memo id is rewritten into resource-name form`() { + val json = """ + { + "memo": 42, + "relatedMemo": 7, + "type": "REFERENCE" + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("memos/42", dto.memo.name) + assertEquals("memos/7", dto.relatedMemo.name) + } + + @Test + fun `null memo fields yield empty RelatedMemoInfoDto without crashing`() { + val json = """ + { + "memo": null, + "relatedMemo": null, + "type": null + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("", dto.memo.name) + assertEquals("", dto.relatedMemo.name) + assertEquals("", dto.type) + } + + @Test + fun `snake_case related_memo alias is accepted`() { + val json = """ + { + "memo": {"name": "memos/abc"}, + "related_memo": {"name": "memos/xyz"}, + "type": "COMMENT" + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("memos/xyz", dto.relatedMemo.name) + } + + @Test + fun `unknown json fields are skipped, not failed on`() { + val json = """ + { + "memo": {"name": "memos/abc", "futureField": "ignore-me"}, + "relatedMemo": {"name": "memos/xyz"}, + "type": "REFERENCE", + "audit": {"createdBy": "system"} + } + """.trimIndent() + + val dto = adapter.fromJson(json)!! + + assertEquals("memos/abc", dto.memo.name) + assertEquals("REFERENCE", dto.type) + } + + @Test + fun `toJson emits object shape and omits blank optional fields`() { + val dto = MemoRelationDto( + memo = RelatedMemoInfoDto(name = "memos/abc", uid = "abc", snippet = ""), + relatedMemo = RelatedMemoInfoDto(name = "memos/xyz", uid = "", snippet = ""), + type = "REFERENCE", + ) + + val json = adapter.toJson(dto) + + // memo has uid but no snippet; relatedMemo has neither + assertEquals( + """{"memo":{"name":"memos/abc","uid":"abc"},"relatedMemo":{"name":"memos/xyz"},"type":"REFERENCE"}""", + json, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c947e66..46cca08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,9 @@ google-services = "4.4.2" # Markdown commonmark = "0.24.0" +# Testing +junit = "4.13.2" + [libraries] # AndroidX Core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -117,6 +120,9 @@ firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics # Coroutines kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +# Testing +junit = { group = "junit", name = "junit", version.ref = "junit" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }