Skip to content

Commit 8b1585a

Browse files
committed
test(v0.6): cover popularity facets, transitive dependencies and verified batch installation
1 parent 827afa2 commit 8b1585a

2 files changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.mods
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.mock.MockEngine
5+
import io.ktor.client.engine.mock.respond
6+
import java.nio.file.Files
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertFailsWith
10+
import kotlin.test.assertTrue
11+
import kotlinx.coroutines.runBlocking
12+
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstance
13+
import pl.syntaxdevteam.syntaxlauncher.core.loader.ModLoaderSelection
14+
15+
class ModrinthCatalogTest {
16+
private val sha = "0123456789012345678901234567890123456789"
17+
private val popular = """{"hits":[{"project_id":"ABCDEFGH","title":"Fast Mod",
18+
"description":"Speeds up graphics","author":"dev","downloads":123456,"icon_url":null}]}"""
19+
20+
private fun version(id: String, project: String, filename: String, dependencies: String = "[]",
21+
game: String = "26.3", loader: String = "fabric", host: String = "cdn.modrinth.com"): String =
22+
"""[{"id":"$id","project_id":"$project","name":"Release","version_number":"1.2.3",
23+
"version_type":"release","game_versions":["$game"],"loaders":["$loader"],
24+
"environment":"client_and_server","date_published":"2026-10-01T00:00:00Z",
25+
"dependencies":$dependencies,"files":[{"filename":"$filename",
26+
"url":"https://$host/data/$project/$filename","size":123,
27+
"hashes":{"sha1":"$sha"},"primary":true}]}]"""
28+
29+
private fun instance() = MinecraftInstance(
30+
id = "ae5a04d1-63b4-4e4c-87c0-3919b8b90e75",
31+
name = "Fabric test", minecraftVersionId = "26.3",
32+
loader = ModLoaderSelection("fabric", "0.18.0"),
33+
)
34+
35+
@Test fun filtersPopularProjectsAndPlansTransitiveRequiredDependencies() = runBlocking {
36+
var checkedFacets = false
37+
val client = HttpClient(MockEngine { req ->
38+
when {
39+
req.url.encodedPath == "/v2/search" -> {
40+
checkedFacets = req.url.parameters["facets"] ==
41+
"""[["project_type:mod"],["categories:fabric"],["versions:26.3"]]""" &&
42+
req.url.parameters["index"] == "downloads"
43+
respond(popular)
44+
}
45+
req.url.encodedPath == "/v2/project/ABCDEFGH" ->
46+
respond("""{"body":"# Fast Mod\nFaster graphics","license":{"id":"MIT"}}""")
47+
req.url.encodedPath == "/v2/project/ABCDEFGH/version" ->
48+
respond(version("RELEASEA", "ABCDEFGH", "fast.jar",
49+
"""[{"project_id":"BCDEFGHI","dependency_type":"required"}]"""))
50+
req.url.encodedPath == "/v2/project/BCDEFGHI/version" ->
51+
respond(version("RELEASEB", "BCDEFGHI", "lib.jar"))
52+
else -> error("Unexpected " + req.url)
53+
}
54+
})
55+
try {
56+
val catalog = ModrinthCatalog(client)
57+
val projects = catalog.search(instance())
58+
assertTrue(checkedFacets)
59+
assertEquals("Fast Mod", projects.single().title)
60+
assertEquals(123456, projects.single().downloads)
61+
assertTrue(catalog.detail(projects.single()).description.contains("Fast Mod"))
62+
val plan = catalog.plan(instance(), projects.single())
63+
assertEquals(listOf("BCDEFGHI", "ABCDEFGH"), plan.entries.map { it.projectId })
64+
assertTrue(plan.entries.first().dependency)
65+
assertTrue(!plan.entries.last().dependency)
66+
assertEquals("fast.jar", plan.entries.last().file.filename)
67+
} finally { client.close() }
68+
}
69+
70+
@Test fun refusesWrongLoaderAndUntrustedCdn() = runBlocking {
71+
val client = HttpClient(MockEngine { req ->
72+
respond(if (req.url.encodedPath.endsWith("/version"))
73+
version("RELEASEA", "ABCDEFGH", "fast.jar", host = "evil.invalid")
74+
else popular)
75+
})
76+
try {
77+
val catalog = ModrinthCatalog(client)
78+
val project = ModrinthProject("ABCDEFGH", "Fast", "", "", 1, null)
79+
assertFailsWith<IllegalStateException> { catalog.plan(instance(), project) }
80+
assertFailsWith<IllegalStateException> {
81+
catalog.search(instance().copy(loader = null))
82+
}
83+
assertTrue(Files.exists(Files.createTempDirectory("catalog-test")))
84+
} finally { client.close() }
85+
}
86+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.mods
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.mock.MockEngine
5+
import io.ktor.client.engine.mock.respond
6+
import java.io.ByteArrayOutputStream
7+
import java.nio.file.Files
8+
import java.nio.file.Path
9+
import java.security.MessageDigest
10+
import java.util.zip.ZipEntry
11+
import java.util.zip.ZipOutputStream
12+
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertFailsWith
15+
import kotlin.test.assertTrue
16+
import kotlinx.coroutines.runBlocking
17+
import pl.syntaxdevteam.syntaxlauncher.core.instance.MinecraftInstanceRepository
18+
import pl.syntaxdevteam.syntaxlauncher.core.loader.ModLoaderSelection
19+
import pl.syntaxdevteam.syntaxlauncher.core.network.DownloadManager
20+
import pl.syntaxdevteam.syntaxlauncher.core.network.DownloadRetryPolicy
21+
import pl.syntaxdevteam.syntaxlauncher.core.storage.LauncherPaths
22+
import pl.syntaxdevteam.syntaxlauncher.core.storage.MinecraftInstallationLayout
23+
24+
class ModrinthInstallerTest {
25+
private fun jar(modId: String): ByteArray {
26+
val bytes = ByteArrayOutputStream()
27+
ZipOutputStream(bytes).use { zip ->
28+
zip.putNextEntry(ZipEntry("fabric.mod.json"))
29+
zip.write("""{"schemaVersion":1,"id":"$modId","name":"$modId","version":"1.0"}""".toByteArray())
30+
zip.closeEntry()
31+
}
32+
return bytes.toByteArray()
33+
}
34+
35+
private fun sha1(bytes: ByteArray): String =
36+
MessageDigest.getInstance("SHA-1").digest(bytes).joinToString("") { "%02x".format(it) }
37+
38+
private fun setup(): Pair<MinecraftInstanceRepository, String> {
39+
val root = Files.createTempDirectory("modrinth-install-test")
40+
val paths = LauncherPaths(root.resolve("data"), root.resolve("config"),
41+
root.resolve("cache"), root.resolve("logs"), root.resolve("runtimes"))
42+
val instances = MinecraftInstanceRepository(paths.dataDir.resolve("instances"),
43+
paths.dataDir.resolve("trash"), MinecraftInstallationLayout.from(paths))
44+
return instances to "26.3"
45+
}
46+
47+
@Test fun installsReviewedDependenciesAsAnInstanceScopedBatch() = runBlocking {
48+
val (instances, game) = setup()
49+
val profile = instances.create("A", game, loader = ModLoaderSelection("fabric", "0.18.0"))
50+
val other = instances.create("B", game, loader = ModLoaderSelection("fabric", "0.18.0"))
51+
val first = jar("dependency")
52+
val second = jar("mainmod")
53+
val client = HttpClient(MockEngine { req ->
54+
respond(if (req.url.encodedPath.endsWith("/dependency.jar")) first else second)
55+
})
56+
try {
57+
val catalog = LocalModManager(instances)
58+
val installer = ModrinthInstaller(instances, catalog,
59+
DownloadManager(client, retryPolicy = DownloadRetryPolicy(maxAttempts = 1)))
60+
val plan = ModrinthInstallPlan(game, "fabric", "ABCDEFGH", listOf(
61+
ModrinthInstallEntry("BCDEFGHI", "RELEASEB", "dep", "1.0",
62+
ModrinthFile("dependency.jar", "https://cdn.modrinth.com/data/dep/dependency.jar",
63+
sha1(first), first.size.toLong()), true),
64+
ModrinthInstallEntry("ABCDEFGH", "RELEASEA", "main", "1.0",
65+
ModrinthFile("main.jar", "https://cdn.modrinth.com/data/main/main.jar",
66+
sha1(second), second.size.toLong()), false),
67+
), emptyList())
68+
val installed = installer.install(profile.id, plan)
69+
assertEquals(setOf("mainmod", "dependency"), installed.mods.mapNotNull { it.metadata?.id }.toSet())
70+
assertTrue(installed.mods.all { it.enabled })
71+
assertTrue(catalog.list(other.id).mods.isEmpty())
72+
assertFailsWith<IllegalStateException> { installer.install(profile.id, plan) }
73+
assertEquals(2, catalog.list(profile.id).mods.size)
74+
} finally { client.close() }
75+
}
76+
77+
@Test fun badChecksumRejectsEntireInstallationWithoutPublishingJars() = runBlocking {
78+
val (instances, game) = setup()
79+
val profile = instances.create("A", game, loader = ModLoaderSelection("fabric", "0.18.0"))
80+
val bytes = jar("mainmod")
81+
val client = HttpClient(MockEngine { respond(bytes) })
82+
try {
83+
val mods = LocalModManager(instances)
84+
val installer = ModrinthInstaller(instances, mods,
85+
DownloadManager(client, retryPolicy = DownloadRetryPolicy(maxAttempts = 1)))
86+
val plan = ModrinthInstallPlan(game, "fabric", "ABCDEFGH",
87+
listOf(ModrinthInstallEntry("ABCDEFGH", "RELEASEA", "main", "1.0",
88+
ModrinthFile("main.jar", "https://cdn.modrinth.com/data/main/main.jar",
89+
"0000000000000000000000000000000000000000", bytes.size.toLong()), false)),
90+
emptyList())
91+
assertFailsWith<Exception> { installer.install(profile.id, plan) }
92+
assertTrue(mods.list(profile.id).mods.isEmpty())
93+
assertTrue(Files.newDirectoryStream(instances.gameDirectory(profile)).use {
94+
it.none { entry -> entry.fileName.toString().startsWith(".modrinth-download-") }
95+
})
96+
} finally { client.close() }
97+
}
98+
}

0 commit comments

Comments
 (0)