feat(server): Routen, Seiten und Artefakte tragen das Repository — /repos/<name>/… (Sitzung D)
Zweite Hälfte von Sitzung D (docs/plan/22-multi-repo.md, PR #13): Der Server bediente bisher genau ein Repository der Registry. Jede Route — API, Seiten, Artefakt-Dateien — arbeitete auf `registry.current()`; ein zweites registriertes Repository wurde gebaut und gepollt, war aber unsichtbar und unerreichbar. Jeder Controller löst sein Repository jetzt je Anfrage auf, statt das bediente als Bohne zu halten; jede Route ist zweimal gemappt. Die unscoped Form ist kein Übergangs-Alias, sondern dauerhaft die Art zu sagen „das bediente Repository" — Lesezeichen und die nach Gitea geposteten Links kennen kein Segment. Entschieden gegen die Repo-Spalte: Die Seiten bleiben je Repository, die Navigation bekommt einen Umschalter. Die Aktionen einer Zeile brauchen das Repository ohnehin, Branches kommen von einem origin und Artefakte aus einem Store — und bei dem einen Repository, das die meisten Installationen haben, wäre eine Spalte nur Rauschen. Das Link-Präfix folgt der ZAHL der bedienten Repositories, nicht dem Weg, über den eine Seite erreicht wurde: mit einem behält die Installation ihre bisherigen URLs (Abnahmekriterium der Sitzung), mit mehreren benennt jeder Link sein Repository. werkator.js liest das Präfix einmal aus einem `werkator-repo-base`-Meta. `BranchPermalinks.permanentUrl` bekommt es ebenfalls — der permanente Schlüssel ist ein Hash des Build-Namens allein, zwei Repositories mit `main` teilten sich sonst eine permanente URL. Fünf neue Tests, Gegenprobe per Mutation gezogen (Präfix fest auf leer → genau der Mehr-Repo-Test fällt). 498 Tests grün, ktlint sauber. PR-Dokument docs/prs/2026-09-03-PR#13-…, Plan, Architektur-Skill und AGENTS.md nachgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4304dd7c4b
commit
c82f2a965c
@@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var branchPermalinks: BranchPermalinks
|
||||
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
||||
|
||||
private val greenBuild =
|
||||
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(artifactStore, branchPermalinks)
|
||||
clearMocks(artifactStore, branchPermalinks, repo, registry)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
every { registry.current() } returns repo
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { artifactStore.artifactDir(any()) } returns null
|
||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
||||
every { branchPermalinks.latestGreenBuild(any()) } throws
|
||||
every { branchPermalinks.latestGreenBuild(any(), any()) } throws
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||
}
|
||||
|
||||
test("serves an html artifact with no-cache headers") {
|
||||
@@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("permanent URL answers 404 when the green build's artifacts are gone") {
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/branches/main/build.log"))
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -17,7 +18,8 @@ import java.time.Instant
|
||||
|
||||
class BranchPermalinksTest : FunSpec() {
|
||||
private val repository = mockk<BuildResultRepository>()
|
||||
private val permalinks = BranchPermalinks(repository)
|
||||
private val repo = mockk<RepoContext>().also { every { it.results } returns repository }
|
||||
private val permalinks = BranchPermalinks()
|
||||
|
||||
private fun result(
|
||||
branch: String,
|
||||
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||
|
||||
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
|
||||
permalinks.latestGreenBuild(repo, "feature_x") shouldBe result("feature/x")
|
||||
}
|
||||
|
||||
test("resolves the full branch key with hash suffix") {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||
|
||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
}
|
||||
|
||||
test("an unknown branch key answers 404") {
|
||||
every { repository.latestPerName() } returns listOf(result("main"))
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "gone") }
|
||||
|
||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||
}
|
||||
@@ -58,7 +60,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||
every { repository.latestGreenFor("main") } returns null
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "main") }
|
||||
|
||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||
}
|
||||
@@ -66,7 +68,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
test("a permanent key matching several branches answers 409 and names the candidates") {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "feature_x") }
|
||||
|
||||
exception.statusCode shouldBe HttpStatus.CONFLICT
|
||||
exception.reason.orEmpty() shouldContain "feature/x"
|
||||
@@ -76,7 +78,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||
|
||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
}
|
||||
|
||||
test("permanentUrl uses the hash-free branch key") {
|
||||
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestGreenFor("main@nightly") } returns nightly
|
||||
|
||||
// sanitized like any branch key: the '@' becomes '_' in the URL
|
||||
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
|
||||
permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.hoennig.werkator.config.WerkatorConfig
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -70,6 +71,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
||||
|
||||
private val greenBuild =
|
||||
@@ -95,14 +99,22 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
branchListing,
|
||||
branchPermalinks,
|
||||
repo,
|
||||
registry,
|
||||
)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.workingDir } returns Paths.get(".")
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { configLoader.load(any()) } returns WerkatorConfig()
|
||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
||||
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
||||
every { controlTokens.token() } returns "test-token"
|
||||
every { branchListing.branches(any()) } returns emptyList()
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||
every { artifactStore.artifactDir("main-key") } returns artifactDir
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,14 @@ import de.hoennig.werkator.metrics.MetricAggregate
|
||||
import de.hoennig.werkator.metrics.SystemMetrics
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
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 io.mockk.mockk
|
||||
import org.hamcrest.Matchers.containsString
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -78,6 +80,9 @@ class UiControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val emptySystemMetrics =
|
||||
@@ -119,8 +124,16 @@ class UiControllerTest : FunSpec() {
|
||||
branchListing,
|
||||
branchPermalinks,
|
||||
repo,
|
||||
registry,
|
||||
)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.workingDir } returns Paths.get(".")
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { configLoader.load(any()) } returns
|
||||
WerkatorConfig(
|
||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||
@@ -144,6 +157,40 @@ class UiControllerTest : FunSpec() {
|
||||
.andExpect(content().string(not(containsString("""href="/current""""))))
|
||||
}
|
||||
|
||||
test("with one served repository the pages keep their existing URLs and show no switcher") {
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(containsString("""href="/branches"""")))
|
||||
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
|
||||
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
|
||||
.andExpect(content().string(not(containsString("""class="repo-switch""""))))
|
||||
}
|
||||
|
||||
test("with several served repositories every link names its repository and the switcher appears") {
|
||||
val other = mockk<RepoContext>()
|
||||
every { other.name } returns "other"
|
||||
every { registry.all() } returns listOf(repo, other)
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/repos/test"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
|
||||
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
|
||||
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
|
||||
.andExpect(content().string(containsString("""class="repo-switch"""")))
|
||||
.andExpect(content().string(containsString("""href="/repos/other"""")))
|
||||
}
|
||||
|
||||
test("a page of a repository this instance does not serve answers 404") {
|
||||
mockMvc
|
||||
.perform(get("/repos/no-such-repo"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
@@ -503,7 +550,7 @@ class UiControllerTest : FunSpec() {
|
||||
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
||||
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
||||
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns successResult
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult
|
||||
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
||||
|
||||
mockMvc
|
||||
@@ -517,7 +564,7 @@ class UiControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("permanent artifact index of a branch without a green build answers 404") {
|
||||
every { branchPermalinks.latestGreenBuild("main") } throws
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } throws
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
||||
|
||||
mockMvc
|
||||
|
||||
Reference in New Issue
Block a user