From 135eb4c98d3ac26ee0a2ade2e6474337575480e7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 10 Mar 2026 21:25:52 -0700 Subject: [PATCH] feat: add kotlin commands and skill pack --- .cursor/rules/kotlin-coding-style.md | 36 ++ .cursor/rules/kotlin-hooks.md | 16 + .cursor/rules/kotlin-patterns.md | 50 ++ .cursor/rules/kotlin-security.md | 58 ++ .cursor/rules/kotlin-testing.md | 38 ++ agents/kotlin-build-resolver.md | 118 ++++ commands/kotlin-build.md | 172 +++++ commands/kotlin-review.md | 140 ++++ commands/kotlin-test.md | 312 +++++++++ rules/kotlin/hooks.md | 17 + skills/kotlin-exposed-patterns/SKILL.md | 719 +++++++++++++++++++++ skills/kotlin-ktor-patterns/SKILL.md | 689 ++++++++++++++++++++ skills/kotlin-patterns/SKILL.md | 711 ++++++++++++++++++++ skills/kotlin-testing/SKILL.md | 824 ++++++++++++++++++++++++ 14 files changed, 3900 insertions(+) create mode 100644 .cursor/rules/kotlin-coding-style.md create mode 100644 .cursor/rules/kotlin-hooks.md create mode 100644 .cursor/rules/kotlin-patterns.md create mode 100644 .cursor/rules/kotlin-security.md create mode 100644 .cursor/rules/kotlin-testing.md create mode 100644 agents/kotlin-build-resolver.md create mode 100644 commands/kotlin-build.md create mode 100644 commands/kotlin-review.md create mode 100644 commands/kotlin-test.md create mode 100644 rules/kotlin/hooks.md create mode 100644 skills/kotlin-exposed-patterns/SKILL.md create mode 100644 skills/kotlin-ktor-patterns/SKILL.md create mode 100644 skills/kotlin-patterns/SKILL.md create mode 100644 skills/kotlin-testing/SKILL.md diff --git a/.cursor/rules/kotlin-coding-style.md b/.cursor/rules/kotlin-coding-style.md new file mode 100644 index 000000000..e63a1bc70 --- /dev/null +++ b/.cursor/rules/kotlin-coding-style.md @@ -0,0 +1,36 @@ +--- +description: "Kotlin coding style extending common rules" +globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"] +alwaysApply: false +--- +# Kotlin Coding Style + +> This file extends the common coding style rule with Kotlin-specific content. + +## Formatting + +- **ktfmt** or **ktlint** are mandatory for consistent formatting +- Use trailing commas in multiline declarations + +## Immutability + +- `val` over `var` always +- Immutable collections by default (`List`, `Map`, `Set`) +- Use `data class` with `copy()` for immutable updates + +## Null Safety + +- Avoid `!!` -- use `?.`, `?:`, `require`, or `checkNotNull` +- Handle platform types explicitly at Java interop boundaries + +## Expression Bodies + +Prefer expression bodies for single-expression functions: + +```kotlin +fun isAdult(age: Int): Boolean = age >= 18 +``` + +## Reference + +See skill: `kotlin-patterns` for comprehensive Kotlin idioms and patterns. diff --git a/.cursor/rules/kotlin-hooks.md b/.cursor/rules/kotlin-hooks.md new file mode 100644 index 000000000..8ed503d44 --- /dev/null +++ b/.cursor/rules/kotlin-hooks.md @@ -0,0 +1,16 @@ +--- +description: "Kotlin hooks extending common rules" +globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"] +alwaysApply: false +--- +# Kotlin Hooks + +> This file extends the common hooks rule with Kotlin-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **ktfmt/ktlint**: Auto-format `.kt` and `.kts` files after edit +- **detekt**: Run static analysis after editing Kotlin files +- **./gradlew build**: Verify compilation after changes diff --git a/.cursor/rules/kotlin-patterns.md b/.cursor/rules/kotlin-patterns.md new file mode 100644 index 000000000..c5958da26 --- /dev/null +++ b/.cursor/rules/kotlin-patterns.md @@ -0,0 +1,50 @@ +--- +description: "Kotlin patterns extending common rules" +globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"] +alwaysApply: false +--- +# Kotlin Patterns + +> This file extends the common patterns rule with Kotlin-specific content. + +## Sealed Classes + +Use sealed classes/interfaces for exhaustive type hierarchies: + +```kotlin +sealed class Result { + data class Success(val data: T) : Result() + data class Failure(val error: AppError) : Result() +} +``` + +## Extension Functions + +Add behavior without inheritance, scoped to where they're used: + +```kotlin +fun String.toSlug(): String = + lowercase().replace(Regex("[^a-z0-9\\s-]"), "").replace(Regex("\\s+"), "-") +``` + +## Scope Functions + +- `let`: Transform nullable or scoped result +- `apply`: Configure an object +- `also`: Side effects +- Avoid nesting scope functions + +## Dependency Injection + +Use Koin for DI in Ktor projects: + +```kotlin +val appModule = module { + single { ExposedUserRepository(get()) } + single { UserService(get()) } +} +``` + +## Reference + +See skill: `kotlin-patterns` for comprehensive Kotlin patterns including coroutines, DSL builders, and delegation. diff --git a/.cursor/rules/kotlin-security.md b/.cursor/rules/kotlin-security.md new file mode 100644 index 000000000..43ad7cc5a --- /dev/null +++ b/.cursor/rules/kotlin-security.md @@ -0,0 +1,58 @@ +--- +description: "Kotlin security extending common rules" +globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"] +alwaysApply: false +--- +# Kotlin Security + +> This file extends the common security rule with Kotlin-specific content. + +## Secret Management + +```kotlin +val apiKey = System.getenv("API_KEY") + ?: throw IllegalStateException("API_KEY not configured") +``` + +## SQL Injection Prevention + +Always use Exposed's parameterized queries: + +```kotlin +// Good: Parameterized via Exposed DSL +UsersTable.selectAll().where { UsersTable.email eq email } + +// Bad: String interpolation in raw SQL +exec("SELECT * FROM users WHERE email = '$email'") +``` + +## Authentication + +Use Ktor's Auth plugin with JWT: + +```kotlin +install(Authentication) { + jwt("jwt") { + verifier( + JWT.require(Algorithm.HMAC256(secret)) + .withAudience(audience) + .withIssuer(issuer) + .build() + ) + validate { credential -> + val payload = credential.payload + if (payload.audience.contains(audience) && + payload.issuer == issuer && + payload.subject != null) { + JWTPrincipal(payload) + } else { + null + } + } + } +} +``` + +## Null Safety as Security + +Kotlin's type system prevents null-related vulnerabilities -- avoid `!!` to maintain this guarantee. diff --git a/.cursor/rules/kotlin-testing.md b/.cursor/rules/kotlin-testing.md new file mode 100644 index 000000000..bf749043e --- /dev/null +++ b/.cursor/rules/kotlin-testing.md @@ -0,0 +1,38 @@ +--- +description: "Kotlin testing extending common rules" +globs: ["**/*.kt", "**/*.kts", "**/build.gradle.kts"] +alwaysApply: false +--- +# Kotlin Testing + +> This file extends the common testing rule with Kotlin-specific content. + +## Framework + +Use **Kotest** with spec styles (StringSpec, FunSpec, BehaviorSpec) and **MockK** for mocking. + +## Coroutine Testing + +Use `runTest` from `kotlinx-coroutines-test`: + +```kotlin +test("async operation completes") { + runTest { + val result = service.fetchData() + result.shouldNotBeEmpty() + } +} +``` + +## Coverage + +Use **Kover** for coverage reporting: + +```bash +./gradlew koverHtmlReport +./gradlew koverVerify +``` + +## Reference + +See skill: `kotlin-testing` for detailed Kotest patterns, MockK usage, and property-based testing. diff --git a/agents/kotlin-build-resolver.md b/agents/kotlin-build-resolver.md new file mode 100644 index 000000000..705afd32a --- /dev/null +++ b/agents/kotlin-build-resolver.md @@ -0,0 +1,118 @@ +--- +name: kotlin-build-resolver +description: Kotlin/Gradle build, compilation, and dependency error resolution specialist. Fixes build errors, Kotlin compiler errors, and Gradle issues with minimal changes. Use when Kotlin builds fail. +tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] +model: sonnet +--- + +# Kotlin Build Error Resolver + +You are an expert Kotlin/Gradle build error resolution specialist. Your mission is to fix Kotlin build errors, Gradle configuration issues, and dependency resolution failures with **minimal, surgical changes**. + +## Core Responsibilities + +1. Diagnose Kotlin compilation errors +2. Fix Gradle build configuration issues +3. Resolve dependency conflicts and version mismatches +4. Handle Kotlin compiler errors and warnings +5. Fix detekt and ktlint violations + +## Diagnostic Commands + +Run these in order: + +```bash +./gradlew build 2>&1 +./gradlew detekt 2>&1 || echo "detekt not configured" +./gradlew ktlintCheck 2>&1 || echo "ktlint not configured" +./gradlew dependencies --configuration runtimeClasspath 2>/dev/null | head -100 +``` + +## Resolution Workflow + +```text +1. ./gradlew build -> Parse error message +2. Read affected file -> Understand context +3. Apply minimal fix -> Only what's needed +4. ./gradlew build -> Verify fix +5. ./gradlew test -> Ensure nothing broke +``` + +## Common Fix Patterns + +| Error | Cause | Fix | +|-------|-------|-----| +| `Unresolved reference: X` | Missing import, typo, missing dependency | Add import or dependency | +| `Type mismatch: Required X, Found Y` | Wrong type, missing conversion | Add conversion or fix type | +| `None of the following candidates is applicable` | Wrong overload, wrong argument types | Fix argument types or add explicit cast | +| `Smart cast impossible` | Mutable property or concurrent access | Use local `val` copy or `let` | +| `'when' expression must be exhaustive` | Missing branch in sealed class `when` | Add missing branches or `else` | +| `Suspend function can only be called from coroutine` | Missing `suspend` or coroutine scope | Add `suspend` modifier or launch coroutine | +| `Cannot access 'X': it is internal in 'Y'` | Visibility issue | Change visibility or use public API | +| `Conflicting declarations` | Duplicate definitions | Remove duplicate or rename | +| `Could not resolve: group:artifact:version` | Missing repository or wrong version | Add repository or fix version | +| `Execution failed for task ':detekt'` | Code style violations | Fix detekt findings | + +## Gradle Troubleshooting + +```bash +# Check dependency tree for conflicts +./gradlew dependencies --configuration runtimeClasspath + +# Force refresh dependencies +./gradlew build --refresh-dependencies + +# Clear project-local Gradle build cache +./gradlew clean && rm -rf .gradle/build-cache/ + +# Check Gradle version compatibility +./gradlew --version + +# Run with debug output +./gradlew build --debug 2>&1 | tail -50 + +# Check for dependency conflicts +./gradlew dependencyInsight --dependency --configuration runtimeClasspath +``` + +## Kotlin Compiler Flags + +```kotlin +// build.gradle.kts - Common compiler options +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") // Strict Java null safety + allWarningsAsErrors = true + } +} +``` + +## Key Principles + +- **Surgical fixes only** -- don't refactor, just fix the error +- **Never** suppress warnings without explicit approval +- **Never** change function signatures unless necessary +- **Always** run `./gradlew build` after each fix to verify +- Fix root cause over suppressing symptoms +- Prefer adding missing imports over wildcard imports + +## Stop Conditions + +Stop and report if: +- Same error persists after 3 fix attempts +- Fix introduces more errors than it resolves +- Error requires architectural changes beyond scope +- Missing external dependencies that need user decision + +## Output Format + +```text +[FIXED] src/main/kotlin/com/example/service/UserService.kt:42 +Error: Unresolved reference: UserRepository +Fix: Added import com.example.repository.UserRepository +Remaining errors: 2 +``` + +Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` + +For detailed Kotlin patterns and code examples, see `skill: kotlin-patterns`. diff --git a/commands/kotlin-build.md b/commands/kotlin-build.md new file mode 100644 index 000000000..bb9f5de1f --- /dev/null +++ b/commands/kotlin-build.md @@ -0,0 +1,172 @@ +--- +description: Fix Kotlin/Gradle build errors, compiler warnings, and dependency issues incrementally. Invokes the kotlin-build-resolver agent for minimal, surgical fixes. +--- + +# Kotlin Build and Fix + +This command invokes the **kotlin-build-resolver** agent to incrementally fix Kotlin build errors with minimal changes. + +## What This Command Does + +1. **Run Diagnostics**: Execute `./gradlew build`, `detekt`, `ktlintCheck` +2. **Parse Errors**: Group by file and sort by severity +3. **Fix Incrementally**: One error at a time +4. **Verify Each Fix**: Re-run build after each change +5. **Report Summary**: Show what was fixed and what remains + +## When to Use + +Use `/kotlin-build` when: +- `./gradlew build` fails with errors +- Kotlin compiler reports errors +- `./gradlew detekt` reports violations +- Gradle dependency resolution fails +- After pulling changes that break the build + +## Diagnostic Commands Run + +```bash +# Primary build check +./gradlew build + +# Static analysis +./gradlew detekt +./gradlew ktlintCheck + +# Dependency issues +./gradlew dependencies --configuration runtimeClasspath +./gradlew build --refresh-dependencies +``` + +## Example Session + +````text +User: /kotlin-build + +Agent: +# Kotlin Build Resolution + +## Initial Diagnostics + +```bash +$ ./gradlew build +e: src/main/kotlin/com/example/service/UserService.kt:25:15 Unresolved reference: UserRepository +e: src/main/kotlin/com/example/routes/UserRoutes.kt:42:9 Type mismatch: inferred type is String but Int was expected +e: src/main/kotlin/com/example/routes/UserRoutes.kt:58:5 'when' expression must be exhaustive +``` + +Errors found: 3 + +## Fix 1: Unresolved Reference + +File: src/main/kotlin/com/example/service/UserService.kt:25 +Error: Unresolved reference: UserRepository +Cause: Missing import + +```kotlin +// Added import +import com.example.repository.UserRepository +``` + +```bash +$ ./gradlew build +# 2 errors remaining +``` + +## Fix 2: Type Mismatch + +File: src/main/kotlin/com/example/routes/UserRoutes.kt:42 +Error: Type mismatch: inferred type is String but Int was expected + +```kotlin +// Changed +val count = call.parameters["count"] +// To +val count = call.parameters["count"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid count") +``` + +```bash +$ ./gradlew build +# 1 error remaining +``` + +## Fix 3: Non-Exhaustive When + +File: src/main/kotlin/com/example/routes/UserRoutes.kt:58 +Error: 'when' expression must be exhaustive + +```kotlin +// Added missing branch +when (user.role) { + Role.ADMIN -> handleAdmin(user) + Role.USER -> handleUser(user) + Role.MODERATOR -> handleModerator(user) // Added +} +``` + +```bash +$ ./gradlew build +# Build successful! +``` + +## Final Verification + +```bash +$ ./gradlew detekt +# No issues + +$ ./gradlew test +# All tests passed +``` + +## Summary + +| Metric | Count | +|--------|-------| +| Build errors fixed | 3 | +| Detekt issues fixed | 0 | +| Files modified | 2 | +| Remaining issues | 0 | + +Build Status: ✅ SUCCESS +```` + +## Common Errors Fixed + +| Error | Typical Fix | +|-------|-------------| +| `Unresolved reference: X` | Add import or dependency | +| `Type mismatch` | Fix type conversion or assignment | +| `'when' must be exhaustive` | Add missing sealed class branches | +| `Suspend function can only be called from coroutine` | Add `suspend` modifier | +| `Smart cast impossible` | Use local `val` or `let` | +| `None of the following candidates is applicable` | Fix argument types | +| `Could not resolve dependency` | Fix version or add repository | + +## Fix Strategy + +1. **Build errors first** - Code must compile +2. **Detekt violations second** - Fix code quality issues +3. **ktlint warnings third** - Fix formatting +4. **One fix at a time** - Verify each change +5. **Minimal changes** - Don't refactor, just fix + +## Stop Conditions + +The agent will stop and report if: +- Same error persists after 3 attempts +- Fix introduces more errors +- Requires architectural changes +- Missing external dependencies + +## Related Commands + +- `/kotlin-test` - Run tests after build succeeds +- `/kotlin-review` - Review code quality +- `/verify` - Full verification loop + +## Related + +- Agent: `agents/kotlin-build-resolver.md` +- Skill: `skills/kotlin-patterns/` diff --git a/commands/kotlin-review.md b/commands/kotlin-review.md new file mode 100644 index 000000000..b8798bb0e --- /dev/null +++ b/commands/kotlin-review.md @@ -0,0 +1,140 @@ +--- +description: Comprehensive Kotlin code review for idiomatic patterns, null safety, coroutine safety, and security. Invokes the kotlin-reviewer agent. +--- + +# Kotlin Code Review + +This command invokes the **kotlin-reviewer** agent for comprehensive Kotlin-specific code review. + +## What This Command Does + +1. **Identify Kotlin Changes**: Find modified `.kt` and `.kts` files via `git diff` +2. **Run Build & Static Analysis**: Execute `./gradlew build`, `detekt`, `ktlintCheck` +3. **Security Scan**: Check for SQL injection, command injection, hardcoded secrets +4. **Null Safety Review**: Analyze `!!` usage, platform type handling, unsafe casts +5. **Coroutine Review**: Check structured concurrency, dispatcher usage, cancellation +6. **Generate Report**: Categorize issues by severity + +## When to Use + +Use `/kotlin-review` when: +- After writing or modifying Kotlin code +- Before committing Kotlin changes +- Reviewing pull requests with Kotlin code +- Onboarding to a new Kotlin codebase +- Learning idiomatic Kotlin patterns + +## Review Categories + +### CRITICAL (Must Fix) +- SQL/Command injection vulnerabilities +- Force-unwrap `!!` without justification +- Platform type null safety violations +- GlobalScope usage (structured concurrency violation) +- Hardcoded credentials +- Unsafe deserialization + +### HIGH (Should Fix) +- Mutable state where immutable suffices +- Blocking calls inside coroutine context +- Missing cancellation checks in long loops +- Non-exhaustive `when` on sealed types +- Large functions (>50 lines) +- Deep nesting (>4 levels) + +### MEDIUM (Consider) +- Non-idiomatic Kotlin (Java-style patterns) +- Missing trailing commas +- Scope function misuse or nesting +- Missing sequence for large collection chains +- Redundant explicit types + +## Automated Checks Run + +```bash +# Build check +./gradlew build + +# Static analysis +./gradlew detekt + +# Formatting check +./gradlew ktlintCheck + +# Tests +./gradlew test +``` + +## Example Usage + +````text +User: /kotlin-review + +Agent: +# Kotlin Code Review Report + +## Files Reviewed +- src/main/kotlin/com/example/service/UserService.kt (modified) +- src/main/kotlin/com/example/routes/UserRoutes.kt (modified) + +## Static Analysis Results +✓ Build: Successful +✓ detekt: No issues +⚠ ktlint: 2 formatting warnings + +## Issues Found + +[CRITICAL] Force-Unwrap Null Safety +File: src/main/kotlin/com/example/service/UserService.kt:28 +Issue: Using !! on nullable repository result +```kotlin +val user = repository.findById(id)!! // NPE risk +``` +Fix: Use safe call with error handling +```kotlin +val user = repository.findById(id) + ?: throw UserNotFoundException("User $id not found") +``` + +[HIGH] GlobalScope Usage +File: src/main/kotlin/com/example/routes/UserRoutes.kt:45 +Issue: Using GlobalScope breaks structured concurrency +```kotlin +GlobalScope.launch { + notificationService.sendWelcome(user) +} +``` +Fix: Use the call's coroutine scope +```kotlin +launch { + notificationService.sendWelcome(user) +} +``` + +## Summary +- CRITICAL: 1 +- HIGH: 1 +- MEDIUM: 0 + +Recommendation: ❌ Block merge until CRITICAL issue is fixed +```` + +## Approval Criteria + +| Status | Condition | +|--------|-----------| +| ✅ Approve | No CRITICAL or HIGH issues | +| ⚠️ Warning | Only MEDIUM issues (merge with caution) | +| ❌ Block | CRITICAL or HIGH issues found | + +## Integration with Other Commands + +- Use `/kotlin-test` first to ensure tests pass +- Use `/kotlin-build` if build errors occur +- Use `/kotlin-review` before committing +- Use `/code-review` for non-Kotlin-specific concerns + +## Related + +- Agent: `agents/kotlin-reviewer.md` +- Skills: `skills/kotlin-patterns/`, `skills/kotlin-testing/` diff --git a/commands/kotlin-test.md b/commands/kotlin-test.md new file mode 100644 index 000000000..bdfc7a772 --- /dev/null +++ b/commands/kotlin-test.md @@ -0,0 +1,312 @@ +--- +description: Enforce TDD workflow for Kotlin. Write Kotest tests first, then implement. Verify 80%+ coverage with Kover. +--- + +# Kotlin TDD Command + +This command enforces test-driven development methodology for Kotlin code using Kotest, MockK, and Kover. + +## What This Command Does + +1. **Define Types/Interfaces**: Scaffold function signatures first +2. **Write Kotest Tests**: Create comprehensive test specs (RED) +3. **Run Tests**: Verify tests fail for the right reason +4. **Implement Code**: Write minimal code to pass (GREEN) +5. **Refactor**: Improve while keeping tests green +6. **Check Coverage**: Ensure 80%+ coverage with Kover + +## When to Use + +Use `/kotlin-test` when: +- Implementing new Kotlin functions or classes +- Adding test coverage to existing Kotlin code +- Fixing bugs (write failing test first) +- Building critical business logic +- Learning TDD workflow in Kotlin + +## TDD Cycle + +``` +RED -> Write failing Kotest test +GREEN -> Implement minimal code to pass +REFACTOR -> Improve code, tests stay green +REPEAT -> Next test case +``` + +## Example Session + +````text +User: /kotlin-test I need a function to validate user registration requests + +Agent: +# TDD Session: Registration Validator + +## Step 1: Define Interface + +```kotlin +// validator/RegistrationValidator.kt +package com.example.validator + +data class RegistrationRequest( + val name: String, + val email: String, + val password: String, +) + +sealed class ValidationResult { + data object Valid : ValidationResult() + data class Invalid(val errors: List) : ValidationResult() +} + +fun validateRegistration(request: RegistrationRequest): ValidationResult { + TODO("not implemented") +} +``` + +## Step 2: Write Kotest Tests (RED) + +```kotlin +// validator/RegistrationValidatorTest.kt +package com.example.validator + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class RegistrationValidatorTest : FunSpec({ + test("valid registration returns Valid") { + val request = RegistrationRequest( + name = "Alice", + email = "alice@example.com", + password = "SecureP@ss1", + ) + + val result = validateRegistration(request) + + result.shouldBeInstanceOf() + } + + test("blank name returns Invalid") { + val request = RegistrationRequest( + name = "", + email = "alice@example.com", + password = "SecureP@ss1", + ) + + val result = validateRegistration(request) + + val invalid = result.shouldBeInstanceOf() + invalid.errors shouldBe listOf("Name is required") + } + + test("invalid email returns Invalid") { + val request = RegistrationRequest( + name = "Alice", + email = "not-an-email", + password = "SecureP@ss1", + ) + + val result = validateRegistration(request) + + val invalid = result.shouldBeInstanceOf() + invalid.errors shouldBe listOf("Invalid email format") + } + + test("short password returns Invalid") { + val request = RegistrationRequest( + name = "Alice", + email = "alice@example.com", + password = "short", + ) + + val result = validateRegistration(request) + + val invalid = result.shouldBeInstanceOf() + invalid.errors shouldBe listOf("Password must be at least 8 characters") + } + + test("multiple errors returns all errors") { + val request = RegistrationRequest( + name = "", + email = "bad", + password = "short", + ) + + val result = validateRegistration(request) + + val invalid = result.shouldBeInstanceOf() + invalid.errors.size shouldBe 3 + } +}) +``` + +## Step 3: Run Tests - Verify FAIL + +```bash +$ ./gradlew test + +RegistrationValidatorTest > valid registration returns Valid FAILED + kotlin.NotImplementedError: An operation is not implemented + +FAILED (5 tests, 0 passed, 5 failed) +``` + +✓ Tests fail as expected (NotImplementedError). + +## Step 4: Implement Minimal Code (GREEN) + +```kotlin +// validator/RegistrationValidator.kt +package com.example.validator + +private val EMAIL_REGEX = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") +private const val MIN_PASSWORD_LENGTH = 8 + +fun validateRegistration(request: RegistrationRequest): ValidationResult { + val errors = buildList { + if (request.name.isBlank()) add("Name is required") + if (!EMAIL_REGEX.matches(request.email)) add("Invalid email format") + if (request.password.length < MIN_PASSWORD_LENGTH) add("Password must be at least $MIN_PASSWORD_LENGTH characters") + } + + return if (errors.isEmpty()) ValidationResult.Valid + else ValidationResult.Invalid(errors) +} +``` + +## Step 5: Run Tests - Verify PASS + +```bash +$ ./gradlew test + +RegistrationValidatorTest > valid registration returns Valid PASSED +RegistrationValidatorTest > blank name returns Invalid PASSED +RegistrationValidatorTest > invalid email returns Invalid PASSED +RegistrationValidatorTest > short password returns Invalid PASSED +RegistrationValidatorTest > multiple errors returns all errors PASSED + +PASSED (5 tests, 5 passed, 0 failed) +``` + +✓ All tests passing! + +## Step 6: Check Coverage + +```bash +$ ./gradlew koverHtmlReport + +Coverage: 100.0% of statements +``` + +✓ Coverage: 100% + +## TDD Complete! +```` + +## Test Patterns + +### StringSpec (Simplest) + +```kotlin +class CalculatorTest : StringSpec({ + "add two positive numbers" { + Calculator.add(2, 3) shouldBe 5 + } +}) +``` + +### BehaviorSpec (BDD) + +```kotlin +class OrderServiceTest : BehaviorSpec({ + Given("a valid order") { + When("placed") { + Then("should be confirmed") { /* ... */ } + } + } +}) +``` + +### Data-Driven Tests + +```kotlin +class ParserTest : FunSpec({ + context("valid inputs") { + withData("2026-01-15", "2026-12-31", "2000-01-01") { input -> + parseDate(input).shouldNotBeNull() + } + } +}) +``` + +### Coroutine Testing + +```kotlin +class AsyncServiceTest : FunSpec({ + test("concurrent fetch completes") { + runTest { + val result = service.fetchAll() + result.shouldNotBeEmpty() + } + } +}) +``` + +## Coverage Commands + +```bash +# Run tests with coverage +./gradlew koverHtmlReport + +# Verify coverage thresholds +./gradlew koverVerify + +# XML report for CI +./gradlew koverXmlReport + +# Open HTML report +open build/reports/kover/html/index.html + +# Run specific test class +./gradlew test --tests "com.example.UserServiceTest" + +# Run with verbose output +./gradlew test --info +``` + +## Coverage Targets + +| Code Type | Target | +|-----------|--------| +| Critical business logic | 100% | +| Public APIs | 90%+ | +| General code | 80%+ | +| Generated code | Exclude | + +## TDD Best Practices + +**DO:** +- Write test FIRST, before any implementation +- Run tests after each change +- Use Kotest matchers for expressive assertions +- Use MockK's `coEvery`/`coVerify` for suspend functions +- Test behavior, not implementation details +- Include edge cases (empty, null, max values) + +**DON'T:** +- Write implementation before tests +- Skip the RED phase +- Test private functions directly +- Use `Thread.sleep()` in coroutine tests +- Ignore flaky tests + +## Related Commands + +- `/kotlin-build` - Fix build errors +- `/kotlin-review` - Review code after implementation +- `/verify` - Run full verification loop + +## Related + +- Skill: `skills/kotlin-testing/` +- Skill: `skills/tdd-workflow/` diff --git a/rules/kotlin/hooks.md b/rules/kotlin/hooks.md new file mode 100644 index 000000000..28bb02fc2 --- /dev/null +++ b/rules/kotlin/hooks.md @@ -0,0 +1,17 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" + - "**/build.gradle.kts" +--- +# Kotlin Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Kotlin-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **ktfmt/ktlint**: Auto-format `.kt` and `.kts` files after edit +- **detekt**: Run static analysis after editing Kotlin files +- **./gradlew build**: Verify compilation after changes diff --git a/skills/kotlin-exposed-patterns/SKILL.md b/skills/kotlin-exposed-patterns/SKILL.md new file mode 100644 index 000000000..3f98ebd57 --- /dev/null +++ b/skills/kotlin-exposed-patterns/SKILL.md @@ -0,0 +1,719 @@ +--- +name: kotlin-exposed-patterns +description: JetBrains Exposed ORM patterns including DSL queries, DAO pattern, transactions, HikariCP connection pooling, Flyway migrations, and repository pattern. +origin: ECC +--- + +# Kotlin Exposed Patterns + +Comprehensive patterns for database access with JetBrains Exposed ORM, including DSL queries, DAO, transactions, and production-ready configuration. + +## When to Use + +- Setting up database access with Exposed +- Writing SQL queries using Exposed DSL or DAO +- Configuring connection pooling with HikariCP +- Creating database migrations with Flyway +- Implementing the repository pattern with Exposed +- Handling JSON columns and complex queries + +## How It Works + +Exposed provides two query styles: DSL for direct SQL-like expressions and DAO for entity lifecycle management. HikariCP manages a pool of reusable database connections configured via `HikariConfig`. Flyway runs versioned SQL migration scripts at startup to keep the schema in sync. All database operations run inside `newSuspendedTransaction` blocks for coroutine safety and atomicity. The repository pattern wraps Exposed queries behind an interface so business logic stays decoupled from the data layer and tests can use an in-memory H2 database. + +## Examples + +### DSL Query + +```kotlin +suspend fun findUserById(id: UUID): UserRow? = + newSuspendedTransaction { + UsersTable.selectAll() + .where { UsersTable.id eq id } + .map { it.toUser() } + .singleOrNull() + } +``` + +### DAO Entity Usage + +```kotlin +suspend fun createUser(request: CreateUserRequest): User = + newSuspendedTransaction { + UserEntity.new { + name = request.name + email = request.email + role = request.role + }.toModel() + } +``` + +### HikariCP Configuration + +```kotlin +val hikariConfig = HikariConfig().apply { + driverClassName = config.driver + jdbcUrl = config.url + username = config.username + password = config.password + maximumPoolSize = config.maxPoolSize + isAutoCommit = false + transactionIsolation = "TRANSACTION_READ_COMMITTED" + validate() +} +``` + +## Database Setup + +### HikariCP Connection Pooling + +```kotlin +// DatabaseFactory.kt +object DatabaseFactory { + fun create(config: DatabaseConfig): Database { + val hikariConfig = HikariConfig().apply { + driverClassName = config.driver + jdbcUrl = config.url + username = config.username + password = config.password + maximumPoolSize = config.maxPoolSize + isAutoCommit = false + transactionIsolation = "TRANSACTION_READ_COMMITTED" + validate() + } + + return Database.connect(HikariDataSource(hikariConfig)) + } +} + +data class DatabaseConfig( + val url: String, + val driver: String = "org.postgresql.Driver", + val username: String = "", + val password: String = "", + val maxPoolSize: Int = 10, +) +``` + +### Flyway Migrations + +```kotlin +// FlywayMigration.kt +fun runMigrations(config: DatabaseConfig) { + Flyway.configure() + .dataSource(config.url, config.username, config.password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .load() + .migrate() +} + +// Application startup +fun Application.module() { + val config = DatabaseConfig( + url = environment.config.property("database.url").getString(), + username = environment.config.property("database.username").getString(), + password = environment.config.property("database.password").getString(), + ) + runMigrations(config) + val database = DatabaseFactory.create(config) + // ... +} +``` + +### Migration Files + +```sql +-- src/main/resources/db/migration/V1__create_users.sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + role VARCHAR(20) NOT NULL DEFAULT 'USER', + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_role ON users(role); +``` + +## Table Definitions + +### DSL Style Tables + +```kotlin +// tables/UsersTable.kt +object UsersTable : UUIDTable("users") { + val name = varchar("name", 100) + val email = varchar("email", 255).uniqueIndex() + val role = enumerationByName("role", 20) + val metadata = jsonb("metadata", Json.Default).nullable() + val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone) + val updatedAt = timestampWithTimeZone("updated_at").defaultExpression(CurrentTimestampWithTimeZone) +} + +object OrdersTable : UUIDTable("orders") { + val userId = uuid("user_id").references(UsersTable.id) + val status = enumerationByName("status", 20) + val totalAmount = long("total_amount") + val currency = varchar("currency", 3) + val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone) +} + +object OrderItemsTable : UUIDTable("order_items") { + val orderId = uuid("order_id").references(OrdersTable.id, onDelete = ReferenceOption.CASCADE) + val productId = uuid("product_id") + val quantity = integer("quantity") + val unitPrice = long("unit_price") +} +``` + +### Composite Tables + +```kotlin +object UserRolesTable : Table("user_roles") { + val userId = uuid("user_id").references(UsersTable.id, onDelete = ReferenceOption.CASCADE) + val roleId = uuid("role_id").references(RolesTable.id, onDelete = ReferenceOption.CASCADE) + override val primaryKey = PrimaryKey(userId, roleId) +} +``` + +## DSL Queries + +### Basic CRUD + +```kotlin +// Insert +suspend fun insertUser(name: String, email: String, role: Role): UUID = + newSuspendedTransaction { + UsersTable.insertAndGetId { + it[UsersTable.name] = name + it[UsersTable.email] = email + it[UsersTable.role] = role + }.value + } + +// Select by ID +suspend fun findUserById(id: UUID): UserRow? = + newSuspendedTransaction { + UsersTable.selectAll() + .where { UsersTable.id eq id } + .map { it.toUser() } + .singleOrNull() + } + +// Select with conditions +suspend fun findActiveAdmins(): List = + newSuspendedTransaction { + UsersTable.selectAll() + .where { (UsersTable.role eq Role.ADMIN) } + .orderBy(UsersTable.name) + .map { it.toUser() } + } + +// Update +suspend fun updateUserEmail(id: UUID, newEmail: String): Boolean = + newSuspendedTransaction { + UsersTable.update({ UsersTable.id eq id }) { + it[email] = newEmail + it[updatedAt] = CurrentTimestampWithTimeZone + } > 0 + } + +// Delete +suspend fun deleteUser(id: UUID): Boolean = + newSuspendedTransaction { + UsersTable.deleteWhere { UsersTable.id eq id } > 0 + } + +// Row mapping +private fun ResultRow.toUser() = UserRow( + id = this[UsersTable.id].value, + name = this[UsersTable.name], + email = this[UsersTable.email], + role = this[UsersTable.role], + metadata = this[UsersTable.metadata], + createdAt = this[UsersTable.createdAt], + updatedAt = this[UsersTable.updatedAt], +) +``` + +### Advanced Queries + +```kotlin +// Join queries +suspend fun findOrdersWithUser(userId: UUID): List = + newSuspendedTransaction { + (OrdersTable innerJoin UsersTable) + .selectAll() + .where { OrdersTable.userId eq userId } + .orderBy(OrdersTable.createdAt, SortOrder.DESC) + .map { row -> + OrderWithUser( + orderId = row[OrdersTable.id].value, + status = row[OrdersTable.status], + totalAmount = row[OrdersTable.totalAmount], + userName = row[UsersTable.name], + ) + } + } + +// Aggregation +suspend fun countUsersByRole(): Map = + newSuspendedTransaction { + UsersTable + .select(UsersTable.role, UsersTable.id.count()) + .groupBy(UsersTable.role) + .associate { row -> + row[UsersTable.role] to row[UsersTable.id.count()] + } + } + +// Subqueries +suspend fun findUsersWithOrders(): List = + newSuspendedTransaction { + UsersTable.selectAll() + .where { + UsersTable.id inSubQuery + OrdersTable.select(OrdersTable.userId).withDistinct() + } + .map { it.toUser() } + } + +// LIKE and pattern matching — always escape user input to prevent wildcard injection +private fun escapeLikePattern(input: String): String = + input.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + +suspend fun searchUsers(query: String): List = + newSuspendedTransaction { + val sanitized = escapeLikePattern(query.lowercase()) + UsersTable.selectAll() + .where { + (UsersTable.name.lowerCase() like "%${sanitized}%") or + (UsersTable.email.lowerCase() like "%${sanitized}%") + } + .map { it.toUser() } + } +``` + +### Pagination + +```kotlin +data class Page( + val data: List, + val total: Long, + val page: Int, + val limit: Int, +) { + val totalPages: Int get() = ((total + limit - 1) / limit).toInt() + val hasNext: Boolean get() = page < totalPages + val hasPrevious: Boolean get() = page > 1 +} + +suspend fun findUsersPaginated(page: Int, limit: Int): Page = + newSuspendedTransaction { + val total = UsersTable.selectAll().count() + val data = UsersTable.selectAll() + .orderBy(UsersTable.createdAt, SortOrder.DESC) + .limit(limit) + .offset(((page - 1) * limit).toLong()) + .map { it.toUser() } + + Page(data = data, total = total, page = page, limit = limit) + } +``` + +### Batch Operations + +```kotlin +// Batch insert +suspend fun insertUsers(users: List): List = + newSuspendedTransaction { + UsersTable.batchInsert(users) { user -> + this[UsersTable.name] = user.name + this[UsersTable.email] = user.email + this[UsersTable.role] = user.role + }.map { it[UsersTable.id].value } + } + +// Upsert (insert or update on conflict) +suspend fun upsertUser(id: UUID, name: String, email: String) { + newSuspendedTransaction { + UsersTable.upsert(UsersTable.email) { + it[UsersTable.id] = EntityID(id, UsersTable) + it[UsersTable.name] = name + it[UsersTable.email] = email + it[updatedAt] = CurrentTimestampWithTimeZone + } + } +} +``` + +## DAO Pattern + +### Entity Definitions + +```kotlin +// entities/UserEntity.kt +class UserEntity(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(UsersTable) + + var name by UsersTable.name + var email by UsersTable.email + var role by UsersTable.role + var metadata by UsersTable.metadata + var createdAt by UsersTable.createdAt + var updatedAt by UsersTable.updatedAt + + val orders by OrderEntity referrersOn OrdersTable.userId + + fun toModel(): User = User( + id = id.value, + name = name, + email = email, + role = role, + metadata = metadata, + createdAt = createdAt, + updatedAt = updatedAt, + ) +} + +class OrderEntity(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(OrdersTable) + + var user by UserEntity referencedOn OrdersTable.userId + var status by OrdersTable.status + var totalAmount by OrdersTable.totalAmount + var currency by OrdersTable.currency + var createdAt by OrdersTable.createdAt + + val items by OrderItemEntity referrersOn OrderItemsTable.orderId +} +``` + +### DAO Operations + +```kotlin +suspend fun findUserByEmail(email: String): User? = + newSuspendedTransaction { + UserEntity.find { UsersTable.email eq email } + .firstOrNull() + ?.toModel() + } + +suspend fun createUser(request: CreateUserRequest): User = + newSuspendedTransaction { + UserEntity.new { + name = request.name + email = request.email + role = request.role + }.toModel() + } + +suspend fun updateUser(id: UUID, request: UpdateUserRequest): User? = + newSuspendedTransaction { + UserEntity.findById(id)?.apply { + request.name?.let { name = it } + request.email?.let { email = it } + updatedAt = OffsetDateTime.now(ZoneOffset.UTC) + }?.toModel() + } +``` + +## Transactions + +### Suspend Transaction Support + +```kotlin +// Good: Use newSuspendedTransaction for coroutine support +suspend fun performDatabaseOperation(): Result = + runCatching { + newSuspendedTransaction { + val user = UserEntity.new { + name = "Alice" + email = "alice@example.com" + } + // All operations in this block are atomic + user.toModel() + } + } + +// Good: Nested transactions with savepoints +suspend fun transferFunds(fromId: UUID, toId: UUID, amount: Long) { + newSuspendedTransaction { + val from = UserEntity.findById(fromId) ?: throw NotFoundException("User $fromId not found") + val to = UserEntity.findById(toId) ?: throw NotFoundException("User $toId not found") + + // Debit + from.balance -= amount + // Credit + to.balance += amount + + // Both succeed or both fail + } +} +``` + +### Transaction Isolation + +```kotlin +suspend fun readCommittedQuery(): List = + newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) { + UserEntity.all().map { it.toModel() } + } + +suspend fun serializableOperation() { + newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) { + // Strictest isolation level for critical operations + } +} +``` + +## Repository Pattern + +### Interface Definition + +```kotlin +interface UserRepository { + suspend fun findById(id: UUID): User? + suspend fun findByEmail(email: String): User? + suspend fun findAll(page: Int, limit: Int): Page + suspend fun search(query: String): List + suspend fun create(request: CreateUserRequest): User + suspend fun update(id: UUID, request: UpdateUserRequest): User? + suspend fun delete(id: UUID): Boolean + suspend fun count(): Long +} +``` + +### Exposed Implementation + +```kotlin +class ExposedUserRepository( + private val database: Database, +) : UserRepository { + + override suspend fun findById(id: UUID): User? = + newSuspendedTransaction(db = database) { + UsersTable.selectAll() + .where { UsersTable.id eq id } + .map { it.toUser() } + .singleOrNull() + } + + override suspend fun findByEmail(email: String): User? = + newSuspendedTransaction(db = database) { + UsersTable.selectAll() + .where { UsersTable.email eq email } + .map { it.toUser() } + .singleOrNull() + } + + override suspend fun findAll(page: Int, limit: Int): Page = + newSuspendedTransaction(db = database) { + val total = UsersTable.selectAll().count() + val data = UsersTable.selectAll() + .orderBy(UsersTable.createdAt, SortOrder.DESC) + .limit(limit) + .offset(((page - 1) * limit).toLong()) + .map { it.toUser() } + Page(data = data, total = total, page = page, limit = limit) + } + + override suspend fun search(query: String): List = + newSuspendedTransaction(db = database) { + val sanitized = escapeLikePattern(query.lowercase()) + UsersTable.selectAll() + .where { + (UsersTable.name.lowerCase() like "%${sanitized}%") or + (UsersTable.email.lowerCase() like "%${sanitized}%") + } + .orderBy(UsersTable.name) + .map { it.toUser() } + } + + override suspend fun create(request: CreateUserRequest): User = + newSuspendedTransaction(db = database) { + UsersTable.insert { + it[name] = request.name + it[email] = request.email + it[role] = request.role + }.resultedValues!!.first().toUser() + } + + override suspend fun update(id: UUID, request: UpdateUserRequest): User? = + newSuspendedTransaction(db = database) { + val updated = UsersTable.update({ UsersTable.id eq id }) { + request.name?.let { name -> it[UsersTable.name] = name } + request.email?.let { email -> it[UsersTable.email] = email } + it[updatedAt] = CurrentTimestampWithTimeZone + } + if (updated > 0) findById(id) else null + } + + override suspend fun delete(id: UUID): Boolean = + newSuspendedTransaction(db = database) { + UsersTable.deleteWhere { UsersTable.id eq id } > 0 + } + + override suspend fun count(): Long = + newSuspendedTransaction(db = database) { + UsersTable.selectAll().count() + } + + private fun ResultRow.toUser() = User( + id = this[UsersTable.id].value, + name = this[UsersTable.name], + email = this[UsersTable.email], + role = this[UsersTable.role], + metadata = this[UsersTable.metadata], + createdAt = this[UsersTable.createdAt], + updatedAt = this[UsersTable.updatedAt], + ) +} +``` + +## JSON Columns + +### JSONB with kotlinx.serialization + +```kotlin +// Custom column type for JSONB +inline fun Table.jsonb( + name: String, + json: Json, +): Column = registerColumn(name, object : ColumnType() { + override fun sqlType() = "JSONB" + + override fun valueFromDB(value: Any): T = when (value) { + is String -> json.decodeFromString(value) + is PGobject -> { + val jsonString = value.value + ?: throw IllegalArgumentException("PGobject value is null for column '$name'") + json.decodeFromString(jsonString) + } + else -> throw IllegalArgumentException("Unexpected value: $value") + } + + override fun notNullValueToDB(value: T): Any = + PGobject().apply { + type = "jsonb" + this.value = json.encodeToString(value) + } +}) + +// Usage in table +@Serializable +data class UserMetadata( + val preferences: Map = emptyMap(), + val tags: List = emptyList(), +) + +object UsersTable : UUIDTable("users") { + val metadata = jsonb("metadata", Json.Default).nullable() +} +``` + +## Testing with Exposed + +### In-Memory Database for Tests + +```kotlin +class UserRepositoryTest : FunSpec({ + lateinit var database: Database + lateinit var repository: UserRepository + + beforeSpec { + database = Database.connect( + url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", + driver = "org.h2.Driver", + ) + transaction(database) { + SchemaUtils.create(UsersTable) + } + repository = ExposedUserRepository(database) + } + + beforeTest { + transaction(database) { + UsersTable.deleteAll() + } + } + + test("create and find user") { + val user = repository.create(CreateUserRequest("Alice", "alice@example.com")) + + user.name shouldBe "Alice" + user.email shouldBe "alice@example.com" + + val found = repository.findById(user.id) + found shouldBe user + } + + test("findByEmail returns null for unknown email") { + val result = repository.findByEmail("unknown@example.com") + result.shouldBeNull() + } + + test("pagination works correctly") { + repeat(25) { i -> + repository.create(CreateUserRequest("User $i", "user$i@example.com")) + } + + val page1 = repository.findAll(page = 1, limit = 10) + page1.data shouldHaveSize 10 + page1.total shouldBe 25 + page1.hasNext shouldBe true + + val page3 = repository.findAll(page = 3, limit = 10) + page3.data shouldHaveSize 5 + page3.hasNext shouldBe false + } +}) +``` + +## Gradle Dependencies + +```kotlin +// build.gradle.kts +dependencies { + // Exposed + implementation("org.jetbrains.exposed:exposed-core:1.0.0") + implementation("org.jetbrains.exposed:exposed-dao:1.0.0") + implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0") + implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0") + implementation("org.jetbrains.exposed:exposed-json:1.0.0") + + // Database driver + implementation("org.postgresql:postgresql:42.7.5") + + // Connection pooling + implementation("com.zaxxer:HikariCP:6.2.1") + + // Migrations + implementation("org.flywaydb:flyway-core:10.22.0") + implementation("org.flywaydb:flyway-database-postgresql:10.22.0") + + // Testing + testImplementation("com.h2database:h2:2.3.232") +} +``` + +## Quick Reference: Exposed Patterns + +| Pattern | Description | +|---------|-------------| +| `object Table : UUIDTable("name")` | Define table with UUID primary key | +| `newSuspendedTransaction { }` | Coroutine-safe transaction block | +| `Table.selectAll().where { }` | Query with conditions | +| `Table.insertAndGetId { }` | Insert and return generated ID | +| `Table.update({ condition }) { }` | Update matching rows | +| `Table.deleteWhere { }` | Delete matching rows | +| `Table.batchInsert(items) { }` | Efficient bulk insert | +| `innerJoin` / `leftJoin` | Join tables | +| `orderBy` / `limit` / `offset` | Sort and paginate | +| `count()` / `sum()` / `avg()` | Aggregation functions | + +**Remember**: Use the DSL style for simple queries and the DAO style when you need entity lifecycle management. Always use `newSuspendedTransaction` for coroutine support, and wrap database operations behind a repository interface for testability. diff --git a/skills/kotlin-ktor-patterns/SKILL.md b/skills/kotlin-ktor-patterns/SKILL.md new file mode 100644 index 000000000..10c9522f8 --- /dev/null +++ b/skills/kotlin-ktor-patterns/SKILL.md @@ -0,0 +1,689 @@ +--- +name: kotlin-ktor-patterns +description: Ktor server patterns including routing DSL, plugins, authentication, Koin DI, kotlinx.serialization, WebSockets, and testApplication testing. +origin: ECC +--- + +# Ktor Server Patterns + +Comprehensive Ktor patterns for building robust, maintainable HTTP servers with Kotlin coroutines. + +## When to Activate + +- Building Ktor HTTP servers +- Configuring Ktor plugins (Auth, CORS, ContentNegotiation, StatusPages) +- Implementing REST APIs with Ktor +- Setting up dependency injection with Koin +- Writing Ktor integration tests with testApplication +- Working with WebSockets in Ktor + +## Application Structure + +### Standard Ktor Project Layout + +```text +src/main/kotlin/ +├── com/example/ +│ ├── Application.kt # Entry point, module configuration +│ ├── plugins/ +│ │ ├── Routing.kt # Route definitions +│ │ ├── Serialization.kt # Content negotiation setup +│ │ ├── Authentication.kt # Auth configuration +│ │ ├── StatusPages.kt # Error handling +│ │ └── CORS.kt # CORS configuration +│ ├── routes/ +│ │ ├── UserRoutes.kt # /users endpoints +│ │ ├── AuthRoutes.kt # /auth endpoints +│ │ └── HealthRoutes.kt # /health endpoints +│ ├── models/ +│ │ ├── User.kt # Domain models +│ │ └── ApiResponse.kt # Response envelopes +│ ├── services/ +│ │ ├── UserService.kt # Business logic +│ │ └── AuthService.kt # Auth logic +│ ├── repositories/ +│ │ ├── UserRepository.kt # Data access interface +│ │ └── ExposedUserRepository.kt +│ └── di/ +│ └── AppModule.kt # Koin modules +src/test/kotlin/ +├── com/example/ +│ ├── routes/ +│ │ └── UserRoutesTest.kt +│ └── services/ +│ └── UserServiceTest.kt +``` + +### Application Entry Point + +```kotlin +// Application.kt +fun main() { + embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true) +} + +fun Application.module() { + configureSerialization() + configureAuthentication() + configureStatusPages() + configureCORS() + configureDI() + configureRouting() +} +``` + +## Routing DSL + +### Basic Routes + +```kotlin +// plugins/Routing.kt +fun Application.configureRouting() { + routing { + userRoutes() + authRoutes() + healthRoutes() + } +} + +// routes/UserRoutes.kt +fun Route.userRoutes() { + val userService by inject() + + route("/users") { + get { + val users = userService.getAll() + call.respond(users) + } + + get("/{id}") { + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing id") + val user = userService.getById(id) + ?: return@get call.respond(HttpStatusCode.NotFound) + call.respond(user) + } + + post { + val request = call.receive() + val user = userService.create(request) + call.respond(HttpStatusCode.Created, user) + } + + put("/{id}") { + val id = call.parameters["id"] + ?: return@put call.respond(HttpStatusCode.BadRequest, "Missing id") + val request = call.receive() + val user = userService.update(id, request) + ?: return@put call.respond(HttpStatusCode.NotFound) + call.respond(user) + } + + delete("/{id}") { + val id = call.parameters["id"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing id") + val deleted = userService.delete(id) + if (deleted) call.respond(HttpStatusCode.NoContent) + else call.respond(HttpStatusCode.NotFound) + } + } +} +``` + +### Route Organization with Authenticated Routes + +```kotlin +fun Route.userRoutes() { + route("/users") { + // Public routes + get { /* list users */ } + get("/{id}") { /* get user */ } + + // Protected routes + authenticate("jwt") { + post { /* create user - requires auth */ } + put("/{id}") { /* update user - requires auth */ } + delete("/{id}") { /* delete user - requires auth */ } + } + } +} +``` + +## Content Negotiation & Serialization + +### kotlinx.serialization Setup + +```kotlin +// plugins/Serialization.kt +fun Application.configureSerialization() { + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = false + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + }) + } +} +``` + +### Serializable Models + +```kotlin +@Serializable +data class UserResponse( + val id: String, + val name: String, + val email: String, + val role: Role, + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, +) + +@Serializable +data class CreateUserRequest( + val name: String, + val email: String, + val role: Role = Role.USER, +) + +@Serializable +data class ApiResponse( + val success: Boolean, + val data: T? = null, + val error: String? = null, +) { + companion object { + fun ok(data: T): ApiResponse = ApiResponse(success = true, data = data) + fun error(message: String): ApiResponse = ApiResponse(success = false, error = message) + } +} + +@Serializable +data class PaginatedResponse( + val data: List, + val total: Long, + val page: Int, + val limit: Int, +) +``` + +### Custom Serializers + +```kotlin +object InstantSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Instant) = + encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Instant = + Instant.parse(decoder.decodeString()) +} +``` + +## Authentication + +### JWT Authentication + +```kotlin +// plugins/Authentication.kt +fun Application.configureAuthentication() { + val jwtSecret = environment.config.property("jwt.secret").getString() + val jwtIssuer = environment.config.property("jwt.issuer").getString() + val jwtAudience = environment.config.property("jwt.audience").getString() + val jwtRealm = environment.config.property("jwt.realm").getString() + + install(Authentication) { + jwt("jwt") { + realm = jwtRealm + verifier( + JWT.require(Algorithm.HMAC256(jwtSecret)) + .withAudience(jwtAudience) + .withIssuer(jwtIssuer) + .build() + ) + validate { credential -> + if (credential.payload.audience.contains(jwtAudience)) { + JWTPrincipal(credential.payload) + } else { + null + } + } + challenge { _, _ -> + call.respond(HttpStatusCode.Unauthorized, ApiResponse.error("Invalid or expired token")) + } + } + } +} + +// Extracting user from JWT +fun ApplicationCall.userId(): String = + principal() + ?.payload + ?.getClaim("userId") + ?.asString() + ?: throw AuthenticationException("No userId in token") +``` + +### Auth Routes + +```kotlin +fun Route.authRoutes() { + val authService by inject() + + route("/auth") { + post("/login") { + val request = call.receive() + val token = authService.login(request.email, request.password) + ?: return@post call.respond( + HttpStatusCode.Unauthorized, + ApiResponse.error("Invalid credentials"), + ) + call.respond(ApiResponse.ok(TokenResponse(token))) + } + + post("/register") { + val request = call.receive() + val user = authService.register(request) + call.respond(HttpStatusCode.Created, ApiResponse.ok(user)) + } + + authenticate("jwt") { + get("/me") { + val userId = call.userId() + val user = authService.getProfile(userId) + call.respond(ApiResponse.ok(user)) + } + } + } +} +``` + +## Status Pages (Error Handling) + +```kotlin +// plugins/StatusPages.kt +fun Application.configureStatusPages() { + install(StatusPages) { + exception { call, cause -> + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("Invalid request body: ${cause.message}"), + ) + } + + exception { call, cause -> + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error(cause.message ?: "Bad request"), + ) + } + + exception { call, _ -> + call.respond( + HttpStatusCode.Unauthorized, + ApiResponse.error("Authentication required"), + ) + } + + exception { call, _ -> + call.respond( + HttpStatusCode.Forbidden, + ApiResponse.error("Access denied"), + ) + } + + exception { call, cause -> + call.respond( + HttpStatusCode.NotFound, + ApiResponse.error(cause.message ?: "Resource not found"), + ) + } + + exception { call, cause -> + call.application.log.error("Unhandled exception", cause) + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse.error("Internal server error"), + ) + } + + status(HttpStatusCode.NotFound) { call, status -> + call.respond(status, ApiResponse.error("Route not found")) + } + } +} +``` + +## CORS Configuration + +```kotlin +// plugins/CORS.kt +fun Application.configureCORS() { + install(CORS) { + allowHost("localhost:3000") + allowHost("example.com", schemes = listOf("https")) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowCredentials = true + maxAgeInSeconds = 3600 + } +} +``` + +## Koin Dependency Injection + +### Module Definition + +```kotlin +// di/AppModule.kt +val appModule = module { + // Database + single { DatabaseFactory.create(get()) } + + // Repositories + single { ExposedUserRepository(get()) } + single { ExposedOrderRepository(get()) } + + // Services + single { UserService(get()) } + single { OrderService(get(), get()) } + single { AuthService(get(), get()) } +} + +// Application setup +fun Application.configureDI() { + install(Koin) { + modules(appModule) + } +} +``` + +### Using Koin in Routes + +```kotlin +fun Route.userRoutes() { + val userService by inject() + + route("/users") { + get { + val users = userService.getAll() + call.respond(ApiResponse.ok(users)) + } + } +} +``` + +### Koin for Testing + +```kotlin +class UserServiceTest : FunSpec(), KoinTest { + override fun extensions() = listOf(KoinExtension(testModule)) + + private val testModule = module { + single { mockk() } + single { UserService(get()) } + } + + private val repository by inject() + private val service by inject() + + init { + test("getUser returns user") { + coEvery { repository.findById("1") } returns testUser + service.getById("1") shouldBe testUser + } + } +} +``` + +## Request Validation + +```kotlin +// Validate request data in routes +fun Route.userRoutes() { + val userService by inject() + + post("/users") { + val request = call.receive() + + // Validate + require(request.name.isNotBlank()) { "Name is required" } + require(request.name.length <= 100) { "Name must be 100 characters or less" } + require(request.email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" } + + val user = userService.create(request) + call.respond(HttpStatusCode.Created, ApiResponse.ok(user)) + } +} + +// Or use a validation extension +fun CreateUserRequest.validate() { + require(name.isNotBlank()) { "Name is required" } + require(name.length <= 100) { "Name must be 100 characters or less" } + require(email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" } +} +``` + +## WebSockets + +```kotlin +fun Application.configureWebSockets() { + install(WebSockets) { + pingPeriod = 15.seconds + timeout = 15.seconds + maxFrameSize = 64 * 1024 // 64 KiB — increase only if your protocol requires larger frames + masking = false // Server-to-client frames are unmasked per RFC 6455; client-to-server are always masked by Ktor + } +} + +fun Route.chatRoutes() { + val connections = Collections.synchronizedSet(LinkedHashSet()) + + webSocket("/chat") { + val thisConnection = Connection(this) + connections += thisConnection + + try { + send("Connected! Users online: ${connections.size}") + + for (frame in incoming) { + frame as? Frame.Text ?: continue + val text = frame.readText() + val message = ChatMessage(thisConnection.name, text) + + // Snapshot under lock to avoid ConcurrentModificationException + val snapshot = synchronized(connections) { connections.toList() } + snapshot.forEach { conn -> + conn.session.send(Json.encodeToString(message)) + } + } + } catch (e: Exception) { + logger.error("WebSocket error", e) + } finally { + connections -= thisConnection + } + } +} + +data class Connection(val session: DefaultWebSocketSession) { + val name: String = "User-${counter.getAndIncrement()}" + + companion object { + private val counter = AtomicInteger(0) + } +} +``` + +## testApplication Testing + +### Basic Route Testing + +```kotlin +class UserRoutesTest : FunSpec({ + test("GET /users returns list of users") { + testApplication { + application { + install(Koin) { modules(testModule) } + configureSerialization() + configureRouting() + } + + val response = client.get("/users") + + response.status shouldBe HttpStatusCode.OK + val body = response.body>>() + body.success shouldBe true + body.data.shouldNotBeNull().shouldNotBeEmpty() + } + } + + test("POST /users creates a user") { + testApplication { + application { + install(Koin) { modules(testModule) } + configureSerialization() + configureStatusPages() + configureRouting() + } + + val client = createClient { + install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { + json() + } + } + + val response = client.post("/users") { + contentType(ContentType.Application.Json) + setBody(CreateUserRequest("Alice", "alice@example.com")) + } + + response.status shouldBe HttpStatusCode.Created + } + } + + test("GET /users/{id} returns 404 for unknown id") { + testApplication { + application { + install(Koin) { modules(testModule) } + configureSerialization() + configureStatusPages() + configureRouting() + } + + val response = client.get("/users/unknown-id") + + response.status shouldBe HttpStatusCode.NotFound + } + } +}) +``` + +### Testing Authenticated Routes + +```kotlin +class AuthenticatedRoutesTest : FunSpec({ + test("protected route requires JWT") { + testApplication { + application { + install(Koin) { modules(testModule) } + configureSerialization() + configureAuthentication() + configureRouting() + } + + val response = client.post("/users") { + contentType(ContentType.Application.Json) + setBody(CreateUserRequest("Alice", "alice@example.com")) + } + + response.status shouldBe HttpStatusCode.Unauthorized + } + } + + test("protected route succeeds with valid JWT") { + testApplication { + application { + install(Koin) { modules(testModule) } + configureSerialization() + configureAuthentication() + configureRouting() + } + + val token = generateTestJWT(userId = "test-user") + + val client = createClient { + install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } + } + + val response = client.post("/users") { + contentType(ContentType.Application.Json) + bearerAuth(token) + setBody(CreateUserRequest("Alice", "alice@example.com")) + } + + response.status shouldBe HttpStatusCode.Created + } + } +}) +``` + +## Configuration + +### application.yaml + +```yaml +ktor: + application: + modules: + - com.example.ApplicationKt.module + deployment: + port: 8080 + +jwt: + secret: ${JWT_SECRET} + issuer: "https://example.com" + audience: "https://example.com/api" + realm: "example" + +database: + url: ${DATABASE_URL} + driver: "org.postgresql.Driver" + maxPoolSize: 10 +``` + +### Reading Config + +```kotlin +fun Application.configureDI() { + val dbUrl = environment.config.property("database.url").getString() + val dbDriver = environment.config.property("database.driver").getString() + val maxPoolSize = environment.config.property("database.maxPoolSize").getString().toInt() + + install(Koin) { + modules(module { + single { DatabaseConfig(dbUrl, dbDriver, maxPoolSize) } + single { DatabaseFactory.create(get()) } + }) + } +} +``` + +## Quick Reference: Ktor Patterns + +| Pattern | Description | +|---------|-------------| +| `route("/path") { get { } }` | Route grouping with DSL | +| `call.receive()` | Deserialize request body | +| `call.respond(status, body)` | Send response with status | +| `call.parameters["id"]` | Read path parameters | +| `call.request.queryParameters["q"]` | Read query parameters | +| `install(Plugin) { }` | Install and configure plugin | +| `authenticate("name") { }` | Protect routes with auth | +| `by inject()` | Koin dependency injection | +| `testApplication { }` | Integration testing | + +**Remember**: Ktor is designed around Kotlin coroutines and DSLs. Keep routes thin, push logic to services, and use Koin for dependency injection. Test with `testApplication` for full integration coverage. diff --git a/skills/kotlin-patterns/SKILL.md b/skills/kotlin-patterns/SKILL.md new file mode 100644 index 000000000..5e75d2710 --- /dev/null +++ b/skills/kotlin-patterns/SKILL.md @@ -0,0 +1,711 @@ +--- +name: kotlin-patterns +description: Idiomatic Kotlin patterns, best practices, and conventions for building robust, efficient, and maintainable Kotlin applications with coroutines, null safety, and DSL builders. +origin: ECC +--- + +# Kotlin Development Patterns + +Idiomatic Kotlin patterns and best practices for building robust, efficient, and maintainable applications. + +## When to Use + +- Writing new Kotlin code +- Reviewing Kotlin code +- Refactoring existing Kotlin code +- Designing Kotlin modules or libraries +- Configuring Gradle Kotlin DSL builds + +## How It Works + +This skill enforces idiomatic Kotlin conventions across seven key areas: null safety using the type system and safe-call operators, immutability via `val` and `copy()` on data classes, sealed classes and interfaces for exhaustive type hierarchies, structured concurrency with coroutines and `Flow`, extension functions for adding behaviour without inheritance, type-safe DSL builders using `@DslMarker` and lambda receivers, and Gradle Kotlin DSL for build configuration. + +## Examples + +**Null safety with Elvis operator:** +```kotlin +fun getUserEmail(userId: String): String { + val user = userRepository.findById(userId) + return user?.email ?: "unknown@example.com" +} +``` + +**Sealed class for exhaustive results:** +```kotlin +sealed class Result { + data class Success(val data: T) : Result() + data class Failure(val error: AppError) : Result() + data object Loading : Result() +} +``` + +**Structured concurrency with async/await:** +```kotlin +suspend fun fetchUserWithPosts(userId: String): UserProfile = + coroutineScope { + val user = async { userService.getUser(userId) } + val posts = async { postService.getUserPosts(userId) } + UserProfile(user = user.await(), posts = posts.await()) + } +``` + +## Core Principles + +### 1. Null Safety + +Kotlin's type system distinguishes nullable and non-nullable types. Leverage it fully. + +```kotlin +// Good: Use non-nullable types by default +fun getUser(id: String): User { + return userRepository.findById(id) + ?: throw UserNotFoundException("User $id not found") +} + +// Good: Safe calls and Elvis operator +fun getUserEmail(userId: String): String { + val user = userRepository.findById(userId) + return user?.email ?: "unknown@example.com" +} + +// Bad: Force-unwrapping nullable types +fun getUserEmail(userId: String): String { + val user = userRepository.findById(userId) + return user!!.email // Throws NPE if null +} +``` + +### 2. Immutability by Default + +Prefer `val` over `var`, immutable collections over mutable ones. + +```kotlin +// Good: Immutable data +data class User( + val id: String, + val name: String, + val email: String, +) + +// Good: Transform with copy() +fun updateEmail(user: User, newEmail: String): User = + user.copy(email = newEmail) + +// Good: Immutable collections +val users: List = listOf(user1, user2) +val filtered = users.filter { it.email.isNotBlank() } + +// Bad: Mutable state +var currentUser: User? = null // Avoid mutable global state +val mutableUsers = mutableListOf() // Avoid unless truly needed +``` + +### 3. Expression Bodies and Single-Expression Functions + +Use expression bodies for concise, readable functions. + +```kotlin +// Good: Expression body +fun isAdult(age: Int): Boolean = age >= 18 + +fun formatFullName(first: String, last: String): String = + "$first $last".trim() + +fun User.displayName(): String = + name.ifBlank { email.substringBefore('@') } + +// Good: When as expression +fun statusMessage(code: Int): String = when (code) { + 200 -> "OK" + 404 -> "Not Found" + 500 -> "Internal Server Error" + else -> "Unknown status: $code" +} + +// Bad: Unnecessary block body +fun isAdult(age: Int): Boolean { + return age >= 18 +} +``` + +### 4. Data Classes for Value Objects + +Use data classes for types that primarily hold data. + +```kotlin +// Good: Data class with copy, equals, hashCode, toString +data class CreateUserRequest( + val name: String, + val email: String, + val role: Role = Role.USER, +) + +// Good: Value class for type safety (zero overhead at runtime) +@JvmInline +value class UserId(val value: String) { + init { + require(value.isNotBlank()) { "UserId cannot be blank" } + } +} + +@JvmInline +value class Email(val value: String) { + init { + require('@' in value) { "Invalid email: $value" } + } +} + +fun getUser(id: UserId): User = userRepository.findById(id) +``` + +## Sealed Classes and Interfaces + +### Modeling Restricted Hierarchies + +```kotlin +// Good: Sealed class for exhaustive when +sealed class Result { + data class Success(val data: T) : Result() + data class Failure(val error: AppError) : Result() + data object Loading : Result() +} + +fun Result.getOrNull(): T? = when (this) { + is Result.Success -> data + is Result.Failure -> null + is Result.Loading -> null +} + +fun Result.getOrThrow(): T = when (this) { + is Result.Success -> data + is Result.Failure -> throw error.toException() + is Result.Loading -> throw IllegalStateException("Still loading") +} +``` + +### Sealed Interfaces for API Responses + +```kotlin +sealed interface ApiError { + val message: String + + data class NotFound(override val message: String) : ApiError + data class Unauthorized(override val message: String) : ApiError + data class Validation( + override val message: String, + val field: String, + ) : ApiError + data class Internal( + override val message: String, + val cause: Throwable? = null, + ) : ApiError +} + +fun ApiError.toStatusCode(): Int = when (this) { + is ApiError.NotFound -> 404 + is ApiError.Unauthorized -> 401 + is ApiError.Validation -> 422 + is ApiError.Internal -> 500 +} +``` + +## Scope Functions + +### When to Use Each + +```kotlin +// let: Transform nullable or scoped result +val length: Int? = name?.let { it.trim().length } + +// apply: Configure an object (returns the object) +val user = User().apply { + name = "Alice" + email = "alice@example.com" +} + +// also: Side effects (returns the object) +val user = createUser(request).also { logger.info("Created user: ${it.id}") } + +// run: Execute a block with receiver (returns result) +val result = connection.run { + prepareStatement(sql) + executeQuery() +} + +// with: Non-extension form of run +val csv = with(StringBuilder()) { + appendLine("name,email") + users.forEach { appendLine("${it.name},${it.email}") } + toString() +} +``` + +### Anti-Patterns + +```kotlin +// Bad: Nesting scope functions +user?.let { u -> + u.address?.let { addr -> + addr.city?.let { city -> + println(city) // Hard to read + } + } +} + +// Good: Chain safe calls instead +val city = user?.address?.city +city?.let { println(it) } +``` + +## Extension Functions + +### Adding Functionality Without Inheritance + +```kotlin +// Good: Domain-specific extensions +fun String.toSlug(): String = + lowercase() + .replace(Regex("[^a-z0-9\\s-]"), "") + .replace(Regex("\\s+"), "-") + .trim('-') + +fun Instant.toLocalDate(zone: ZoneId = ZoneId.systemDefault()): LocalDate = + atZone(zone).toLocalDate() + +// Good: Collection extensions +fun List.second(): T = this[1] + +fun List.secondOrNull(): T? = getOrNull(1) + +// Good: Scoped extensions (not polluting global namespace) +class UserService { + private fun User.isActive(): Boolean = + status == Status.ACTIVE && lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) + + fun getActiveUsers(): List = userRepository.findAll().filter { it.isActive() } +} +``` + +## Coroutines + +### Structured Concurrency + +```kotlin +// Good: Structured concurrency with coroutineScope +suspend fun fetchUserWithPosts(userId: String): UserProfile = + coroutineScope { + val userDeferred = async { userService.getUser(userId) } + val postsDeferred = async { postService.getUserPosts(userId) } + + UserProfile( + user = userDeferred.await(), + posts = postsDeferred.await(), + ) + } + +// Good: supervisorScope when children can fail independently +suspend fun fetchDashboard(userId: String): Dashboard = + supervisorScope { + val user = async { userService.getUser(userId) } + val notifications = async { notificationService.getRecent(userId) } + val recommendations = async { recommendationService.getFor(userId) } + + Dashboard( + user = user.await(), + notifications = try { + notifications.await() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + emptyList() + }, + recommendations = try { + recommendations.await() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + emptyList() + }, + ) + } +``` + +### Flow for Reactive Streams + +```kotlin +// Good: Cold flow with proper error handling +fun observeUsers(): Flow> = flow { + while (currentCoroutineContext().isActive) { + val users = userRepository.findAll() + emit(users) + delay(5.seconds) + } +}.catch { e -> + logger.error("Error observing users", e) + emit(emptyList()) +} + +// Good: Flow operators +fun searchUsers(query: Flow): Flow> = + query + .debounce(300.milliseconds) + .distinctUntilChanged() + .filter { it.length >= 2 } + .mapLatest { q -> userRepository.search(q) } + .catch { emit(emptyList()) } +``` + +### Cancellation and Cleanup + +```kotlin +// Good: Respect cancellation +suspend fun processItems(items: List) { + items.forEach { item -> + ensureActive() // Check cancellation before expensive work + processItem(item) + } +} + +// Good: Cleanup with try/finally +suspend fun acquireAndProcess() { + val resource = acquireResource() + try { + resource.process() + } finally { + withContext(NonCancellable) { + resource.release() // Always release, even on cancellation + } + } +} +``` + +## Delegation + +### Property Delegation + +```kotlin +// Lazy initialization +val expensiveData: List by lazy { + userRepository.findAll() +} + +// Observable property +var name: String by Delegates.observable("initial") { _, old, new -> + logger.info("Name changed from '$old' to '$new'") +} + +// Map-backed properties +class Config(private val map: Map) { + val host: String by map + val port: Int by map + val debug: Boolean by map +} + +val config = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to true)) +``` + +### Interface Delegation + +```kotlin +// Good: Delegate interface implementation +class LoggingUserRepository( + private val delegate: UserRepository, + private val logger: Logger, +) : UserRepository by delegate { + // Only override what you need to add logging to + override suspend fun findById(id: String): User? { + logger.info("Finding user by id: $id") + return delegate.findById(id).also { + logger.info("Found user: ${it?.name ?: "null"}") + } + } +} +``` + +## DSL Builders + +### Type-Safe Builders + +```kotlin +// Good: DSL with @DslMarker +@DslMarker +annotation class HtmlDsl + +@HtmlDsl +class HTML { + private val children = mutableListOf() + + fun head(init: Head.() -> Unit) { + children += Head().apply(init) + } + + fun body(init: Body.() -> Unit) { + children += Body().apply(init) + } + + override fun toString(): String = children.joinToString("\n") +} + +fun html(init: HTML.() -> Unit): HTML = HTML().apply(init) + +// Usage +val page = html { + head { title("My Page") } + body { + h1("Welcome") + p("Hello, World!") + } +} +``` + +### Configuration DSL + +```kotlin +data class ServerConfig( + val host: String = "0.0.0.0", + val port: Int = 8080, + val ssl: SslConfig? = null, + val database: DatabaseConfig? = null, +) + +data class SslConfig(val certPath: String, val keyPath: String) +data class DatabaseConfig(val url: String, val maxPoolSize: Int = 10) + +class ServerConfigBuilder { + var host: String = "0.0.0.0" + var port: Int = 8080 + private var ssl: SslConfig? = null + private var database: DatabaseConfig? = null + + fun ssl(certPath: String, keyPath: String) { + ssl = SslConfig(certPath, keyPath) + } + + fun database(url: String, maxPoolSize: Int = 10) { + database = DatabaseConfig(url, maxPoolSize) + } + + fun build(): ServerConfig = ServerConfig(host, port, ssl, database) +} + +fun serverConfig(init: ServerConfigBuilder.() -> Unit): ServerConfig = + ServerConfigBuilder().apply(init).build() + +// Usage +val config = serverConfig { + host = "0.0.0.0" + port = 443 + ssl("/certs/cert.pem", "/certs/key.pem") + database("jdbc:postgresql://localhost:5432/mydb", maxPoolSize = 20) +} +``` + +## Sequences for Lazy Evaluation + +```kotlin +// Good: Use sequences for large collections with multiple operations +val result = users.asSequence() + .filter { it.isActive } + .map { it.email } + .filter { it.endsWith("@company.com") } + .take(10) + .toList() + +// Good: Generate infinite sequences +val fibonacci: Sequence = sequence { + var a = 0L + var b = 1L + while (true) { + yield(a) + val next = a + b + a = b + b = next + } +} + +val first20 = fibonacci.take(20).toList() +``` + +## Gradle Kotlin DSL + +### build.gradle.kts Configuration + +```kotlin +// Check for latest versions: https://kotlinlang.org/docs/releases.html +plugins { + kotlin("jvm") version "2.3.10" + kotlin("plugin.serialization") version "2.3.10" + id("io.ktor.plugin") version "3.4.0" + id("org.jetbrains.kotlinx.kover") version "0.9.7" + id("io.gitlab.arturbosch.detekt") version "1.23.8" +} + +group = "com.example" +version = "1.0.0" + +kotlin { + jvmToolchain(21) +} + +dependencies { + // Ktor + implementation("io.ktor:ktor-server-core:3.4.0") + implementation("io.ktor:ktor-server-netty:3.4.0") + implementation("io.ktor:ktor-server-content-negotiation:3.4.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.0") + + // Exposed + implementation("org.jetbrains.exposed:exposed-core:1.0.0") + implementation("org.jetbrains.exposed:exposed-dao:1.0.0") + implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0") + implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0") + + // Koin + implementation("io.insert-koin:koin-ktor:4.2.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + + // Testing + testImplementation("io.kotest:kotest-runner-junit5:6.1.4") + testImplementation("io.kotest:kotest-assertions-core:6.1.4") + testImplementation("io.kotest:kotest-property:6.1.4") + testImplementation("io.mockk:mockk:1.14.9") + testImplementation("io.ktor:ktor-server-test-host:3.4.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") +} + +tasks.withType { + useJUnitPlatform() +} + +detekt { + config.setFrom(files("config/detekt/detekt.yml")) + buildUponDefaultConfig = true +} +``` + +## Error Handling Patterns + +### Result Type for Domain Operations + +```kotlin +// Good: Use Kotlin's Result or a custom sealed class +suspend fun createUser(request: CreateUserRequest): Result = runCatching { + require(request.name.isNotBlank()) { "Name cannot be blank" } + require('@' in request.email) { "Invalid email format" } + + val user = User( + id = UserId(UUID.randomUUID().toString()), + name = request.name, + email = Email(request.email), + ) + userRepository.save(user) + user +} + +// Good: Chain results +val displayName = createUser(request) + .map { it.name } + .getOrElse { "Unknown" } +``` + +### require, check, error + +```kotlin +// Good: Preconditions with clear messages +fun withdraw(account: Account, amount: Money): Account { + require(amount.value > 0) { "Amount must be positive: $amount" } + check(account.balance >= amount) { "Insufficient balance: ${account.balance} < $amount" } + + return account.copy(balance = account.balance - amount) +} +``` + +## Collection Operations + +### Idiomatic Collection Processing + +```kotlin +// Good: Chained operations +val activeAdminEmails: List = users + .filter { it.role == Role.ADMIN && it.isActive } + .sortedBy { it.name } + .map { it.email } + +// Good: Grouping and aggregation +val usersByRole: Map> = users.groupBy { it.role } + +val oldestByRole: Map = users.groupBy { it.role } + .mapValues { (_, users) -> users.minByOrNull { it.createdAt } } + +// Good: Associate for map creation +val usersById: Map = users.associateBy { it.id } + +// Good: Partition for splitting +val (active, inactive) = users.partition { it.isActive } +``` + +## Quick Reference: Kotlin Idioms + +| Idiom | Description | +|-------|-------------| +| `val` over `var` | Prefer immutable variables | +| `data class` | For value objects with equals/hashCode/copy | +| `sealed class/interface` | For restricted type hierarchies | +| `value class` | For type-safe wrappers with zero overhead | +| Expression `when` | Exhaustive pattern matching | +| Safe call `?.` | Null-safe member access | +| Elvis `?:` | Default value for nullables | +| `let`/`apply`/`also`/`run`/`with` | Scope functions for clean code | +| Extension functions | Add behavior without inheritance | +| `copy()` | Immutable updates on data classes | +| `require`/`check` | Precondition assertions | +| Coroutine `async`/`await` | Structured concurrent execution | +| `Flow` | Cold reactive streams | +| `sequence` | Lazy evaluation | +| Delegation `by` | Reuse implementation without inheritance | + +## Anti-Patterns to Avoid + +```kotlin +// Bad: Force-unwrapping nullable types +val name = user!!.name + +// Bad: Platform type leakage from Java +fun getLength(s: String) = s.length // Safe +fun getLength(s: String?) = s?.length ?: 0 // Handle nulls from Java + +// Bad: Mutable data classes +data class MutableUser(var name: String, var email: String) + +// Bad: Using exceptions for control flow +try { + val user = findUser(id) +} catch (e: NotFoundException) { + // Don't use exceptions for expected cases +} + +// Good: Use nullable return or Result +val user: User? = findUserOrNull(id) + +// Bad: Ignoring coroutine scope +GlobalScope.launch { /* Avoid GlobalScope */ } + +// Good: Use structured concurrency +coroutineScope { + launch { /* Properly scoped */ } +} + +// Bad: Deeply nested scope functions +user?.let { u -> + u.address?.let { a -> + a.city?.let { c -> process(c) } + } +} + +// Good: Direct null-safe chain +user?.address?.city?.let { process(it) } +``` + +**Remember**: Kotlin code should be concise but readable. Leverage the type system for safety, prefer immutability, and use coroutines for concurrency. When in doubt, let the compiler help you. diff --git a/skills/kotlin-testing/SKILL.md b/skills/kotlin-testing/SKILL.md new file mode 100644 index 000000000..bfc2772f3 --- /dev/null +++ b/skills/kotlin-testing/SKILL.md @@ -0,0 +1,824 @@ +--- +name: kotlin-testing +description: Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices. +origin: ECC +--- + +# Kotlin Testing Patterns + +Comprehensive Kotlin testing patterns for writing reliable, maintainable tests following TDD methodology with Kotest and MockK. + +## When to Use + +- Writing new Kotlin functions or classes +- Adding test coverage to existing Kotlin code +- Implementing property-based tests +- Following TDD workflow in Kotlin projects +- Configuring Kover for code coverage + +## How It Works + +1. **Identify target code** — Find the function, class, or module to test +2. **Write a Kotest spec** — Choose a spec style (StringSpec, FunSpec, BehaviorSpec) matching the test scope +3. **Mock dependencies** — Use MockK to isolate the unit under test +4. **Run tests (RED)** — Verify the test fails with the expected error +5. **Implement code (GREEN)** — Write minimal code to pass the test +6. **Refactor** — Improve the implementation while keeping tests green +7. **Check coverage** — Run `./gradlew koverHtmlReport` and verify 80%+ coverage + +## Examples + +The following sections contain detailed, runnable examples for each testing pattern: + +### Quick Reference + +- **Kotest specs** — StringSpec, FunSpec, BehaviorSpec, DescribeSpec examples in [Kotest Spec Styles](#kotest-spec-styles) +- **Mocking** — MockK setup, coroutine mocking, argument capture in [MockK](#mockk) +- **TDD walkthrough** — Full RED/GREEN/REFACTOR cycle with EmailValidator in [TDD Workflow for Kotlin](#tdd-workflow-for-kotlin) +- **Coverage** — Kover configuration and commands in [Kover Coverage](#kover-coverage) +- **Ktor testing** — testApplication setup in [Ktor testApplication Testing](#ktor-testapplication-testing) + +## TDD Workflow for Kotlin + +### The RED-GREEN-REFACTOR Cycle + +``` +RED -> Write a failing test first +GREEN -> Write minimal code to pass the test +REFACTOR -> Improve code while keeping tests green +REPEAT -> Continue with next requirement +``` + +### Step-by-Step TDD in Kotlin + +```kotlin +// Step 1: Define the interface/signature +// EmailValidator.kt +package com.example.validator + +fun validateEmail(email: String): Result { + TODO("not implemented") +} + +// Step 2: Write failing test (RED) +// EmailValidatorTest.kt +package com.example.validator + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.result.shouldBeFailure +import io.kotest.matchers.result.shouldBeSuccess + +class EmailValidatorTest : StringSpec({ + "valid email returns success" { + validateEmail("user@example.com").shouldBeSuccess("user@example.com") + } + + "empty email returns failure" { + validateEmail("").shouldBeFailure() + } + + "email without @ returns failure" { + validateEmail("userexample.com").shouldBeFailure() + } +}) + +// Step 3: Run tests - verify FAIL +// $ ./gradlew test +// EmailValidatorTest > valid email returns success FAILED +// kotlin.NotImplementedError: An operation is not implemented + +// Step 4: Implement minimal code (GREEN) +fun validateEmail(email: String): Result { + if (email.isBlank()) return Result.failure(IllegalArgumentException("Email cannot be blank")) + if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @")) + val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format")) + return Result.success(email) +} + +// Step 5: Run tests - verify PASS +// $ ./gradlew test +// EmailValidatorTest > valid email returns success PASSED +// EmailValidatorTest > empty email returns failure PASSED +// EmailValidatorTest > email without @ returns failure PASSED + +// Step 6: Refactor if needed, verify tests still pass +``` + +## Kotest Spec Styles + +### StringSpec (Simplest) + +```kotlin +class CalculatorTest : StringSpec({ + "add two positive numbers" { + Calculator.add(2, 3) shouldBe 5 + } + + "add negative numbers" { + Calculator.add(-1, -2) shouldBe -3 + } + + "add zero" { + Calculator.add(0, 5) shouldBe 5 + } +}) +``` + +### FunSpec (JUnit-like) + +```kotlin +class UserServiceTest : FunSpec({ + val repository = mockk() + val service = UserService(repository) + + test("getUser returns user when found") { + val expected = User(id = "1", name = "Alice") + coEvery { repository.findById("1") } returns expected + + val result = service.getUser("1") + + result shouldBe expected + } + + test("getUser throws when not found") { + coEvery { repository.findById("999") } returns null + + shouldThrow { + service.getUser("999") + } + } +}) +``` + +### BehaviorSpec (BDD Style) + +```kotlin +class OrderServiceTest : BehaviorSpec({ + val repository = mockk() + val paymentService = mockk() + val service = OrderService(repository, paymentService) + + Given("a valid order request") { + val request = CreateOrderRequest( + userId = "user-1", + items = listOf(OrderItem("product-1", quantity = 2)), + ) + + When("the order is placed") { + coEvery { paymentService.charge(any()) } returns PaymentResult.Success + coEvery { repository.save(any()) } answers { firstArg() } + + val result = service.placeOrder(request) + + Then("it should return a confirmed order") { + result.status shouldBe OrderStatus.CONFIRMED + } + + Then("it should charge payment") { + coVerify(exactly = 1) { paymentService.charge(any()) } + } + } + + When("payment fails") { + coEvery { paymentService.charge(any()) } returns PaymentResult.Declined + + Then("it should throw PaymentException") { + shouldThrow { + service.placeOrder(request) + } + } + } + } +}) +``` + +### DescribeSpec (RSpec Style) + +```kotlin +class UserValidatorTest : DescribeSpec({ + describe("validateUser") { + val validator = UserValidator() + + context("with valid input") { + it("accepts a normal user") { + val user = CreateUserRequest("Alice", "alice@example.com") + validator.validate(user).shouldBeValid() + } + } + + context("with invalid name") { + it("rejects blank name") { + val user = CreateUserRequest("", "alice@example.com") + validator.validate(user).shouldBeInvalid() + } + + it("rejects name exceeding max length") { + val user = CreateUserRequest("A".repeat(256), "alice@example.com") + validator.validate(user).shouldBeInvalid() + } + } + } +}) +``` + +## Kotest Matchers + +### Core Matchers + +```kotlin +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.* +import io.kotest.matchers.collections.* +import io.kotest.matchers.nulls.* + +// Equality +result shouldBe expected +result shouldNotBe unexpected + +// Strings +name shouldStartWith "Al" +name shouldEndWith "ice" +name shouldContain "lic" +name shouldMatch Regex("[A-Z][a-z]+") +name.shouldBeBlank() + +// Collections +list shouldContain "item" +list shouldHaveSize 3 +list.shouldBeSorted() +list.shouldContainAll("a", "b", "c") +list.shouldBeEmpty() + +// Nulls +result.shouldNotBeNull() +result.shouldBeNull() + +// Types +result.shouldBeInstanceOf() + +// Numbers +count shouldBeGreaterThan 0 +price shouldBeInRange 1.0..100.0 + +// Exceptions +shouldThrow { + validateAge(-1) +}.message shouldBe "Age must be positive" + +shouldNotThrow { + validateAge(25) +} +``` + +### Custom Matchers + +```kotlin +fun beActiveUser() = object : Matcher { + override fun test(value: User) = MatcherResult( + value.isActive && value.lastLogin != null, + { "User ${value.id} should be active with a last login" }, + { "User ${value.id} should not be active" }, + ) +} + +// Usage +user should beActiveUser() +``` + +## MockK + +### Basic Mocking + +```kotlin +class UserServiceTest : FunSpec({ + val repository = mockk() + val logger = mockk(relaxed = true) // Relaxed: returns defaults + val service = UserService(repository, logger) + + beforeTest { + clearMocks(repository, logger) + } + + test("findUser delegates to repository") { + val expected = User(id = "1", name = "Alice") + every { repository.findById("1") } returns expected + + val result = service.findUser("1") + + result shouldBe expected + verify(exactly = 1) { repository.findById("1") } + } + + test("findUser returns null for unknown id") { + every { repository.findById(any()) } returns null + + val result = service.findUser("unknown") + + result.shouldBeNull() + } +}) +``` + +### Coroutine Mocking + +```kotlin +class AsyncUserServiceTest : FunSpec({ + val repository = mockk() + val service = UserService(repository) + + test("getUser suspending function") { + coEvery { repository.findById("1") } returns User(id = "1", name = "Alice") + + val result = service.getUser("1") + + result.name shouldBe "Alice" + coVerify { repository.findById("1") } + } + + test("getUser with delay") { + coEvery { repository.findById("1") } coAnswers { + delay(100) // Simulate async work + User(id = "1", name = "Alice") + } + + val result = service.getUser("1") + result.name shouldBe "Alice" + } +}) +``` + +### Argument Capture + +```kotlin +test("save captures the user argument") { + val slot = slot() + coEvery { repository.save(capture(slot)) } returns Unit + + service.createUser(CreateUserRequest("Alice", "alice@example.com")) + + slot.captured.name shouldBe "Alice" + slot.captured.email shouldBe "alice@example.com" + slot.captured.id.shouldNotBeNull() +} +``` + +### Spy and Partial Mocking + +```kotlin +test("spy on real object") { + val realService = UserService(repository) + val spy = spyk(realService) + + every { spy.generateId() } returns "fixed-id" + + spy.createUser(request) + + verify { spy.generateId() } // Overridden + // Other methods use real implementation +} +``` + +## Coroutine Testing + +### runTest for Suspend Functions + +```kotlin +import kotlinx.coroutines.test.runTest + +class CoroutineServiceTest : FunSpec({ + test("concurrent fetches complete together") { + runTest { + val service = DataService(testScope = this) + + val result = service.fetchAllData() + + result.users.shouldNotBeEmpty() + result.products.shouldNotBeEmpty() + } + } + + test("timeout after delay") { + runTest { + val service = SlowService() + + shouldThrow { + withTimeout(100) { + service.slowOperation() // Takes > 100ms + } + } + } + } +}) +``` + +### Testing Flows + +```kotlin +import io.kotest.matchers.collections.shouldContainInOrder +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest + +class FlowServiceTest : FunSpec({ + test("observeUsers emits updates") { + runTest { + val service = UserFlowService() + + val emissions = service.observeUsers() + .take(3) + .toList() + + emissions shouldHaveSize 3 + emissions.last().shouldNotBeEmpty() + } + } + + test("searchUsers debounces input") { + runTest { + val service = SearchService() + val queries = MutableSharedFlow() + + val results = mutableListOf>() + val job = launch { + service.searchUsers(queries).collect { results.add(it) } + } + + queries.emit("a") + queries.emit("ab") + queries.emit("abc") // Only this should trigger search + advanceTimeBy(500) + + results shouldHaveSize 1 + job.cancel() + } + } +}) +``` + +### TestDispatcher + +```kotlin +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle + +class DispatcherTest : FunSpec({ + test("uses test dispatcher for controlled execution") { + val dispatcher = StandardTestDispatcher() + + runTest(dispatcher) { + var completed = false + + launch { + delay(1000) + completed = true + } + + completed shouldBe false + advanceTimeBy(1000) + completed shouldBe true + } + } +}) +``` + +## Property-Based Testing + +### Kotest Property Testing + +```kotlin +import io.kotest.core.spec.style.FunSpec +import io.kotest.property.Arb +import io.kotest.property.arbitrary.* +import io.kotest.property.forAll +import io.kotest.property.checkAll +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString + +// Note: The serialization roundtrip test below requires the User data class +// to be annotated with @Serializable (from kotlinx.serialization). + +class PropertyTest : FunSpec({ + test("string reverse is involutory") { + forAll { s -> + s.reversed().reversed() == s + } + } + + test("list sort is idempotent") { + forAll(Arb.list(Arb.int())) { list -> + list.sorted() == list.sorted().sorted() + } + } + + test("serialization roundtrip preserves data") { + checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email -> + User(name = name, email = "$email@test.com") + }) { user -> + val json = Json.encodeToString(user) + val decoded = Json.decodeFromString(json) + decoded shouldBe user + } + } +}) +``` + +### Custom Generators + +```kotlin +val userArb: Arb = Arb.bind( + Arb.string(minSize = 1, maxSize = 50), + Arb.email(), + Arb.enum(), +) { name, email, role -> + User( + id = UserId(UUID.randomUUID().toString()), + name = name, + email = Email(email), + role = role, + ) +} + +val moneyArb: Arb = Arb.bind( + Arb.long(1L..1_000_000L), + Arb.enum(), +) { amount, currency -> + Money(amount, currency) +} +``` + +## Data-Driven Testing + +### withData in Kotest + +```kotlin +class ParserTest : FunSpec({ + context("parsing valid dates") { + withData( + "2026-01-15" to LocalDate(2026, 1, 15), + "2026-12-31" to LocalDate(2026, 12, 31), + "2000-01-01" to LocalDate(2000, 1, 1), + ) { (input, expected) -> + parseDate(input) shouldBe expected + } + } + + context("rejecting invalid dates") { + withData( + nameFn = { "rejects '$it'" }, + "not-a-date", + "2026-13-01", + "2026-00-15", + "", + ) { input -> + shouldThrow { + parseDate(input) + } + } + } +}) +``` + +## Test Lifecycle and Fixtures + +### BeforeTest / AfterTest + +```kotlin +class DatabaseTest : FunSpec({ + lateinit var db: Database + + beforeSpec { + db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") + transaction(db) { + SchemaUtils.create(UsersTable) + } + } + + afterSpec { + transaction(db) { + SchemaUtils.drop(UsersTable) + } + } + + beforeTest { + transaction(db) { + UsersTable.deleteAll() + } + } + + test("insert and retrieve user") { + transaction(db) { + UsersTable.insert { + it[name] = "Alice" + it[email] = "alice@example.com" + } + } + + val users = transaction(db) { + UsersTable.selectAll().map { it[UsersTable.name] } + } + + users shouldContain "Alice" + } +}) +``` + +### Kotest Extensions + +```kotlin +// Reusable test extension +class DatabaseExtension : BeforeSpecListener, AfterSpecListener { + lateinit var db: Database + + override suspend fun beforeSpec(spec: Spec) { + db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") + } + + override suspend fun afterSpec(spec: Spec) { + // cleanup + } +} + +class UserRepositoryTest : FunSpec({ + val dbExt = DatabaseExtension() + register(dbExt) + + test("save and find user") { + val repo = UserRepository(dbExt.db) + // ... + } +}) +``` + +## Kover Coverage + +### Gradle Configuration + +```kotlin +// build.gradle.kts +plugins { + id("org.jetbrains.kotlinx.kover") version "0.9.7" +} + +kover { + reports { + total { + html { onCheck = true } + xml { onCheck = true } + } + filters { + excludes { + classes("*.generated.*", "*.config.*") + } + } + verify { + rule { + minBound(80) // Fail build below 80% coverage + } + } + } +} +``` + +### Coverage Commands + +```bash +# Run tests with coverage +./gradlew koverHtmlReport + +# Verify coverage thresholds +./gradlew koverVerify + +# XML report for CI +./gradlew koverXmlReport + +# View HTML report (use the command for your OS) +# macOS: open build/reports/kover/html/index.html +# Linux: xdg-open build/reports/kover/html/index.html +# Windows: start build/reports/kover/html/index.html +``` + +### Coverage Targets + +| Code Type | Target | +|-----------|--------| +| Critical business logic | 100% | +| Public APIs | 90%+ | +| General code | 80%+ | +| Generated / config code | Exclude | + +## Ktor testApplication Testing + +```kotlin +class ApiRoutesTest : FunSpec({ + test("GET /users returns list") { + testApplication { + application { + configureRouting() + configureSerialization() + } + + val response = client.get("/users") + + response.status shouldBe HttpStatusCode.OK + val users = response.body>() + users.shouldNotBeEmpty() + } + } + + test("POST /users creates user") { + testApplication { + application { + configureRouting() + configureSerialization() + } + + val response = client.post("/users") { + contentType(ContentType.Application.Json) + setBody(CreateUserRequest("Alice", "alice@example.com")) + } + + response.status shouldBe HttpStatusCode.Created + } + } +}) +``` + +## Testing Commands + +```bash +# Run all tests +./gradlew test + +# Run specific test class +./gradlew test --tests "com.example.UserServiceTest" + +# Run specific test +./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found" + +# Run with verbose output +./gradlew test --info + +# Run with coverage +./gradlew koverHtmlReport + +# Run detekt (static analysis) +./gradlew detekt + +# Run ktlint (formatting check) +./gradlew ktlintCheck + +# Continuous testing +./gradlew test --continuous +``` + +## Best Practices + +**DO:** +- Write tests FIRST (TDD) +- Use Kotest's spec styles consistently across the project +- Use MockK's `coEvery`/`coVerify` for suspend functions +- Use `runTest` for coroutine testing +- Test behavior, not implementation +- Use property-based testing for pure functions +- Use `data class` test fixtures for clarity + +**DON'T:** +- Mix testing frameworks (pick Kotest and stick with it) +- Mock data classes (use real instances) +- Use `Thread.sleep()` in coroutine tests (use `advanceTimeBy`) +- Skip the RED phase in TDD +- Test private functions directly +- Ignore flaky tests + +## Integration with CI/CD + +```yaml +# GitHub Actions example +test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Run tests with coverage + run: ./gradlew test koverXmlReport + + - name: Verify coverage + run: ./gradlew koverVerify + + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + files: build/reports/kover/report.xml + token: ${{ secrets.CODECOV_TOKEN }} +``` + +**Remember**: Tests are documentation. They show how your Kotlin code is meant to be used. Use Kotest's expressive matchers to make tests readable and MockK for clean mocking of dependencies.