diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e4540ce5..58bedadd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,6 +3,8 @@ "allow": [ "Bash(./gradlew clean:*)", "Bash(./gradlew compileKotlin:*)", + "Bash(./gradlew ktlintCheck:*)", + "Bash(./gradlew ktlintFormat:*)", "Bash(./gradlew tasks:*)", "Bash(./gradlew test:*)", "Bash(./mvnw clean:*)", @@ -14,7 +16,9 @@ "Bash(docker compose run:*)", "Bash(docker compose up:*)", "Bash(find:*)", + "Bash(golangci-lint run:*)", "Bash(java:*)", + "Bash(make lint:*)", "Bash(mvn clean compile:*)", "Bash(mvn clean test:*)", "Bash(mvn compile:*)", @@ -24,6 +28,7 @@ "Bash(mvn:*)", "Bash(npm install)", "Bash(npm run build:*)", + "Bash(npm run lint:*)", "Bash(npm run test:integration:*)", "Bash(npm test:*)", "Bash(npx prisma generate:*)", @@ -42,6 +47,7 @@ "Bash(poetry run ruff:*)", "Bash(python -m pytest:*)", "Bash(python3:*)", + "Bash(ruff check:*)", "Bash(schemathesis run:*)", "Bash(tee:*)", "Bash(tree:*)" diff --git a/docs/OPERATION_MODES.md b/docs/OPERATION_MODES.md new file mode 100644 index 00000000..f9a4a225 --- /dev/null +++ b/docs/OPERATION_MODES.md @@ -0,0 +1,301 @@ +# Operation Modes Across All Implementations + +All implementations of the Lamp Control API now support three distinct operation modes for better control over database migrations and server startup. This is particularly useful for production deployments, CI/CD pipelines, and container orchestration. + +## The Three Modes + +### 1. **`serve`** (Default Mode) +Runs database migrations first, then starts the HTTP server. + +**Use case:** Development environments, simple deployments where you want automatic migrations. + +### 2. **`migrate`** (Migration-Only Mode) +Runs database migrations and exits without starting the server. + +**Use case:** Production deployments using init containers, CI/CD pipelines, manual migration execution. + +### 3. **`serve-only`** (Server-Only Mode) +Starts the HTTP server without running migrations. + +**Use case:** Production environments where migrations are run separately (e.g., by a dedicated migration pod/container). + +--- + +## Usage by Implementation + +### Go + +```bash +# Default: Run migrations and start server +./lamp-control-api --mode=serve + +# Run migrations only +./lamp-control-api --mode=migrate + +# Start server without migrations +./lamp-control-api --mode=serve-only +``` + +**Build and run:** +```bash +cd src/go +go build -o lamp-control-api ./cmd/lamp-control-api +./lamp-control-api --mode=serve +``` + +--- + +### Kotlin (Ktor) + +```bash +# Default: Run migrations and start server +java -jar build/libs/lamp-control-api.jar --mode=serve + +# Run migrations only +java -jar build/libs/lamp-control-api.jar --mode=migrate + +# Start server without migrations +java -jar build/libs/lamp-control-api.jar --mode=serve-only +``` + +**Build and run:** +```bash +cd src/kotlin +./gradlew build +java -jar build/libs/*.jar --mode=serve +``` + +--- + +### Java (Spring Boot) + +```bash +# Default: Run migrations and start server +java -jar target/lamp-control-api.jar --mode=serve + +# Run migrations only +java -jar target/lamp-control-api.jar --mode=migrate + +# Start server without migrations +java -jar target/lamp-control-api.jar --mode=serve-only +``` + +**Build and run:** +```bash +cd src/java +mvn clean package +java -jar target/*.jar --mode=serve +``` + +--- + +### Python (FastAPI) + +```bash +# Default: Run migrations and start server +python -m src.openapi_server.cli --mode=serve + +# Run migrations only +python -m src.openapi_server.cli --mode=migrate + +# Start server without migrations +python -m src.openapi_server.cli --mode=serve-only +``` + +**With Poetry:** +```bash +cd src/python +poetry install +poetry run python -m src.openapi_server.cli --mode=serve +``` + +--- + +### TypeScript (Fastify) + +```bash +# Default: Run migrations and start server +node dist/cli.js --mode=serve + +# Run migrations only +node dist/cli.js --mode=migrate + +# Start server without migrations +node dist/cli.js --mode=serve-only +``` + +**Build and run:** +```bash +cd src/typescript +npm install +npm run build +node dist/cli.js --mode=serve +``` + +--- + +### C# (.NET) + +```bash +# Default: Run migrations and start server +dotnet run --project LampControlApi -- --mode=serve + +# Run migrations only +dotnet run --project LampControlApi -- --mode=migrate + +# Start server without migrations +dotnet run --project LampControlApi -- --mode=serve-only +``` + +**Build and run:** +```bash +cd src/csharp +dotnet build +dotnet run --project LampControlApi -- --mode=serve +``` + +--- + +## Production Deployment Patterns + +### Pattern 1: Init Container (Kubernetes) + +Run migrations in an init container, then start the application: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lamp-control-api +spec: + template: + spec: + initContainers: + - name: migrations + image: lamp-control-api:latest + args: ["--mode=migrate"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: db-credentials + key: url + containers: + - name: app + image: lamp-control-api:latest + args: ["--mode=serve-only"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: db-credentials + key: url +``` + +### Pattern 2: Separate Migration Job + +Run migrations as a one-off job before deployment: + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: lamp-control-migrate +spec: + template: + spec: + containers: + - name: migrate + image: lamp-control-api:latest + args: ["--mode=migrate"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: db-credentials + key: url + restartPolicy: Never +``` + +Then deploy the application with `--mode=serve-only`. + +### Pattern 3: CI/CD Pipeline + +```yaml +# .github/workflows/deploy.yml +steps: + - name: Run Database Migrations + run: | + docker run --rm \ + -e DATABASE_URL=${{ secrets.DATABASE_URL }} \ + lamp-control-api:${{ github.sha }} \ + --mode=migrate + + - name: Deploy Application + run: | + # Deploy with serve-only mode + kubectl set image deployment/lamp-control-api \ + app=lamp-control-api:${{ github.sha }} + kubectl set env deployment/lamp-control-api MODE=serve-only +``` + +--- + +## Benefits of the Three-Mode Approach + +1. **Separation of Concerns**: Migrations and application startup are decoupled +2. **Zero-Downtime Deployments**: Run migrations before deploying new application versions +3. **Better Control**: Explicit control over when and how migrations run +4. **Debugging**: Run migrations separately to troubleshoot issues +5. **Security**: Migrations can run with elevated privileges while the app runs with restricted access +6. **Rollback Safety**: Verify migrations succeed before starting the application + +--- + +## Migration Tool by Implementation + +| Language | Migration Tool | Location | +|------------|---------------------|------------------------------------| +| Go | golang-migrate | `src/go/api/migrations/` | +| Kotlin | Flyway | `src/kotlin/src/main/resources/db/migration/` | +| Java | Flyway | `src/java/src/main/resources/db/migration/` | +| Python | Alembic | `src/python/alembic/versions/` | +| TypeScript | Prisma Migrate | `src/typescript/prisma/migrations/` | +| C# | EF Core Migrations | Managed by Entity Framework | + +--- + +## Troubleshooting + +### Migrations fail in `serve` mode +- Check database connectivity +- Verify DATABASE_URL or connection configuration +- Run with `--mode=migrate` to see detailed migration output +- Check migration file syntax + +### Migrations don't run in `serve` mode +- Ensure PostgreSQL is configured (DATABASE_URL is set) +- Check that migrations are embedded/included in the build +- Verify migration tool dependencies are installed + +### Application starts but database schema is outdated +- You may have used `--mode=serve-only` when you should have used `--mode=serve` +- Run `--mode=migrate` manually to update the schema +- Check that the correct database is being targeted + +--- + +## Environment Variables + +All implementations respect these environment variables for PostgreSQL configuration: + +- `DATABASE_URL`: Full PostgreSQL connection string (preferred) +- `DB_HOST`: Database host (default: localhost) +- `DB_PORT`: Database port (default: 5432) +- `DB_NAME`: Database name +- `DB_USER`: Database user +- `DB_PASSWORD`: Database password +- `DB_POOL_MIN_SIZE`: Minimum pool size +- `DB_POOL_MAX_SIZE`: Maximum pool size + +If no PostgreSQL configuration is found, implementations fall back to in-memory storage. diff --git a/src/csharp/LampControlApi/Program.cs b/src/csharp/LampControlApi/Program.cs index d520deff..285f7c72 100644 --- a/src/csharp/LampControlApi/Program.cs +++ b/src/csharp/LampControlApi/Program.cs @@ -4,6 +4,16 @@ using LampControlApi.Services; using Microsoft.EntityFrameworkCore; +// Parse operation mode from command line arguments +var mode = args.FirstOrDefault(arg => arg.StartsWith("--mode="))?.Split('=')[1] ?? "serve"; + +// Handle migrate-only mode +if (mode == "migrate") +{ + RunMigrationsOnly(args); + return; +} + var builder = WebApplication.CreateBuilder(args); // Configure Kestrel to use PORT environment variable if set (required for Cloud Run) @@ -61,6 +71,30 @@ var app = builder.Build(); +// Run migrations if in 'serve' mode (default) and PostgreSQL is configured +if (mode == "serve" && usePostgres) +{ + Console.WriteLine("Running database migrations..."); + using (var scope = app.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + try + { + dbContext.Database.Migrate(); + Console.WriteLine("Migrations completed successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Migration failed: {ex.Message}"); + Environment.Exit(1); + } + } +} +else if (mode == "serve-only") +{ + Console.WriteLine("Starting server without running migrations..."); +} + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -88,6 +122,43 @@ app.Run(); +// Helper method for migrate-only mode +static void RunMigrationsOnly(string[] args) +{ + Console.WriteLine("Running migrations only..."); + + var builder = WebApplication.CreateBuilder(args); + var connectionString = builder.Configuration.GetConnectionString("LampControl"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + Console.WriteLine("No PostgreSQL configuration found, nothing to migrate"); + return; + } + + builder.Services.AddDbContext(options => + { + options.UseNpgsql(connectionString!); + }); + + var app = builder.Build(); + + using (var scope = app.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + try + { + dbContext.Database.Migrate(); + Console.WriteLine("Migrations completed successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Migration failed: {ex.Message}"); + Environment.Exit(1); + } + } +} + /// /// Entry point for the LampControlApi application. This partial class is used for test accessibility. /// diff --git a/src/go/cmd/lamp-control-api/main.go b/src/go/cmd/lamp-control-api/main.go index 6329f669..6d69b1c1 100644 --- a/src/go/cmd/lamp-control-api/main.go +++ b/src/go/cmd/lamp-control-api/main.go @@ -40,72 +40,112 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { } } -func main() { - port := flag.String("port", "8080", "Port for test HTTP server") - requireDB := flag.Bool("require-db", false, "Fail if PostgreSQL connection is configured but fails") - flag.Parse() +// runMigrationsOnly runs database migrations and exits +func runMigrationsOnly(requireDB bool) { + dbConfig := api.NewDatabaseConfigFromEnv() + if dbConfig == nil { + log.Println("No PostgreSQL configuration found, nothing to migrate") + if requireDB { + log.Fatal("PostgreSQL configuration required but not found (--require-db flag set)") + } - ctx := context.Background() + return + } - swagger, err := api.GetSwagger() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err) + log.Printf("Running migrations for database: host=%s port=%d database=%s user=%s", + dbConfig.Host, dbConfig.Port, dbConfig.Database, dbConfig.User) + + connectionString := dbConfig.ConnectionString() + if len(connectionString) >= 10 && connectionString[:10] == "host=" { + connectionString = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", + dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) + } + + if err := api.RunMigrations(connectionString); err != nil { + log.Printf("Migration failed: %v", err) os.Exit(1) } - // Keep servers array to allow proper path validation in middleware - // The middleware will validate that requests match the /v1 base path from the OpenAPI spec + log.Println("Migrations completed successfully") +} - // Create repository based on environment configuration +// initializeRepository creates and initializes the lamp repository +func initializeRepository(ctx context.Context, runMigrations bool, requireDB bool) (*api.LampAPI, interface{ Close() }) { var lampAPI *api.LampAPI var pool interface{ Close() } dbConfig := api.NewDatabaseConfigFromEnv() if dbConfig != nil { - // PostgreSQL connection parameters are set, use PostgreSQL repository log.Printf("Initializing PostgreSQL repository with config: host=%s port=%d database=%s user=%s", dbConfig.Host, dbConfig.Port, dbConfig.Database, dbConfig.User) - // Run database migrations before creating connection pool - connectionString := dbConfig.ConnectionString() - // golang-migrate requires postgres:// prefix instead of postgresql:// - // and needs sslmode parameter - if len(connectionString) >= 10 && connectionString[:10] == "host=" { - // If using component-based connection string, convert to URL format for migrate - connectionString = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", - dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) - } - - if err := api.RunMigrations(connectionString); err != nil { - log.Printf("Failed to run database migrations: %v", err) - if *requireDB { - log.Fatal("Database migrations required but failed (--require-db flag set)") + // Run database migrations if requested + if runMigrations { + connectionString := dbConfig.ConnectionString() + if len(connectionString) >= 10 && connectionString[:10] == "host=" { + connectionString = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", + dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) } - log.Printf("Falling back to in-memory repository") - lampAPI = api.NewLampAPI() - } else { - pgPool, err := api.CreateConnectionPool(ctx, dbConfig) - if err != nil { - log.Printf("Failed to connect to PostgreSQL: %v", err) - if *requireDB { - log.Fatal("PostgreSQL connection required but failed (--require-db flag set)") + + if err := api.RunMigrations(connectionString); err != nil { + log.Printf("Failed to run database migrations: %v", err) + if requireDB { + log.Fatal("Database migrations required but failed (--require-db flag set)") } log.Printf("Falling back to in-memory repository") lampAPI = api.NewLampAPI() - } else { - log.Printf("Successfully connected to PostgreSQL") - pool = pgPool - postgresRepo := api.NewPostgresLampRepository(pgPool) - lampAPI = api.NewLampAPIWithRepository(postgresRepo) + return lampAPI, nil + } + } + + pgPool, err := api.CreateConnectionPool(ctx, dbConfig) + if err != nil { + log.Printf("Failed to connect to PostgreSQL: %v", err) + if requireDB { + log.Fatal("PostgreSQL connection required but failed (--require-db flag set)") } + log.Printf("Falling back to in-memory repository") + lampAPI = api.NewLampAPI() + } else { + log.Printf("Successfully connected to PostgreSQL") + pool = pgPool + postgresRepo := api.NewPostgresLampRepository(pgPool) + lampAPI = api.NewLampAPIWithRepository(postgresRepo) } } else { - // No PostgreSQL configuration, use in-memory repository log.Printf("No PostgreSQL configuration found, using in-memory repository") lampAPI = api.NewLampAPI() } + return lampAPI, pool +} + +func main() { + port := flag.String("port", "8080", "Port for test HTTP server") + requireDB := flag.Bool("require-db", false, "Fail if PostgreSQL connection is configured but fails") + mode := flag.String("mode", "serve", "Operation mode: 'serve' (migrate and start server), 'migrate' (run migrations only), 'serve-only' (start server without migrations)") + flag.Parse() + + // Handle migrate-only mode + if *mode == "migrate" { + runMigrationsOnly(*requireDB) + + return + } + + ctx := context.Background() + + swagger, err := api.GetSwagger() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err) + os.Exit(1) + } + + // Initialize repository based on mode + runMigrations := *mode == "serve" // Only run migrations in default 'serve' mode + lampAPI, pool := initializeRepository(ctx, runMigrations, *requireDB) + // Create an instance of our handler which satisfies the generated interface lamp := api.NewStrictHandler(lampAPI, nil) diff --git a/src/java/pmd-rules.xml b/src/java/pmd-rules.xml index 6a05c4d8..8d1ab93a 100644 --- a/src/java/pmd-rules.xml +++ b/src/java/pmd-rules.xml @@ -2,44 +2,63 @@ - + Custom PMD ruleset for the project - - + + - + + - + + + + + + + + + - + - - + + - + + + + + + + + + + + + + + - - - - + - + diff --git a/src/java/pom.xml b/src/java/pom.xml index c1bf83dc..ed255b86 100644 --- a/src/java/pom.xml +++ b/src/java/pom.xml @@ -349,6 +349,13 @@ jsr305 3.0.2 + + + com.github.spotbugs + spotbugs-annotations + 4.8.3 + provided + com.fasterxml.jackson.dataformat jackson-dataformat-yaml diff --git a/src/java/src/main/java/org/openapitools/ApplicationMode.java b/src/java/src/main/java/org/openapitools/ApplicationMode.java new file mode 100644 index 00000000..a40b827d --- /dev/null +++ b/src/java/src/main/java/org/openapitools/ApplicationMode.java @@ -0,0 +1,110 @@ +package org.openapitools; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Locale; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +/** Handles different application startup modes: serve, migrate, and serve-only. */ +public class ApplicationMode { + private static final Logger logger = LoggerFactory.getLogger(ApplicationMode.class); + + public enum Mode { + SERVE, // Default: run migrations then start server + MIGRATE, // Run migrations only and exit + SERVE_ONLY // Start server without running migrations + } + + /** Run migrations only and exit */ + public static void runMigrationsOnly(String[] args) { + logger.info("Running migrations only..."); + + // Start Spring context to get DataSource + System.setProperty("server.port", "0"); // Don't start HTTP server + System.setProperty("spring.flyway.enabled", "true"); + + try { + ConfigurableApplicationContext context = + SpringApplication.run(OpenApiGeneratorApplication.class, args); + + DataSource dataSource = context.getBean(DataSource.class); + + // Run Flyway migrations manually + Flyway flyway = + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .validateOnMigrate(true) + .load(); + + int migrationsExecuted = flyway.migrate().migrationsExecuted; + + if (migrationsExecuted > 0) { + logger.info("Successfully executed {} migration(s)", migrationsExecuted); + } else { + logger.info("Database schema is up to date"); + } + + context.close(); + logger.info("Migrations completed successfully"); + + } catch (Exception e) { + logger.error("Migration failed", e); + System.exit(1); + } + } + + /** + * Determine the operation mode from command line arguments. + * + * @param args command line arguments + * @return the operation mode + */ + @SuppressFBWarnings( + value = "CRLF_INJECTION_LOGS", + justification = "Mode value is sanitized by removing CR/LF characters before logging") + public static Mode parseMode(String[] args) { + for (String arg : args) { + if (arg.startsWith("--mode=")) { + String mode = arg.substring(7).toLowerCase(Locale.ROOT); + switch (mode) { + case "migrate": + return Mode.MIGRATE; + case "serve-only": + return Mode.SERVE_ONLY; + case "serve": + return Mode.SERVE; + default: + // Sanitize mode value to prevent CRLF injection in logs + String sanitizedMode = mode.replaceAll("[\\r\\n]", "_"); + logger.error( + "Invalid mode: {}. Valid modes are: serve, migrate, serve-only", sanitizedMode); + System.exit(1); + } + } + } + return Mode.SERVE; // Default mode + } + + /** Configure Spring properties based on the mode */ + public static void configureMode(Mode mode) { + switch (mode) { + case SERVE: + logger.info("Starting server with automatic migrations..."); + System.setProperty("spring.flyway.enabled", "true"); + break; + case SERVE_ONLY: + logger.info("Starting server without running migrations..."); + System.setProperty("spring.flyway.enabled", "false"); + break; + case MIGRATE: + // Handled separately in runMigrationsOnly() + break; + } + } +} diff --git a/src/java/src/main/java/org/openapitools/OpenApiGeneratorApplication.java b/src/java/src/main/java/org/openapitools/OpenApiGeneratorApplication.java index e2ddba37..a7ca47da 100644 --- a/src/java/src/main/java/org/openapitools/OpenApiGeneratorApplication.java +++ b/src/java/src/main/java/org/openapitools/OpenApiGeneratorApplication.java @@ -24,6 +24,19 @@ public class OpenApiGeneratorApplication { public static void main(final String[] args) { + // Determine operation mode from command line arguments + ApplicationMode.Mode mode = ApplicationMode.parseMode(args); + + // Handle migrate-only mode + if (mode == ApplicationMode.Mode.MIGRATE) { + ApplicationMode.runMigrationsOnly(args); + return; + } + + // Configure Spring properties based on mode + ApplicationMode.configureMode(mode); + + // Start Spring Boot application SpringApplication.run(OpenApiGeneratorApplication.class, args); } diff --git a/src/kotlin/build.gradle.kts b/src/kotlin/build.gradle.kts index b4c05ed5..70cf2f65 100644 --- a/src/kotlin/build.gradle.kts +++ b/src/kotlin/build.gradle.kts @@ -72,7 +72,7 @@ tasks.test { // Allow JUnit Pioneer to modify environment variables via reflection jvmArgs( "--add-opens=java.base/java.util=ALL-UNNAMED", - "--add-opens=java.base/java.lang=ALL-UNNAMED" + "--add-opens=java.base/java.lang=ALL-UNNAMED", ) } @@ -83,6 +83,18 @@ tasks.jacocoTestReport { html.required.set(true) csv.required.set(false) } + classDirectories.setFrom( + files(classDirectories.files.map { + fileTree(it) { + exclude( + "**/com/lampcontrol/Application*", + "**/com/lampcontrol/api/AppMain*", + "**/com/lampcontrol/api/Configuration*", + "**/com/lampcontrol/api/Paths*" + ) + } + }) + ) } tasks.jacocoTestCoverageVerification { @@ -93,6 +105,18 @@ tasks.jacocoTestCoverageVerification { } } } + classDirectories.setFrom( + files(classDirectories.files.map { + fileTree(it) { + exclude( + "**/com/lampcontrol/Application*", + "**/com/lampcontrol/api/AppMain*", + "**/com/lampcontrol/api/Configuration*", + "**/com/lampcontrol/api/Paths*" + ) + } + }) + ) } tasks.test { diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/Application.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/Application.kt index 186af326..899c0c6c 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/Application.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/Application.kt @@ -1,11 +1,66 @@ package com.lampcontrol +import com.lampcontrol.database.DatabaseConfig +import com.lampcontrol.database.FlywayConfig import com.lampcontrol.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* +import org.slf4j.LoggerFactory +import kotlin.system.exitProcess + +private val logger = LoggerFactory.getLogger("Application") + +fun main(args: Array) { + // Parse command line arguments + val mode = args.find { it.startsWith("--mode=") }?.substringAfter("=") ?: "serve" + + when (mode) { + "migrate" -> runMigrationsOnly() + "serve" -> startServer(runMigrations = true) + "serve-only" -> startServer(runMigrations = false) + else -> { + logger.error("Invalid mode: $mode. Valid modes are: serve, migrate, serve-only") + exitProcess(1) + } + } +} + +/** + * Run database migrations only and exit + */ +fun runMigrationsOnly() { + logger.info("Running migrations only...") + val config = DatabaseConfig.fromEnv() + + if (config == null) { + logger.warn("No PostgreSQL configuration found, nothing to migrate") + return + } + + logger.info("Running migrations for database: ${config.host}:${config.port}/${config.database}") + + val success = FlywayConfig.runMigrations(config) + if (!success) { + logger.error("Migrations failed") + exitProcess(1) + } + + logger.info("Migrations completed successfully") +} + +/** + * Start the server with optional migrations + */ +fun startServer(runMigrations: Boolean) { + if (runMigrations) { + logger.info("Starting server with automatic migrations...") + } else { + logger.info("Starting server without running migrations...") + // Set system property to skip migrations in DatabaseFactory + System.setProperty("skip.migrations", "true") + } -fun main() { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module) .start(wait = true) } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/AppMain.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/AppMain.kt index 32ecd396..58e4e3f5 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/AppMain.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/AppMain.kt @@ -1,31 +1,31 @@ package com.lampcontrol.api +import com.codahale.metrics.Slf4jReporter +import com.lampcontrol.api.apis.DefaultApi +import com.lampcontrol.di.ServiceContainer +import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.* -import io.ktor.server.resources.* +import io.ktor.server.metrics.dropwizard.* import io.ktor.server.plugins.autohead.* import io.ktor.server.plugins.compression.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.defaultheaders.* import io.ktor.server.plugins.hsts.* -import com.codahale.metrics.Slf4jReporter -import io.ktor.server.metrics.dropwizard.* -import java.util.concurrent.TimeUnit +import io.ktor.server.resources.* import io.ktor.server.routing.* -import com.lampcontrol.api.apis.DefaultApi -import com.lampcontrol.di.ServiceContainer -import io.ktor.serialization.kotlinx.json.json - +import java.util.concurrent.TimeUnit fun Application.main() { val lampService = ServiceContainer.lampService - + install(DefaultHeaders) install(DropwizardMetrics) { - val reporter = Slf4jReporter.forRegistry(registry) - .outputTo(this@main.log) - .convertRatesTo(TimeUnit.SECONDS) - .convertDurationsTo(TimeUnit.MILLISECONDS) - .build() + val reporter = + Slf4jReporter.forRegistry(registry) + .outputTo(this@main.log) + .convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.MILLISECONDS) + .build() reporter.start(10, TimeUnit.SECONDS) } install(ContentNegotiation) { diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/Configuration.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/Configuration.kt index 1215f947..2ab32707 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/Configuration.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/Configuration.kt @@ -4,12 +4,10 @@ package com.lampcontrol.api import io.ktor.http.* import io.ktor.server.auth.* import io.ktor.server.config.* -import io.ktor.util.* -import java.time.Duration -import java.util.concurrent.TimeUnit import io.ktor.server.plugins.compression.* import io.ktor.server.plugins.hsts.* - +import io.ktor.util.* +import java.util.concurrent.TimeUnit /** * Application block for [HSTS] configuration. @@ -59,5 +57,5 @@ fun applicationAuthProvider(config: ApplicationConfig): OAuthServerSettings = requestMethod = HttpMethod.Get, clientId = config.property("auth.oauth.petstore_auth.clientId").getString(), clientSecret = config.property("auth.oauth.petstore_auth.clientSecret").getString(), - defaultScopes = listOf("write:pets", "read:pets") + defaultScopes = listOf("write:pets", "read:pets"), ) diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/Paths.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/Paths.kt index b8131176..fed7626b 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/Paths.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/Paths.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,46 +11,50 @@ */ package com.lampcontrol.api +import com.lampcontrol.api.models.* import io.ktor.resources.* import kotlinx.serialization.* -import com.lampcontrol.api.models.* object Paths { /** * Create a new lamp - * - * @param lampCreate + * + * @param lampCreate */ - @Resource("/lamps") class createLamp() + @Resource("/lamps") + class createLamp() /** * Delete a lamp - * - * @param lampId + * + * @param lampId */ - @Resource("/lamps/{lampId}") class deleteLamp(val lampId: kotlin.String) + @Resource("/lamps/{lampId}") + class deleteLamp(val lampId: kotlin.String) /** * Get a specific lamp - * - * @param lampId + * + * @param lampId */ - @Resource("/lamps/{lampId}") class getLamp(val lampId: kotlin.String) + @Resource("/lamps/{lampId}") + class getLamp(val lampId: kotlin.String) /** * List all lamps - * + * * @param cursor (optional) * @param pageSize (optional, default to 25) */ - @Resource("/lamps") class listLamps(val cursor: kotlin.String? = null, val pageSize: kotlin.Int? = null) + @Resource("/lamps") + class listLamps(val cursor: kotlin.String? = null, val pageSize: kotlin.Int? = null) /** * Update a lamp's status - * - * @param lampId - * @param lampUpdate + * + * @param lampId + * @param lampUpdate */ - @Resource("/lamps/{lampId}") class updateLamp(val lampId: kotlin.String) - + @Resource("/lamps/{lampId}") + class updateLamp(val lampId: kotlin.String) } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt index 29c923c6..8770478b 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,30 +11,24 @@ */ package com.lampcontrol.api.apis +import com.lampcontrol.api.Paths +import com.lampcontrol.api.models.Error +import com.lampcontrol.api.models.LampCreate +import com.lampcontrol.api.models.LampUpdate +import com.lampcontrol.api.models.ListLamps200Response +import com.lampcontrol.service.LampService import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* -import io.ktor.server.response.* import io.ktor.server.request.* -import com.lampcontrol.api.Paths -import io.ktor.server.resources.options +import io.ktor.server.resources.delete import io.ktor.server.resources.get import io.ktor.server.resources.post import io.ktor.server.resources.put -import io.ktor.server.resources.delete -import io.ktor.server.resources.head -import io.ktor.server.resources.patch +import io.ktor.server.response.* import io.ktor.server.routing.* -import com.lampcontrol.api.infrastructure.ApiPrincipal -import com.lampcontrol.api.models.Lamp -import com.lampcontrol.api.models.LampCreate -import com.lampcontrol.api.models.LampUpdate -import com.lampcontrol.api.models.ListLamps200Response -import com.lampcontrol.api.models.Error -import com.lampcontrol.service.LampService fun Route.DefaultApi(lampService: LampService) { - post { try { val lampCreate = call.receive() @@ -73,7 +67,7 @@ fun Route.DefaultApi(lampService: LampService) { if (pageSize < 1 || pageSize > 100) { call.respond( HttpStatusCode.BadRequest, - Error(error = "Invalid numeric parameter") + Error(error = "Invalid numeric parameter"), ) return@get } @@ -81,16 +75,17 @@ fun Route.DefaultApi(lampService: LampService) { val lamps = lampService.getAllLamps() // Construct response object matching OpenAPI schema: { data: [...], hasMore: boolean, nextCursor: string? } - val response = ListLamps200Response( - data = lamps, - hasMore = false, - nextCursor = null - ) + val response = + ListLamps200Response( + data = lamps, + hasMore = false, + nextCursor = null, + ) call.respond(HttpStatusCode.OK, response) } catch (e: Exception) { call.respond( HttpStatusCode.BadRequest, - Error(error = "Invalid numeric parameter") + Error(error = "Invalid numeric parameter"), ) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/infrastructure/ApiKeyAuth.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/infrastructure/ApiKeyAuth.kt index b8a3b785..3b7824f6 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/infrastructure/ApiKeyAuth.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/infrastructure/ApiKeyAuth.kt @@ -8,17 +8,17 @@ import io.ktor.server.response.* enum class ApiKeyLocation(val location: String) { QUERY("query"), - HEADER("header") + HEADER("header"), } data class ApiKeyCredential(val value: String) : Credential + data class ApiPrincipal(val apiKeyCredential: ApiKeyCredential?) : Principal /** * Represents an Api Key authentication provider */ class ApiKeyAuthenticationProvider(configuration: Configuration) : AuthenticationProvider(configuration) { - private val authenticationFunction = configuration.authenticationFunction private val apiKeyName: String = configuration.apiKeyName @@ -30,11 +30,12 @@ class ApiKeyAuthenticationProvider(configuration: Configuration) : Authenticatio val credentials = call.request.apiKeyAuthenticationCredentials(apiKeyName, apiKeyLocation) val principal = credentials?.let { authenticationFunction.invoke(call, it) } - val cause = when { - credentials == null -> AuthenticationFailedCause.NoCredentials - principal == null -> AuthenticationFailedCause.InvalidCredentials - else -> null - } + val cause = + when { + credentials == null -> AuthenticationFailedCause.NoCredentials + principal == null -> AuthenticationFailedCause.InvalidCredentials + else -> null + } if (cause != null) { context.challenge(apiKeyName, cause) { challenge, call -> @@ -43,9 +44,9 @@ class ApiKeyAuthenticationProvider(configuration: Configuration) : Authenticatio HttpAuthHeader.Parameterized( "API_KEY", mapOf("key" to apiKeyName), - HeaderValueEncoding.QUOTED_ALWAYS - ) - ) + HeaderValueEncoding.QUOTED_ALWAYS, + ), + ), ) challenge.complete() } @@ -57,10 +58,9 @@ class ApiKeyAuthenticationProvider(configuration: Configuration) : Authenticatio } class Configuration internal constructor(name: String?) : Config(name) { - internal var authenticationFunction: suspend ApplicationCall.(ApiKeyCredential) -> Principal? = { throw NotImplementedError( - "Api Key auth validate function is not specified. Use apiKeyAuth { validate { ... } } to fix." + "Api Key auth validate function is not specified. Use apiKeyAuth { validate { ... } } to fix.", ) } @@ -69,9 +69,9 @@ class ApiKeyAuthenticationProvider(configuration: Configuration) : Authenticatio var apiKeyLocation: ApiKeyLocation = ApiKeyLocation.QUERY /** - * Sets a validation function that will check given [ApiKeyCredential] instance and return [Principal], - * or null if credential does not correspond to an authenticated principal - */ + * Sets a validation function that will check given [ApiKeyCredential] instance and return [Principal], + * or null if credential does not correspond to an authenticated principal + */ fun validate(body: suspend ApplicationCall.(ApiKeyCredential) -> Principal?) { authenticationFunction = body } @@ -80,7 +80,7 @@ class ApiKeyAuthenticationProvider(configuration: Configuration) : Authenticatio fun AuthenticationConfig.apiKeyAuth( name: String? = null, - configure: ApiKeyAuthenticationProvider.Configuration.() -> Unit + configure: ApiKeyAuthenticationProvider.Configuration.() -> Unit, ) { val configuration = ApiKeyAuthenticationProvider.Configuration(name).apply(configure) val provider = ApiKeyAuthenticationProvider(configuration) @@ -89,12 +89,13 @@ fun AuthenticationConfig.apiKeyAuth( fun ApplicationRequest.apiKeyAuthenticationCredentials( apiKeyName: String, - apiKeyLocation: ApiKeyLocation + apiKeyLocation: ApiKeyLocation, ): ApiKeyCredential? { - val value: String? = when (apiKeyLocation) { - ApiKeyLocation.QUERY -> this.queryParameters[apiKeyName] - ApiKeyLocation.HEADER -> this.headers[apiKeyName] - } + val value: String? = + when (apiKeyLocation) { + ApiKeyLocation.QUERY -> this.queryParameters[apiKeyName] + ApiKeyLocation.HEADER -> this.headers[apiKeyName] + } return when (value) { null -> null else -> ApiKeyCredential(value) diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Error.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Error.kt index 6ba7c34c..d1ddb34d 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Error.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Error.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,15 +11,14 @@ */ package com.lampcontrol.api.models - import kotlinx.serialization.Serializable + /** - * + * * @param error Error type identifier */ @Serializable data class Error( - /* Error type identifier */ - val error: kotlin.String + // Error type identifier + val error: kotlin.String, ) - diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Lamp.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Lamp.kt index 79aff7cf..fb09362b 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Lamp.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/Lamp.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,11 +11,11 @@ */ package com.lampcontrol.api.models - import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable + /** - * + * * @param id Unique identifier for the lamp * @param status Whether the lamp is turned on (true) or off (false) * @param createdAt Timestamp when the lamp was created @@ -23,13 +23,12 @@ import kotlinx.serialization.Serializable */ @Serializable data class Lamp( - /* Unique identifier for the lamp */ + // Unique identifier for the lamp @Contextual val id: java.util.UUID, - /* Whether the lamp is turned on (true) or off (false) */ + // Whether the lamp is turned on (true) or off (false) val status: kotlin.Boolean, - /* Timestamp when the lamp was created */ + // Timestamp when the lamp was created val createdAt: kotlin.String, - /* Timestamp when the lamp was last updated */ - val updatedAt: kotlin.String + // Timestamp when the lamp was last updated + val updatedAt: kotlin.String, ) - diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampCreate.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampCreate.kt index 762b0779..ec58f4ad 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampCreate.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampCreate.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,15 +11,14 @@ */ package com.lampcontrol.api.models - import kotlinx.serialization.Serializable + /** - * + * * @param status Initial status of the lamp (on/off) */ @Serializable data class LampCreate( - /* Initial status of the lamp (on/off) */ - val status: kotlin.Boolean + // Initial status of the lamp (on/off) + val status: kotlin.Boolean, ) - diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampUpdate.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampUpdate.kt index 9184ce7a..4802cd7d 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampUpdate.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/LampUpdate.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,15 +11,14 @@ */ package com.lampcontrol.api.models - import kotlinx.serialization.Serializable + /** - * + * * @param status New status of the lamp (on/off) */ @Serializable data class LampUpdate( - /* New status of the lamp (on/off) */ - val status: kotlin.Boolean + // New status of the lamp (on/off) + val status: kotlin.Boolean, ) - diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/ListLamps200Response.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/ListLamps200Response.kt index 6281e61c..128d66bd 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/ListLamps200Response.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/models/ListLamps200Response.kt @@ -1,9 +1,9 @@ /** * Lamp Control API -* A simple API for controlling lamps, demonstrating CRUD operations. +* A simple API for controlling lamps, demonstrating CRUD operations. * * The version of the OpenAPI document: 1.0.0 -* +* * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,19 +11,17 @@ */ package com.lampcontrol.api.models -import com.lampcontrol.api.models.Lamp - import kotlinx.serialization.Serializable + /** - * - * @param `data` - * @param hasMore - * @param nextCursor + * + * @param `data` + * @param hasMore + * @param nextCursor */ @Serializable data class ListLamps200Response( val `data`: kotlin.collections.List, val hasMore: kotlin.Boolean, - val nextCursor: kotlin.String? = null + val nextCursor: kotlin.String? = null, ) - diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/database/DatabaseFactory.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/database/DatabaseFactory.kt index 7f8f0375..0e4b4b90 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/database/DatabaseFactory.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/database/DatabaseFactory.kt @@ -12,6 +12,7 @@ import java.sql.Connection */ object DatabaseFactory { private val logger = LoggerFactory.getLogger(DatabaseFactory::class.java) + /** * Initialize database connection from environment variables. * Returns null if no PostgreSQL configuration is found. @@ -23,27 +24,33 @@ object DatabaseFactory { return null } - // Run database migrations before initializing connection pool - val migrationSuccess = FlywayConfig.runMigrations(config) - if (!migrationSuccess) { - logger.error("Database migrations failed. Database connection will not be initialized.") - return null + // Run database migrations before initializing connection pool (unless skipped) + val skipMigrations = System.getProperty("skip.migrations") == "true" + if (!skipMigrations) { + val migrationSuccess = FlywayConfig.runMigrations(config) + if (!migrationSuccess) { + logger.error("Database migrations failed. Database connection will not be initialized.") + return null + } + } else { + logger.info("Skipping database migrations (serve-only mode)") } - val hikariConfig = HikariConfig().apply { - jdbcUrl = config.connectionString() - driverClassName = "org.postgresql.Driver" - username = config.user - password = config.password - maximumPoolSize = config.poolMax - minimumIdle = config.poolMin - maxLifetime = config.maxLifetimeMs - idleTimeout = config.idleTimeoutMs - connectionTimeout = config.connectionTimeoutMs - isAutoCommit = false - transactionIsolation = "TRANSACTION_REPEATABLE_READ" - validate() - } + val hikariConfig = + HikariConfig().apply { + jdbcUrl = config.connectionString() + driverClassName = "org.postgresql.Driver" + username = config.user + password = config.password + maximumPoolSize = config.poolMax + minimumIdle = config.poolMin + maxLifetime = config.maxLifetimeMs + idleTimeout = config.idleTimeoutMs + connectionTimeout = config.connectionTimeoutMs + isAutoCommit = false + transactionIsolation = "TRANSACTION_REPEATABLE_READ" + validate() + } val dataSource = HikariDataSource(hikariConfig) val database = Database.connect(dataSource) @@ -68,7 +75,7 @@ data class DatabaseConfig( val poolMax: Int, val maxLifetimeMs: Long, val idleTimeoutMs: Long, - val connectionTimeoutMs: Long + val connectionTimeoutMs: Long, ) { companion object { /** @@ -87,9 +94,10 @@ data class DatabaseConfig( val user = System.getenv("DB_USER") // Check if PostgreSQL is configured - val postgresConfigured = !databaseUrl.isNullOrEmpty() || - !database.isNullOrEmpty() || - (!host.isNullOrEmpty() && !user.isNullOrEmpty()) + val postgresConfigured = + !databaseUrl.isNullOrEmpty() || + !database.isNullOrEmpty() || + (!host.isNullOrEmpty() && !user.isNullOrEmpty()) if (!postgresConfigured) { return null @@ -111,7 +119,7 @@ data class DatabaseConfig( poolMax = System.getenv("DB_POOL_MAX_SIZE")?.toIntOrNull() ?: 4, maxLifetimeMs = System.getenv("DB_MAX_LIFETIME_MS")?.toLongOrNull() ?: 3600000, // 1 hour idleTimeoutMs = System.getenv("DB_IDLE_TIMEOUT_MS")?.toLongOrNull() ?: 1800000, // 30 minutes - connectionTimeoutMs = System.getenv("DB_CONNECTION_TIMEOUT_MS")?.toLongOrNull() ?: 30000 // 30 seconds + connectionTimeoutMs = System.getenv("DB_CONNECTION_TIMEOUT_MS")?.toLongOrNull() ?: 30000, // 30 seconds ) } @@ -120,12 +128,13 @@ data class DatabaseConfig( */ private fun parseDatabaseUrl(url: String): DatabaseConfig { val regex = Regex("""postgres(?:ql)?://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)""") - val match = regex.matchEntire(url) - ?: throw IllegalArgumentException( - "Invalid DATABASE_URL value: '$url'. Expected format like " + - "'postgresql://user:password@host:5432/database' or " + - "'postgres://user:password@host:5432/database'." - ) + val match = + regex.matchEntire(url) + ?: throw IllegalArgumentException( + "Invalid DATABASE_URL value: '$url'. Expected format like " + + "'postgresql://user:password@host:5432/database' or " + + "'postgres://user:password@host:5432/database'.", + ) val (user, password, host, port, database) = match.destructured @@ -139,7 +148,7 @@ data class DatabaseConfig( poolMax = System.getenv("DB_POOL_MAX_SIZE")?.toIntOrNull() ?: 4, maxLifetimeMs = System.getenv("DB_MAX_LIFETIME_MS")?.toLongOrNull() ?: 3600000, // 1 hour idleTimeoutMs = System.getenv("DB_IDLE_TIMEOUT_MS")?.toLongOrNull() ?: 1800000, // 30 minutes - connectionTimeoutMs = System.getenv("DB_CONNECTION_TIMEOUT_MS")?.toLongOrNull() ?: 30000 // 30 seconds + connectionTimeoutMs = System.getenv("DB_CONNECTION_TIMEOUT_MS")?.toLongOrNull() ?: 30000, // 30 seconds ) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/database/FlywayConfig.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/database/FlywayConfig.kt index 58044e66..d4bc9671 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/database/FlywayConfig.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/database/FlywayConfig.kt @@ -1,6 +1,5 @@ package com.lampcontrol.database -import com.zaxxer.hikari.HikariDataSource import org.flywaydb.core.Flyway import org.slf4j.LoggerFactory @@ -21,17 +20,18 @@ object FlywayConfig { return try { logger.info("Starting database migrations with Flyway") - val flyway = Flyway.configure() - .dataSource( - config.connectionString(), - config.user, - config.password - ) - .locations("classpath:db/migration") - .baselineOnMigrate(true) - .baselineVersion("0") - .validateOnMigrate(true) - .load() + val flyway = + Flyway.configure() + .dataSource( + config.connectionString(), + config.user, + config.password, + ) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion("0") + .validateOnMigrate(true) + .load() val migrationsExecuted = flyway.migrate() @@ -39,12 +39,12 @@ object FlywayConfig { logger.info( "Successfully executed {} migration(s). Current schema version: {}", migrationsExecuted.migrationsExecuted, - migrationsExecuted.targetSchemaVersion + migrationsExecuted.targetSchemaVersion, ) } else { logger.info( "Database schema is up to date at version: {}", - flyway.info().current()?.version ?: "baseline" + flyway.info().current()?.version ?: "baseline", ) } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/entity/LampEntity.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/entity/LampEntity.kt index 21e12606..93421d68 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/entity/LampEntity.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/entity/LampEntity.kt @@ -11,7 +11,7 @@ data class LampEntity( val id: UUID, val status: Boolean, val createdAt: Instant, - val updatedAt: Instant + val updatedAt: Instant, ) { companion object { /** @@ -23,18 +23,18 @@ data class LampEntity( id = UUID.randomUUID(), status = status, createdAt = now, - updatedAt = now + updatedAt = now, ) } } - + /** * Create an updated copy of this entity with a new status and updated timestamp */ fun withUpdatedStatus(newStatus: Boolean): LampEntity { return copy( status = newStatus, - updatedAt = Instant.now() + updatedAt = Instant.now(), ) } -} \ No newline at end of file +} diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/mapper/LampMapper.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/mapper/LampMapper.kt index 7747338f..5cce449e 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/mapper/LampMapper.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/mapper/LampMapper.kt @@ -5,7 +5,6 @@ import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate import com.lampcontrol.entity.LampEntity import java.time.Instant -import java.time.format.DateTimeFormatter /** * Mapper to convert between domain entities and API models. @@ -13,7 +12,6 @@ import java.time.format.DateTimeFormatter * from the external API contract. */ class LampMapper { - /** * Convert from domain entity to API model */ @@ -22,10 +20,10 @@ class LampMapper { id = entity.id, status = entity.status, createdAt = entity.createdAt.toString(), - updatedAt = entity.updatedAt.toString() + updatedAt = entity.updatedAt.toString(), ) } - + /** * Convert from API model to domain entity */ @@ -34,28 +32,31 @@ class LampMapper { id = apiModel.id, status = apiModel.status, createdAt = parseInstant(apiModel.createdAt), - updatedAt = parseInstant(apiModel.updatedAt) + updatedAt = parseInstant(apiModel.updatedAt), ) } - + /** * Convert from API create model to domain entity */ fun toDomainEntityCreate(apiModel: LampCreate): LampEntity { return LampEntity.create(apiModel.status) } - + /** * Update domain entity from API update model */ - fun updateDomainEntity(entity: LampEntity, updateModel: LampUpdate): LampEntity { + fun updateDomainEntity( + entity: LampEntity, + updateModel: LampUpdate, + ): LampEntity { return entity.withUpdatedStatus(updateModel.status) } - + /** * Parse ISO 8601 timestamp string to Instant */ private fun parseInstant(timestampStr: String): Instant { return Instant.parse(timestampStr) } -} \ No newline at end of file +} diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Routing.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Routing.kt index cb1d29da..884bf8b6 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Routing.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Routing.kt @@ -1,24 +1,24 @@ package com.lampcontrol.plugins +import com.lampcontrol.api.apis.DefaultApi +import com.lampcontrol.di.ServiceContainer import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* -import com.lampcontrol.api.apis.DefaultApi -import com.lampcontrol.di.ServiceContainer fun Application.configureRouting() { val lampService = ServiceContainer.lampService - + routing { get("/") { call.respondText("Lamp Control API - Kotlin Implementation") } - + // Health check endpoint get("/health") { call.respond(mapOf("status" to "ok")) } - + // API v1 routes route("/v1") { DefaultApi(lampService) diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Serialization.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Serialization.kt index 6344018b..17532087 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Serialization.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/Serialization.kt @@ -10,13 +10,16 @@ import java.util.UUID fun Application.configureSerialization() { install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - serializersModule = SerializersModule { - contextual(UUID::class, UUIDSerializer) - } - }) + json( + Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + serializersModule = + SerializersModule { + contextual(UUID::class, UUIDSerializer) + } + }, + ) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt index 1ec9e64e..af168dbd 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt @@ -3,8 +3,8 @@ package com.lampcontrol.plugins import com.lampcontrol.api.models.Error import io.ktor.http.* import io.ktor.server.application.* -import io.ktor.server.plugins.statuspages.* import io.ktor.server.plugins.* +import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* import kotlinx.serialization.SerializationException @@ -16,14 +16,14 @@ fun Application.configureStatusPages() { exception { call, cause -> call.respond( HttpStatusCode.BadRequest, - Error(error = "Invalid numeric parameter") + Error(error = "Invalid numeric parameter"), ) } exception { call, cause -> call.respond( HttpStatusCode.BadRequest, - Error(error = "Invalid JSON format") + Error(error = "Invalid JSON format"), ) } @@ -35,7 +35,7 @@ fun Application.configureStatusPages() { if (nf is NumberFormatException) { call.respond( HttpStatusCode.BadRequest, - Error(error = "Invalid numeric parameter") + Error(error = "Invalid numeric parameter"), ) } else { // Fallback to the generic handler below by rethrowing so it is caught by the @@ -47,14 +47,14 @@ fun Application.configureStatusPages() { exception { call, cause -> call.respond( HttpStatusCode.BadRequest, - Error(error = "Invalid argument") + Error(error = "Invalid argument"), ) } exception { call, cause -> call.respond( HttpStatusCode.InternalServerError, - Error(error = "Internal server error") + Error(error = "Internal server error"), ) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt index ff1f475f..c517dce4 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt @@ -10,9 +10,14 @@ import java.util.* */ interface LampRepository { suspend fun getAllLamps(): List + suspend fun getLampById(id: UUID): LampEntity? + suspend fun createLamp(entity: LampEntity): LampEntity + suspend fun updateLamp(entity: LampEntity): LampEntity? + suspend fun deleteLamp(id: UUID): Boolean + suspend fun lampExists(id: UUID): Boolean } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/repository/PostgresLampRepository.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/repository/PostgresLampRepository.kt index fbf76248..01ca5949 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/repository/PostgresLampRepository.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/repository/PostgresLampRepository.kt @@ -14,76 +14,82 @@ import java.util.* * Implements soft deletes using the deleted_at column. */ class PostgresLampRepository : LampRepository { - /** * Execute a database transaction with proper suspend support */ - private suspend fun dbQuery(block: suspend () -> T): T = - newSuspendedTransaction { block() } - - override suspend fun getAllLamps(): List = dbQuery { - LampsTable - .selectAll() - .where { LampsTable.deletedAt.isNull() } - .map { rowToEntity(it) } - } + private suspend fun dbQuery(block: suspend () -> T): T = newSuspendedTransaction { block() } - override suspend fun getLampById(id: UUID): LampEntity? = dbQuery { - LampsTable - .selectAll() - .where { (LampsTable.id eq id) and LampsTable.deletedAt.isNull() } - .map { rowToEntity(it) } - .singleOrNull() - } + override suspend fun getAllLamps(): List = + dbQuery { + LampsTable + .selectAll() + .where { LampsTable.deletedAt.isNull() } + .map { rowToEntity(it) } + } - override suspend fun createLamp(entity: LampEntity): LampEntity = dbQuery { - LampsTable.insert { - it[id] = entity.id - it[isOn] = entity.status - it[createdAt] = entity.createdAt - it[updatedAt] = entity.updatedAt - it[deletedAt] = null + override suspend fun getLampById(id: UUID): LampEntity? = + dbQuery { + LampsTable + .selectAll() + .where { (LampsTable.id eq id) and LampsTable.deletedAt.isNull() } + .map { rowToEntity(it) } + .singleOrNull() } - entity - } - override suspend fun updateLamp(entity: LampEntity): LampEntity? = dbQuery { - val now = Instant.now() - val rowsUpdated = LampsTable.update( - where = { (LampsTable.id eq entity.id) and LampsTable.deletedAt.isNull() } - ) { - it[isOn] = entity.status - it[updatedAt] = now + override suspend fun createLamp(entity: LampEntity): LampEntity = + dbQuery { + LampsTable.insert { + it[id] = entity.id + it[isOn] = entity.status + it[createdAt] = entity.createdAt + it[updatedAt] = entity.updatedAt + it[deletedAt] = null + } + entity } - if (rowsUpdated > 0) { - // Return the updated entity with new updatedAt timestamp without extra DB round-trip - LampEntity( - id = entity.id, - status = entity.status, - createdAt = entity.createdAt, - updatedAt = now - ) - } else { - null + override suspend fun updateLamp(entity: LampEntity): LampEntity? = + dbQuery { + val now = Instant.now() + val rowsUpdated = + LampsTable.update( + where = { (LampsTable.id eq entity.id) and LampsTable.deletedAt.isNull() }, + ) { + it[isOn] = entity.status + it[updatedAt] = now + } + + if (rowsUpdated > 0) { + // Return the updated entity with new updatedAt timestamp without extra DB round-trip + LampEntity( + id = entity.id, + status = entity.status, + createdAt = entity.createdAt, + updatedAt = now, + ) + } else { + null + } } - } - override suspend fun deleteLamp(id: UUID): Boolean = dbQuery { - val rowsUpdated = LampsTable.update( - where = { (LampsTable.id eq id) and LampsTable.deletedAt.isNull() } - ) { - it[deletedAt] = Instant.now() + override suspend fun deleteLamp(id: UUID): Boolean = + dbQuery { + val rowsUpdated = + LampsTable.update( + where = { (LampsTable.id eq id) and LampsTable.deletedAt.isNull() }, + ) { + it[deletedAt] = Instant.now() + } + rowsUpdated > 0 } - rowsUpdated > 0 - } - override suspend fun lampExists(id: UUID): Boolean = dbQuery { - LampsTable - .selectAll() - .where { (LampsTable.id eq id) and LampsTable.deletedAt.isNull() } - .count() > 0 - } + override suspend fun lampExists(id: UUID): Boolean = + dbQuery { + LampsTable + .selectAll() + .where { (LampsTable.id eq id) and LampsTable.deletedAt.isNull() } + .count() > 0 + } /** * Convert a database row to a LampEntity @@ -93,7 +99,7 @@ class PostgresLampRepository : LampRepository { id = row[LampsTable.id], status = row[LampsTable.isOn], createdAt = row[LampsTable.createdAt], - updatedAt = row[LampsTable.updatedAt] + updatedAt = row[LampsTable.updatedAt], ) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/serialization/UUIDSerializer.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/serialization/UUIDSerializer.kt index 877c5c7b..4b85f23a 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/serialization/UUIDSerializer.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/serialization/UUIDSerializer.kt @@ -11,7 +11,10 @@ import java.util.UUID object UUIDSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: UUID) { + override fun serialize( + encoder: Encoder, + value: UUID, + ) { encoder.encodeString(value.toString()) } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt index 4fc69d92..1cfa17a9 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt @@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap class InMemoryLampRepository : LampRepository { // In-memory storage for lamps - using UUID keys private val lamps = ConcurrentHashMap() - + /** * Get all lamps */ diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt index dc834051..e5428420 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt @@ -3,7 +3,6 @@ package com.lampcontrol.service import com.lampcontrol.api.models.Lamp import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate -import com.lampcontrol.entity.LampEntity import com.lampcontrol.mapper.LampMapper import com.lampcontrol.repository.LampRepository import java.util.* @@ -14,9 +13,8 @@ import java.util.* */ class LampService( private val lampRepository: LampRepository, - private val lampMapper: LampMapper + private val lampMapper: LampMapper, ) { - /** * Get all lamps as API models */ @@ -29,11 +27,12 @@ class LampService( * Get a lamp by string ID and return as API model */ suspend fun getLampById(lampId: String): Lamp? { - val uuid = try { - UUID.fromString(lampId) - } catch (e: IllegalArgumentException) { - return null - } + val uuid = + try { + UUID.fromString(lampId) + } catch (e: IllegalArgumentException) { + return null + } return lampRepository.getLampById(uuid) ?.let { lampMapper.toApiModel(it) } @@ -51,12 +50,16 @@ class LampService( /** * Update a lamp by string ID with API update model */ - suspend fun updateLamp(lampId: String, lampUpdate: LampUpdate): Lamp? { - val uuid = try { - UUID.fromString(lampId) - } catch (e: IllegalArgumentException) { - return null - } + suspend fun updateLamp( + lampId: String, + lampUpdate: LampUpdate, + ): Lamp? { + val uuid = + try { + UUID.fromString(lampId) + } catch (e: IllegalArgumentException) { + return null + } val existingEntity = lampRepository.getLampById(uuid) ?: return null val updatedEntity = lampMapper.updateDomainEntity(existingEntity, lampUpdate) @@ -68,11 +71,12 @@ class LampService( * Delete a lamp by string ID */ suspend fun deleteLamp(lampId: String): Boolean { - val uuid = try { - UUID.fromString(lampId) - } catch (e: IllegalArgumentException) { - return false - } + val uuid = + try { + UUID.fromString(lampId) + } catch (e: IllegalArgumentException) { + return false + } return lampRepository.deleteLamp(uuid) } @@ -81,12 +85,13 @@ class LampService( * Check if a lamp exists by string ID */ suspend fun lampExists(lampId: String): Boolean { - val uuid = try { - UUID.fromString(lampId) - } catch (e: IllegalArgumentException) { - return false - } + val uuid = + try { + UUID.fromString(lampId) + } catch (e: IllegalArgumentException) { + return false + } return lampRepository.lampExists(uuid) } -} \ No newline at end of file +} diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApiKeyAuthTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApiKeyAuthTest.kt index 43a65f05..7bc8e243 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApiKeyAuthTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApiKeyAuthTest.kt @@ -1,89 +1,95 @@ package com.lampcontrol.api -import com.lampcontrol.api.infrastructure.ApiKeyLocation import com.lampcontrol.api.infrastructure.ApiKeyCredential +import com.lampcontrol.api.infrastructure.ApiKeyLocation import com.lampcontrol.api.infrastructure.ApiPrincipal import com.lampcontrol.api.infrastructure.apiKeyAuth -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.auth.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.testing.* +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.authenticate +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication import org.junit.jupiter.api.Test import kotlin.test.assertEquals class ApiKeyAuthTest { - @Test - fun `header-based API key auth works`() = testApplication { - application { - install(Authentication) { - apiKeyAuth("apiKeyHeader") { - apiKeyName = "X-API-KEY" - apiKeyLocation = ApiKeyLocation.HEADER - validate { cred: ApiKeyCredential -> - if (cred.value == "topsecret") ApiPrincipal(cred) else null + fun `header-based API key auth works`() = + testApplication { + application { + install(Authentication) { + apiKeyAuth("apiKeyHeader") { + apiKeyName = "X-API-KEY" + apiKeyLocation = ApiKeyLocation.HEADER + validate { cred: ApiKeyCredential -> + if (cred.value == "topsecret") ApiPrincipal(cred) else null + } } } - } - routing { - authenticate("apiKeyHeader") { - get("/secure") { call.respondText("secure") } + routing { + authenticate("apiKeyHeader") { + get("/secure") { call.respondText("secure") } + } } } - } - // No credentials -> 401 - val noCreds = client.get("/secure") - assertEquals(HttpStatusCode.Unauthorized, noCreds.status) + // No credentials -> 401 + val noCreds = client.get("/secure") + assertEquals(HttpStatusCode.Unauthorized, noCreds.status) - // Invalid credentials -> 401 - val badCreds = client.get("/secure") { - header("X-API-KEY", "wrong") - } - assertEquals(HttpStatusCode.Unauthorized, badCreds.status) + // Invalid credentials -> 401 + val badCreds = + client.get("/secure") { + header("X-API-KEY", "wrong") + } + assertEquals(HttpStatusCode.Unauthorized, badCreds.status) - // Valid credentials -> 200 - val ok = client.get("/secure") { - header("X-API-KEY", "topsecret") + // Valid credentials -> 200 + val ok = + client.get("/secure") { + header("X-API-KEY", "topsecret") + } + assertEquals(HttpStatusCode.OK, ok.status) + assertEquals("secure", ok.bodyAsText()) } - assertEquals(HttpStatusCode.OK, ok.status) - assertEquals("secure", ok.bodyAsText()) - } @Test - fun `query-based API key auth works`() = testApplication { - application { - install(Authentication) { - apiKeyAuth("apiKeyQuery") { - apiKeyName = "api_key" - apiKeyLocation = ApiKeyLocation.QUERY - validate { cred: ApiKeyCredential -> - if (cred.value == "letmein") ApiPrincipal(cred) else null + fun `query-based API key auth works`() = + testApplication { + application { + install(Authentication) { + apiKeyAuth("apiKeyQuery") { + apiKeyName = "api_key" + apiKeyLocation = ApiKeyLocation.QUERY + validate { cred: ApiKeyCredential -> + if (cred.value == "letmein") ApiPrincipal(cred) else null + } } } - } - routing { - authenticate("apiKeyQuery") { - get("/secure-q") { call.respondText("secure-q") } + routing { + authenticate("apiKeyQuery") { + get("/secure-q") { call.respondText("secure-q") } + } } } - } - // No api_key -> 401 - val noKey = client.get("/secure-q") - assertEquals(HttpStatusCode.Unauthorized, noKey.status) + // No api_key -> 401 + val noKey = client.get("/secure-q") + assertEquals(HttpStatusCode.Unauthorized, noKey.status) - // Wrong api_key -> 401 - val wrong = client.get("/secure-q?api_key=bad") - assertEquals(HttpStatusCode.Unauthorized, wrong.status) + // Wrong api_key -> 401 + val wrong = client.get("/secure-q?api_key=bad") + assertEquals(HttpStatusCode.Unauthorized, wrong.status) - // Correct api_key -> 200 - val ok = client.get("/secure-q?api_key=letmein") - assertEquals(HttpStatusCode.OK, ok.status) - assertEquals("secure-q", ok.bodyAsText()) - } + // Correct api_key -> 200 + val ok = client.get("/secure-q?api_key=letmein") + assertEquals(HttpStatusCode.OK, ok.status) + assertEquals("secure-q", ok.bodyAsText()) + } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApplicationTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApplicationTest.kt index a8a58bd0..6943e7a5 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApplicationTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/api/ApplicationTest.kt @@ -2,15 +2,21 @@ package com.lampcontrol.api import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate -import com.lampcontrol.serialization.UUIDSerializer import com.lampcontrol.module -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.serialization.json.Json +import com.lampcontrol.serialization.UUIDSerializer +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import org.junit.jupiter.api.Test import java.util.UUID @@ -19,188 +25,202 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class ApplicationTest { - // Configure JSON with the same settings as the application - private val json = Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - serializersModule = SerializersModule { - contextual(UUID::class, UUIDSerializer) + private val json = + Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + serializersModule = + SerializersModule { + contextual(UUID::class, UUIDSerializer) + } } - } - + @Test - fun testHealthEndpoint() = testApplication { - application { - module() - } - - client.get("/health").apply { - assertEquals(HttpStatusCode.OK, status) - val responseBody = json.decodeFromString>(bodyAsText()) - assertEquals("ok", responseBody["status"]) + fun testHealthEndpoint() = + testApplication { + application { + module() + } + + client.get("/health").apply { + assertEquals(HttpStatusCode.OK, status) + val responseBody = json.decodeFromString>(bodyAsText()) + assertEquals("ok", responseBody["status"]) + } } - } @Test - fun testRootEndpoint() = testApplication { - application { - module() - } - - client.get("/").apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals("Lamp Control API - Kotlin Implementation", bodyAsText()) + fun testRootEndpoint() = + testApplication { + application { + module() + } + + client.get("/").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals("Lamp Control API - Kotlin Implementation", bodyAsText()) + } } - } @Test - fun testListLampsEmpty() = testApplication { - application { - module() - } - - client.get("/v1/lamps").apply { - assertEquals(HttpStatusCode.OK, status) - // Response should be an object with `data` array per OpenAPI - val respJson = bodyAsText() - val parsed = json.decodeFromString(respJson) - assertTrue(parsed.data.isEmpty()) + fun testListLampsEmpty() = + testApplication { + application { + module() + } + + client.get("/v1/lamps").apply { + assertEquals(HttpStatusCode.OK, status) + // Response should be an object with `data` array per OpenAPI + val respJson = bodyAsText() + val parsed = json.decodeFromString(respJson) + assertTrue(parsed.data.isEmpty()) + } } - } @Test - fun testCreateAndGetLamp() = testApplication { - application { - module() - } - - // Create a lamp - val lampCreate = LampCreate(status = true) - val createResponse = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody(json.encodeToString(lampCreate)) + fun testCreateAndGetLamp() = + testApplication { + application { + module() + } + + // Create a lamp + val lampCreate = LampCreate(status = true) + val createResponse = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(lampCreate)) + } + + assertEquals(HttpStatusCode.Created, createResponse.status) + val createdLampJson = createResponse.bodyAsText() + assertNotNull(createdLampJson) + println("DEBUG: Created lamp response: $createdLampJson") + + // Use proper JSON parsing instead of string manipulation + val createdLamp = json.decodeFromString(createdLampJson) + assertTrue(createdLamp.status) + + // Get the created lamp + val getResponse = client.get("/v1/lamps/${createdLamp.id}") + assertEquals(HttpStatusCode.OK, getResponse.status) + + val retrievedLampJson = getResponse.bodyAsText() + println("DEBUG: Retrieved lamp response: $retrievedLampJson") + val retrievedLamp = json.decodeFromString(retrievedLampJson) + assertTrue(retrievedLamp.status) + assertEquals(createdLamp.id, retrievedLamp.id) } - - assertEquals(HttpStatusCode.Created, createResponse.status) - val createdLampJson = createResponse.bodyAsText() - assertNotNull(createdLampJson) - println("DEBUG: Created lamp response: $createdLampJson") - - // Use proper JSON parsing instead of string manipulation - val createdLamp = json.decodeFromString(createdLampJson) - assertTrue(createdLamp.status) - - // Get the created lamp - val getResponse = client.get("/v1/lamps/${createdLamp.id}") - assertEquals(HttpStatusCode.OK, getResponse.status) - - val retrievedLampJson = getResponse.bodyAsText() - println("DEBUG: Retrieved lamp response: $retrievedLampJson") - val retrievedLamp = json.decodeFromString(retrievedLampJson) - assertTrue(retrievedLamp.status) - assertEquals(createdLamp.id, retrievedLamp.id) - } @Test - fun testGetNonExistentLamp() = testApplication { - application { - module() - } - - client.get("/v1/lamps/non-existent-id").apply { - assertEquals(HttpStatusCode.NotFound, status) - assertTrue(bodyAsText().contains("Lamp not found")) + fun testGetNonExistentLamp() = + testApplication { + application { + module() + } + + client.get("/v1/lamps/non-existent-id").apply { + assertEquals(HttpStatusCode.NotFound, status) + assertTrue(bodyAsText().contains("Lamp not found")) + } } - } @Test - fun testUpdateLamp() = testApplication { - application { - module() - } - - // Create a lamp - val lampCreate = LampCreate(status = true) - val createResponse = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody(json.encodeToString(lampCreate)) - } - - assertEquals(HttpStatusCode.Created, createResponse.status) - val createdLampJson = createResponse.bodyAsText() - - // Use proper JSON parsing instead of string manipulation - val createdLamp = json.decodeFromString(createdLampJson) - - // Update the lamp - val lampUpdate = LampUpdate(status = false) - val updateResponse = client.put("/v1/lamps/${createdLamp.id}") { - contentType(ContentType.Application.Json) - setBody(json.encodeToString(lampUpdate)) + fun testUpdateLamp() = + testApplication { + application { + module() + } + + // Create a lamp + val lampCreate = LampCreate(status = true) + val createResponse = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(lampCreate)) + } + + assertEquals(HttpStatusCode.Created, createResponse.status) + val createdLampJson = createResponse.bodyAsText() + + // Use proper JSON parsing instead of string manipulation + val createdLamp = json.decodeFromString(createdLampJson) + + // Update the lamp + val lampUpdate = LampUpdate(status = false) + val updateResponse = + client.put("/v1/lamps/${createdLamp.id}") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(lampUpdate)) + } + + assertEquals(HttpStatusCode.OK, updateResponse.status) + val updatedLampJson = updateResponse.bodyAsText() + val updatedLamp = json.decodeFromString(updatedLampJson) + assertEquals(false, updatedLamp.status) + assertEquals(createdLamp.id, updatedLamp.id) } - - assertEquals(HttpStatusCode.OK, updateResponse.status) - val updatedLampJson = updateResponse.bodyAsText() - val updatedLamp = json.decodeFromString(updatedLampJson) - assertEquals(false, updatedLamp.status) - assertEquals(createdLamp.id, updatedLamp.id) - } @Test - fun testUpdateNonExistentLamp() = testApplication { - application { - module() - } - - val lampUpdate = LampUpdate(status = false) - client.put("/v1/lamps/non-existent-id") { - contentType(ContentType.Application.Json) - setBody(json.encodeToString(lampUpdate)) - }.apply { - assertEquals(HttpStatusCode.NotFound, status) - assertTrue(bodyAsText().contains("Lamp not found")) + fun testUpdateNonExistentLamp() = + testApplication { + application { + module() + } + + val lampUpdate = LampUpdate(status = false) + client.put("/v1/lamps/non-existent-id") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(lampUpdate)) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + assertTrue(bodyAsText().contains("Lamp not found")) + } } - } @Test - fun testDeleteLamp() = testApplication { - application { - module() - } - - // Create a lamp - val lampCreate = LampCreate(status = true) - val createResponse = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody(json.encodeToString(lampCreate)) + fun testDeleteLamp() = + testApplication { + application { + module() + } + + // Create a lamp + val lampCreate = LampCreate(status = true) + val createResponse = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(lampCreate)) + } + + assertEquals(HttpStatusCode.Created, createResponse.status) + val createdLampJson = createResponse.bodyAsText() + + // Use proper JSON parsing instead of string manipulation + val createdLamp = json.decodeFromString(createdLampJson) + + // Delete the lamp + val deleteResponse = client.delete("/v1/lamps/${createdLamp.id}") + assertEquals(HttpStatusCode.NoContent, deleteResponse.status) + + // Verify lamp is deleted + val getResponse = client.get("/v1/lamps/${createdLamp.id}") + assertEquals(HttpStatusCode.NotFound, getResponse.status) } - - assertEquals(HttpStatusCode.Created, createResponse.status) - val createdLampJson = createResponse.bodyAsText() - - // Use proper JSON parsing instead of string manipulation - val createdLamp = json.decodeFromString(createdLampJson) - - // Delete the lamp - val deleteResponse = client.delete("/v1/lamps/${createdLamp.id}") - assertEquals(HttpStatusCode.NoContent, deleteResponse.status) - - // Verify lamp is deleted - val getResponse = client.get("/v1/lamps/${createdLamp.id}") - assertEquals(HttpStatusCode.NotFound, getResponse.status) - } @Test - fun testDeleteNonExistentLamp() = testApplication { - application { - module() - } - - client.delete("/v1/lamps/non-existent-id").apply { - assertEquals(HttpStatusCode.NotFound, status) - assertTrue(bodyAsText().contains("Lamp not found")) + fun testDeleteNonExistentLamp() = + testApplication { + application { + module() + } + + client.delete("/v1/lamps/non-existent-id").apply { + assertEquals(HttpStatusCode.NotFound, status) + assertTrue(bodyAsText().contains("Lamp not found")) + } } - } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/api/DebugTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/api/DebugTest.kt index 942050aa..4528d519 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/api/DebugTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/api/DebugTest.kt @@ -2,45 +2,50 @@ package com.lampcontrol.api import com.lampcontrol.api.models.LampCreate import com.lampcontrol.module -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.test.Test class DebugTest { @Test - fun debugJsonOutput() = testApplication { - application { - module() - } - - // Create a lamp - val lampCreate = LampCreate(status = true) - val createResponse = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody(Json.encodeToString(lampCreate)) - } - - println("Create response status: ${createResponse.status}") - val responseBody = createResponse.bodyAsText() - println("Create response body: $responseBody") - - // Try to extract the ID manually - if (responseBody.contains("\"id\":\"")) { - val lampIdStart = responseBody.indexOf("\"id\":\"") + 6 - val lampIdEnd = responseBody.indexOf("\"", lampIdStart) - val lampId = responseBody.substring(lampIdStart, lampIdEnd) - println("Extracted lamp ID: $lampId") - - // Try to get the lamp - val getResponse = client.get("/v1/lamps/$lampId") - println("Get response status: ${getResponse.status}") - println("Get response body: ${getResponse.bodyAsText()}") - } else { - println("No id field found in response") + fun debugJsonOutput() = + testApplication { + application { + module() + } + + // Create a lamp + val lampCreate = LampCreate(status = true) + val createResponse = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody(Json.encodeToString(lampCreate)) + } + + println("Create response status: ${createResponse.status}") + val responseBody = createResponse.bodyAsText() + println("Create response body: $responseBody") + + // Try to extract the ID manually + if (responseBody.contains("\"id\":\"")) { + val lampIdStart = responseBody.indexOf("\"id\":\"") + 6 + val lampIdEnd = responseBody.indexOf("\"", lampIdStart) + val lampId = responseBody.substring(lampIdStart, lampIdEnd) + println("Extracted lamp ID: $lampId") + + // Try to get the lamp + val getResponse = client.get("/v1/lamps/$lampId") + println("Get response status: ${getResponse.status}") + println("Get response body: ${getResponse.bodyAsText()}") + } else { + println("No id field found in response") + } } - } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/api/EdgeCaseTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/api/EdgeCaseTest.kt index d13d2e06..42fff0bf 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/api/EdgeCaseTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/api/EdgeCaseTest.kt @@ -1,160 +1,185 @@ package com.lampcontrol.api import com.lampcontrol.module -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.serialization.json.* +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue class EdgeCaseTest { - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - @Test - fun `test malformed JSON request`() = testApplication { - application { - module() + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true } - - // Test invalid JSON - val response = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody("{invalid json}") - } - assertEquals(HttpStatusCode.BadRequest, response.status) - } @Test - fun `test missing required fields`() = testApplication { - application { - module() - } - - // Test request without status field - val response = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody("{}") + fun `test malformed JSON request`() = + testApplication { + application { + module() + } + + // Test invalid JSON + val response = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody("{invalid json}") + } + assertEquals(HttpStatusCode.BadRequest, response.status) } - assertEquals(HttpStatusCode.BadRequest, response.status) - } @Test - fun `test unsupported media type`() = testApplication { - application { - module() - } - - // Test XML content type (not supported) - should return 400 Bad Request for content type mismatch - val response = client.post("/v1/lamps") { - contentType(ContentType.Application.Xml) - setBody("true") + fun `test missing required fields`() = + testApplication { + application { + module() + } + + // Test request without status field + val response = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody("{}") + } + assertEquals(HttpStatusCode.BadRequest, response.status) } - assertEquals(HttpStatusCode.BadRequest, response.status) - } @Test - fun `test large request body`() = testApplication { - application { - module() - } - - // Test with large JSON (should still work) - val largeRequest = buildJsonObject { - put("status", true) - put("description", "x".repeat(1000)) // Large description - } - - val response = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody(largeRequest.toString()) + fun `test unsupported media type`() = + testApplication { + application { + module() + } + + // Test XML content type (not supported) - should return 400 Bad Request for content type mismatch + val response = + client.post("/v1/lamps") { + contentType(ContentType.Application.Xml) + setBody("true") + } + assertEquals(HttpStatusCode.BadRequest, response.status) } - // Should still create successfully if only extra fields - assertEquals(HttpStatusCode.Created, response.status) - } @Test - fun `test special characters in lamp ID path`() = testApplication { - application { - module() + fun `test large request body`() = + testApplication { + application { + module() + } + + // Test with large JSON (should still work) + val largeRequest = + buildJsonObject { + put("status", true) + put("description", "x".repeat(1000)) // Large description + } + + val response = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody(largeRequest.toString()) + } + // Should still create successfully if only extra fields + assertEquals(HttpStatusCode.Created, response.status) } - - // Test with special characters in path - val response = client.get("/v1/lamps/invalid%20id%20with%20spaces") - assertEquals(HttpStatusCode.NotFound, response.status) - } @Test - fun `test concurrent lamp operations`() = testApplication { - application { - module() - } - - // Create a lamp - val createResponse = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody("""{"status": true}""") - } - assertEquals(HttpStatusCode.Created, createResponse.status) - - val createdLamp = json.parseToJsonElement(createResponse.bodyAsText()).jsonObject - val lampId = createdLamp["id"]?.jsonPrimitive?.content - assertNotNull(lampId) - - // Try to update and get the lamp concurrently - val updateResponse = client.put("/v1/lamps/$lampId") { - contentType(ContentType.Application.Json) - setBody("""{"status": false}""") + fun `test special characters in lamp ID path`() = + testApplication { + application { + module() + } + + // Test with special characters in path + val response = client.get("/v1/lamps/invalid%20id%20with%20spaces") + assertEquals(HttpStatusCode.NotFound, response.status) } - assertEquals(HttpStatusCode.OK, updateResponse.status) - - val getResponse = client.get("/v1/lamps/$lampId") - assertEquals(HttpStatusCode.OK, getResponse.status) - } @Test - fun `test multiple lamp creation and retrieval`() = testApplication { - application { - module() - } - - // Create multiple lamps - val lampIds = mutableListOf() - - repeat(5) { i -> - val response = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody("""{"status": ${i % 2 == 0}}""") + fun `test concurrent lamp operations`() = + testApplication { + application { + module() } - assertEquals(HttpStatusCode.Created, response.status) - - val lamp = json.parseToJsonElement(response.bodyAsText()).jsonObject - val lampId = lamp["id"]?.jsonPrimitive?.content + + // Create a lamp + val createResponse = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody("""{"status": true}""") + } + assertEquals(HttpStatusCode.Created, createResponse.status) + + val createdLamp = json.parseToJsonElement(createResponse.bodyAsText()).jsonObject + val lampId = createdLamp["id"]?.jsonPrimitive?.content assertNotNull(lampId) - lampIds.add(lampId) + + // Try to update and get the lamp concurrently + val updateResponse = + client.put("/v1/lamps/$lampId") { + contentType(ContentType.Application.Json) + setBody("""{"status": false}""") + } + assertEquals(HttpStatusCode.OK, updateResponse.status) + + val getResponse = client.get("/v1/lamps/$lampId") + assertEquals(HttpStatusCode.OK, getResponse.status) } - - // Get all lamps - val allLampsResponse = client.get("/v1/lamps") - assertEquals(HttpStatusCode.OK, allLampsResponse.status) - - // Response is an object with `data` array - val respObj = json.parseToJsonElement(allLampsResponse.bodyAsText()).jsonObject - val allLamps = respObj["data"]?.jsonArray - assertNotNull(allLamps) - assertTrue(allLamps.size >= 5) - - // Verify each lamp exists - lampIds.forEach { lampId -> - val response = client.get("/v1/lamps/$lampId") - assertEquals(HttpStatusCode.OK, response.status) + + @Test + fun `test multiple lamp creation and retrieval`() = + testApplication { + application { + module() + } + + // Create multiple lamps + val lampIds = mutableListOf() + + repeat(5) { i -> + val response = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody("""{"status": ${i % 2 == 0}}""") + } + assertEquals(HttpStatusCode.Created, response.status) + + val lamp = json.parseToJsonElement(response.bodyAsText()).jsonObject + val lampId = lamp["id"]?.jsonPrimitive?.content + assertNotNull(lampId) + lampIds.add(lampId) + } + + // Get all lamps + val allLampsResponse = client.get("/v1/lamps") + assertEquals(HttpStatusCode.OK, allLampsResponse.status) + + // Response is an object with `data` array + val respObj = json.parseToJsonElement(allLampsResponse.bodyAsText()).jsonObject + val allLamps = respObj["data"]?.jsonArray + assertNotNull(allLamps) + assertTrue(allLamps.size >= 5) + + // Verify each lamp exists + lampIds.forEach { lampId -> + val response = client.get("/v1/lamps/$lampId") + assertEquals(HttpStatusCode.OK, response.status) + } } - } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/api/models/ApiModelsTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/api/models/ApiModelsTest.kt index b0d282a2..91889238 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/api/models/ApiModelsTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/api/models/ApiModelsTest.kt @@ -1,12 +1,11 @@ package com.lampcontrol.api.models import org.junit.jupiter.api.Test -import java.util.* +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotNull class ApiModelsTest { - @Test fun `ListLamps200Response can be created with data`() { val lamp1 = Lamp(id = UUID.randomUUID(), status = true, createdAt = "2024-01-01T00:00:00Z", updatedAt = "2024-01-01T00:00:00Z") @@ -63,12 +62,13 @@ class ApiModelsTest { @Test fun `Lamp can be created with all properties`() { val id = UUID.randomUUID() - val lamp = Lamp( - id = id, - status = true, - createdAt = "2024-01-01T00:00:00Z", - updatedAt = "2024-01-01T00:00:00Z" - ) + val lamp = + Lamp( + id = id, + status = true, + createdAt = "2024-01-01T00:00:00Z", + updatedAt = "2024-01-01T00:00:00Z", + ) assertEquals(id, lamp.id) assertEquals(true, lamp.status) diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigCompanionTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigCompanionTest.kt index b852e04a..fd75eba3 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigCompanionTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigCompanionTest.kt @@ -2,7 +2,6 @@ package com.lampcontrol.database import org.junit.jupiter.api.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull /** @@ -10,7 +9,6 @@ import kotlin.test.assertNotNull * These tests focus on exercising the code paths in fromEnv() and parseDatabaseUrl(). */ class DatabaseConfigCompanionTest { - @Test fun `fromEnv can be called without exceptions`() { // This exercises the fromEnv() code path @@ -43,18 +41,19 @@ class DatabaseConfigCompanionTest { @Test fun `DatabaseConfig can be created via constructor`() { // This tests the primary constructor which is part of the companion's scope - val config = DatabaseConfig( - host = "testhost", - port = 5432, - database = "testdb", - user = "testuser", - password = "testpass", - poolMin = 0, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "testhost", + port = 5432, + database = "testdb", + user = "testuser", + password = "testpass", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertNotNull(config) assertEquals("testhost", config.host) @@ -79,13 +78,14 @@ class DatabaseConfigCompanionTest { @Test fun `DatabaseConfig construction exercises all parameter paths`() { - val configs = listOf( - DatabaseConfig("h1", 5432, "d1", "u1", "p1", 0, 1, 3600000, 1800000, 30000), - DatabaseConfig("h2", 5433, "d2", "u2", "p2", 1, 2, 3600000, 1800000, 30000), - DatabaseConfig("h3", 5434, "d3", "u3", "p3", 2, 3, 3600000, 1800000, 30000), - DatabaseConfig("h4", 5435, "d4", "u4", "p4", 3, 4, 3600000, 1800000, 30000), - DatabaseConfig("h5", 5436, "d5", "u5", "p5", 4, 5, 3600000, 1800000, 30000) - ) + val configs = + listOf( + DatabaseConfig("h1", 5432, "d1", "u1", "p1", 0, 1, 3600000, 1800000, 30000), + DatabaseConfig("h2", 5433, "d2", "u2", "p2", 1, 2, 3600000, 1800000, 30000), + DatabaseConfig("h3", 5434, "d3", "u3", "p3", 2, 3, 3600000, 1800000, 30000), + DatabaseConfig("h4", 5435, "d4", "u4", "p4", 3, 4, 3600000, 1800000, 30000), + DatabaseConfig("h5", 5436, "d5", "u5", "p5", 4, 5, 3600000, 1800000, 30000), + ) configs.forEachIndexed { index, config -> assertEquals("h${index + 1}", config.host) @@ -121,13 +121,14 @@ class DatabaseConfigCompanionTest { @Test fun `DatabaseConfig with various connection string formats`() { - val testCases = listOf( - Triple("localhost", 5432, "jdbc:postgresql://localhost:5432/db"), - Triple("127.0.0.1", 5432, "jdbc:postgresql://127.0.0.1:5432/db"), - Triple("db.example.com", 5432, "jdbc:postgresql://db.example.com:5432/db"), - Triple("10.0.0.1", 5433, "jdbc:postgresql://10.0.0.1:5433/db"), - Triple("postgres.local", 5434, "jdbc:postgresql://postgres.local:5434/db") - ) + val testCases = + listOf( + Triple("localhost", 5432, "jdbc:postgresql://localhost:5432/db"), + Triple("127.0.0.1", 5432, "jdbc:postgresql://127.0.0.1:5432/db"), + Triple("db.example.com", 5432, "jdbc:postgresql://db.example.com:5432/db"), + Triple("10.0.0.1", 5433, "jdbc:postgresql://10.0.0.1:5433/db"), + Triple("postgres.local", 5434, "jdbc:postgresql://postgres.local:5434/db"), + ) testCases.forEach { (host, port, expected) -> val config = DatabaseConfig(host, port, "db", "user", "pass", 0, 4, 3600000, 1800000, 30000) @@ -168,18 +169,19 @@ class DatabaseConfigCompanionTest { @Test fun `DatabaseConfig copy with all parameters changed`() { val original = DatabaseConfig("h1", 1, "d1", "u1", "p1", 1, 2, 3600000, 1800000, 30000) - val modified = original.copy( - host = "h2", - port = 2, - database = "d2", - user = "u2", - password = "p2", - poolMin = 3, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val modified = + original.copy( + host = "h2", + port = 2, + database = "d2", + user = "u2", + password = "p2", + poolMin = 3, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("h2", modified.host) assertEquals(2, modified.port) diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentTest.kt index 32677122..81c73c09 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentTest.kt @@ -9,21 +9,21 @@ import kotlin.test.assertNotNull * These tests focus on the static factory methods and edge cases. */ class DatabaseConfigEnvironmentTest { - @Test fun `DatabaseConfig can be constructed with all parameters`() { - val config = DatabaseConfig( - host = "production.db.example.com", - port = 5432, - database = "production_db", - user = "prod_user", - password = "secure_password_123", - poolMin = 10, - poolMax = 50, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "production.db.example.com", + port = 5432, + database = "production_db", + user = "prod_user", + password = "secure_password_123", + poolMin = 10, + poolMax = 50, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertNotNull(config) assertEquals("production.db.example.com", config.host) @@ -37,18 +37,19 @@ class DatabaseConfigEnvironmentTest { @Test fun `DatabaseConfig with empty password`() { - val config = DatabaseConfig( - host = "localhost", - port = 5432, - database = "testdb", - user = "testuser", - password = "", - poolMin = 0, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "testdb", + user = "testuser", + password = "", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("", config.password) assertNotNull(config.connectionString()) @@ -56,18 +57,19 @@ class DatabaseConfigEnvironmentTest { @Test fun `DatabaseConfig with very large pool size`() { - val config = DatabaseConfig( - host = "localhost", - port = 5432, - database = "db", - user = "user", - password = "pass", - poolMin = 100, - poolMax = 500, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "db", + user = "user", + password = "pass", + poolMin = 100, + poolMax = 500, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals(100, config.poolMin) assertEquals(500, config.poolMax) @@ -75,18 +77,19 @@ class DatabaseConfigEnvironmentTest { @Test fun `DatabaseConfig with IPv6 host`() { - val config = DatabaseConfig( - host = "::1", - port = 5432, - database = "db", - user = "user", - password = "pass", - poolMin = 0, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "::1", + port = 5432, + database = "db", + user = "user", + password = "pass", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("::1", config.host) assertEquals("jdbc:postgresql://::1:5432/db", config.connectionString()) @@ -110,18 +113,19 @@ class DatabaseConfigEnvironmentTest { @Test fun `DatabaseConfig component destructuring works`() { - val config = DatabaseConfig( - host = "myhost", - port = 5433, - database = "mydb", - user = "myuser", - password = "mypass", - poolMin = 5, - poolMax = 20, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "myhost", + port = 5433, + database = "mydb", + user = "myuser", + password = "mypass", + poolMin = 5, + poolMax = 20, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) val (host, port, db, user, pass, min, max, maxLifetime, idleTimeout, connectionTimeout) = config @@ -153,18 +157,19 @@ class DatabaseConfigEnvironmentTest { @Test fun `DatabaseConfig with FQDN host`() { - val config = DatabaseConfig( - host = "db.production.company.com", - port = 5432, - database = "maindb", - user = "appuser", - password = "secret", - poolMin = 1, - poolMax = 10, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "db.production.company.com", + port = 5432, + database = "maindb", + user = "appuser", + password = "secret", + poolMin = 1, + poolMax = 10, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) val connStr = config.connectionString() assert(connStr.contains("db.production.company.com")) diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentVariableTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentVariableTest.kt index 4358a1d2..6766f934 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentVariableTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigEnvironmentVariableTest.kt @@ -1,19 +1,18 @@ package com.lampcontrol.database -import org.junitpioneer.jupiter.SetEnvironmentVariable -import org.junitpioneer.jupiter.ClearEnvironmentVariable import org.junit.jupiter.api.Test +import org.junitpioneer.jupiter.ClearEnvironmentVariable +import org.junitpioneer.jupiter.SetEnvironmentVariable import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertFailsWith /** * Tests for DatabaseConfig.fromEnv() using JUnit Pioneer to set environment variables. * These tests exercise the actual environment variable parsing code paths. */ class DatabaseConfigEnvironmentVariableTest { - @Test @SetEnvironmentVariable(key = "DATABASE_URL", value = "postgresql://testuser:testpass@localhost:5432/testdb") fun `fromEnv parses DATABASE_URL correctly`() { @@ -25,8 +24,8 @@ class DatabaseConfigEnvironmentVariableTest { assertEquals("localhost", config.host) assertEquals(5432, config.port) assertEquals("testdb", config.database) - assertEquals(0, config.poolMin) // Default - assertEquals(4, config.poolMax) // Default + assertEquals(0, config.poolMin) // Default + assertEquals(4, config.poolMax) // Default } @Test @@ -84,9 +83,9 @@ class DatabaseConfigEnvironmentVariableTest { assertNotNull(config) assertEquals("myhost", config.host) assertEquals("myuser", config.user) - assertEquals(5432, config.port) // Default - assertEquals("lamp_control", config.database) // Default - assertEquals("", config.password) // Default + assertEquals(5432, config.port) // Default + assertEquals("lamp_control", config.database) // Default + assertEquals("", config.password) // Default } @Test @@ -116,10 +115,10 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals("localhost", config.host) // Default - assertEquals(5432, config.port) // Default + assertEquals("localhost", config.host) // Default + assertEquals(5432, config.port) // Default assertEquals("my_database", config.database) - assertEquals("lamp_user", config.user) // Default + assertEquals("lamp_user", config.user) // Default } @Test @@ -150,7 +149,7 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(5432, config.port) // Falls back to default + assertEquals(5432, config.port) // Falls back to default } @Test @@ -161,7 +160,7 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(0, config.poolMin) // Falls back to default + assertEquals(0, config.poolMin) // Falls back to default } @Test @@ -172,7 +171,7 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(4, config.poolMax) // Falls back to default + assertEquals(4, config.poolMax) // Falls back to default } @Test @@ -290,9 +289,9 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(7200000L, config.maxLifetimeMs) // 2 hours - assertEquals(900000L, config.idleTimeoutMs) // 15 minutes - assertEquals(60000L, config.connectionTimeoutMs) // 60 seconds + assertEquals(7200000L, config.maxLifetimeMs) // 2 hours + assertEquals(900000L, config.idleTimeoutMs) // 15 minutes + assertEquals(60000L, config.connectionTimeoutMs) // 60 seconds } @Test @@ -304,9 +303,9 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(1800000L, config.maxLifetimeMs) // 30 minutes - assertEquals(600000L, config.idleTimeoutMs) // 10 minutes - assertEquals(20000L, config.connectionTimeoutMs) // 20 seconds + assertEquals(1800000L, config.maxLifetimeMs) // 30 minutes + assertEquals(600000L, config.idleTimeoutMs) // 10 minutes + assertEquals(20000L, config.connectionTimeoutMs) // 20 seconds } @Test @@ -316,7 +315,7 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(3600000L, config.maxLifetimeMs) // Falls back to 1 hour default + assertEquals(3600000L, config.maxLifetimeMs) // Falls back to 1 hour default } @Test @@ -326,7 +325,7 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(1800000L, config.idleTimeoutMs) // Falls back to 30 minutes default + assertEquals(1800000L, config.idleTimeoutMs) // Falls back to 30 minutes default } @Test @@ -336,6 +335,6 @@ class DatabaseConfigEnvironmentVariableTest { val config = DatabaseConfig.fromEnv() assertNotNull(config) - assertEquals(30000L, config.connectionTimeoutMs) // Falls back to 30 seconds default + assertEquals(30000L, config.connectionTimeoutMs) // Falls back to 30 seconds default } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigParsingTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigParsingTest.kt index 2ffb557a..13932f47 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigParsingTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigParsingTest.kt @@ -1,17 +1,14 @@ package com.lampcontrol.database import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull /** * Tests for DatabaseConfig parsing logic, including environment variable handling. * These tests use reflection to set environment variables temporarily. */ class DatabaseConfigParsingTest { - @Test fun `fromEnv returns null when no environment variables are set`() { // Clear any existing env vars for this test @@ -24,46 +21,49 @@ class DatabaseConfigParsingTest { @Test fun `connectionString formats correctly for various hosts`() { - val testCases = listOf( - Triple("localhost", 5432, "testdb") to "jdbc:postgresql://localhost:5432/testdb", - Triple("127.0.0.1", 5433, "mydb") to "jdbc:postgresql://127.0.0.1:5433/mydb", - Triple("db.example.com", 5434, "production") to "jdbc:postgresql://db.example.com:5434/production", - Triple("postgres.local", 5435, "dev") to "jdbc:postgresql://postgres.local:5435/dev", - Triple("10.0.0.50", 5432, "lamp_control") to "jdbc:postgresql://10.0.0.50:5432/lamp_control" - ) + val testCases = + listOf( + Triple("localhost", 5432, "testdb") to "jdbc:postgresql://localhost:5432/testdb", + Triple("127.0.0.1", 5433, "mydb") to "jdbc:postgresql://127.0.0.1:5433/mydb", + Triple("db.example.com", 5434, "production") to "jdbc:postgresql://db.example.com:5434/production", + Triple("postgres.local", 5435, "dev") to "jdbc:postgresql://postgres.local:5435/dev", + Triple("10.0.0.50", 5432, "lamp_control") to "jdbc:postgresql://10.0.0.50:5432/lamp_control", + ) testCases.forEach { (params, expected) -> val (host, port, database) = params - val config = DatabaseConfig( - host = host, - port = port, - database = database, - user = "testuser", - password = "testpass", - poolMin = 0, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = host, + port = port, + database = database, + user = "testuser", + password = "testpass", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals(expected, config.connectionString()) } } @Test fun `DatabaseConfig with special characters in database name`() { - val config = DatabaseConfig( - host = "localhost", - port = 5432, - database = "lamp_control_dev_2024", - user = "user", - password = "pass", - poolMin = 1, - poolMax = 5, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "lamp_control_dev_2024", + user = "user", + password = "pass", + poolMin = 1, + poolMax = 5, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("jdbc:postgresql://localhost:5432/lamp_control_dev_2024", config.connectionString()) } @@ -72,18 +72,19 @@ class DatabaseConfigParsingTest { val ports = listOf(5433, 5434, 5435, 15432, 25432) ports.forEach { port -> - val config = DatabaseConfig( - host = "localhost", - port = port, - database = "testdb", - user = "user", - password = "pass", - poolMin = 0, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = port, + database = "testdb", + user = "user", + password = "pass", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("jdbc:postgresql://localhost:$port/testdb", config.connectionString()) } } @@ -134,13 +135,14 @@ class DatabaseConfigParsingTest { @Test fun `DatabaseConfig with minimum pool size variations`() { - val configs = listOf( - DatabaseConfig("h", 5432, "d", "u", "p", 0, 4, 3600000, 1800000, 30000), - DatabaseConfig("h", 5432, "d", "u", "p", 1, 4, 3600000, 1800000, 30000), - DatabaseConfig("h", 5432, "d", "u", "p", 5, 10, 3600000, 1800000, 30000), - DatabaseConfig("h", 5432, "d", "u", "p", 10, 20, 3600000, 1800000, 30000), - DatabaseConfig("h", 5432, "d", "u", "p", 50, 100, 3600000, 1800000, 30000) - ) + val configs = + listOf( + DatabaseConfig("h", 5432, "d", "u", "p", 0, 4, 3600000, 1800000, 30000), + DatabaseConfig("h", 5432, "d", "u", "p", 1, 4, 3600000, 1800000, 30000), + DatabaseConfig("h", 5432, "d", "u", "p", 5, 10, 3600000, 1800000, 30000), + DatabaseConfig("h", 5432, "d", "u", "p", 10, 20, 3600000, 1800000, 30000), + DatabaseConfig("h", 5432, "d", "u", "p", 50, 100, 3600000, 1800000, 30000), + ) configs.forEach { config -> assert(config.poolMin >= 0) @@ -163,15 +165,16 @@ class DatabaseConfigParsingTest { @Test fun `DatabaseConfig with various pool configurations`() { - val poolConfigs = listOf( - Pair(0, 1), - Pair(0, 4), - Pair(1, 2), - Pair(5, 10), - Pair(10, 10), - Pair(20, 50), - Pair(50, 200) - ) + val poolConfigs = + listOf( + Pair(0, 1), + Pair(0, 4), + Pair(1, 2), + Pair(5, 10), + Pair(10, 10), + Pair(20, 50), + Pair(50, 200), + ) poolConfigs.forEach { (min, max) -> val config = DatabaseConfig("host", 5432, "db", "user", "pass", min, max, 3600000, 1800000, 30000) @@ -232,13 +235,14 @@ class DatabaseConfigParsingTest { @Test fun `connectionString with domain names`() { - val domains = listOf( - "localhost", - "db.example.com", - "postgres.local", - "database-server", - "my-postgres-db.cloud.provider.com" - ) + val domains = + listOf( + "localhost", + "db.example.com", + "postgres.local", + "database-server", + "my-postgres-db.cloud.provider.com", + ) domains.forEach { domain -> val config = DatabaseConfig(domain, 5432, "mydb", "user", "pass", 0, 4, 3600000, 1800000, 30000) diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigTest.kt index dccaca9c..838d3bb1 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseConfigTest.kt @@ -3,24 +3,23 @@ package com.lampcontrol.database import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertFailsWith class DatabaseConfigTest { - @Test fun `connectionString builds correct JDBC URL with custom host and port`() { - val config = DatabaseConfig( - host = "db.example.com", - port = 5433, - database = "mydb", - user = "myuser", - password = "mypass", - poolMin = 2, - poolMax = 10, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "db.example.com", + port = 5433, + database = "mydb", + user = "myuser", + password = "mypass", + poolMin = 2, + poolMax = 10, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) val connectionString = config.connectionString() @@ -29,18 +28,19 @@ class DatabaseConfigTest { @Test fun `connectionString works with localhost`() { - val config = DatabaseConfig( - host = "localhost", - port = 5432, - database = "lamp_control", - user = "lamp_user", - password = "password", - poolMin = 0, - poolMax = 4, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "lamp_control", + user = "lamp_user", + password = "password", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) val connectionString = config.connectionString() @@ -49,18 +49,19 @@ class DatabaseConfigTest { @Test fun `DatabaseConfig can be created with custom values`() { - val config = DatabaseConfig( - host = "192.168.1.100", - port = 5433, - database = "testdb", - user = "testuser", - password = "testpass", - poolMin = 5, - poolMax = 20, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "192.168.1.100", + port = 5433, + database = "testdb", + user = "testuser", + password = "testpass", + poolMin = 5, + poolMax = 20, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertNotNull(config) assertEquals("192.168.1.100", config.host) @@ -74,18 +75,19 @@ class DatabaseConfigTest { @Test fun `DatabaseConfig with minimal configuration`() { - val config = DatabaseConfig( - host = "localhost", - port = 5432, - database = "testdb", - user = "user", - password = "", - poolMin = 0, - poolMax = 1, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "testdb", + user = "user", + password = "", + poolMin = 0, + poolMax = 1, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("jdbc:postgresql://localhost:5432/testdb", config.connectionString()) assertEquals("user", config.user) @@ -94,29 +96,31 @@ class DatabaseConfigTest { @Test fun `DatabaseConfig with IP address host`() { - val config = DatabaseConfig( - host = "10.0.0.1", - port = 5432, - database = "db", - user = "admin", - password = "secret", - poolMin = 1, - poolMax = 5, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "10.0.0.1", + port = 5432, + database = "db", + user = "admin", + password = "secret", + poolMin = 1, + poolMax = 5, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("jdbc:postgresql://10.0.0.1:5432/db", config.connectionString()) } @Test fun `DatabaseConfig supports various database names`() { - val configs = listOf( - DatabaseConfig("host", 5432, "my-db", "user", "pass", 0, 4, 3600000, 1800000, 30000), - DatabaseConfig("host", 5432, "my_db", "user", "pass", 0, 4, 3600000, 1800000, 30000), - DatabaseConfig("host", 5432, "mydb123", "user", "pass", 0, 4, 3600000, 1800000, 30000) - ) + val configs = + listOf( + DatabaseConfig("host", 5432, "my-db", "user", "pass", 0, 4, 3600000, 1800000, 30000), + DatabaseConfig("host", 5432, "my_db", "user", "pass", 0, 4, 3600000, 1800000, 30000), + DatabaseConfig("host", 5432, "mydb123", "user", "pass", 0, 4, 3600000, 1800000, 30000), + ) configs.forEach { config -> assertNotNull(config.connectionString()) @@ -125,18 +129,19 @@ class DatabaseConfigTest { @Test fun `DatabaseConfig pool configuration`() { - val config = DatabaseConfig( - host = "localhost", - port = 5432, - database = "db", - user = "user", - password = "pass", - poolMin = 10, - poolMax = 50, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val config = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "db", + user = "user", + password = "pass", + poolMin = 10, + poolMax = 50, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals(10, config.poolMin) assertEquals(50, config.poolMax) @@ -144,11 +149,12 @@ class DatabaseConfigTest { @Test fun `connectionString format is consistent`() { - val configs = listOf( - DatabaseConfig("host1", 5432, "db1", "u1", "p1", 0, 4, 3600000, 1800000, 30000), - DatabaseConfig("host2", 5433, "db2", "u2", "p2", 1, 5, 3600000, 1800000, 30000), - DatabaseConfig("host3", 5434, "db3", "u3", "p3", 2, 6, 3600000, 1800000, 30000) - ) + val configs = + listOf( + DatabaseConfig("host1", 5432, "db1", "u1", "p1", 0, 4, 3600000, 1800000, 30000), + DatabaseConfig("host2", 5433, "db2", "u2", "p2", 1, 5, 3600000, 1800000, 30000), + DatabaseConfig("host3", 5434, "db3", "u3", "p3", 2, 6, 3600000, 1800000, 30000), + ) configs.forEach { config -> val connStr = config.connectionString() @@ -221,11 +227,12 @@ class DatabaseConfigTest { @Test fun `connectionString handles various host formats`() { - val configs = listOf( - DatabaseConfig("example.com", 5432, "db", "user", "pass", 0, 4, 3600000, 1800000, 30000), - DatabaseConfig("db.internal.network", 5433, "mydb", "admin", "secret", 1, 10, 3600000, 1800000, 30000), - DatabaseConfig("192.168.1.100", 5434, "testdb", "test", "test", 2, 8, 3600000, 1800000, 30000) - ) + val configs = + listOf( + DatabaseConfig("example.com", 5432, "db", "user", "pass", 0, 4, 3600000, 1800000, 30000), + DatabaseConfig("db.internal.network", 5433, "mydb", "admin", "secret", 1, 10, 3600000, 1800000, 30000), + DatabaseConfig("192.168.1.100", 5434, "testdb", "test", "test", 2, 8, 3600000, 1800000, 30000), + ) configs.forEach { config -> val connStr = config.connectionString() diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseFactoryTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseFactoryTest.kt index f1eea042..0195f05a 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseFactoryTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseFactoryTest.kt @@ -1,11 +1,9 @@ package com.lampcontrol.database import org.junit.jupiter.api.Test -import kotlin.test.assertNull import kotlin.test.assertNotNull class DatabaseFactoryTest { - @Test fun `init returns null when no database configuration is present`() { // Given: No DATABASE_URL or DB_NAME environment variables are set in test environment diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseUrlParsingTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseUrlParsingTest.kt index 63ac5e08..684dd88f 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseUrlParsingTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/DatabaseUrlParsingTest.kt @@ -10,17 +10,17 @@ import kotlin.test.assertNotNull * by simulating what would happen if environment variables were set. */ class DatabaseUrlParsingTest { - @Test fun `DATABASE_URL format parsing logic validation`() { // Test the regex pattern that parseDatabaseUrl uses - val validUrls = listOf( - "postgresql://user:pass@localhost:5432/dbname", - "postgres://user:pass@localhost:5432/dbname", - "postgresql://myuser:mypass@db.example.com:5433/production", - "postgres://testuser:testpass@127.0.0.1:5432/testdb", - "postgresql://admin:secret123@10.0.0.1:5432/lamp_control" - ) + val validUrls = + listOf( + "postgresql://user:pass@localhost:5432/dbname", + "postgres://user:pass@localhost:5432/dbname", + "postgresql://myuser:mypass@db.example.com:5433/production", + "postgres://testuser:testpass@127.0.0.1:5432/testdb", + "postgresql://admin:secret123@10.0.0.1:5432/lamp_control", + ) val regex = Regex("""postgres(?:ql)?://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)""") @@ -69,13 +69,14 @@ class DatabaseUrlParsingTest { fun `DATABASE_URL regex handles various host formats`() { val regex = Regex("""postgres(?:ql)?://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)""") - val hostFormats = listOf( - "postgresql://user:pass@localhost:5432/db", - "postgresql://user:pass@127.0.0.1:5432/db", - "postgresql://user:pass@db.example.com:5432/db", - "postgresql://user:pass@postgres-server:5432/db", - "postgresql://user:pass@10.0.0.50:5432/db" - ) + val hostFormats = + listOf( + "postgresql://user:pass@localhost:5432/db", + "postgresql://user:pass@127.0.0.1:5432/db", + "postgresql://user:pass@db.example.com:5432/db", + "postgresql://user:pass@postgres-server:5432/db", + "postgresql://user:pass@10.0.0.50:5432/db", + ) hostFormats.forEach { url -> val match = regex.matchEntire(url) @@ -105,12 +106,13 @@ class DatabaseUrlParsingTest { // Note: The regex uses [^@]+ for password, which means it stops at the first @ // This is correct because @ separates password from host - val testCases = listOf( - "postgresql://user:simple@host:5432/db" to "simple", - "postgresql://user:pass123!@host:5432/db" to "pass123!", - "postgresql://user:p-ssw0rd_123@host:5432/db" to "p-ssw0rd_123", - "postgresql://user:SecurePass123@host:5432/db" to "SecurePass123" - ) + val testCases = + listOf( + "postgresql://user:simple@host:5432/db" to "simple", + "postgresql://user:pass123!@host:5432/db" to "pass123!", + "postgresql://user:p-ssw0rd_123@host:5432/db" to "p-ssw0rd_123", + "postgresql://user:SecurePass123@host:5432/db" to "SecurePass123", + ) testCases.forEach { (url, expectedPassword) -> val match = regex.matchEntire(url) @@ -125,12 +127,13 @@ class DatabaseUrlParsingTest { fun `DATABASE_URL regex handles usernames with special characters`() { val regex = Regex("""postgres(?:ql)?://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)""") - val testCases = listOf( - "postgresql://simple:pass@host:5432/db" to "simple", - "postgresql://user_name:pass@host:5432/db" to "user_name", - "postgresql://user-name:pass@host:5432/db" to "user-name", - "postgresql://user.name:pass@host:5432/db" to "user.name" - ) + val testCases = + listOf( + "postgresql://simple:pass@host:5432/db" to "simple", + "postgresql://user_name:pass@host:5432/db" to "user_name", + "postgresql://user-name:pass@host:5432/db" to "user-name", + "postgresql://user.name:pass@host:5432/db" to "user.name", + ) testCases.forEach { (url, expectedUser) -> val match = regex.matchEntire(url) @@ -145,14 +148,15 @@ class DatabaseUrlParsingTest { fun `DATABASE_URL regex handles database names with underscores and numbers`() { val regex = Regex("""postgres(?:ql)?://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)""") - val dbNames = listOf( - "simple", - "lamp_control", - "my_database_2024", - "test-db", - "production123", - "db_dev_v2" - ) + val dbNames = + listOf( + "simple", + "lamp_control", + "my_database_2024", + "test-db", + "production123", + "db_dev_v2", + ) dbNames.forEach { dbName -> val url = "postgresql://user:pass@host:5432/$dbName" @@ -215,18 +219,20 @@ class DatabaseUrlParsingTest { val (user, password, host, port, database) = match.destructured // These would be the values extracted by parseDatabaseUrl - val expectedConfig = DatabaseConfig( - host = host, - port = port.toInt(), - database = database, - user = user, - password = password, - poolMin = 0, // Default - poolMax = 4, // Default - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val expectedConfig = + DatabaseConfig( + host = host, + port = port.toInt(), + database = database, + user = user, + password = password, + // Default pool settings + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals("testuser", expectedConfig.user) assertEquals("testpass", expectedConfig.password) @@ -241,18 +247,22 @@ class DatabaseUrlParsingTest { // This test documents the expected behavior when individual env vars are set // Testing the logic that would execute if DB_HOST and DB_USER were set - val expectedConfig = DatabaseConfig( - host = "localhost", // From DB_HOST - port = 5432, // Default or from DB_PORT - database = "lamp_control", // Default or from DB_NAME - user = "lamp_user", // From DB_USER - password = "", // Default or from DB_PASSWORD - poolMin = 0, // Default or from DB_POOL_MIN_SIZE - poolMax = 4, // Default or from DB_POOL_MAX_SIZE - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + // Values from environment variables: + // host from DB_HOST, port from DB_PORT (default 5432), database from DB_NAME (default lamp_control) + // user from DB_USER, password from DB_PASSWORD, pool sizes from DB_POOL_MIN_SIZE/DB_POOL_MAX_SIZE + val expectedConfig = + DatabaseConfig( + host = "localhost", + port = 5432, + database = "lamp_control", + user = "lamp_user", + password = "", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertNotNull(expectedConfig) assertEquals("localhost", expectedConfig.host) @@ -272,18 +282,19 @@ class DatabaseUrlParsingTest { assertNotNull(match) // If DATABASE_URL is not set, individual env vars should be used - val individualConfig = DatabaseConfig( - host = "env_host", - port = 5434, - database = "env_db", - user = "env_user", - password = "env_pass", - poolMin = 1, - poolMax = 5, - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + val individualConfig = + DatabaseConfig( + host = "env_host", + port = 5434, + database = "env_db", + user = "env_user", + password = "env_pass", + poolMin = 1, + poolMax = 5, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertNotNull(individualConfig) assertEquals("env_host", individualConfig.host) @@ -292,18 +303,20 @@ class DatabaseUrlParsingTest { @Test fun `pool size defaults when not specified in environment`() { // Tests the default pool size values used when env vars are not set - val config = DatabaseConfig( - host = "host", - port = 5432, - database = "db", - user = "user", - password = "pass", - poolMin = 0, // Default from DB_POOL_MIN_SIZE - poolMax = 4, // Default from DB_POOL_MAX_SIZE - maxLifetimeMs = 3600000, - idleTimeoutMs = 1800000, - connectionTimeoutMs = 30000 - ) + // poolMin defaults from DB_POOL_MIN_SIZE, poolMax defaults from DB_POOL_MAX_SIZE + val config = + DatabaseConfig( + host = "host", + port = 5432, + database = "db", + user = "user", + password = "pass", + poolMin = 0, + poolMax = 4, + maxLifetimeMs = 3600000, + idleTimeoutMs = 1800000, + connectionTimeoutMs = 30000, + ) assertEquals(0, config.poolMin) assertEquals(4, config.poolMax) @@ -317,11 +330,13 @@ class DatabaseUrlParsingTest { // 3. Both DB_HOST and DB_USER are explicitly provided // Simulate these detection conditions - val conditions = listOf( - "DATABASE_URL set" to true, // databaseUrl is not empty - "DB_NAME set" to true, // database is not empty - "DB_HOST and DB_USER set" to true // host and user are not empty - ) + // Each condition maps to: databaseUrl is not empty, database is not empty, host and user are not empty + val conditions = + listOf( + "DATABASE_URL set" to true, + "DB_NAME set" to true, + "DB_HOST and DB_USER set" to true, + ) conditions.forEach { (condition, shouldBeConfigured) -> assertEquals(true, shouldBeConfigured, "PostgreSQL should be configured when: $condition") diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/database/LampsTableTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/database/LampsTableTest.kt index 9f8e78a6..bf4a475a 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/database/LampsTableTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/database/LampsTableTest.kt @@ -5,7 +5,6 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull class LampsTableTest { - @Test fun `LampsTable has correct table name`() { assertEquals("lamps", LampsTable.tableName) diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/entity/LampEntityTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/entity/LampEntityTest.kt index 20d7cac1..a7b1e981 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/entity/LampEntityTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/entity/LampEntityTest.kt @@ -1,12 +1,12 @@ package com.lampcontrol.entity -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.* class LampEntityTest { - @Test fun `should create new lamp entity with generated ID and timestamps`() { // When @@ -59,9 +59,9 @@ class LampEntityTest { // Then - should be able to create and manipulate without API dependencies val entityClass = entity::class.java val apiModelPackage = "com.lampcontrol.api.models" - + // Verify no direct dependency on API model classes assertFalse(entityClass.name.contains(apiModelPackage)) assertNotNull(entity.toString()) // Basic functionality works } -} \ No newline at end of file +} diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/mapper/LampMapperTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/mapper/LampMapperTest.kt index a14dc989..54670870 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/mapper/LampMapperTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/mapper/LampMapperTest.kt @@ -4,10 +4,12 @@ import com.lampcontrol.api.models.Lamp import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate import com.lampcontrol.entity.LampEntity -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test import java.time.Instant -import java.util.* +import java.util.UUID class LampMapperTest { private val mapper = LampMapper() @@ -17,12 +19,13 @@ class LampMapperTest { // Given val uuid = UUID.randomUUID() val now = Instant.now() - val entity = LampEntity( - id = uuid, - status = true, - createdAt = now, - updatedAt = now - ) + val entity = + LampEntity( + id = uuid, + status = true, + createdAt = now, + updatedAt = now, + ) // When val apiModel = mapper.toApiModel(entity) @@ -39,12 +42,13 @@ class LampMapperTest { // Given val uuid = UUID.randomUUID() val timestamp = "2023-01-01T00:00:00Z" - val apiModel = Lamp( - id = uuid, - status = false, - createdAt = timestamp, - updatedAt = timestamp - ) + val apiModel = + Lamp( + id = uuid, + status = false, + createdAt = timestamp, + updatedAt = timestamp, + ) // When val entity = mapper.toDomainEntity(apiModel) @@ -86,4 +90,4 @@ class LampMapperTest { assertEquals(originalEntity.createdAt, updatedEntity.createdAt) assertNotEquals(originalEntity.updatedAt, updatedEntity.updatedAt) } -} \ No newline at end of file +} diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/models/ErrorModelTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/models/ErrorModelTest.kt index 8d2e6dcd..04b4ad18 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/models/ErrorModelTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/models/ErrorModelTest.kt @@ -2,7 +2,8 @@ package com.lampcontrol.models import com.lampcontrol.api.models.Error import org.junit.jupiter.api.Test -import kotlin.test.* +import kotlin.test.assertEquals +import kotlin.test.assertNotNull class ErrorModelTest { @Test diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/models/ListLamps200ResponseTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/models/ListLamps200ResponseTest.kt index 1bfec506..d41f98ef 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/models/ListLamps200ResponseTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/models/ListLamps200ResponseTest.kt @@ -1,10 +1,12 @@ package com.lampcontrol.models -import com.lampcontrol.api.models.ListLamps200Response import com.lampcontrol.api.models.Lamp +import com.lampcontrol.api.models.ListLamps200Response import org.junit.jupiter.api.Test import java.util.UUID -import kotlin.test.* +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull class ListLamps200ResponseTest { @Test diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/models/ModelTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/models/ModelTest.kt index 39febb81..f0455252 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/models/ModelTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/models/ModelTest.kt @@ -3,33 +3,34 @@ package com.lampcontrol.models import com.lampcontrol.api.models.Lamp import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate -import kotlinx.serialization.json.Json -import kotlinx.serialization.encodeToString +import com.lampcontrol.serialization.UUIDSerializer import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule -import com.lampcontrol.serialization.UUIDSerializer import org.junit.jupiter.api.Test -import java.util.* +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotNull class ModelTest { - - private val json = Json { - serializersModule = SerializersModule { - contextual(UUID::class, UUIDSerializer) + private val json = + Json { + serializersModule = + SerializersModule { + contextual(UUID::class, UUIDSerializer) + } } - } @Test fun `test Lamp model serialization`() { val uuid = UUID.randomUUID() - val now = java.time.Instant.now().toString() - val lamp = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) - + val now = java.time.Instant.now().toString() + val lamp = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) + val serialized = json.encodeToString(lamp) val deserialized = json.decodeFromString(serialized) - + assertEquals(lamp.id, deserialized.id) assertEquals(lamp.status, deserialized.status) } @@ -37,29 +38,29 @@ class ModelTest { @Test fun `test LampCreate model serialization`() { val lampCreate = LampCreate(status = false) - + val serialized = json.encodeToString(lampCreate) val deserialized = json.decodeFromString(serialized) - + assertEquals(lampCreate.status, deserialized.status) } @Test fun `test LampUpdate model serialization`() { val lampUpdate = LampUpdate(status = true) - + val serialized = json.encodeToString(lampUpdate) val deserialized = json.decodeFromString(serialized) - + assertEquals(lampUpdate.status, deserialized.status) } @Test fun `test Lamp model toString`() { val uuid = UUID.randomUUID() - val now = java.time.Instant.now().toString() - val lamp = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) - + val now = java.time.Instant.now().toString() + val lamp = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) + val string = lamp.toString() assertNotNull(string) } @@ -67,14 +68,14 @@ class ModelTest { @Test fun `test Lamp model equals and hashCode`() { val uuid = UUID.randomUUID() - val now = java.time.Instant.now().toString() - val lamp1 = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) - val lamp2 = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) - val lamp3 = Lamp(id = UUID.randomUUID(), status = false, createdAt = now, updatedAt = now) - + val now = java.time.Instant.now().toString() + val lamp1 = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) + val lamp2 = Lamp(id = uuid, status = true, createdAt = now, updatedAt = now) + val lamp3 = Lamp(id = UUID.randomUUID(), status = false, createdAt = now, updatedAt = now) + assertEquals(lamp1, lamp2) assertEquals(lamp1.hashCode(), lamp2.hashCode()) - + // Different lamps should not be equal assertEquals(false, lamp1 == lamp3) } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/CorsPreflightTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/CorsPreflightTest.kt index 87939f99..fbf25c7c 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/CorsPreflightTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/CorsPreflightTest.kt @@ -1,24 +1,27 @@ package com.lampcontrol.plugins import com.lampcontrol.module -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* +import io.ktor.client.request.header +import io.ktor.client.request.options +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.testApplication import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull class CorsPreflightTest { @Test - fun `CORS preflight responds with 200 and headers`() = testApplication { - application { module() } - val res = client.options("/v1/lamps") { - header(HttpHeaders.Origin, "http://example.com") - header(HttpHeaders.AccessControlRequestMethod, "GET") + fun `CORS preflight responds with 200 and headers`() = + testApplication { + application { module() } + val res = + client.options("/v1/lamps") { + header(HttpHeaders.Origin, "http://example.com") + header(HttpHeaders.AccessControlRequestMethod, "GET") + } + assertEquals(HttpStatusCode.OK, res.status) + // At least verify some CORS headers are present + assertNotNull(res.headers["Access-Control-Allow-Origin"]) } - assertEquals(HttpStatusCode.OK, res.status) - // At least verify some CORS headers are present - assertNotNull(res.headers["Access-Control-Allow-Origin"]) - } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/PluginsTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/PluginsTest.kt index 930ccd3a..453ff1ac 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/PluginsTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/PluginsTest.kt @@ -1,71 +1,82 @@ package com.lampcontrol.plugins import com.lampcontrol.module -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.serialization.json.Json +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.junit.jupiter.api.Test import kotlin.test.assertEquals class PluginsTest { - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } @Test - fun `test application plugins configuration`() = testApplication { - application { - module() - } - - // Test call logging and monitoring by making requests - val response1 = client.get("/health") - assertEquals(HttpStatusCode.OK, response1.status) - val healthResponse = json.decodeFromString>(response1.bodyAsText()) - assertEquals("ok", healthResponse["status"]) - - // Test CORS headers - val response2 = client.get("/") { - header("Origin", "http://localhost:3000") - } - assertEquals(HttpStatusCode.OK, response2.status) - - // Test call ID handling - val response3 = client.get("/health") { - header("X-Request-Id", "test-123") + fun `test application plugins configuration`() = + testApplication { + application { + module() + } + + // Test call logging and monitoring by making requests + val response1 = client.get("/health") + assertEquals(HttpStatusCode.OK, response1.status) + val healthResponse = json.decodeFromString>(response1.bodyAsText()) + assertEquals("ok", healthResponse["status"]) + + // Test CORS headers + val response2 = + client.get("/") { + header("Origin", "http://localhost:3000") + } + assertEquals(HttpStatusCode.OK, response2.status) + + // Test call ID handling + val response3 = + client.get("/health") { + header("X-Request-Id", "test-123") + } + assertEquals(HttpStatusCode.OK, response3.status) + val healthResponse3 = json.decodeFromString>(response3.bodyAsText()) + assertEquals("ok", healthResponse3["status"]) } - assertEquals(HttpStatusCode.OK, response3.status) - val healthResponse3 = json.decodeFromString>(response3.bodyAsText()) - assertEquals("ok", healthResponse3["status"]) - } @Test - fun `test error handling with status pages`() = testApplication { - application { - module() + fun `test error handling with status pages`() = + testApplication { + application { + module() + } + + // Test 404 for non-existent endpoint + val response = client.get("/non-existent-endpoint") + assertEquals(HttpStatusCode.NotFound, response.status) } - - // Test 404 for non-existent endpoint - val response = client.get("/non-existent-endpoint") - assertEquals(HttpStatusCode.NotFound, response.status) - } @Test - fun `test content negotiation`() = testApplication { - application { - module() - } - - // Test JSON content type handling - val response = client.post("/v1/lamps") { - contentType(ContentType.Application.Json) - setBody("""{"status": true}""") + fun `test content negotiation`() = + testApplication { + application { + module() + } + + // Test JSON content type handling + val response = + client.post("/v1/lamps") { + contentType(ContentType.Application.Json) + setBody("""{"status": true}""") + } + assertEquals(HttpStatusCode.Created, response.status) } - assertEquals(HttpStatusCode.Created, response.status) - } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/StatusPagesTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/StatusPagesTest.kt index bf64bd02..b5036cc9 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/StatusPagesTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/plugins/StatusPagesTest.kt @@ -1,88 +1,95 @@ package com.lampcontrol.plugins import com.lampcontrol.module -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.testing.* +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication import kotlinx.serialization.SerializationException import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class StatusPagesTest { - @Test - fun `unhandled exception returns 500`() = testApplication { - application { - module() - routing { - get("/boom") { throw RuntimeException("boom") } + fun `unhandled exception returns 500`() = + testApplication { + application { + module() + routing { + get("/boom") { throw RuntimeException("boom") } + } } + val res = client.get("/boom") + assertEquals(HttpStatusCode.InternalServerError, res.status) + assertTrue(res.bodyAsText().contains("Internal server error")) } - val res = client.get("/boom") - assertEquals(HttpStatusCode.InternalServerError, res.status) - assertTrue(res.bodyAsText().contains("Internal server error")) - } @Test - fun `serialization exception returns 400`() = testApplication { - application { - module() - routing { - get("/serr") { throw SerializationException("bad json") } + fun `serialization exception returns 400`() = + testApplication { + application { + module() + routing { + get("/serr") { throw SerializationException("bad json") } + } } + val res = client.get("/serr") + assertEquals(HttpStatusCode.BadRequest, res.status) } - val res = client.get("/serr") - assertEquals(HttpStatusCode.BadRequest, res.status) - } @Test - fun `illegal argument returns 400`() = testApplication { - application { - module() - routing { - get("/ierr") { throw IllegalArgumentException("nope") } + fun `illegal argument returns 400`() = + testApplication { + application { + module() + routing { + get("/ierr") { throw IllegalArgumentException("nope") } + } } + val res = client.get("/ierr") + assertEquals(HttpStatusCode.BadRequest, res.status) } - val res = client.get("/ierr") - assertEquals(HttpStatusCode.BadRequest, res.status) - } @Test - fun `number format exception returns 400`() = testApplication { - application { - module() - routing { - get("/nfe") { - // simulate parsing a malformed numeric query param - val v = call.request.queryParameters["pageSize"] ?: "null" - val parsed = v.toInt() // will throw NumberFormatException for non-numeric - call.respondText("ok $parsed") + fun `number format exception returns 400`() = + testApplication { + application { + module() + routing { + get("/nfe") { + // simulate parsing a malformed numeric query param + val v = call.request.queryParameters["pageSize"] ?: "null" + val parsed = v.toInt() // will throw NumberFormatException for non-numeric + call.respondText("ok $parsed") + } } } - } - val res = client.get("/nfe?pageSize=null") - assertEquals(HttpStatusCode.BadRequest, res.status) - } + val res = client.get("/nfe?pageSize=null") + assertEquals(HttpStatusCode.BadRequest, res.status) + } @Test - fun `bad request wrapping number format returns 400`() = testApplication { - application { - module() - routing { - get("/breq") { - // Simulate Ktor wrapping a NumberFormatException inside BadRequestException - throw io.ktor.server.plugins.BadRequestException("Can't transform call to resource", NumberFormatException("For input string: \"null\"")) + fun `bad request wrapping number format returns 400`() = + testApplication { + application { + module() + routing { + get("/breq") { + // Simulate Ktor wrapping a NumberFormatException inside BadRequestException + throw io.ktor.server.plugins.BadRequestException( + "Can't transform call to resource", + NumberFormatException("For input string: \"null\""), + ) + } } } - } - val res = client.get("/breq") - assertEquals(HttpStatusCode.BadRequest, res.status) - } + val res = client.get("/breq") + assertEquals(HttpStatusCode.BadRequest, res.status) + } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/repository/LampRepositoryFactoryTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/repository/LampRepositoryFactoryTest.kt index 9410df1d..ebbd7004 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/repository/LampRepositoryFactoryTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/repository/LampRepositoryFactoryTest.kt @@ -7,7 +7,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class LampRepositoryFactoryTest { - @Test fun `create returns a repository instance`() { // When diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/repository/PostgresLampRepositoryTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/repository/PostgresLampRepositoryTest.kt index 3350cda3..23570ccb 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/repository/PostgresLampRepositoryTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/repository/PostgresLampRepositoryTest.kt @@ -8,12 +8,15 @@ import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.jupiter.api.* +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant -import java.util.* +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -25,29 +28,30 @@ import kotlin.test.assertTrue */ @Testcontainers class PostgresLampRepositoryTest { - companion object { @Container - private val postgres = PostgreSQLContainer("postgres:16").apply { - withDatabaseName("lamp_control_test") - withUsername("test") - withPassword("test") - } + private val postgres = + PostgreSQLContainer("postgres:16").apply { + withDatabaseName("lamp_control_test") + withUsername("test") + withPassword("test") + } private lateinit var database: Database @JvmStatic @BeforeAll fun setupDatabase() { - val hikariConfig = HikariConfig().apply { - jdbcUrl = postgres.jdbcUrl - driverClassName = "org.postgresql.Driver" - username = postgres.username - password = postgres.password - maximumPoolSize = 5 - minimumIdle = 1 - isAutoCommit = false - } + val hikariConfig = + HikariConfig().apply { + jdbcUrl = postgres.jdbcUrl + driverClassName = "org.postgresql.Driver" + username = postgres.username + password = postgres.password + maximumPoolSize = 5 + minimumIdle = 1 + isAutoCommit = false + } val dataSource = HikariDataSource(hikariConfig) database = Database.connect(dataSource) @@ -82,211 +86,228 @@ class PostgresLampRepositoryTest { } @Test - fun `getAllLamps returns empty list when no lamps exist`() = runBlocking { - val lamps = repository.getAllLamps() + fun `getAllLamps returns empty list when no lamps exist`() = + runBlocking { + val lamps = repository.getAllLamps() - assertTrue(lamps.isEmpty()) - } + assertTrue(lamps.isEmpty()) + } @Test - fun `createLamp persists lamp to database`() = runBlocking { - val entity = LampEntity.create(status = true) + fun `createLamp persists lamp to database`() = + runBlocking { + val entity = LampEntity.create(status = true) - val created = repository.createLamp(entity) + val created = repository.createLamp(entity) - assertNotNull(created) - assertEquals(entity.id, created.id) - assertEquals(entity.status, created.status) - assertEquals(entity.createdAt, created.createdAt) - assertEquals(entity.updatedAt, created.updatedAt) - } + assertNotNull(created) + assertEquals(entity.id, created.id) + assertEquals(entity.status, created.status) + assertEquals(entity.createdAt, created.createdAt) + assertEquals(entity.updatedAt, created.updatedAt) + } @Test - fun `getLampById returns lamp when it exists`() = runBlocking { - val entity = LampEntity.create(status = false) - repository.createLamp(entity) + fun `getLampById returns lamp when it exists`() = + runBlocking { + val entity = LampEntity.create(status = false) + repository.createLamp(entity) - val retrieved = repository.getLampById(entity.id) + val retrieved = repository.getLampById(entity.id) - assertNotNull(retrieved) - assertEquals(entity.id, retrieved.id) - assertEquals(entity.status, retrieved.status) - } + assertNotNull(retrieved) + assertEquals(entity.id, retrieved.id) + assertEquals(entity.status, retrieved.status) + } @Test - fun `getLampById returns null when lamp does not exist`() = runBlocking { - val nonExistentId = UUID.randomUUID() + fun `getLampById returns null when lamp does not exist`() = + runBlocking { + val nonExistentId = UUID.randomUUID() - val retrieved = repository.getLampById(nonExistentId) + val retrieved = repository.getLampById(nonExistentId) - assertNull(retrieved) - } + assertNull(retrieved) + } @Test - fun `getAllLamps returns all non-deleted lamps`() = runBlocking { - val lamp1 = LampEntity.create(status = true) - val lamp2 = LampEntity.create(status = false) - val lamp3 = LampEntity.create(status = true) + fun `getAllLamps returns all non-deleted lamps`() = + runBlocking { + val lamp1 = LampEntity.create(status = true) + val lamp2 = LampEntity.create(status = false) + val lamp3 = LampEntity.create(status = true) - repository.createLamp(lamp1) - repository.createLamp(lamp2) - repository.createLamp(lamp3) + repository.createLamp(lamp1) + repository.createLamp(lamp2) + repository.createLamp(lamp3) - val lamps = repository.getAllLamps() + val lamps = repository.getAllLamps() - assertEquals(3, lamps.size) - assertTrue(lamps.any { it.id == lamp1.id }) - assertTrue(lamps.any { it.id == lamp2.id }) - assertTrue(lamps.any { it.id == lamp3.id }) - } + assertEquals(3, lamps.size) + assertTrue(lamps.any { it.id == lamp1.id }) + assertTrue(lamps.any { it.id == lamp2.id }) + assertTrue(lamps.any { it.id == lamp3.id }) + } @Test - fun `updateLamp updates status and timestamp`() = runBlocking { - val entity = LampEntity.create(status = true) - repository.createLamp(entity) + fun `updateLamp updates status and timestamp`() = + runBlocking { + val entity = LampEntity.create(status = true) + repository.createLamp(entity) - // Wait a bit to ensure timestamp changes - Thread.sleep(10) + // Wait a bit to ensure timestamp changes + Thread.sleep(10) - val updated = entity.withUpdatedStatus(newStatus = false) - val result = repository.updateLamp(updated) + val updated = entity.withUpdatedStatus(newStatus = false) + val result = repository.updateLamp(updated) - assertNotNull(result) - assertEquals(updated.id, result.id) - assertEquals(false, result.status) - assertTrue(result.updatedAt > entity.updatedAt) - } + assertNotNull(result) + assertEquals(updated.id, result.id) + assertEquals(false, result.status) + assertTrue(result.updatedAt > entity.updatedAt) + } @Test - fun `updateLamp returns null when lamp does not exist`() = runBlocking { - val nonExistentEntity = LampEntity( - id = UUID.randomUUID(), - status = true, - createdAt = Instant.now(), - updatedAt = Instant.now() - ) + fun `updateLamp returns null when lamp does not exist`() = + runBlocking { + val nonExistentEntity = + LampEntity( + id = UUID.randomUUID(), + status = true, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) - val result = repository.updateLamp(nonExistentEntity) + val result = repository.updateLamp(nonExistentEntity) - assertNull(result) - } + assertNull(result) + } @Test - fun `deleteLamp soft deletes lamp`() = runBlocking { - val entity = LampEntity.create(status = true) - repository.createLamp(entity) + fun `deleteLamp soft deletes lamp`() = + runBlocking { + val entity = LampEntity.create(status = true) + repository.createLamp(entity) - val deleted = repository.deleteLamp(entity.id) + val deleted = repository.deleteLamp(entity.id) - assertTrue(deleted) + assertTrue(deleted) - // Verify lamp is not returned by queries - val retrieved = repository.getLampById(entity.id) - assertNull(retrieved) + // Verify lamp is not returned by queries + val retrieved = repository.getLampById(entity.id) + assertNull(retrieved) - val allLamps = repository.getAllLamps() - assertTrue(allLamps.none { it.id == entity.id }) - } + val allLamps = repository.getAllLamps() + assertTrue(allLamps.none { it.id == entity.id }) + } @Test - fun `deleteLamp returns false when lamp does not exist`() = runBlocking { - val nonExistentId = UUID.randomUUID() + fun `deleteLamp returns false when lamp does not exist`() = + runBlocking { + val nonExistentId = UUID.randomUUID() - val deleted = repository.deleteLamp(nonExistentId) + val deleted = repository.deleteLamp(nonExistentId) - assertTrue(!deleted) - } + assertTrue(!deleted) + } @Test - fun `deleteLamp returns false when lamp already deleted`() = runBlocking { - val entity = LampEntity.create(status = true) - repository.createLamp(entity) - repository.deleteLamp(entity.id) + fun `deleteLamp returns false when lamp already deleted`() = + runBlocking { + val entity = LampEntity.create(status = true) + repository.createLamp(entity) + repository.deleteLamp(entity.id) - // Try to delete again - val deleted = repository.deleteLamp(entity.id) + // Try to delete again + val deleted = repository.deleteLamp(entity.id) - assertTrue(!deleted) - } + assertTrue(!deleted) + } @Test - fun `lampExists returns true when lamp exists`() = runBlocking { - val entity = LampEntity.create(status = true) - repository.createLamp(entity) + fun `lampExists returns true when lamp exists`() = + runBlocking { + val entity = LampEntity.create(status = true) + repository.createLamp(entity) - val exists = repository.lampExists(entity.id) + val exists = repository.lampExists(entity.id) - assertTrue(exists) - } + assertTrue(exists) + } @Test - fun `lampExists returns false when lamp does not exist`() = runBlocking { - val nonExistentId = UUID.randomUUID() + fun `lampExists returns false when lamp does not exist`() = + runBlocking { + val nonExistentId = UUID.randomUUID() - val exists = repository.lampExists(nonExistentId) + val exists = repository.lampExists(nonExistentId) - assertTrue(!exists) - } + assertTrue(!exists) + } @Test - fun `lampExists returns false when lamp is deleted`() = runBlocking { - val entity = LampEntity.create(status = true) - repository.createLamp(entity) - repository.deleteLamp(entity.id) + fun `lampExists returns false when lamp is deleted`() = + runBlocking { + val entity = LampEntity.create(status = true) + repository.createLamp(entity) + repository.deleteLamp(entity.id) - val exists = repository.lampExists(entity.id) + val exists = repository.lampExists(entity.id) - assertTrue(!exists) - } + assertTrue(!exists) + } @Test - fun `updateLamp does not affect deleted lamps`() = runBlocking { - val entity = LampEntity.create(status = true) - repository.createLamp(entity) - repository.deleteLamp(entity.id) + fun `updateLamp does not affect deleted lamps`() = + runBlocking { + val entity = LampEntity.create(status = true) + repository.createLamp(entity) + repository.deleteLamp(entity.id) - val updated = entity.withUpdatedStatus(newStatus = false) - val result = repository.updateLamp(updated) + val updated = entity.withUpdatedStatus(newStatus = false) + val result = repository.updateLamp(updated) - assertNull(result) - } + assertNull(result) + } @Test - fun `multiple lamps can be managed independently`() = runBlocking { - val lamp1 = LampEntity.create(status = true) - val lamp2 = LampEntity.create(status = false) + fun `multiple lamps can be managed independently`() = + runBlocking { + val lamp1 = LampEntity.create(status = true) + val lamp2 = LampEntity.create(status = false) - repository.createLamp(lamp1) - repository.createLamp(lamp2) + repository.createLamp(lamp1) + repository.createLamp(lamp2) - // Update lamp1 - val updated1 = lamp1.withUpdatedStatus(newStatus = false) - repository.updateLamp(updated1) + // Update lamp1 + val updated1 = lamp1.withUpdatedStatus(newStatus = false) + repository.updateLamp(updated1) - // Delete lamp2 - repository.deleteLamp(lamp2.id) + // Delete lamp2 + repository.deleteLamp(lamp2.id) - // Verify final state - val allLamps = repository.getAllLamps() - assertEquals(1, allLamps.size) - assertEquals(lamp1.id, allLamps[0].id) - assertEquals(false, allLamps[0].status) - } + // Verify final state + val allLamps = repository.getAllLamps() + assertEquals(1, allLamps.size) + assertEquals(lamp1.id, allLamps[0].id) + assertEquals(false, allLamps[0].status) + } @Test - fun `created and updated timestamps are preserved correctly`() = runBlocking { - val entity = LampEntity.create(status = true) - val createdTime = entity.createdAt + fun `created and updated timestamps are preserved correctly`() = + runBlocking { + val entity = LampEntity.create(status = true) + val createdTime = entity.createdAt - repository.createLamp(entity) + repository.createLamp(entity) - Thread.sleep(10) + Thread.sleep(10) - val updated = entity.withUpdatedStatus(newStatus = false) - val result = repository.updateLamp(updated) + val updated = entity.withUpdatedStatus(newStatus = false) + val result = repository.updateLamp(updated) - assertNotNull(result) - assertEquals(createdTime, result.createdAt) - assertTrue(result.updatedAt > createdTime) - } + assertNotNull(result) + assertEquals(createdTime, result.createdAt) + assertTrue(result.updatedAt > createdTime) + } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/serialization/UUIDSerializerTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/serialization/UUIDSerializerTest.kt index 96f74e23..1ad5b6b9 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/serialization/UUIDSerializerTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/serialization/UUIDSerializerTest.kt @@ -1,27 +1,28 @@ package com.lampcontrol.serialization -import kotlinx.serialization.json.Json -import kotlinx.serialization.encodeToString import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import org.junit.jupiter.api.Test -import java.util.* +import java.util.UUID import kotlin.test.assertEquals class UUIDSerializerTest { - - private val json = Json { - serializersModule = SerializersModule { - contextual(UUID::class, UUIDSerializer) + private val json = + Json { + serializersModule = + SerializersModule { + contextual(UUID::class, UUIDSerializer) + } } - } @Test fun `test UUID serialization`() { val uuid = UUID.randomUUID() val serialized = json.encodeToString(UUIDSerializer, uuid) val deserialized = json.decodeFromString(UUIDSerializer, serialized) - + assertEquals(uuid, deserialized) } @@ -30,9 +31,9 @@ class UUIDSerializerTest { val uuid = UUID.fromString("01ad9dac-6699-436d-9516-d473a6e62447") val serialized = json.encodeToString(UUIDSerializer, uuid) val expected = "\"01ad9dac-6699-436d-9516-d473a6e62447\"" - + assertEquals(expected, serialized) - + val deserialized = json.decodeFromString(UUIDSerializer, serialized) assertEquals(uuid, deserialized) } @@ -40,7 +41,7 @@ class UUIDSerializerTest { @Test fun `test invalid UUID string throws exception`() { val invalidUuidString = "\"invalid-uuid\"" - + try { json.decodeFromString(UUIDSerializer, invalidUuidString) throw AssertionError("Expected exception was not thrown") diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt index 86ddef46..024ef9bf 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt @@ -3,37 +3,43 @@ package com.lampcontrol.service import com.lampcontrol.entity.LampEntity import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test -import kotlin.test.* +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class InMemoryLampRepositoryTest { private val repo = InMemoryLampRepository() @Test - fun `create, retrieve, update and delete lamp lifecycle`() = runTest { - val entity = LampEntity.create(true) - val created = repo.createLamp(entity) + fun `create, retrieve, update and delete lamp lifecycle`() = + runTest { + val entity = LampEntity.create(true) + val created = repo.createLamp(entity) - assertNotNull(created.id) - assertTrue(created.status) - assertNotNull(created.createdAt) - assertNotNull(created.updatedAt) + assertNotNull(created.id) + assertTrue(created.status) + assertNotNull(created.createdAt) + assertNotNull(created.updatedAt) - // retrieval - val byId = repo.getLampById(created.id) - assertEquals(created, byId) + // retrieval + val byId = repo.getLampById(created.id) + assertEquals(created, byId) - // update - val beforeUpdatedAt = created.updatedAt - val updatedEntity = created.withUpdatedStatus(false) - val updated = repo.updateLamp(updatedEntity) - assertNotNull(updated) - assertFalse(updated!!.status) - assertNotEquals(beforeUpdatedAt, updated.updatedAt) + // update + val beforeUpdatedAt = created.updatedAt + val updatedEntity = created.withUpdatedStatus(false) + val updated = repo.updateLamp(updatedEntity) + assertNotNull(updated) + assertFalse(updated!!.status) + assertNotEquals(beforeUpdatedAt, updated.updatedAt) - // existence and delete - assertTrue(repo.lampExists(created.id)) - assertTrue(repo.deleteLamp(created.id)) - assertFalse(repo.lampExists(created.id)) - assertNull(repo.getLampById(created.id)) - } + // existence and delete + assertTrue(repo.lampExists(created.id)) + assertTrue(repo.deleteLamp(created.id)) + assertFalse(repo.lampExists(created.id)) + assertNull(repo.getLampById(created.id)) + } } diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt index bbc1f5db..61cb97ab 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt @@ -5,10 +5,13 @@ import com.lampcontrol.api.models.LampUpdate import com.lampcontrol.mapper.LampMapper import com.lampcontrol.repository.LampRepository import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.util.* class LampServiceTest { private lateinit var lampService: LampService @@ -21,91 +24,102 @@ class LampServiceTest { } @Test - fun `createLamp should create a new lamp with generated UUID`() = runTest { - val lampCreate = LampCreate(status = true) - val createdLamp = lampService.createLamp(lampCreate) + fun `createLamp should create a new lamp with generated UUID`() = + runTest { + val lampCreate = LampCreate(status = true) + val createdLamp = lampService.createLamp(lampCreate) - assertNotNull(createdLamp.id) - assertEquals(true, createdLamp.status) - assertTrue(lampService.lampExists(createdLamp.id.toString())) - } + assertNotNull(createdLamp.id) + assertEquals(true, createdLamp.status) + assertTrue(lampService.lampExists(createdLamp.id.toString())) + } @Test - fun `getAllLamps should return empty list initially`() = runTest { - val lamps = lampService.getAllLamps() - assertTrue(lamps.isEmpty()) - } + fun `getAllLamps should return empty list initially`() = + runTest { + val lamps = lampService.getAllLamps() + assertTrue(lamps.isEmpty()) + } @Test - fun `getAllLamps should return all created lamps`() = runTest { - val lamp1 = lampService.createLamp(LampCreate(status = true)) - val lamp2 = lampService.createLamp(LampCreate(status = false)) - - val lamps = lampService.getAllLamps() - assertEquals(2, lamps.size) - assertTrue(lamps.contains(lamp1)) - assertTrue(lamps.contains(lamp2)) - } + fun `getAllLamps should return all created lamps`() = + runTest { + val lamp1 = lampService.createLamp(LampCreate(status = true)) + val lamp2 = lampService.createLamp(LampCreate(status = false)) + + val lamps = lampService.getAllLamps() + assertEquals(2, lamps.size) + assertTrue(lamps.contains(lamp1)) + assertTrue(lamps.contains(lamp2)) + } @Test - fun `getLampById should return null for non-existent lamp`() = runTest { - val lamp = lampService.getLampById("non-existent-id") - assertNull(lamp) - } + fun `getLampById should return null for non-existent lamp`() = + runTest { + val lamp = lampService.getLampById("non-existent-id") + assertNull(lamp) + } @Test - fun `getLampById should return existing lamp`() = runTest { - val createdLamp = lampService.createLamp(LampCreate(status = true)) - val retrievedLamp = lampService.getLampById(createdLamp.id.toString()) + fun `getLampById should return existing lamp`() = + runTest { + val createdLamp = lampService.createLamp(LampCreate(status = true)) + val retrievedLamp = lampService.getLampById(createdLamp.id.toString()) - assertNotNull(retrievedLamp) - assertEquals(createdLamp, retrievedLamp) - } + assertNotNull(retrievedLamp) + assertEquals(createdLamp, retrievedLamp) + } @Test - fun `updateLamp should return null for non-existent lamp`() = runTest { - val lampUpdate = LampUpdate(status = false) - val updatedLamp = lampService.updateLamp("non-existent-id", lampUpdate) + fun `updateLamp should return null for non-existent lamp`() = + runTest { + val lampUpdate = LampUpdate(status = false) + val updatedLamp = lampService.updateLamp("non-existent-id", lampUpdate) - assertNull(updatedLamp) - } + assertNull(updatedLamp) + } @Test - fun `updateLamp should update existing lamp status`() = runTest { - val createdLamp = lampService.createLamp(LampCreate(status = true)) - val lampUpdate = LampUpdate(status = false) - val updatedLamp = lampService.updateLamp(createdLamp.id.toString(), lampUpdate) - - assertNotNull(updatedLamp) - assertEquals(createdLamp.id, updatedLamp!!.id) - assertEquals(false, updatedLamp.status) - } + fun `updateLamp should update existing lamp status`() = + runTest { + val createdLamp = lampService.createLamp(LampCreate(status = true)) + val lampUpdate = LampUpdate(status = false) + val updatedLamp = lampService.updateLamp(createdLamp.id.toString(), lampUpdate) + + assertNotNull(updatedLamp) + assertEquals(createdLamp.id, updatedLamp!!.id) + assertEquals(false, updatedLamp.status) + } @Test - fun `deleteLamp should return false for non-existent lamp`() = runTest { - val deleted = lampService.deleteLamp("non-existent-id") - assertFalse(deleted) - } + fun `deleteLamp should return false for non-existent lamp`() = + runTest { + val deleted = lampService.deleteLamp("non-existent-id") + assertFalse(deleted) + } @Test - fun `deleteLamp should delete existing lamp`() = runTest { - val createdLamp = lampService.createLamp(LampCreate(status = true)) - val lampId = createdLamp.id.toString() - - assertTrue(lampService.lampExists(lampId)) - assertTrue(lampService.deleteLamp(lampId)) - assertFalse(lampService.lampExists(lampId)) - assertNull(lampService.getLampById(lampId)) - } + fun `deleteLamp should delete existing lamp`() = + runTest { + val createdLamp = lampService.createLamp(LampCreate(status = true)) + val lampId = createdLamp.id.toString() + + assertTrue(lampService.lampExists(lampId)) + assertTrue(lampService.deleteLamp(lampId)) + assertFalse(lampService.lampExists(lampId)) + assertNull(lampService.getLampById(lampId)) + } @Test - fun `lampExists should return false for non-existent lamp`() = runTest { - assertFalse(lampService.lampExists("non-existent-id")) - } + fun `lampExists should return false for non-existent lamp`() = + runTest { + assertFalse(lampService.lampExists("non-existent-id")) + } @Test - fun `lampExists should return true for existing lamp`() = runTest { - val createdLamp = lampService.createLamp(LampCreate(status = true)) - assertTrue(lampService.lampExists(createdLamp.id.toString())) - } + fun `lampExists should return true for existing lamp`() = + runTest { + val createdLamp = lampService.createLamp(LampCreate(status = true)) + assertTrue(lampService.lampExists(createdLamp.id.toString())) + } } diff --git a/src/python/src/openapi_server/cli.py b/src/python/src/openapi_server/cli.py new file mode 100644 index 00000000..09f9706a --- /dev/null +++ b/src/python/src/openapi_server/cli.py @@ -0,0 +1,126 @@ +"""Command-line interface for Lamp Control API. + +This module provides CLI commands for running the application in different modes: +- serve: Run migrations and start the server (default) +- migrate: Run migrations only +- serve-only: Start server without running migrations +""" + +import argparse +import logging +import sys +from pathlib import Path + +import uvicorn + +from alembic import command +from alembic.config import Config +from src.openapi_server.infrastructure.config import DatabaseSettings + +logger = logging.getLogger(__name__) + + +def run_migrations_only(): + """Run database migrations only and exit.""" + logger.info("Running migrations only...") + + settings = DatabaseSettings() + if not settings.use_postgres(): + logger.warning("No PostgreSQL configuration found, nothing to migrate") + return + + logger.info(f"Running migrations for database: {settings.database_url}") + + try: + # Get the alembic.ini path (relative to this file) + alembic_ini = Path(__file__).parent.parent.parent / "alembic.ini" + if not alembic_ini.exists(): + logger.error(f"alembic.ini not found at {alembic_ini}") + sys.exit(1) + + # Create Alembic config + alembic_cfg = Config(str(alembic_ini)) + alembic_cfg.set_main_option("sqlalchemy.url", settings.database_url) + + # Run migrations + command.upgrade(alembic_cfg, "head") + + logger.info("Migrations completed successfully") + + except Exception as e: + logger.error(f"Migration failed: {e}") + sys.exit(1) + + +def start_server(run_migrations: bool = True): + """Start the FastAPI server. + + Args: + run_migrations: Whether to run migrations before starting the server + """ + if run_migrations: + logger.info("Starting server with automatic migrations...") + settings = DatabaseSettings() + if settings.use_postgres(): + try: + alembic_ini = Path(__file__).parent.parent.parent / "alembic.ini" + if alembic_ini.exists(): + alembic_cfg = Config(str(alembic_ini)) + alembic_cfg.set_main_option("sqlalchemy.url", settings.database_url) + command.upgrade(alembic_cfg, "head") + logger.info("Migrations completed") + else: + logger.warning(f"alembic.ini not found at {alembic_ini}, skipping migrations") + except Exception as e: + logger.error(f"Migration failed: {e}") + sys.exit(1) + else: + logger.info("Starting server without running migrations...") + + # Start uvicorn server + uvicorn.run( + "src.openapi_server.main:app", + host="0.0.0.0", + port=8080, + log_level="info", + ) + + +def main(): + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + description="Lamp Control API - FastAPI application for controlling lamps" + ) + parser.add_argument( + "--mode", + choices=["serve", "migrate", "serve-only"], + default="serve", + help="Operation mode: serve (default, migrate and start server), " + "migrate (run migrations only), serve-only (start server without migrations)", + ) + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="INFO", + help="Logging level (default: INFO)", + ) + + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # Execute based on mode + if args.mode == "migrate": + run_migrations_only() + elif args.mode == "serve": + start_server(run_migrations=True) + elif args.mode == "serve-only": + start_server(run_migrations=False) + + +if __name__ == "__main__": + main() diff --git a/src/python/src/openapi_server/entities/lamp_entity.py b/src/python/src/openapi_server/entities/lamp_entity.py index a8fb363f..b00fc514 100644 --- a/src/python/src/openapi_server/entities/lamp_entity.py +++ b/src/python/src/openapi_server/entities/lamp_entity.py @@ -49,6 +49,10 @@ def __eq__(self, other: object) -> bool: return False return self.id == other.id and self.status == other.status + def __hash__(self) -> int: + """Generate hash based on ID.""" + return hash(self.id) + def __repr__(self) -> str: """String representation of the lamp entity.""" return ( diff --git a/src/python/src/openapi_server/impl/default_api_impl.py b/src/python/src/openapi_server/impl/default_api_impl.py index 8766b25a..387b1a62 100644 --- a/src/python/src/openapi_server/impl/default_api_impl.py +++ b/src/python/src/openapi_server/impl/default_api_impl.py @@ -2,6 +2,8 @@ from uuid import uuid4 +from fastapi import HTTPException + from src.openapi_server.apis.default_api_base import BaseDefaultApi from src.openapi_server.mappers.lamp_mapper import LampMapper from src.openapi_server.models.lamp import Lamp @@ -44,8 +46,6 @@ async def create_lamp(self, lamp_create: LampCreate) -> Lamp: HTTPException: If the lamp creation data is invalid. """ if lamp_create is None: - from fastapi import HTTPException - raise HTTPException(status_code=400, detail="Invalid request data") lamp_id = str(uuid4()) @@ -69,8 +69,6 @@ async def delete_lamp(self, lamp_id: str) -> None: try: await self.repository.delete(lamp_id) except LampNotFoundError as err: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Lamp not found") from err async def get_lamp(self, lamp_id: str) -> Lamp: @@ -87,8 +85,6 @@ async def get_lamp(self, lamp_id: str) -> Lamp: """ lamp_entity = await self.repository.get(lamp_id) if lamp_entity is None: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Lamp not found") # Convert domain entity to API model @@ -130,8 +126,6 @@ async def update_lamp(self, lamp_id: str, lamp_update: LampUpdate) -> Lamp: HTTPException: If the lamp is not found or update data is invalid. """ if lamp_update is None: - from fastapi import HTTPException - raise HTTPException(status_code=400, detail="Invalid request data") try: @@ -147,6 +141,4 @@ async def update_lamp(self, lamp_id: str, lamp_update: LampUpdate) -> Lamp: # Convert domain entity back to API model return LampMapper.to_api_model(final_entity) except LampNotFoundError as err: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Lamp not found") from err diff --git a/src/python/src/openapi_server/test/test_default_api_impl.py b/src/python/src/openapi_server/test/test_default_api_impl.py index 1309c639..05f22228 100644 --- a/src/python/src/openapi_server/test/test_default_api_impl.py +++ b/src/python/src/openapi_server/test/test_default_api_impl.py @@ -1,5 +1,6 @@ """Unit tests for the DefaultApiImpl class.""" +from datetime import datetime from unittest.mock import AsyncMock import pytest @@ -31,8 +32,6 @@ def api_impl(mock_lamp_repository): @pytest.fixture def sample_lamp(): """Fixture that provides a sample lamp API model for testing.""" - from datetime import datetime - return Lamp(id="test-lamp-1", status=True, created_at=datetime.now(), updated_at=datetime.now()) diff --git a/src/python/src/openapi_server/test/test_postgres_lamp_repository.py b/src/python/src/openapi_server/test/test_postgres_lamp_repository.py index 7d529a37..a05adc44 100644 --- a/src/python/src/openapi_server/test/test_postgres_lamp_repository.py +++ b/src/python/src/openapi_server/test/test_postgres_lamp_repository.py @@ -8,6 +8,7 @@ from pathlib import Path from uuid import uuid4 +import psycopg2 import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -40,8 +41,6 @@ def postgres_container(): schema_sql = f.read() # Get a connection and execute the schema - import psycopg2 - # Use individual connection parameters instead of URL conn = psycopg2.connect( host=postgres.get_container_host_ip(), diff --git a/src/typescript/coverage/coverage-summary.json b/src/typescript/coverage/coverage-summary.json index bf5c8aba..a8de7493 100644 --- a/src/typescript/coverage/coverage-summary.json +++ b/src/typescript/coverage/coverage-summary.json @@ -1,8 +1,8 @@ -{"total": {"lines":{"total":62,"covered":53,"skipped":0,"pct":85.48},"statements":{"total":62,"covered":53,"skipped":0,"pct":85.48},"functions":{"total":17,"covered":14,"skipped":0,"pct":82.35},"branches":{"total":9,"covered":8,"skipped":0,"pct":88.88},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/workspaces/lamp-control-api-reference/src/typescript/src/index.ts": {"lines":{"total":6,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":6,"covered":0,"skipped":0,"pct":0},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/workspaces/lamp-control-api-reference/src/typescript/src/domain/errors/DomainError.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/workspaces/lamp-control-api-reference/src/typescript/src/infrastructure/app.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/workspaces/lamp-control-api-reference/src/typescript/src/infrastructure/security.ts": {"lines":{"total":1,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":1,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/workspaces/lamp-control-api-reference/src/typescript/src/infrastructure/repositories/InMemoryLampRepository.ts": {"lines":{"total":17,"covered":17,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":17,"covered":17,"skipped":0,"pct":100},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} -,"/workspaces/lamp-control-api-reference/src/typescript/src/infrastructure/services/service.ts": {"lines":{"total":28,"covered":26,"skipped":0,"pct":92.85},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":28,"covered":26,"skipped":0,"pct":92.85},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} +{"total": {"lines":{"total":121,"covered":74,"skipped":0,"pct":61.15},"statements":{"total":122,"covered":74,"skipped":0,"pct":60.65},"functions":{"total":30,"covered":24,"skipped":0,"pct":80},"branches":{"total":32,"covered":16,"skipped":0,"pct":50},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/Users/davide/Documents/GitHub/lamp/lamp-control-api-reference/src/typescript/src/cli.ts": {"lines":{"total":43,"covered":0,"skipped":0,"pct":0},"functions":{"total":6,"covered":0,"skipped":0,"pct":0},"statements":{"total":44,"covered":0,"skipped":0,"pct":0},"branches":{"total":15,"covered":0,"skipped":0,"pct":0}} +,"/Users/davide/Documents/GitHub/lamp/lamp-control-api-reference/src/typescript/src/domain/errors/DomainError.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/Users/davide/Documents/GitHub/lamp/lamp-control-api-reference/src/typescript/src/infrastructure/mappers/LampMapper.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/Users/davide/Documents/GitHub/lamp/lamp-control-api-reference/src/typescript/src/infrastructure/repositories/InMemoryLampRepository.ts": {"lines":{"total":17,"covered":17,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":17,"covered":17,"skipped":0,"pct":100},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} +,"/Users/davide/Documents/GitHub/lamp/lamp-control-api-reference/src/typescript/src/infrastructure/repositories/PrismaLampRepository.ts": {"lines":{"total":19,"covered":17,"skipped":0,"pct":89.47},"functions":{"total":7,"covered":7,"skipped":0,"pct":100},"statements":{"total":19,"covered":17,"skipped":0,"pct":89.47},"branches":{"total":9,"covered":8,"skipped":0,"pct":88.88}} +,"/Users/davide/Documents/GitHub/lamp/lamp-control-api-reference/src/typescript/src/infrastructure/services/service.ts": {"lines":{"total":34,"covered":32,"skipped":0,"pct":94.11},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":34,"covered":32,"skipped":0,"pct":94.11},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} } diff --git a/src/typescript/jest.config.js b/src/typescript/jest.config.js index 67e5951b..fda2bb67 100644 --- a/src/typescript/jest.config.js +++ b/src/typescript/jest.config.js @@ -27,6 +27,7 @@ export default { 'src/**/*.ts', '!src/types/**/*.ts', '!src/index.ts', + '!src/cli.ts', '!src/infrastructure/app.ts', '!src/infrastructure/security.ts', '!src/infrastructure/database/client.ts', diff --git a/src/typescript/src/cli.ts b/src/typescript/src/cli.ts new file mode 100644 index 00000000..7e896e83 --- /dev/null +++ b/src/typescript/src/cli.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Command-line interface for Lamp Control API + * + * Supports three operation modes: + * - serve: Run migrations and start server (default) + * - migrate: Run migrations only + * - serve-only: Start server without migrations + */ + +import { execSync } from 'child_process'; +import { buildApp } from './infrastructure/app.ts'; + +const PORT = parseInt(process.env.PORT || '8080', 10); +const HOST = process.env.HOST || '0.0.0.0'; + +/** + * Run Prisma migrations only and exit + */ +async function runMigrationsOnly(): Promise { + console.warn('Running migrations only...'); + + if (process.env.USE_POSTGRES !== 'true') { + console.warn('No PostgreSQL configuration found (USE_POSTGRES not set), nothing to migrate'); + return; + } + + try { + console.warn('Running Prisma migrations...'); + execSync('npx prisma migrate deploy', { + stdio: 'inherit', + env: process.env, + }); + console.warn('Migrations completed successfully'); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } +} + +/** + * Start the server with optional migrations + */ +async function startServer(runMigrations: boolean): Promise { + if (runMigrations) { + console.warn('Starting server with automatic migrations...'); + if (process.env.USE_POSTGRES === 'true') { + try { + console.warn('Running Prisma migrations...'); + execSync('npx prisma migrate deploy', { + stdio: 'inherit', + env: process.env, + }); + console.warn('Migrations completed'); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } + } + } else { + console.warn('Starting server without running migrations...'); + } + + const server = await buildApp(); + server.listen({ port: PORT, host: HOST }, (err: Error | null, address: string) => { + if (err) { + server.log.error(err); + process.exit(1); + } + server.log.info(`Server listening at ${address}`); + }); +} + +/** + * Main CLI entry point + */ +async function main(): Promise { + const args = process.argv.slice(2); + const modeArg = args.find((arg) => arg.startsWith('--mode=')); + const mode = modeArg ? modeArg.split('=')[1] : 'serve'; + + switch (mode) { + case 'migrate': + await runMigrationsOnly(); + break; + case 'serve': + await startServer(true); + break; + case 'serve-only': + await startServer(false); + break; + default: + console.error(`Invalid mode: ${mode}. Valid modes are: serve, migrate, serve-only`); + process.exit(1); + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/typescript/src/infrastructure/database/client.ts b/src/typescript/src/infrastructure/database/client.ts index 924781bc..fc9aa7be 100644 --- a/src/typescript/src/infrastructure/database/client.ts +++ b/src/typescript/src/infrastructure/database/client.ts @@ -32,7 +32,8 @@ export async function closePrismaClient(): Promise { // This ensures the client is only created when actually used, preventing // errors when DATABASE_URL is not set (e.g., in unit tests using in-memory storage) export const prismaClient = new Proxy({} as PrismaClient, { - get(_target, prop) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(_target, prop): any { const client = getPrismaClient(); return client[prop as keyof PrismaClient]; }, diff --git a/src/typescript/tests/integration/PrismaLampRepository.test.ts b/src/typescript/tests/integration/PrismaLampRepository.test.ts index 70295de8..45ce85cb 100644 --- a/src/typescript/tests/integration/PrismaLampRepository.test.ts +++ b/src/typescript/tests/integration/PrismaLampRepository.test.ts @@ -115,6 +115,8 @@ describe('PrismaLampRepository Integration Tests', () => { it('should update lamp status', async () => { const created = await repository.create({ status: false }); + // Wait a small amount to ensure timestamps differ + await new Promise((resolve) => setTimeout(resolve, 10)); const updated = await repository.update(created.id, { status: true }); expect(updated.status).toBe(true);