package de.hoennig.gittally.server import com.ninjasquad.springmockk.MockkBean import de.hoennig.gittally.build.ArtifactStore import de.hoennig.gittally.build.BuildExecutor import de.hoennig.gittally.build.BuildResult import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.RunningBuild import de.hoennig.gittally.config.BranchConfig import de.hoennig.gittally.config.BuildDefinition import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.GitTallyConfig import de.hoennig.gittally.config.GiteaConfig import de.hoennig.gittally.config.ServerConfig import de.hoennig.gittally.git.GitService import de.hoennig.gittally.metrics.MetricAggregate import de.hoennig.gittally.metrics.SystemMetrics import de.hoennig.gittally.metrics.SystemMetricsCollector import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import io.mockk.clearMocks import io.mockk.every import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.not import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest import org.springframework.http.HttpStatus import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.web.server.ResponseStatusException import java.nio.file.Files import java.nio.file.Path import java.time.Duration import java.time.Instant @WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"]) class UiControllerTest : FunSpec() { private val tempDir: Path = Files.createTempDirectory("gittally-ui-test") @Autowired lateinit var mockMvc: MockMvc @MockkBean lateinit var repository: BuildResultRepository @MockkBean lateinit var buildExecutor: BuildExecutor @MockkBean lateinit var artifactStore: ArtifactStore @MockkBean lateinit var controlTokens: ControlTokenService @MockkBean lateinit var configLoader: ConfigLoader @MockkBean lateinit var gitService: GitService @MockkBean lateinit var metricsCollector: SystemMetricsCollector @MockkBean lateinit var branchListing: BranchListing @MockkBean lateinit var branchPermalinks: BranchPermalinks private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val emptySystemMetrics = SystemMetrics( timestamp = null, sampleCount = 0, cpuCount = 8, ramTotalGib = null, diskTotalGib = null, cpuUsed = null, cpuIdle = null, ramUsedGib = null, ramFreeGib = null, diskUsedGib = null, diskFreeGib = null, repoSizeGib = null, ) private val successResult = BuildResult( branch = "main", commit = "0123456789abcdef0123456789abcdef01234567", status = BuildStatus.SUCCESS, startedAt = startedAt, duration = Duration.ofSeconds(83), artifactKey = "main-abc123-key", ) init { beforeEach { clearMocks( repository, buildExecutor, artifactStore, controlTokens, configLoader, gitService, metricsCollector, branchListing, branchPermalinks, ) every { configLoader.load(any()) } returns GitTallyConfig( server = ServerConfig(impressumUrl = "https://example.org/imprint"), gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), ) every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns GitTallyConfig() every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { controlTokens.token() } returns "test-token" every { repository.latestGreenFor(any()) } returns null } test("latest view renders the empty state, and the nav no longer offers the current view") { every { repository.latestPerName() } returns emptyList() mockMvc .perform(get("/")) .andExpect(status().isOk) .andExpect(content().string(containsString("No builds recorded yet."))) .andExpect(content().string(containsString("""data-api="/api/builds/latest""""))) .andExpect(content().string(containsString("""id="reload-button""""))) .andExpect(content().string(not(containsString("""href="/current"""")))) } test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") { every { repository.latestPerName() } returns listOf(successResult) mockMvc .perform(get("/")) .andExpect(status().isOk) .andExpect(content().string(containsString("""status status-success"""))) .andExpect(content().string(containsString("https://git.example.org/acme/widget/src/branch/main"))) .andExpect( content().string(containsString("https://git.example.org/acme/widget/commit/0123456789abcdef0123456789abcdef01234567")), ).andExpect(content().string(containsString("/builds/main-abc123-key"))) .andExpect(content().string(containsString("""data-action="restart""""))) .andExpect(content().string(containsString("""data-action="delete""""))) // the control token must never reach the browser: reading a page is unauthenticated .andExpect(content().string(not(containsString("gittally-control-token")))) .andExpect(content().string(not(containsString("test-token")))) .andExpect(content().string(containsString("https://example.org/imprint"))) .andExpect(content().string(containsString("1:23"))) } test("branches view renders built and never-built branches with restart actions") { every { branchListing.branches(any()) } returns listOf( BranchDto.from("main", "ignored-head", successResult, isLatestGreen = true), BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null), ) mockMvc .perform(get("/branches")) .andExpect(status().isOk) .andExpect(content().string(containsString("status status-success"))) .andExpect(content().string(containsString("status status-unknown"))) .andExpect(content().string(containsString("feature/x"))) .andExpect(content().string(containsString("fedcba987654"))) .andExpect(content().string(containsString("""data-api="/api/branches""""))) .andExpect(content().string(containsString("""data-action="restart""""))) .andExpect(content().string(containsString("""href="/branches/main""""))) .andExpect(content().string(containsString("Permanent link"))) } test("the permanent link shows on the branch's latest green build only, the live link while it runs") { val running = successResult.copy(status = BuildStatus.RUNNING, duration = null, artifactKey = "running-key") every { repository.history() } returns listOf(running, successResult) every { repository.latestGreenFor("main") } returns successResult val page = mockMvc .perform(get("/history")) .andExpect(status().isOk) .andReturn() .response.contentAsString Regex("""href="/branches/main"""").findAll(page).count() shouldBe 1 Regex("""href="/current"""").findAll(page).count() shouldBe 1 page shouldContain "Watch this build live" } test("history view renders mixed history without restart actions") { every { repository.history() } returns listOf( successResult.copy( branch = "main", name = "main", status = BuildStatus.RUNNING, duration = null, artifactKey = "run-key", ), successResult, successResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"), ) mockMvc .perform(get("/history")) .andExpect(status().isOk) .andExpect(content().string(containsString("status status-running"))) .andExpect(content().string(containsString("status status-success"))) .andExpect(content().string(containsString("status status-failed"))) .andExpect(content().string(containsString("feature/x"))) .andExpect(content().string(not(containsString("""data-action="restart"""")))) } test("current view renders a card per running build with cancel button and started-at attribute") { val build = RunningBuild( branch = "main", commit = successResult.commit, artifactKey = "main-abc123-running", startedAt = startedAt, stagingDir = tempDir, liveLogFile = tempDir.resolve("build.log"), ) every { buildExecutor.currentBuilds() } returns listOf(build) every { repository.history() } returns listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = build.artifactKey, duration = null)) mockMvc .perform(get("/current")) .andExpect(status().isOk) .andExpect(content().string(containsString("build-card"))) .andExpect(content().string(containsString("""data-started-at="2026-07-07T10:00:00Z""""))) .andExpect(content().string(containsString("""data-action="cancel""""))) .andExpect(content().string(containsString("status status-running"))) } test("current view renders a clear no-build state") { every { buildExecutor.currentBuilds() } returns emptyList() every { repository.history() } returns emptyList() mockMvc .perform(get("/current")) .andExpect(status().isOk) .andExpect(content().string(containsString("No build is currently running."))) } test("artifact index renders build command, log links, and topmost report index pages") { val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) Files.writeString(artifactDir.resolve("build.stdout.log"), "out") Files.writeString(artifactDir.resolve("build.stderr.log"), "err") Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "") Files.createDirectories(artifactDir.resolve("reports/tests/test/packages")) Files.writeString(artifactDir.resolve("reports/tests/test/packages/index.html"), "") every { repository.history() } returns listOf(successResult) every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir mockMvc .perform(get("/builds/main-abc123-key")) .andExpect(status().isOk) .andExpect(content().string(containsString("./gradlew --console=plain --no-daemon test"))) .andExpect(content().string(containsString("""/artifacts/main-abc123-key/build.stdout.log" target="_blank""""))) .andExpect(content().string(containsString("/artifacts/main-abc123-key/build.stderr.log"))) .andExpect( content().string(containsString("""/artifacts/main-abc123-key/reports/tests/test/index.html" target="_blank"""")), ).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html")))) } test("the artifact page shows the command of the build's own definition, not the plain branch command") { val pitestResult = successResult.copy( build = "pitest", name = "main@pitest", artifactKey = "main-pitest-key", ) every { repository.history() } returns listOf(pitestResult) every { artifactStore.artifactDir("main-pitest-key") } returns null every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns GitTallyConfig( branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")), buildDefinitions = mapOf("pitest" to BuildDefinition(buildCommand = "./gradlew pitestFull")), ) mockMvc .perform(get("/builds/main-pitest-key")) .andExpect(status().isOk) .andExpect(content().string(containsString("./gradlew pitestFull"))) .andExpect(content().string(not(containsString("./gradlew quick-check")))) } test("artifact index links a single index-less report page as a directory, keeping the URL stable") { val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) Files.createDirectories(artifactDir.resolve("reports/profile")) Files.writeString(artifactDir.resolve("reports/profile/profile-2026-08-10-18-36-12.html"), "") Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "") Files.createDirectories(artifactDir.resolve("reports/tests/test/classes")) Files.writeString(artifactDir.resolve("reports/tests/test/classes/SomeTest.html"), "") every { repository.history() } returns listOf(successResult) every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir val page = mockMvc .perform(get("/builds/main-abc123-key")) .andExpect(status().isOk) .andReturn() .response.contentAsString page shouldContain "reports/profile/" page shouldNotContain "profile-2026-08-10-18-36-12.html" page shouldContain "reports/tests/test/index.html" page shouldNotContain "SomeTest.html" } test("artifact index links the pages of an index-less report directory holding several") { val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) Files.createDirectories(artifactDir.resolve("reports/pmd")) Files.writeString(artifactDir.resolve("reports/pmd/main.html"), "") Files.writeString(artifactDir.resolve("reports/pmd/test.html"), "") every { repository.history() } returns listOf(successResult) every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir val page = mockMvc .perform(get("/builds/main-abc123-key")) .andExpect(status().isOk) .andReturn() .response.contentAsString page shouldContain "reports/pmd/main.html" page shouldContain "reports/pmd/test.html" } test("report links carry a failed-badge from the report's failures counter") { val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.writeString( artifactDir.resolve("reports/tests/test/index.html"), """
2

failures

""", ) Files.createDirectories(artifactDir.resolve("reports/tests/unitTest")) Files.writeString( artifactDir.resolve("reports/tests/unitTest/index.html"), """
0

failures

""", ) Files.createDirectories(artifactDir.resolve("reports/jacoco")) Files.writeString(artifactDir.resolve("reports/jacoco/index.html"), "no counter") every { repository.history() } returns listOf(successResult) every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir val page = mockMvc .perform(get("/builds/main-abc123-key")) .andExpect(status().isOk) .andReturn() .response.contentAsString page shouldContain "2 failed" page shouldNotContain "0 failed" // exactly one badge: the report with failures, none for the clean or counter-less reports Regex(""" failed""").findAll(page).count() shouldBe 1 } test("of a failed build, only the logs carrying a failure line get a failed-badge") { val artifactDir = Files.createDirectories(tempDir.resolve("failed-key")) Files.writeString(artifactDir.resolve("build.log"), "compiling\nBUILD FAILED in 20s\n") // a failed test, far away from any BUILD FAILED line Files.writeString(artifactDir.resolve("build.stdout.log"), "SomeTest > works() FAILED\ncompiling\n") Files.writeString(artifactDir.resolve("build.stderr.log"), "warning: this test has failed before\n") every { repository.history() } returns listOf(successResult.copy(status = BuildStatus.FAILED, artifactKey = "failed-key")) every { artifactStore.artifactDir("failed-key") } returns artifactDir val page = mockMvc .perform(get("/builds/failed-key")) .andExpect(status().isOk) .andReturn() .response.contentAsString // two badges for the logs, plus the one of the build status itself Regex(""">failed""").findAll(page).count() shouldBe 3 // lower-case prose about failing is not a failure line page.substringAfter("build.stderr.log").substringBefore("") shouldNotContain "status-failed" } test("logs of a successful build are not scanned for failure lines") { val artifactDir = Files.createDirectories(tempDir.resolve("green-key")) Files.writeString(artifactDir.resolve("build.log"), "BUILD FAILED in a nested build\nBUILD SUCCESSFUL\n") every { repository.history() } returns listOf(successResult.copy(artifactKey = "green-key")) every { artifactStore.artifactDir("green-key") } returns artifactDir val page = mockMvc .perform(get("/builds/green-key")) .andExpect(status().isOk) .andReturn() .response.contentAsString page shouldContain "build.log" page shouldNotContain "status-failed" } test("legacy page names redirect permanently to the new routes") { mockMvc .perform(get("/index.html")) .andExpect(status().isMovedPermanently) .andExpect(header().string("Location", "/")) mockMvc .perform(get("/branches.html")) .andExpect(status().isMovedPermanently) .andExpect(header().string("Location", "/branches")) mockMvc .perform(get("/history.html")) .andExpect(status().isMovedPermanently) .andExpect(header().string("Location", "/history")) } test("release notes page renders the version history and the footer links to it") { mockMvc .perform(get("/releases")) .andExpect(status().isOk) .andExpect(content().string(containsString("Release Notes"))) .andExpect(content().string(containsString("v0.9.0"))) .andExpect(content().string(containsString("Kotlin/Spring Boot"))) // the footer version (rendered on every page) is the link to this page .andExpect(content().string(containsString("""href="/releases""""))) } test("artifact index of a pruned build explains the missing artifacts") { every { repository.history() } returns listOf(successResult) every { artifactStore.artifactDir("main-abc123-key") } returns null mockMvc .perform(get("/builds/main-abc123-key")) .andExpect(status().isOk) .andExpect(content().string(containsString("No log files are stored for this build"))) } test("permanent artifact index renders the latest green build with permanent file links") { val artifactDir = Files.createDirectories(tempDir.resolve("permanent-main-key")) Files.writeString(artifactDir.resolve("build.stdout.log"), "out") Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "") every { branchPermalinks.latestGreenBuild("main") } returns successResult every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir mockMvc .perform(get("/branches/main")) .andExpect(status().isOk) .andExpect(content().string(containsString("latest green build of branch"))) .andExpect(content().string(containsString("""/branches/main/build.stdout.log" target="_blank""""))) .andExpect(content().string(containsString("""/branches/main/reports/tests/test/index.html""""))) .andExpect(content().string(containsString("/builds/main-abc123-key"))) .andExpect(content().string(not(containsString("/artifacts/main-abc123-key")))) } test("permanent artifact index of a branch without a green build answers 404") { every { branchPermalinks.latestGreenBuild("main") } throws ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build") mockMvc .perform(get("/branches/main")) .andExpect(status().isNotFound) } test("artifact index of an unknown key answers 404") { every { repository.history() } returns emptyList() every { artifactStore.artifactDir("no-such-key") } returns null mockMvc .perform(get("/builds/no-such-key")) .andExpect(status().isNotFound) } test("system view renders metric rows, totals, and the polling hook") { every { metricsCollector.snapshot() } returns emptySystemMetrics.copy( timestamp = Instant.parse("2026-07-07T10:00:00Z"), sampleCount = 5, ramTotalGib = 32.0, cpuUsed = MetricAggregate(current = 1.0, min = 0.5, max = 2.0, avg = 1.25), ) mockMvc .perform(get("/system")) .andExpect(status().isOk) .andExpect(content().string(containsString("""data-api="/api/system""""))) .andExpect(content().string(containsString("""data-metric="cpuUsed""""))) .andExpect(content().string(containsString("CPU used (cores)"))) .andExpect(content().string(containsString("1.25"))) .andExpect(content().string(containsString("8 cores"))) .andExpect(content().string(containsString("32.00 GiB"))) } test("system view renders n/a for unavailable metrics") { every { metricsCollector.snapshot() } returns emptySystemMetrics mockMvc .perform(get("/system")) .andExpect(status().isOk) .andExpect(content().string(containsString("Repo size (GiB)"))) .andExpect(content().string(containsString("n/a"))) } test("branch names with HTML metacharacters render escaped") { val nasty = "feat/" every { repository.latestPerName() } returns listOf(successResult.copy(branch = nasty, name = nasty)) mockMvc .perform(get("/")) .andExpect(status().isOk) .andExpect(content().string(not(containsString("