Plan phase 1

This commit is contained in:
2026-04-24 16:21:25 +02:00
parent 7ac1555a4c
commit d6cec3fe07
9 changed files with 4221 additions and 1 deletions

View File

@@ -0,0 +1,498 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 05
type: execute
wave: 2
depends_on: [01, 02]
files_modified:
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/resources/application.conf
- server/src/main/resources/db/migration/.gitkeep
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
autonomous: true
requirements: [INFRA-02]
requirements_addressed: [INFRA-02]
must_haves:
truths:
- "GET /health returns 200 with Content-Type: application/json and body {\"status\":\"ok\"} (D-16)"
- "Server reads database.url / database.user / database.password from application.conf, with localhost defaults and env overrides via HOCON ${?X} syntax (PITFALL #5)"
- "Flyway runs Flyway.configure().dataSource(url, user, password).locations(\"classpath:db/migration\").load().migrate() during Application.module() startup"
- "Server fails loudly with IllegalStateException if Postgres is unreachable — the exception is thrown from Database.migrate() and NOT swallowed"
- "server/src/main/resources/db/migration/ directory exists (with .gitkeep) so Flyway.locations classpath resolution finds it even when empty"
- "ApplicationTest.kt has a test named 'health endpoint returns 200 with status ok' (or similar) that does NOT require a running Postgres — it composes routing in isolation"
- "Application.kt uses explicit Ktor imports (no wildcard imports) so D-11 allWarningsAsErrors is satisfied"
artifacts:
- path: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
provides: "main() → embeddedServer(Netty, SERVER_PORT, ::module).start(); Application.module() installs ContentNegotiation(json), invokes Database.migrate(this), and registers GET /health"
exports: ["main", "Application.module"]
- path: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt"
provides: "object Database { fun migrate(app: Application) } — reads HOCON config, runs Flyway, throws IllegalStateException on failure"
exports: ["Database"]
- path: "server/src/main/resources/application.conf"
provides: "HOCON config with ktor.deployment.port (8080 + ${?PORT}) and database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})"
- path: "server/src/main/resources/db/migration/.gitkeep"
provides: "Empty directory placeholder ensuring classpath:db/migration resolves for Flyway even when no SQL files exist yet"
- path: "server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt"
provides: "ApplicationTest with /health route assertion — composes routing without calling Database.migrate (no Postgres required)"
key_links:
- from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
to: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt"
via: "Database.migrate(this) inside Application.module()"
pattern: "Database\\.migrate\\(this\\)"
- from: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt"
to: "server/src/main/resources/application.conf"
via: "app.environment.config.property(\"database.url\").getString() etc."
pattern: "config\\.property\\(\"database\\."
- from: "Flyway.configure().locations(...)"
to: "server/src/main/resources/db/migration/"
via: "classpath:db/migration"
pattern: "classpath:db/migration"
---
<objective>
Deliver the server's running-but-empty state: a `GET /health` route returning `{"status":"ok"}`, HOCON-based config (`application.conf`) with env-var overrides, a `Database` object that runs Flyway against Postgres at boot time (failing loudly if Postgres is unreachable), and an updated `ApplicationTest.kt` that asserts the route in isolation without requiring a running database. Also scaffold `server/src/main/resources/db/migration/` as an empty directory so Flyway's classpath resolution succeeds before Phase 3 adds `V1__init.sql`.
Purpose: This plan closes D-16 — Phase 3 drops its first migration into an already-working migrator; Phase 11 deploys to the homelab with the same Ktor HOCON config reading real env vars. The fail-loud contract for unreachable Postgres is load-bearing: it surfaces config errors at boot, not at first 5xx.
Output: 2 Kotlin source files (Application.kt rewrite + Database.kt new), 1 HOCON config, 1 directory placeholder, 1 test rewrite.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
@shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt
@CLAUDE.md
<interfaces>
<!-- Ktor 3.4.1 APIs (already in recipe.jvm.server via libs.ktor.*) -->
From io.ktor.server.application:
```kotlin
interface Application
interface ApplicationEnvironment {
val config: ApplicationConfig
}
interface ApplicationConfig {
fun property(path: String): ApplicationConfigValue
fun propertyOrNull(path: String): ApplicationConfigValue?
}
interface ApplicationConfigValue {
fun getString(): String
}
```
From io.ktor.server.engine + io.ktor.server.netty:
```kotlin
fun embeddedServer(factory: ApplicationEngineFactory<...>, port: Int, host: String, module: Application.() -> Unit): EmbeddedServer
object Netty : ApplicationEngineFactory<...>
```
From io.ktor.server.plugins.contentnegotiation + io.ktor.serialization.kotlinx.json:
```kotlin
object ContentNegotiation : BaseApplicationPlugin<...>
fun ContentNegotiationConfig.json() // installs kotlinx.serialization JSON converter
```
From io.ktor.server.routing + io.ktor.server.response:
```kotlin
fun Application.routing(block: Route.() -> Unit)
fun Route.get(path: String, handler: suspend RoutingContext.() -> Unit)
suspend fun ApplicationCall.respond(message: Any)
```
From io.ktor.server.testing (in testImplementation via recipe.jvm.server):
```kotlin
fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit)
// ApplicationTestBuilder provides:
fun application(block: Application.() -> Unit)
val client: HttpClient
```
From org.flywaydb.core:
```kotlin
object Flyway {
fun configure(): FluentConfiguration
}
// FluentConfiguration:
fun dataSource(url: String, user: String, password: String): FluentConfiguration
fun locations(vararg locations: String): FluentConfiguration
fun baselineOnMigrate(b: Boolean): FluentConfiguration
fun validateOnMigrate(b: Boolean): FluentConfiguration
fun cleanDisabled(b: Boolean): FluentConfiguration
fun load(): Flyway
// Flyway instance:
fun migrate(): MigrateResult
```
From kotlinx.serialization:
```kotlin
@Serializable
```
From org.slf4j:
```kotlin
object LoggerFactory {
fun getLogger(clazz: Class<*>): Logger
}
// org.slf4j.Logger: .info(msg: String, vararg args: Any), .error(msg: String, t: Throwable)
```
From shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (DO NOT modify):
```kotlin
package dev.ulfrx.recipe
const val SERVER_PORT: Int = 8080 // or whatever current value is
```
Current server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (to replace):
```kotlin
package dev.ulfrx.recipe
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) }
fun Application.module() { routing { get("/") { call.respondText("Ktor: ${Greeting().greet()}") } } }
```
Current server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (to replace):
```kotlin
// testRoot() asserts GET / returns "Ktor: ${Greeting().greet()}" — to be replaced with /health assertion
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create application.conf + db/migration/.gitkeep + Database.kt</name>
<files>server/src/main/resources/application.conf, server/src/main/resources/db/migration/.gitkeep, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt</files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 988-1023 (canonical Database.kt — SLF4J variant since server uses Logback not Kermit)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1029-1051 (canonical application.conf HOCON)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 692-717 (PITFALL #5`${?X}` env-var HOCON syntax)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 719-724 (PITFALL #6 — Flyway runtime API, not plugin at build time)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 990-1076 (Database.kt + application.conf + .gitkeep deltas)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server /health + Flyway + Postgres env overrides)
- server/build.gradle.kts (verify Plan 03 made `implementation(projects.shared)` present so `SERVER_PORT` is still reachable)
</read_first>
<action>
Create three files.
**File 1: `server/src/main/resources/application.conf`** (HOCON, 01-RESEARCH.md lines 1031-1051):
```hocon
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ dev.ulfrx.recipe.ApplicationKt.module ]
}
}
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
CRITICAL (PITFALL #5):
- The two-line `url = "default"; url = ${?DATABASE_URL}` pattern is MANDATORY. `${?X}` is optional substitution — the second line is a no-op when `DATABASE_URL` is unset, and an override when it is set. Do NOT use `${X}` (required — crashes if unset) or `${X:default}` (wrong HOCON syntax).
- `"jdbc:postgresql://localhost:5432/recipe"`, `"recipe"`, `"recipe"` MATCH the docker-compose defaults in Plan 06 exactly — allows `docker compose up -d postgres && ./gradlew :server:run` with zero extra env config.
- `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]` — even though `main()` uses programmatic `embeddedServer(...)` in Application.kt, this key is informational for Ktor's HOCON config loader and future EngineMain switching.
**File 2: `server/src/main/resources/db/migration/.gitkeep`** — empty zero-byte file. Git does not track empty directories; this marker ensures `server/src/main/resources/db/migration/` ships in the repo so `classpath:db/migration` resolves for Flyway. Phase 3 drops `V1__init.sql` here.
**File 3: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`** (SLF4J variant — the server uses Logback already, NOT Kermit; RESEARCH.md lines 996-1023 + lines 1025-1027 explain the logger choice):
```kotlin
package dev.ulfrx.recipe
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.slf4j.LoggerFactory
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
fun migrate(app: Application) {
val url = app.environment.config.property("database.url").getString()
val user = app.environment.config.property("database.user").getString()
val password = app.environment.config.property("database.password").getString()
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway.configure()
.dataSource(url, user, password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.validateOnMigrate(true)
.cleanDisabled(true)
.load()
.migrate()
}.onFailure { ex ->
log.error("Flyway migration failed — cannot start server", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
}
```
CRITICAL:
- `throw IllegalStateException(...)` is the fail-loud contract (D-16). Do NOT wrap it in a generic `try { } catch { return false }` — the server MUST refuse to start if the DB is unreachable.
- Use SLF4J (`LoggerFactory.getLogger(...)`), NOT Kermit. The server has Logback wired via `logback.xml`; Kermit is the CLIENT logger (composeApp only).
- Log credentials are NOT logged — only `url` and `user` appear in the info line. `password` is used for `dataSource(...)` only.
- `cleanDisabled = true` prevents accidental `flywayClean` wiping tables in dev/prod (matches `recipe.jvm.server.gradle.kts` plugin config — double-enforcement).
- `baselineOnMigrate = true` tolerates an existing DB with no Flyway history (defensive — Phase 1's DB is empty, Phase 11's homelab DB may pre-exist).
- `locations("classpath:db/migration")` points to the resource directory the `.gitkeep` keeps alive.
</action>
<verify>
<automated>test -f server/src/main/resources/application.conf && test -f server/src/main/resources/db/migration/.gitkeep && test -f server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'port = 8080' server/src/main/resources/application.conf && grep -q 'port = \${?PORT}' server/src/main/resources/application.conf && grep -q 'url = "jdbc:postgresql://localhost:5432/recipe"' server/src/main/resources/application.conf && grep -q 'url = \${?DATABASE_URL}' server/src/main/resources/application.conf && grep -q 'user = "recipe"' server/src/main/resources/application.conf && grep -q 'user = \${?DATABASE_USER}' server/src/main/resources/application.conf && grep -q 'password = "recipe"' server/src/main/resources/application.conf && grep -q 'password = \${?DATABASE_PASSWORD}' server/src/main/resources/application.conf && grep -q 'object Database' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.flywaydb.core.Flyway' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.slf4j.LoggerFactory' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'cleanDisabled(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'baselineOnMigrate(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'throw IllegalStateException' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'classpath:db/migration' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt</automated>
</verify>
<acceptance_criteria>
- `server/src/main/resources/application.conf` exists and contains exactly 6 env-var override lines (`port = ${?PORT}`, `url = ${?DATABASE_URL}`, `user = ${?DATABASE_USER}`, `password = ${?DATABASE_PASSWORD}` plus the two defaults for `port = 8080` and the DB trio)
- `application.conf` default values match docker-compose defaults: URL `jdbc:postgresql://localhost:5432/recipe`, user `recipe`, password `recipe`
- `application.conf` contains `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]`
- `server/src/main/resources/db/migration/.gitkeep` exists (zero-byte file acceptable)
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` exists and declares `object Database`
- `Database.kt` imports `io.ktor.server.application.Application`, `org.flywaydb.core.Flyway`, `org.slf4j.LoggerFactory`
- `Database.kt` defines `fun migrate(app: Application)` that reads `app.environment.config.property("database.url|user|password").getString()`
- `Database.kt` body contains `Flyway.configure().dataSource(url, user, password).locations("classpath:db/migration").baselineOnMigrate(true).validateOnMigrate(true).cleanDisabled(true).load().migrate()` (all chained)
- `Database.kt` wraps the migration in `runCatching { ... }.onFailure { ... throw IllegalStateException(...) }` (fail-loud contract)
- `Database.kt` does NOT import `co.touchlab.kermit.Logger` (server uses SLF4J)
- `Database.kt` log.info line does NOT format the password value (only url + user in the format string)
</acceptance_criteria>
<done>HOCON config, Flyway migration resource dir, and fail-loud Database.migrate exist.</done>
</task>
<task type="auto">
<name>Task 2: Rewrite Application.kt to install ContentNegotiation, call Database.migrate, expose /health</name>
<files>server/src/main/kotlin/dev/ulfrx/recipe/Application.kt</files>
<read_first>
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (current 20 lines — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 952-985 (canonical Application.kt with ContentNegotiation + /health)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 940-986 (Application.kt deltas)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (sentinel JSON body for /health — Claude's discretion; use trivial `{"status":"ok"}`)
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (verify `SERVER_PORT` constant is defined)
</read_first>
<action>
Replace the entire content of `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` with:
```kotlin
package dev.ulfrx.recipe
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
@Serializable
private data class Health(val status: String)
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this)
configureRouting()
}
fun Application.configureRouting() {
routing {
get("/health") {
call.respond(Health(status = "ok"))
}
}
}
```
DELETIONS:
- DROP the wildcard imports (`io.ktor.server.application.*`, `io.ktor.server.engine.*`, `io.ktor.server.netty.*`, `io.ktor.server.response.*`, `io.ktor.server.routing.*`) — replaced with explicit imports to satisfy D-11 allWarningsAsErrors (wildcard-unused warnings would fail the build)
- DROP `get("/") { call.respondText("Ktor: ${Greeting().greet()}") }` — replaced by `/health`
ADDITIONS:
- ADD `install(ContentNegotiation) { json() }` — required for `@Serializable` response serialization
- ADD `Database.migrate(this)` call inside `Application.module()` — fails loudly if Postgres unreachable
- ADD `@Serializable private data class Health(val status: String)` — the /health response shape
- ADD `Application.configureRouting()` extension function — extracted from `module()` so the test (Task 3) can compose routing WITHOUT invoking `Database.migrate()`
KEEP:
- `package dev.ulfrx.recipe` (unchanged)
- `fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) }` — programmatic boot, unchanged shape
- `SERVER_PORT` constant is referenced from `shared/` (unchanged)
CRITICAL:
- The extraction of `configureRouting()` from `module()` is load-bearing for the test. Task 3 needs to test routing without calling `Database.migrate(this)` (which requires a real Postgres).
- `install(ContentNegotiation) { json() }` — MUST be installed before any route returns a `@Serializable` type. Both `module()` (for production) and the test (Task 3) must install it.
</action>
<verify>
<automated>grep -q '^package dev.ulfrx.recipe$' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.server.plugins.contentnegotiation.ContentNegotiation' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.serialization.kotlinx.json.json' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import kotlinx.serialization.Serializable' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -qE 'import io\.ktor\.server\.application\.\*' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'install(ContentNegotiation)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'Database.migrate(this)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'get("/health")' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'data class Health(val status: String)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'fun Application.configureRouting()' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -q 'call.respondText' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'embeddedServer(Netty, port = SERVER_PORT' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt</automated>
</verify>
<acceptance_criteria>
- `Application.kt` has no wildcard imports (`import X.*`) — every `io.ktor.*` import is explicit
- `Application.kt` imports `io.ktor.server.plugins.contentnegotiation.ContentNegotiation`, `io.ktor.serialization.kotlinx.json.json`, `kotlinx.serialization.Serializable`
- `Application.kt` defines `@Serializable private data class Health(val status: String)`
- `Application.module()` body calls, in order: `install(ContentNegotiation) { json() }`, then `Database.migrate(this)`, then `configureRouting()`
- `Application.configureRouting()` is a top-level extension function containing the `routing { get("/health") { call.respond(Health(status = "ok")) } }` block
- `main()` is unchanged from its current shape: `embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true)`
- No `get("/")` route remains (template root greeting is removed)
- No `call.respondText(...)` in Application.kt (Health returned via `call.respond(Health(...))` → kotlinx-json serializer)
</acceptance_criteria>
<done>Application.kt installs ContentNegotiation, runs Flyway at boot, exposes /health JSON, splits routing for testability.</done>
</task>
<task type="auto">
<name>Task 3: Rewrite ApplicationTest.kt to assert GET /health returns 200 with JSON body</name>
<files>server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt</files>
<read_first>
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (current 20-line content — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1084-1125 (canonical ApplicationTest.kt variant)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1079-1125 (test delta explaining the no-Postgres-required refactor)
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (the freshly rewritten file — the test references `configureRouting()` from this file)
- .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 52-53 (automated command this test must satisfy: `./gradlew :server:test --tests "*Health*"`)
</read_first>
<action>
Replace the entire content of `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` with:
```kotlin
package dev.ulfrx.recipe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApplicationTest {
@Test
fun `health endpoint returns 200 with status ok`() = testApplication {
application {
install(ContentNegotiation) {
json()
}
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
}
}
```
CRITICAL:
- The test invokes `configureRouting()` directly (extracted in Task 2) and does NOT call `Database.migrate(...)`. This is the KEY refactor: the test runs without a running Postgres, so `./gradlew :server:test` can succeed in CI / fresh clones.
- `install(ContentNegotiation) { json() }` is explicitly installed inside `application { }` — because the production `Application.module()` installs it, but the test composes only `configureRouting()` and must install the plugin itself.
- Imports are explicit (no wildcards) to satisfy D-11 allWarningsAsErrors.
- Assertions check for `"status"` and `"ok"` substrings in the JSON body — this is a structural check that works regardless of JSON field ordering.
- The test function name uses backtick-quoted natural-language identifier (`` `health endpoint returns 200 with status ok` ``) — standard Kotlin test-naming convention; the test will run via `./gradlew :server:test --tests "*health*"` or similar wildcards.
DELETIONS:
- DROP the existing `testRoot()` test — it asserted the template's `/` route response with `"Ktor: ${Greeting().greet()}"`, which no longer exists.
- DROP wildcard imports `io.ktor.client.request.*`, `io.ktor.client.statement.*`, `io.ktor.http.*`, `io.ktor.server.testing.*`, `kotlin.test.*`.
</action>
<verify>
<automated>test -f server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'health endpoint returns 200' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'configureRouting()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Database.migrate' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'install(ContentNegotiation)' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'client.get("/health")' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'HttpStatusCode.OK' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'testRoot' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Greeting().greet()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -qE 'import kotlin\.test\.\*' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && cd /Users/rwilk/dev/repo/recipe && ./gradlew :server:test --tests "*health*" -q 2>&1 | tail -5 && echo "gradle exit: $?"</automated>
</verify>
<acceptance_criteria>
- `ApplicationTest.kt` defines exactly one `@Test` method whose name contains `health` (case-insensitive)
- Test body invokes `configureRouting()` and does NOT invoke `Database.migrate(...)` (no-Postgres invariant)
- Test installs `ContentNegotiation { json() }` inside `application { ... }`
- Test asserts `response.status == HttpStatusCode.OK`
- Test asserts response body contains substring `"status"` AND `"ok"`
- No wildcard imports
- No reference to the removed `testRoot`, `Greeting`, or `respondText` — the old template test is fully replaced
- `./gradlew :server:test --tests "*health*"` runs and exits 0 (proves the test compiles AND passes; no Postgres needed because `configureRouting()` is composed directly)
</acceptance_criteria>
<done>/health test passes without requiring Postgres; old template test removed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP client (unauthenticated) → GET /health | `/health` is intentionally unauthenticated (observability); reveals only `{"status":"ok"}` — no implementation detail, no version, no uptime. |
| Ktor process → Postgres (JDBC) | HOCON defaults connect to `localhost:5432` with dev credentials. Real credentials arrive via `DATABASE_URL`/`DATABASE_USER`/`DATABASE_PASSWORD` env vars in Phase 11 homelab deploy. |
| Developer → server/src/main/resources/application.conf | Committed to git; MUST contain only non-secret dev defaults. Real secrets never land in `application.conf`. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-05-01 | Information Disclosure | `/health` endpoint leaking implementation details | mitigate | Body is `{"status":"ok"}` only — no version, no commit hash, no uptime, no DB state. Per ASVS V14 guidance + 01-RESEARCH.md § Security Domain. |
| T-01-05-02 | Information Disclosure | `application.conf` committed with real secrets | mitigate | Defaults are non-secret localhost creds (`recipe/recipe/recipe`). Real secrets MUST arrive via `${?DATABASE_URL}` env override — never committed. Task 1 acceptance criteria enforces the six `${?X}` lines. |
| T-01-05-03 | Tampering / Destruction | `flywayClean` wiping DB | mitigate | `cleanDisabled(true)` is set in BOTH `recipe.jvm.server.gradle.kts` (plugin CLI guard) AND in `Database.kt` runtime call (programmatic guard). Double-enforced per RESEARCH.md § Security Domain. |
| T-01-05-04 | Denial of Service | Server silently runs with broken DB | mitigate | `Database.migrate()` throws `IllegalStateException` on any Flyway/JDBC error — server cannot start. Fail-loud contract (D-16) prevents stealth failure modes. |
| T-01-05-05 | Error Handling (leaky stack traces) | 500 responses exposing internal stack | accept | No 500 handler registered yet; Ktor's default returns generic 500. Phase 2+ may introduce a StatusPages handler; Phase 1 scope is /health only which cannot 500 under normal operation. |
| T-01-05-06 | Supply Chain | Flyway 12.4.0 + flyway-database-postgresql transitive deps | mitigate | Pinned versions via catalog (Plan 01). Postgres JDBC 42.7.10 pinned. No `latest.release` ranges. |
</threat_model>
<verification>
Phase-level verification for this plan:
- Task 3 `<automated>` runs `./gradlew :server:test --tests "*health*"` which proves:
- Application.kt compiles (confirms Task 2's explicit imports are correct)
- ApplicationTest.kt compiles (confirms Task 3's imports are correct)
- The /health route returns 200 with JSON containing `"status"` and `"ok"`
- Database.migrate is NOT required for the test (no Postgres needed in CI — D-11 test-runtime invariant)
- `tools/verify-no-version-literals.sh` continues to exit 0 (no build files modified; server/build.gradle.kts was rewritten in Plan 03, untouched here).
- Manual verification (deferred to Plan 07 or manual step):
- `docker compose up -d postgres && ./gradlew :server:run & sleep 5 && curl -sf http://localhost:8080/health | grep '"ok"'` — proves end-to-end boot + route + DB migration path.
</verification>
<success_criteria>
- `server/src/main/resources/application.conf` exists with HOCON + 6 env overrides
- `server/src/main/resources/db/migration/.gitkeep` exists
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` runs Flyway with fail-loud contract
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` installs ContentNegotiation, calls Database.migrate, exposes GET /health returning `{"status":"ok"}`
- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` passes via `./gradlew :server:test --tests "*health*"` WITHOUT a running Postgres
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-05-SUMMARY.md` recording: files created/modified, HOCON env-var pattern used (the `${?X}` two-line form), the fail-loud Database.migrate contract, and the `./gradlew :server:test` result.
</output>