Skip to content

Commit 674eb5f

Browse files
authored
test: add Kotlin JVM instrumentation scaffolding and characterization tests (#36)
1 parent efa6fd1 commit 674eb5f

10 files changed

Lines changed: 187 additions & 12 deletions

File tree

.github/workflows/android-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ jobs:
4444
working-directory: android
4545
run: chmod +x gradlew && ./gradlew :app:assembleDebug --stacktrace
4646

47+
- name: Run JVM unit tests
48+
working-directory: android
49+
run: chmod +x gradlew && ./gradlew :app:testDebugUnitTest --stacktrace
50+
4751
- name: Upload APK artifact
4852
uses: actions/upload-artifact@v4
4953
with:

android/app/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,13 @@ dependencies {
148148
// Debug
149149
debugImplementation("androidx.compose.ui:ui-tooling")
150150
debugImplementation("androidx.compose.ui:ui-test-manifest")
151+
152+
// JVM unit tests
153+
testImplementation("junit:junit:4.13.2")
154+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
155+
156+
// Instrumented tests (configure the runner so connectedAndroidTest doesn't NPE)
157+
androidTestImplementation("androidx.test:runner:1.6.2")
158+
androidTestImplementation("androidx.test.ext:junit:1.1.5")
159+
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
151160
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.masterdns.vpn
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
import org.junit.runner.RunWith
7+
8+
@RunWith(AndroidJUnit4::class)
9+
class RunnerResolvesTest {
10+
@Test
11+
fun runnerClassResolves() {
12+
// The mere fact this test compiles and the runner loads proves
13+
// the androidTestImplementation dependencies are on the classpath.
14+
assertEquals(4, 2 + 2)
15+
}
16+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.masterdns.vpn.ui.settings
2+
3+
internal fun normalizeResolverBalancingStrategy(value: String?, fallback: Int): Int {
4+
val parsed = value?.trim()?.toIntOrNull()
5+
if (parsed != null && parsed in 1..8) return parsed
6+
if (parsed == 0) return 2
7+
return if (fallback in 1..8) fallback else 2
8+
}

android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,6 @@ class SettingsViewModel @Inject constructor(
9999
}
100100
}
101101

102-
private fun normalizeResolverBalancingStrategy(value: String?, fallback: Int): Int {
103-
val parsed = value?.trim()?.toIntOrNull()
104-
if (parsed != null && parsed in 1..8) return parsed
105-
if (parsed == 0) return 2
106-
return if (fallback in 1..8) fallback else 2
107-
}
108-
109102
private fun buildUpdatedProfile(profile: ProfileEntity, values: Map<String, String>): ProfileEntity {
110103
val mergedAdvanced = parseAdvanced(profile.advancedJson).toMutableMap()
111104
values.forEach { (key, value) ->

android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ object ConfigGenerator {
216216
}
217217
}
218218

219-
private fun escapeToml(s: String): String {
219+
internal fun escapeToml(s: String): String {
220220
return s.replace("\\", "\\\\").replace("\"", "\\\"")
221221
}
222222
}

android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,13 @@ object ResolverAnalyzer {
163163
return runCatching { gson.fromJson(json, ResolverImportStats::class.java) }.getOrNull()
164164
}
165165

166-
private data class ResolverEntry(
166+
internal data class ResolverEntry(
167167
val host: String,
168168
val port: Int,
169169
val hasExplicitPort: Boolean
170170
)
171171

172-
private fun parseEntry(line: String): ResolverEntry? {
172+
internal fun parseEntry(line: String): ResolverEntry? {
173173
val text = line.trim()
174174
if (text.isEmpty()) return null
175175
if (text.startsWith("[")) {
@@ -196,7 +196,7 @@ object ResolverAnalyzer {
196196
return ResolverEntry(text, DEFAULT_PORT, false)
197197
}
198198

199-
private fun parseIp(host: String): String? {
199+
internal fun parseIp(host: String): String? {
200200
val text = host.trim()
201201
val numericCandidate = when {
202202
"." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}"))
@@ -207,7 +207,7 @@ object ResolverAnalyzer {
207207
return runCatching { InetAddress.getByName(text).hostAddress }.getOrNull()
208208
}
209209

210-
private fun expandCidr(value: String): List<String>? {
210+
internal fun expandCidr(value: String): List<String>? {
211211
val parts = value.split("/")
212212
if (parts.size != 2) return null
213213
val normalizedBase = parseIp(parts[0].trim()) ?: return null
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.masterdns.vpn
2+
3+
import com.masterdns.vpn.util.ConfigGenerator
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
class ConfigGeneratorTest {
8+
@Test
9+
fun escapeToml_plainStringKeptAsIs() {
10+
assertEquals("hello world", ConfigGenerator.escapeToml("hello world"))
11+
}
12+
13+
@Test
14+
fun escapeToml_quoteIsEscaped() {
15+
assertEquals("a\\\"b", ConfigGenerator.escapeToml("a\"b"))
16+
}
17+
18+
@Test
19+
fun escapeToml_backslashIsEscaped() {
20+
assertEquals("a\\\\b", ConfigGenerator.escapeToml("a\\b"))
21+
}
22+
23+
@Test
24+
fun escapeToml_newlineIsPassedThroughLiteral() {
25+
// Documented current behavior: escapeToml does NOT escape newlines,
26+
// so values containing \n can inject new top-level keys into the
27+
// generated TOML. This test locks in that behavior so plan 008
28+
// (TOML whitelist) can flip it explicitly.
29+
assertEquals("a\nb", ConfigGenerator.escapeToml("a\nb"))
30+
}
31+
32+
@Test
33+
fun escapeToml_emptyString() {
34+
assertEquals("", ConfigGenerator.escapeToml(""))
35+
}
36+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.masterdns.vpn
2+
3+
import com.masterdns.vpn.util.ResolverAnalyzer
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertNotNull
6+
import org.junit.Assert.assertNull
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
10+
class ResolverAnalyzerTest {
11+
private val analyzer = ResolverAnalyzer
12+
13+
@Test
14+
fun parseEntry_ipv4DefaultPort() {
15+
val e = analyzer.parseEntry("1.2.3.4")
16+
assertNotNull(e)
17+
assertEquals("1.2.3.4", e!!.host)
18+
assertEquals(53, e.port)
19+
assertEquals(false, e.hasExplicitPort)
20+
}
21+
22+
@Test
23+
fun parseEntry_ipv4ExplicitPort() {
24+
val e = analyzer.parseEntry("1.2.3.4:5353")
25+
assertNotNull(e)
26+
assertEquals("1.2.3.4", e!!.host)
27+
assertEquals(5353, e.port)
28+
assertEquals(true, e.hasExplicitPort)
29+
}
30+
31+
@Test
32+
fun parseEntry_ipv6BracketedWithPort() {
33+
val e = analyzer.parseEntry("[::1]:53")
34+
assertNotNull(e)
35+
assertEquals("::1", e!!.host)
36+
assertEquals(53, e.port)
37+
}
38+
39+
@Test
40+
fun parseEntry_blankReturnsNull() {
41+
assertNull(analyzer.parseEntry(""))
42+
}
43+
44+
@Test
45+
fun parseEntry_portOutOfRangeFallsThroughToDefaultHostString() {
46+
// Characterization of ACTUAL behavior: parseEntry does not reject
47+
// out-of-range ports as null. For "1.2.3.4:65536" the canHavePort
48+
// branch fails (65536 not in 1..65535) and execution falls through to
49+
// ResolverEntry(text, DEFAULT_PORT, false) — i.e. the whole "1.2.3.4:65536"
50+
// string becomes the host with default port 53 and hasExplicitPort=false.
51+
// This is the behavior plan 008 (TOML validation) will tighten; lock it in.
52+
val e = analyzer.parseEntry("1.2.3.4:65536")
53+
assertNotNull("parseEntry returns non-null for out-of-range port — fall-through to default entry", e)
54+
assertEquals("1.2.3.4:65536", e!!.host)
55+
assertEquals(53, e.port)
56+
assertEquals(false, e.hasExplicitPort)
57+
}
58+
59+
@Test
60+
fun expandCidr_v4Slash30Yields2UsableHosts() {
61+
val hosts = analyzer.expandCidr("192.168.1.0/30") ?: return
62+
// /30 -> hostBits=2 -> total=4. For IPv4 prefixBits<31, usableStart=1,
63+
// usableEndExclusive=total-1=3 -> offsets 1..2 -> 192.168.1.1 and .2.
64+
assertEquals(2, hosts.size)
65+
}
66+
67+
@Test
68+
fun expandCidr_hostBitsGT16ReturnsEmpty() {
69+
// hostBits>16 hits the `if (hostBits > 16) return emptyList()` guard
70+
// in ResolverAnalyzer.kt:220. For IPv4 /8: totalBits=32, prefixBits=8,
71+
// hostBits=24 (>16) -> empty.
72+
val hosts = analyzer.expandCidr("10.0.0.0/8")
73+
assertTrue("expected empty (hostBits=24 > 16 guard)", hosts?.isEmpty() == true)
74+
}
75+
76+
@Test
77+
fun parseIp_normalizes_ipv4() {
78+
assertEquals("1.2.3.4", analyzer.parseIp("1.2.3.4"))
79+
}
80+
81+
@Test
82+
fun parseIp_rejectsNonNumeric() {
83+
assertNull(analyzer.parseIp("example.com"))
84+
}
85+
86+
@Test
87+
fun analyzeAndNormalize_truncatedLargeInput() {
88+
// MAX_IMPORT_BYTES = 2*1024*1024 = 2097152. Each "1.1.1.1\n" is 8 UTF-8
89+
// bytes (incl. the trailing newline). Use 300_000 repetitions ->
90+
// 300_000 * 8 = 2_400_000 bytes > 2_097_152 -> early-return with
91+
// truncated=true and empty normalizedText.
92+
val big = "1.1.1.1\n".repeat(300_000)
93+
val result = analyzer.analyzeAndNormalize(big, "big.txt")
94+
assertNotNull(result)
95+
assertEquals(true, result!!.stats.truncated)
96+
}
97+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.masterdns.vpn.ui.settings
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
class ResolverBalancingStrategiesTest {
7+
@Test fun validValueUsesIt() { assertEquals(5, normalizeResolverBalancingStrategy("5", 2)) }
8+
@Test fun zeroFallsBackTo2() { assertEquals(2, normalizeResolverBalancingStrategy("0", 3)) }
9+
@Test fun nonNumericFallsBack() { assertEquals(3, normalizeResolverBalancingStrategy("abc", 3)) }
10+
@Test fun outOfRangeFallsBack() { assertEquals(2, normalizeResolverBalancingStrategy("99", 2)) }
11+
@Test fun nullAndInvalidFallbackYields2() { assertEquals(2, normalizeResolverBalancingStrategy(null, 99)) }
12+
}

0 commit comments

Comments
 (0)