From 135eb4c98d3ac26ee0a2ade2e6474337575480e7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 10 Mar 2026 21:25:52 -0700 Subject: [PATCH 1/3] 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 00000000..e63a1bc7 --- /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 00000000..8ed503d4 --- /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 00000000..c5958da2 --- /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 00000000..43ad7cc5 --- /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 00000000..bf749043 --- /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 00000000..705afd32 --- /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 00000000..bb9f5de1 --- /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 00000000..b8798bb0 --- /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 00000000..bdfc7a77 --- /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 00000000..28bb02fc --- /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 00000000..3f98ebd5 --- /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 00000000..10c9522f --- /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 00000000..5e75d271 --- /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 00000000..bfc2772f --- /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. From e692a2886cb0d212a94791b0ccee2aed850bb3e9 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 12 Mar 2026 23:47:17 -0700 Subject: [PATCH 2/3] fix: address kotlin doc review feedback --- .cursor/rules/kotlin-coding-style.md | 9 ++++++--- commands/kotlin-build.md | 10 ++++++---- skills/kotlin-testing/SKILL.md | 26 +++++++++++++------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.cursor/rules/kotlin-coding-style.md b/.cursor/rules/kotlin-coding-style.md index e63a1bc7..ee7cd1f6 100644 --- a/.cursor/rules/kotlin-coding-style.md +++ b/.cursor/rules/kotlin-coding-style.md @@ -9,13 +9,16 @@ alwaysApply: false ## Formatting -- **ktfmt** or **ktlint** are mandatory for consistent formatting +- Auto-formatting via **ktfmt** or **ktlint** (configured in `kotlin-hooks.md`) - Use trailing commas in multiline declarations ## Immutability -- `val` over `var` always -- Immutable collections by default (`List`, `Map`, `Set`) +The global immutability requirement is enforced in the common coding style rule. +For Kotlin specifically: + +- Prefer `val` over `var` +- Use immutable collection types (`List`, `Map`, `Set`) - Use `data class` with `copy()` for immutable updates ## Null Safety diff --git a/commands/kotlin-build.md b/commands/kotlin-build.md index bb9f5de1..01c75ccb 100644 --- a/commands/kotlin-build.md +++ b/commands/kotlin-build.md @@ -27,14 +27,16 @@ Use `/kotlin-build` when: ```bash # Primary build check -./gradlew build +./gradlew build 2>&1 # Static analysis -./gradlew detekt -./gradlew ktlintCheck +./gradlew detekt 2>&1 || echo "detekt not configured" +./gradlew ktlintCheck 2>&1 || echo "ktlint not configured" # Dependency issues -./gradlew dependencies --configuration runtimeClasspath +./gradlew dependencies --configuration runtimeClasspath 2>/dev/null | head -100 + +# Optional deep refresh when caches or dependency metadata are suspect ./gradlew build --refresh-dependencies ``` diff --git a/skills/kotlin-testing/SKILL.md b/skills/kotlin-testing/SKILL.md index bfc2772f..ae326216 100644 --- a/skills/kotlin-testing/SKILL.md +++ b/skills/kotlin-testing/SKILL.md @@ -38,7 +38,7 @@ The following sections contain detailed, runnable examples for each testing patt - **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 +### TDD Workflow for Kotlin ### The RED-GREEN-REFACTOR Cycle @@ -105,7 +105,7 @@ fun validateEmail(email: String): Result { // Step 6: Refactor if needed, verify tests still pass ``` -## Kotest Spec Styles +### Kotest Spec Styles ### StringSpec (Simplest) @@ -222,7 +222,7 @@ class UserValidatorTest : DescribeSpec({ }) ``` -## Kotest Matchers +### Kotest Matchers ### Core Matchers @@ -287,7 +287,7 @@ fun beActiveUser() = object : Matcher { user should beActiveUser() ``` -## MockK +### MockK ### Basic Mocking @@ -380,7 +380,7 @@ test("spy on real object") { } ``` -## Coroutine Testing +### Coroutine Testing ### runTest for Suspend Functions @@ -485,7 +485,7 @@ class DispatcherTest : FunSpec({ }) ``` -## Property-Based Testing +### Property-Based Testing ### Kotest Property Testing @@ -551,7 +551,7 @@ val moneyArb: Arb = Arb.bind( } ``` -## Data-Driven Testing +### Data-Driven Testing ### withData in Kotest @@ -583,7 +583,7 @@ class ParserTest : FunSpec({ }) ``` -## Test Lifecycle and Fixtures +### Test Lifecycle and Fixtures ### BeforeTest / AfterTest @@ -654,7 +654,7 @@ class UserRepositoryTest : FunSpec({ }) ``` -## Kover Coverage +### Kover Coverage ### Gradle Configuration @@ -711,7 +711,7 @@ kover { | General code | 80%+ | | Generated / config code | Exclude | -## Ktor testApplication Testing +### Ktor testApplication Testing ```kotlin class ApiRoutesTest : FunSpec({ @@ -748,7 +748,7 @@ class ApiRoutesTest : FunSpec({ }) ``` -## Testing Commands +### Testing Commands ```bash # Run all tests @@ -776,7 +776,7 @@ class ApiRoutesTest : FunSpec({ ./gradlew test --continuous ``` -## Best Practices +### Best Practices **DO:** - Write tests FIRST (TDD) @@ -795,7 +795,7 @@ class ApiRoutesTest : FunSpec({ - Test private functions directly - Ignore flaky tests -## Integration with CI/CD +### Integration with CI/CD ```yaml # GitHub Actions example From 99d443b16e1fb7833378e7763e256f977f05d18e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 12 Mar 2026 23:53:23 -0700 Subject: [PATCH 3/3] fix: align kotlin diagnostics and heading hierarchy --- agents/kotlin-build-resolver.md | 2 +- commands/kotlin-build.md | 2 +- skills/kotlin-testing/SKILL.md | 46 ++++++++++++++++----------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/agents/kotlin-build-resolver.md b/agents/kotlin-build-resolver.md index 705afd32..e8c45599 100644 --- a/agents/kotlin-build-resolver.md +++ b/agents/kotlin-build-resolver.md @@ -25,7 +25,7 @@ Run these in order: ./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 +./gradlew dependencies --configuration runtimeClasspath 2>&1 | head -100 ``` ## Resolution Workflow diff --git a/commands/kotlin-build.md b/commands/kotlin-build.md index 01c75ccb..70709c8d 100644 --- a/commands/kotlin-build.md +++ b/commands/kotlin-build.md @@ -34,7 +34,7 @@ Use `/kotlin-build` when: ./gradlew ktlintCheck 2>&1 || echo "ktlint not configured" # Dependency issues -./gradlew dependencies --configuration runtimeClasspath 2>/dev/null | head -100 +./gradlew dependencies --configuration runtimeClasspath 2>&1 | head -100 # Optional deep refresh when caches or dependency metadata are suspect ./gradlew build --refresh-dependencies diff --git a/skills/kotlin-testing/SKILL.md b/skills/kotlin-testing/SKILL.md index ae326216..8819c4f4 100644 --- a/skills/kotlin-testing/SKILL.md +++ b/skills/kotlin-testing/SKILL.md @@ -40,7 +40,7 @@ The following sections contain detailed, runnable examples for each testing patt ### TDD Workflow for Kotlin -### The RED-GREEN-REFACTOR Cycle +#### The RED-GREEN-REFACTOR Cycle ``` RED -> Write a failing test first @@ -49,7 +49,7 @@ REFACTOR -> Improve code while keeping tests green REPEAT -> Continue with next requirement ``` -### Step-by-Step TDD in Kotlin +#### Step-by-Step TDD in Kotlin ```kotlin // Step 1: Define the interface/signature @@ -107,7 +107,7 @@ fun validateEmail(email: String): Result { ### Kotest Spec Styles -### StringSpec (Simplest) +#### StringSpec (Simplest) ```kotlin class CalculatorTest : StringSpec({ @@ -125,7 +125,7 @@ class CalculatorTest : StringSpec({ }) ``` -### FunSpec (JUnit-like) +#### FunSpec (JUnit-like) ```kotlin class UserServiceTest : FunSpec({ @@ -151,7 +151,7 @@ class UserServiceTest : FunSpec({ }) ``` -### BehaviorSpec (BDD Style) +#### BehaviorSpec (BDD Style) ```kotlin class OrderServiceTest : BehaviorSpec({ @@ -193,7 +193,7 @@ class OrderServiceTest : BehaviorSpec({ }) ``` -### DescribeSpec (RSpec Style) +#### DescribeSpec (RSpec Style) ```kotlin class UserValidatorTest : DescribeSpec({ @@ -224,7 +224,7 @@ class UserValidatorTest : DescribeSpec({ ### Kotest Matchers -### Core Matchers +#### Core Matchers ```kotlin import io.kotest.matchers.shouldBe @@ -272,7 +272,7 @@ shouldNotThrow { } ``` -### Custom Matchers +#### Custom Matchers ```kotlin fun beActiveUser() = object : Matcher { @@ -289,7 +289,7 @@ user should beActiveUser() ### MockK -### Basic Mocking +#### Basic Mocking ```kotlin class UserServiceTest : FunSpec({ @@ -321,7 +321,7 @@ class UserServiceTest : FunSpec({ }) ``` -### Coroutine Mocking +#### Coroutine Mocking ```kotlin class AsyncUserServiceTest : FunSpec({ @@ -349,7 +349,7 @@ class AsyncUserServiceTest : FunSpec({ }) ``` -### Argument Capture +#### Argument Capture ```kotlin test("save captures the user argument") { @@ -364,7 +364,7 @@ test("save captures the user argument") { } ``` -### Spy and Partial Mocking +#### Spy and Partial Mocking ```kotlin test("spy on real object") { @@ -382,7 +382,7 @@ test("spy on real object") { ### Coroutine Testing -### runTest for Suspend Functions +#### runTest for Suspend Functions ```kotlin import kotlinx.coroutines.test.runTest @@ -413,7 +413,7 @@ class CoroutineServiceTest : FunSpec({ }) ``` -### Testing Flows +#### Testing Flows ```kotlin import io.kotest.matchers.collections.shouldContainInOrder @@ -459,7 +459,7 @@ class FlowServiceTest : FunSpec({ }) ``` -### TestDispatcher +#### TestDispatcher ```kotlin import kotlinx.coroutines.test.StandardTestDispatcher @@ -487,7 +487,7 @@ class DispatcherTest : FunSpec({ ### Property-Based Testing -### Kotest Property Testing +#### Kotest Property Testing ```kotlin import io.kotest.core.spec.style.FunSpec @@ -527,7 +527,7 @@ class PropertyTest : FunSpec({ }) ``` -### Custom Generators +#### Custom Generators ```kotlin val userArb: Arb = Arb.bind( @@ -553,7 +553,7 @@ val moneyArb: Arb = Arb.bind( ### Data-Driven Testing -### withData in Kotest +#### withData in Kotest ```kotlin class ParserTest : FunSpec({ @@ -585,7 +585,7 @@ class ParserTest : FunSpec({ ### Test Lifecycle and Fixtures -### BeforeTest / AfterTest +#### BeforeTest / AfterTest ```kotlin class DatabaseTest : FunSpec({ @@ -627,7 +627,7 @@ class DatabaseTest : FunSpec({ }) ``` -### Kotest Extensions +#### Kotest Extensions ```kotlin // Reusable test extension @@ -656,7 +656,7 @@ class UserRepositoryTest : FunSpec({ ### Kover Coverage -### Gradle Configuration +#### Gradle Configuration ```kotlin // build.gradle.kts @@ -684,7 +684,7 @@ kover { } ``` -### Coverage Commands +#### Coverage Commands ```bash # Run tests with coverage @@ -702,7 +702,7 @@ kover { # Windows: start build/reports/kover/html/index.html ``` -### Coverage Targets +#### Coverage Targets | Code Type | Target | |-----------|--------|