diff --git a/docs/adrs/0002-2026-06-09.gradle-version.md b/docs/adrs/0002-2026-06-09.gradle-version.md new file mode 100644 index 0000000..63b1674 --- /dev/null +++ b/docs/adrs/0002-2026-06-09.gradle-version.md @@ -0,0 +1,113 @@ +# Gradle Version + +**Status:** +- proposed: 2026-06-09 +- accepted: - +- rejected: - +- superseded: - + +**Decision [proposed]:** Gradle 8.14 — minimum required by Spring Boot 4.0; Gradle 9 deferred until openapi-generator and ktlint-gradle confirm compatibility. + +## Context and Problem Statement + +GitTally is a greenfield Kotlin/Spring Boot project. +A Gradle version must be chosen for the initial setup. + +[Gradle 9](https://docs.gradle.org/9.3.0/release-notes.html) (currently 9.5.1) is now stable and available. +The question is whether it is the right choice at project start, or whether staying on Gradle 8.x is safer. + +### Technical Background + +**Configuration Cache** is a Gradle feature that serializes the result of the *configuration phase* +(evaluating all build scripts, resolving plugins, building the task graph) so that subsequent +builds can skip it entirely. +In large projects the configuration phase alone can take tens of seconds; +caching it makes every incremental build substantially faster. +In Gradle 8.x Configuration Cache is opt-in. +In Gradle 9 it is stable and **mandatory**: any plugin that reads mutable environment state or +modifies the project model after configuration will fail hard rather than emit a warning. +This enforcement fixed longstanding correctness issues across the ecosystem, +but it also means any plugin that has *not* been updated will break the build outright. + +The Gradle version choice is not isolated to the build tool itself. +It has a direct dependency chain: + +- [Gradle 9](https://docs.gradle.org/current/userguide/compatibility.html) is officially + supported by Spring Boot 4.0 only. + Spring Boot 4.0 also requires Gradle 8.14 as a minimum for the 8.x line. +- [Spring Boot 4.0](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Release-Notes) + requires Spring Framework 7 and Jakarta EE 11 — a generation step up from Spring Boot 3.x + (Spring Framework 6, Jakarta EE 10). +- Plugin ecosystem readiness varies: the + [openapi-generator Gradle plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084) + has a known Gradle 9 issue in its generated build files, and + [ktlint-gradle 12.1.1](https://github.com/JLLeitschuh/ktlint-gradle) Gradle 9 support + needs verification. +- The [`io.spring.dependency-management` plugin](https://github.com/spring-gradle-plugins/dependency-management-plugin) + used to resolve the Spring BOM has known friction with Gradle 9 + and would need to be replaced by a native `platform()` import. + +The Spring Boot version decision (ADR 0003) concluded that Spring Boot 4.0 is required. +Spring Boot 4.0 mandates Gradle 8.14 as minimum, meaning a wrapper upgrade from the +current 8.8 is necessary regardless of whether Gradle 9 is chosen. + +## Considered Options + +* Gradle 8.14 (minimum for Spring Boot 4.0) +* Gradle 9.x + +### [Gradle 8.x](https://docs.gradle.org/8.8/release-notes.html) + +The currently configured series (wrapper at 8.8, to be bumped to 8.14+ for Spring Boot 4.0). + +#### Advantages + +- Plugin compatibility is well understood for ktlint-gradle and openapi-generator-gradle-plugin. +- Configuration Cache is opt-in — teams can enable it incrementally and diagnose failures + without blocking the build. +- Lower risk for adopting spec-first OpenAPI: + the [openapi-generator-gradle-plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084) + has a known Gradle 9 incompatibility in generated build files. + +#### Disadvantages + +- Not on the current Gradle major line; the Configuration Cache benefits are not enforced. +- An eventual migration to Gradle 9 will still be needed. + +### [Gradle 9.x](https://docs.gradle.org/9.3.0/release-notes.html) + +The current major release (9.5.1 as of May 2026), officially supported alongside Spring Boot 4.0. + +#### Advantages + +- Aligns with the latest Spring Boot generation in a fully supported pairing. +- Configuration Cache is mandatory: faster incremental builds and build-script correctness + is enforced across the whole plugin chain — problems surface immediately rather than silently. +- `io.spring.dependency-management` plugin is replaced by cleaner native `platform()` BOM import. +- Greenfield is the easiest moment to adopt it; no migration debt later. + +#### Disadvantages + +- [openapi-generator-gradle-plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084) + has a known Gradle 9 issue in generated `build.gradle` files; impact on the spec-first + code generation task specifically needs verification. +- [ktlint-gradle 12.1.1](https://github.com/JLLeitschuh/ktlint-gradle) Gradle 9 support + needs verification. +- Configuration Cache mandatory means any currently uncached plugin will break the build + until updated, complicating initial setup. + +## Decision Outcome + +**Gradle 8.14** (latest stable 8.x) is chosen over Gradle 9 for the initial setup. + +The main reasons: + +- Spring Boot 4.0 requires Gradle 8.14 as minimum; upgrading from 8.8 is necessary in any case. +- Gradle 9 is technically compatible with Spring Boot 4.0, but the mandatory Configuration Cache + creates unacceptable risk until the openapi-generator-gradle-plugin Gradle 9 compatibility + is confirmed (see [issue #22084](https://github.com/OpenAPITools/openapi-generator/issues/22084)). +- ktlint-gradle 12.1.1 Gradle 9 support is unverified; a formatter breaking the build on day + one is a poor start. + +Upgrade trigger: revisit Gradle 9 once openapi-generator and ktlint-gradle confirm support. +The upgrade itself will be low-risk at that point since the Spring Boot 4.0 step is already done. diff --git a/docs/adrs/0003-2026-06-09.spring-boot-version.md b/docs/adrs/0003-2026-06-09.spring-boot-version.md new file mode 100644 index 0000000..b9c538d --- /dev/null +++ b/docs/adrs/0003-2026-06-09.spring-boot-version.md @@ -0,0 +1,132 @@ +# Spring Boot Version + +**Status:** +- proposed: 2026-06-09 +- accepted: - +- rejected: - +- superseded: - + +**Decision [proposed]:** Spring Boot 4.0 — only actively-supported version; springmockk workaround via @TestConfiguration until a compatible release is published. + +## Context and Problem Statement + +GitTally is a greenfield Kotlin/Spring Boot project. +A Spring Boot version must be chosen for the initial setup. + +The choice is constrained by the support lifecycle: as of June 2026, +almost the entire Spring Boot 3.x release train is already end-of-life. + +### Technical Background + +[Spring Boot support lifecycle](https://endoflife.date/spring-boot) +([HeroDevs April 2026 overview](https://www.herodevs.com/blog-posts/spring-boot-versions-eol-dates-and-latest-releases-april-2026)) +as of June 2026: + +| Version | EOL date | Status | +|---------|----------------|---------------------------------| +| 3.5.x | June 30, 2026 | Expires this month | +| 4.0.x | December 2026 | **Active** (current: 4.0.6) | + +Spring Boot 3.5.x is the final 3.x release; there will be no 3.6. +The Spring project ended the 3.x train at 3.5 and continues in the 4.x line +([Spring Boot milestones](https://github.com/spring-projects/spring-boot/milestones)). +Spring Boot 4.0 moves to Spring Framework 7 and Jakarta EE 11. + +The key ecosystem dependencies and their Spring Boot 4.0 compatibility status: + +| Dependency | Role | Spring Boot 4.0 status | +|---|---|---| +| [picocli-spring-boot-starter](https://picocli.info/) | CLI wiring | Untested; last documented target is Spring Boot 3.1. The integration is minimal (one `IFactory` bean), so it likely works in practice. | +| [springmockk](https://github.com/Ninja-Squad/springmockk) | `@MockkBean` in tests | **Incompatible** — confirmed Spring Boot 3.x only. | +| [kotest-extensions-spring](https://github.com/kotest/kotest-extensions-spring) | Kotest + `@SpringBootTest` | Compatibility unconfirmed. | +| [wiremock-spring-boot](https://wiremock.org/docs/spring-boot/) | HTTP stubbing in tests | **Compatible** — Spring Boot 4.0 explicitly supported since January 2026. | + +## Considered Options + +* Spring Boot 3.5.x (latest 3.x) +* Spring Boot 4.0.x + +### Spring Boot 3.5.x + +The last actively-supported 3.x release. + +#### Advantages + +- Full compatibility with all current dependencies + (picocli-spring-boot-starter, springmockk, kotest-extensions-spring). +- Well-known ecosystem; documentation and community resources are mature. + +#### Disadvantages + +- EOL June 30, 2026 — expires this month. + Starting a project on a version ending within days is indefensible. +- Upgrading to Spring Boot 4.0 immediately after project start incurs migration cost + at the worst possible time (early, before the codebase is stable). + +### [Spring Boot 4.0](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Release-Notes) + +The current and only actively-supported major release. + +#### Advantages + +- The only version receiving security patches and updates beyond June 2026. +- Spring Framework 7 and Jakarta EE 11: active, long-term foundation. +- Aligns with Gradle 9 when that upgrade is eventually made (ADR 0002). +- Wiremock Spring Boot integration fully supports it. + +#### Disadvantages + +- **springmockk is incompatible.** The `@MockkBean` / `@SpykBean` annotations are not available. + This matters in particular for `@WebMvcTest` (controller error-behaviour) and + `@DataJpaTest`-adjacent tests where collaborators need to be mocked in the Spring context. + The workaround is to register MockK mocks as Spring beans via `@TestConfiguration`, + which preserves full MockK syntax (`every { }`, `verify { }`) at the cost of one + inner configuration class per test file and an explicit `clearMocks()` call in `beforeEach`: + ```kotlin + @WebMvcTest(SomeController::class) + @Import(SomeControllerTest.Mocks::class) + class SomeControllerTest : FunSpec() { + @TestConfiguration + class Mocks { + @Bean fun someService(): SomeService = mockk() + } + @Autowired lateinit var someService: SomeService + init { + beforeEach { clearMocks(someService) } + // full MockK syntax available + } + } + ``` + This is more verbose than `@MockkBean` but avoids mixing Mockito and MockK. + Revisit once springmockk publishes a Spring Boot 4.0-compatible release. +- **picocli-spring-boot-starter is untested against Spring Boot 4.0.** + The risk is low (the auto-configuration is a single `IFactory` bean with no framework-version-specific API), + but it cannot be assumed safe until verified by a running context test. +- **kotest-extensions-spring** compatibility needs verification before the first + `@SpringBootTest` test is written. +- Relatively new GA; fewer community examples for the 4.0 generation. + +## Decision Outcome + +**Spring Boot 4.0** is chosen. + +The main reasons: + +- Spring Boot 3.5.x expires this month; starting on it is not a viable option. +- Spring Boot 4.0 is the only release with active support through the foreseeable project lifetime. +- The ecosystem blockers are manageable: + - picocli-spring-boot-starter risk is low and will be confirmed by the existing + context-wiring smoke test (`ApplicationContextTest`). + - springmockk is a test-only dependency; the short-term workaround + (`@TestConfiguration` with `mockk()` beans, `clearMocks()` in `beforeEach`) + preserves full MockK syntax in `@WebMvcTest` and `@DataJpaTest` tests + without falling back to Mockito. + - kotest-extensions-spring compatibility must be verified before the + first `@SpringBootTest` test is written. + +Open items before the code is upgraded: +1. Bump Spring Boot to 4.0.6 and verify `ApplicationContextTest` still passes. +2. Confirm picocli-spring-boot-starter works under the new context. +3. Confirm kotest-extensions-spring works with Spring Boot 4.0. +4. Replace `@MockkBean` usages with the `@TestConfiguration` + `mockk()` pattern + until springmockk publishes a compatible release. diff --git a/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt new file mode 100644 index 0000000..068fd5d --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt @@ -0,0 +1,23 @@ +package de.hoennig.gittally.framework + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +interface Greeter { + fun greet(name: String): String +} + +class MockKSmokeTest : FunSpec({ + + test("mockk stubs and verifies") { + val greeter = mockk() + every { greeter.greet("world") } returns "hello, world" + + greeter.greet("world") shouldBe "hello, world" + + verify(exactly = 1) { greeter.greet("world") } + } +}) diff --git a/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt new file mode 100644 index 0000000..15dfc42 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt @@ -0,0 +1,65 @@ +package de.hoennig.gittally.framework + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import + +interface MessageService { + fun getMessage(): String +} + +/** Workaround for springmockk incompatibility: register MockK mock via @TestConfiguration. */ +@SpringBootTest +@Import(SpringMockKSmokeTest.Mocks::class) +class SpringMockKSmokeTest : FunSpec() { + + @TestConfiguration + class Mocks { + @Bean + fun messageService(): MessageService = mockk() + } + + @Autowired + lateinit var messageService: MessageService + + init { + beforeEach { clearMocks(messageService) } + + test("MockK mock registered as Spring bean can be stubbed and verified") { + every { messageService.getMessage() } returns "hello from mock" + + messageService.getMessage() shouldBe "hello from mock" + + verify(exactly = 1) { messageService.getMessage() } + } + } +} + +/** Verifies whether @MockkBean works with the current Spring Boot version (springmockk 4.0.2). */ +@SpringBootTest +class MockkBeanSmokeTest : FunSpec() { + + @MockkBean + lateinit var messageService: MessageService + + init { + beforeEach { clearMocks(messageService) } + + test("@MockkBean stubs and verifies a Spring-managed MockK mock") { + every { messageService.getMessage() } returns "hello from @MockkBean" + + messageService.getMessage() shouldBe "hello from @MockkBean" + + verify(exactly = 1) { messageService.getMessage() } + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt new file mode 100644 index 0000000..01282ba --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt @@ -0,0 +1,17 @@ +package de.hoennig.gittally.framework + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.testcontainers.containers.GenericContainer +import org.testcontainers.utility.DockerImageName + +class TestcontainersSmokeTest : FunSpec({ + + test("Testcontainers starts a container") { + val container = GenericContainer(DockerImageName.parse("alpine:3")) + .withCommand("sh", "-c", "sleep 30") + container.start() + container.isRunning shouldBe true + container.stop() + } +}) diff --git a/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt new file mode 100644 index 0000000..c85269e --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt @@ -0,0 +1,32 @@ +package de.hoennig.gittally.framework + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +class WireMockSmokeTest : FunSpec({ + + val server = WireMockServer(options().dynamicPort()) + + beforeSpec { server.start() } + afterSpec { server.stop() } + + test("WireMock stubs an HTTP endpoint") { + server.stubFor(get("/ping").willReturn(aResponse().withStatus(200).withBody("pong"))) + + val response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder().uri(URI.create("http://localhost:${server.port()}/ping")).build(), + HttpResponse.BodyHandlers.ofString(), + ) + + response.statusCode() shouldBe 200 + response.body() shouldBe "pong" + } +})