feat(update): add update detection and notification on status screen (WB-008)

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
Rukira 2026-08-25 10:30:52 +01:00
parent 02cbf20946
commit 613ceb01b7
10 changed files with 557 additions and 8 deletions

View file

@ -0,0 +1,67 @@
package com.rukira.wowbackup.ui.status
import com.rukira.wowbackup.update.UpdateInfo
import com.rukira.wowbackup.update.UpdateService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class)
class StatusViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testViewModelReceivesUpdateInfo() = runTest(testDispatcher) {
val mockService = object : UpdateService {
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo {
return UpdateInfo(
latestVersion = "1.0.2",
releaseUrl = "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2",
)
}
}
val viewModel = StatusViewModel(updateService = mockService)
advanceUntilIdle()
val state = viewModel.state.first { it.updateInfo != null }
assertNotNull(state.updateInfo)
assertEquals("1.0.2", state.updateInfo!!.latestVersion)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", state.updateInfo!!.releaseUrl)
}
@Test
fun testViewModelHandlesNoUpdate() = runTest(testDispatcher) {
val mockService = object : UpdateService {
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = null
}
val viewModel = StatusViewModel(updateService = mockService)
advanceUntilIdle()
val state = viewModel.state.value
assertNull(state.updateInfo)
}
}

View file

@ -0,0 +1,97 @@
package com.rukira.wowbackup.update
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SemVerTest {
@Test
fun testParseStandardVersions() {
val v1 = SemVer.parseOrNull("1.0.0")
assertNotNull(v1)
assertEquals(1, v1.major)
assertEquals(0, v1.minor)
assertEquals(0, v1.patch)
assertNull(v1.preRelease)
val v2 = SemVer.parseOrNull("v1.2.3")
assertNotNull(v2)
assertEquals(1, v2.major)
assertEquals(2, v2.minor)
assertEquals(3, v2.patch)
assertNull(v2.preRelease)
val v3 = SemVer.parseOrNull("V2.0")
assertNotNull(v3)
assertEquals(2, v3.major)
assertEquals(0, v3.minor)
assertEquals(0, v3.patch)
val v4 = SemVer.parseOrNull("0.0.1")
assertNotNull(v4)
assertEquals(0, v4.major)
assertEquals(0, v4.minor)
assertEquals(1, v4.patch)
}
@Test
fun testParsePreReleaseVersions() {
val v = SemVer.parseOrNull("1.0.0-beta.1")
assertNotNull(v)
assertEquals(1, v.major)
assertEquals(0, v.minor)
assertEquals(0, v.patch)
assertEquals("beta.1", v.preRelease)
}
@Test
fun testParseInvalidVersions() {
assertNull(SemVer.parseOrNull(null))
assertNull(SemVer.parseOrNull(""))
assertNull(SemVer.parseOrNull(" "))
assertNull(SemVer.parseOrNull("invalid"))
assertNull(SemVer.parseOrNull("1.a.3"))
assertNull(SemVer.parseOrNull("1.2.3.4"))
}
@Test
fun testVersionComparison() {
val v001 = SemVer.parseOrNull("0.0.1")!!
val v100 = SemVer.parseOrNull("1.0.0")!!
val v101 = SemVer.parseOrNull("1.0.1")!!
val v102 = SemVer.parseOrNull("v1.0.2")!!
val v110 = SemVer.parseOrNull("1.1.0")!!
val v200 = SemVer.parseOrNull("2.0.0")!!
assertTrue(v100.isNewerThan(v001))
assertTrue(v101.isNewerThan(v100))
assertTrue(v102.isNewerThan(v101))
assertTrue(v110.isNewerThan(v102))
assertTrue(v200.isNewerThan(v110))
assertFalse(v101.isNewerThan(v102))
assertFalse(v102.isNewerThan(SemVer.parseOrNull("1.0.2")!!))
}
@Test
fun testPreReleaseComparison() {
val release = SemVer.parseOrNull("1.0.0")!!
val preRelease = SemVer.parseOrNull("1.0.0-alpha")!!
val preReleaseBeta = SemVer.parseOrNull("1.0.0-beta")!!
assertTrue(release.isNewerThan(preRelease))
assertFalse(preRelease.isNewerThan(release))
assertTrue(preReleaseBeta.isNewerThan(preRelease))
}
@Test
fun testToString() {
assertEquals("1.0.0", SemVer.parseOrNull("1.0.0")?.toString())
assertEquals("1.2.0", SemVer.parseOrNull("v1.2")?.toString())
assertEquals("1.0.0-rc1", SemVer.parseOrNull("1.0.0-rc1")?.toString())
}
}

View file

@ -0,0 +1,172 @@
package com.rukira.wowbackup.update
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import java.net.InetSocketAddress
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class UpdateCheckerTest {
private lateinit var server: HttpServer
private var serverPort: Int = 0
@BeforeTest
fun setUp() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
serverPort = server.address.port
server.start()
}
@AfterTest
fun tearDown() {
server.stop(0)
}
@Test
fun testUpdateAvailableWhenRemoteIsNewer() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
)
val result = checker.checkForUpdate(currentVersion = "1.0.1")
assertNotNull(result)
assertEquals("1.0.2", result.latestVersion)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", result.releaseUrl)
}
@Test
fun testNoUpdateWhenUpToDate() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.2")
assertNull(result)
}
@Test
fun testNoUpdateWhenRunningNewerVersion() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "2.0.0")
assertNull(result)
}
@Test
fun testHttpErrorGracefullyReturnsNull() = runBlocking {
server.createContext("/releases/latest") { exchange ->
exchange.sendResponseHeaders(404, 0)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNull(result)
}
@Test
fun testMalformedJsonGracefullyReturnsNull() = runBlocking {
server.createContext("/releases/latest") { exchange ->
val bytes = "Not valid JSON".toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNull(result)
}
@Test
fun testFallbackUrlUsedWhenHtmlUrlMissing() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNotNull(result)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases", result.releaseUrl)
}
}