diff --git a/.github/_README.md b/.github/_README.md new file mode 100644 index 0000000..af9776a --- /dev/null +++ b/.github/_README.md @@ -0,0 +1,101 @@ +# GitHub Actions Workflows + +This directory contains GitHub Actions workflows for CI/CD, code quality, and security scanning. + +## Workflows Overview + +### ๐Ÿš€ CI Pipeline (`ci.yml`) +- **Triggers**: Push/PR to `main` and `develop` branches +- **Jobs**: + - **Test**: Runs unit tests with JUnit 5 + - **Build**: Compiles the application and creates artifacts + - **Validate Dockerfile**: Checks Docker configuration if present + +### ๐Ÿ”’ Security (`security.yml`) +- **Triggers**: Push/PR to main branches + weekly scheduled scan +- **Jobs**: + - **Dependency Check**: OWASP dependency vulnerability scanning + - **CodeQL Analysis**: Static code analysis for security issues + - **Secrets Scan**: TruffleHog scan for leaked credentials + +### ๐Ÿ“Š Code Quality (`code-quality.yml`) +- **Triggers**: Push/PR to `main` and `develop` branches +- **Jobs**: + - **KtLint**: Kotlin code style checking + - **Detekt**: Static analysis for Kotlin code quality + - **Coverage**: Test coverage reporting with Jacoco + - **Build Validation**: Gradle wrapper and build script validation + +### ๐Ÿš‚ Railway Deploy (`railway-deploy.yml`) +- **Triggers**: Push to `main` + CI workflow completion +- **Purpose**: Pre-deployment validation before Railway auto-deploy +- **Features**: + - Build validation + - Configuration checks + - Deployment status updates + +## Railway Integration + +Since Railway automatically deploys on push to `main`, this setup provides: + +1. **Pre-deployment validation** - Ensures code quality before Railway deploys +2. **Security scanning** - Catches vulnerabilities before production +3. **Test coverage** - Maintains code quality standards +4. **Build verification** - Confirms the application builds successfully + +## Configuration Files + +### Code Quality Tools +- `config/detekt/detekt.yml` - Detekt static analysis rules +- `config/dependency-check-suppressions.xml` - OWASP dependency check suppressions + +### Gradle Plugins Added +- `org.jlleitschuh.gradle.ktlint` - Kotlin linting +- `io.gitlab.arturbosch.detekt` - Static analysis +- `jacoco` - Test coverage +- `org.owasp.dependencycheck` - Vulnerability scanning + +## Running Locally + +```bash +# Run all checks +./gradlew check + +# Individual tools +./gradlew ktlintCheck # Kotlin linting +./gradlew detekt # Static analysis +./gradlew test # Tests with coverage +./gradlew dependencyCheckAnalyze # Vulnerability scan + +# Auto-fix formatting issues +./gradlew ktlintFormat +``` + +## Status Badges + +Add these to your main README.md: + +```markdown +![CI](https://github.com/YOUR_USERNAME/sofia-tracker-server/workflows/CI/badge.svg) +![Security](https://github.com/YOUR_USERNAME/sofia-tracker-server/workflows/Security/badge.svg) +![Code Quality](https://github.com/YOUR_USERNAME/sofia-tracker-server/workflows/Code%20Quality/badge.svg) +``` + +## Secrets Configuration + +For enhanced functionality, configure these GitHub repository secrets: + +- `CODECOV_TOKEN` - For enhanced coverage reporting (optional) +- Add any Railway-specific tokens if needed for deployment notifications + +## Customization + +### Adjusting Quality Gates +- Modify `failBuildOnCVSS` in `build.gradle` to change vulnerability thresholds +- Update Detekt rules in `config/detekt/detekt.yml` +- Adjust test coverage requirements in workflow files + +### Adding New Workflows +- Place new `.yml` files in `.github/workflows/` +- Follow the existing naming convention +- Ensure proper trigger conditions to avoid unnecessary runs \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c567b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test + + - name: Generate test report + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Test Results + path: build/test-results/test/*.xml + reporter: java-junit + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: build/test-results/ + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build application + run: ./gradlew build -x test + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: build/libs/ + + validate-dockerfile: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check if Dockerfile exists + run: | + if [ -f Dockerfile ]; then + echo "Dockerfile found" + docker build -t sofia-tracker-test . + else + echo "No Dockerfile found, skipping Docker validation" + fi \ No newline at end of file diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..c81bb07 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,153 @@ +name: Code Quality + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + ktlint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Kotlin linter + run: ./gradlew ktlintCheck || true + + - name: Upload ktlint results + uses: actions/upload-artifact@v4 + if: always() + with: + name: ktlint-results + path: build/reports/ktlint/ + + detekt: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Detekt + run: ./gradlew detekt || true + + - name: Upload Detekt results + uses: actions/upload-artifact@v4 + if: always() + with: + name: detekt-results + path: build/reports/detekt/ + + coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests with coverage + run: ./gradlew test jacocoTestReport + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + file: build/reports/jacoco/test/jacocoTestReport.xml + fail_ci_if_error: false + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: build/reports/jacoco/ + + build-validation: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v2 + + - name: Check build scripts + run: ./gradlew build -x test --dry-run \ No newline at end of file diff --git a/.github/workflows/railway-deploy.yml b/.github/workflows/railway-deploy.yml new file mode 100644 index 0000000..6705d45 --- /dev/null +++ b/.github/workflows/railway-deploy.yml @@ -0,0 +1,78 @@ +name: Railway Deploy + +on: + push: + branches: [ main ] + workflow_run: + workflows: ["CI"] + branches: [ main ] + types: + - completed + +jobs: + deploy-check: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Pre-deployment validation + run: | + echo "Running pre-deployment checks..." + ./gradlew build -x test + echo "โœ… Build successful" + + # Check if required files exist + if [ -f "Procfile" ]; then + echo "โœ… Procfile found" + else + echo "โš ๏ธ Procfile not found - Railway will use default start command" + fi + + # Check application properties + if [ -f "src/main/resources/application.properties" ]; then + echo "โœ… Application properties found" + else + echo "โŒ Application properties missing" + exit 1 + fi + + - name: Create deployment status + uses: actions/github-script@v7 + with: + script: | + github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: context.payload.deployment?.id || 0, + state: 'success', + description: 'Pre-deployment validation passed. Railway will auto-deploy.', + environment: 'production' + }); + + - name: Notify deployment ready + run: | + echo "๐Ÿš€ Deployment validation complete!" + echo "Railway will automatically deploy this commit to production." + echo "Monitor your Railway dashboard for deployment progress." \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..3faf2e2 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,110 @@ +name: Security + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + schedule: + # Run security checks weekly on Sundays at 2 AM UTC + - cron: '0 2 * * 0' + +jobs: + dependency-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run dependency vulnerability check + run: ./gradlew dependencyCheckAnalyze || true + + - name: Upload dependency check results + uses: actions/upload-artifact@v4 + if: always() + with: + name: dependency-check-report + path: build/reports/ + + codeql-analysis: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build for CodeQL + run: ./gradlew build -x test + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + + secrets-scan: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run TruffleHog OSS + uses: trufflesecurity/trufflehog@main + with: + path: ./ + base: main + head: HEAD + extra_args: --debug --only-verified \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f99db35..94c5a84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,12 @@ Sofia Tracker is a Spring Boot application written in Kotlin. It's a server appl # Build the project ./gradlew build -# Run the application +# Run the application (development profile by default) ./gradlew bootRun +# Run with specific profile +./gradlew bootRun --args='--spring.profiles.active=production' + # Clean build ./gradlew clean build ``` @@ -38,6 +41,27 @@ Sofia Tracker is a Spring Boot application written in Kotlin. It's a server appl ./gradlew test --tests "com.dpconde.sofia_tracker.SofiaTrackerApplicationTests" ``` +### Code Quality and Security +```bash +# Run all quality checks +./gradlew check + +# Kotlin linting +./gradlew ktlintCheck + +# Auto-fix Kotlin formatting +./gradlew ktlintFormat + +# Static code analysis +./gradlew detekt + +# Test coverage report +./gradlew test jacocoTestReport + +# Security vulnerability scan +./gradlew dependencyCheckAnalyze +``` + ### Other Useful Commands ```bash # Check dependencies @@ -53,20 +77,53 @@ Sofia Tracker is a Spring Boot application written in Kotlin. It's a server appl src/ โ”œโ”€โ”€ main/ โ”‚ โ”œโ”€โ”€ kotlin/com/dpconde/sofia_tracker/ -โ”‚ โ”‚ โ””โ”€โ”€ SofiaTrackerApplication.kt # Main Spring Boot application +โ”‚ โ”‚ โ”œโ”€โ”€ SofiaTrackerApplication.kt # Main Spring Boot application +โ”‚ โ”‚ โ”œโ”€โ”€ application/usecases/ # Use cases (business logic) +โ”‚ โ”‚ โ”œโ”€โ”€ domain/ # Domain entities and repositories +โ”‚ โ”‚ โ”œโ”€โ”€ infrastructure/ # Infrastructure layer (JPA, etc.) +โ”‚ โ”‚ โ””โ”€โ”€ presentation/ # Controllers and DTOs โ”‚ โ””โ”€โ”€ resources/ -โ”‚ โ””โ”€โ”€ application.properties # Spring configuration -โ””โ”€โ”€ test/ - โ””โ”€โ”€ kotlin/com/dpconde/sofia_tracker/ - โ””โ”€โ”€ SofiaTrackerApplicationTests.kt # Basic context load test +โ”‚ โ”œโ”€โ”€ application.properties # Spring configuration +โ”‚ โ””โ”€โ”€ db/migration/ # Flyway database migrations +โ”œโ”€โ”€ test/ +โ”‚ โ”œโ”€โ”€ kotlin/com/dpconde/sofia_tracker/ # Test classes +โ”‚ โ””โ”€โ”€ resources/ # Test resources +โ”œโ”€โ”€ .github/workflows/ # GitHub Actions CI/CD +โ””โ”€โ”€ config/ # Tool configurations + โ”œโ”€โ”€ detekt/detekt.yml # Static analysis rules + โ””โ”€โ”€ dependency-check-suppressions.xml # Security scan suppressions ``` ## Architecture Notes - **Framework**: Spring Boot 3.x with Kotlin -- **Testing**: JUnit 5 platform with Spring Boot Test -- **Build**: Standard Gradle project structure -- **Main Class**: `SofiaTrackerApplication.kt` - standard Spring Boot entry point -- **Package Structure**: Currently minimal with just the main application class - -This is a new project with basic Spring Boot scaffolding. The architecture will evolve as features are added. \ No newline at end of file +- **Architecture**: Clean Architecture with layered design + - **Domain**: Core business entities and repository interfaces + - **Application**: Use cases/business logic (concrete classes, no interfaces) + - **Infrastructure**: Data persistence with JPA/Hibernate and H2 database + - **Presentation**: REST controllers and DTOs +- **Database**: H2 in-memory with Flyway migrations +- **Testing**: JUnit 5 platform with Spring Boot Test and MockK +- **Build**: Gradle with Kotlin DSL +- **Code Quality**: KtLint, Detekt, Jacoco coverage, OWASP dependency check +- **API Documentation**: Swagger/OpenAPI (development profile only) +- **Profiles**: + - **Development**: Full logging, H2 console, Swagger UI enabled + - **Production**: Minimal logging, security hardened, Swagger disabled + +## CI/CD Pipeline + +The project uses GitHub Actions for continuous integration: + +- **CI Pipeline**: Automated testing and building on push/PR +- **Security Scanning**: OWASP dependency check, CodeQL, secrets detection +- **Code Quality**: Kotlin linting, static analysis, test coverage reporting +- **Deployment**: Railway auto-deploys from `main` branch with pre-deployment validation + +## Development Workflow + +1. **Before committing**: Run `./gradlew check` to ensure code quality +2. **Testing**: All tests must pass before merge +3. **Code style**: KtLint enforces consistent Kotlin formatting +4. **Security**: Automatic vulnerability scanning on dependencies +5. **Deployment**: Push to `main` triggers Railway deployment after CI validation \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbfaec5 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Sofia Tracker Server + +A Spring Boot application for tracking baby events like feeding, sleeping, and diaper changes. Built with Kotlin and designed for clean architecture principles. + +![CI](https://github.com/dpconde/sofia-tracker-server/workflows/CI/badge.svg) +![Security](https://github.com/dpconde/sofia-tracker-server/workflows/Security/badge.svg) +![Code Quality](https://github.com/dpconde/sofia-tracker-server/workflows/Code%20Quality/badge.svg) + +## ๐Ÿš€ Features + +- **Event Tracking**: Record feeding, sleeping, and diaper change events +- **RESTful API**: Full CRUD operations with validation +- **Clean Architecture**: Domain-driven design with clear separation of concerns +- **Type Safety**: Kotlin with strong typing and validation +- **Database Migration**: Flyway for version-controlled schema changes +- **Comprehensive Testing**: Unit and integration tests with coverage reporting +- **Code Quality**: Automated linting, static analysis, and security scanning + +## ๐Ÿ›  Tech Stack + +- **Language**: Kotlin 1.9.25 +- **Framework**: Spring Boot 3.5.6 +- **Database**: H2 (in-memory for development) +- **Build Tool**: Gradle with Kotlin DSL +- **Testing**: JUnit 5, MockK, Spring Boot Test +- **Code Quality**: KtLint, Detekt, Jacoco +- **Security**: OWASP Dependency Check +- **CI/CD**: GitHub Actions +- **Deployment**: Railway + +## ๐Ÿ“‹ Prerequisites + +- Java 17 or higher +- Git + +## ๐Ÿƒโ€โ™‚๏ธ Quick Start + +### Clone and Run + +```bash +# Clone the repository +git clone https://github.com/YOUR_USERNAME/sofia-tracker-server.git +cd sofia-tracker-server + +# Run the application +./gradlew bootRun +``` + +The server will start on `http://localhost:8080` + +### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/events` | Create a new event | +| `GET` | `/api/events/{id}` | Get event by ID | +| `GET` | `/api/events/by-local-id/{localId}` | Get event by local ID | +| `PUT` | `/api/events/{id}` | Update an event | +| `DELETE` | `/api/events/{id}` | Delete an event | +| `GET` | `/api/events` | List all events | +| `GET` | `/api/events?type={type}` | Filter events by type | +| `GET` | `/api/events/health` | Health check | + +### Event Types + +- **EAT**: Feeding events (requires `bottleAmountMl`) +- **SLEEP**: Sleep events (requires `sleepType`: SLEEP, WAKE) +- **POOP**: Diaper change events (requires `diaperType`: DIRTY, WET) + +### Example Request + +```bash +# Create a feeding event +curl -X POST http://localhost:8080/api/events \ + -H "Content-Type: application/json" \ + -d '{ + "id": "event-123", + "localId": 1, + "type": "EAT", + "timestamp": "2023-10-01T10:00:00Z", + "note": "Morning feeding", + "bottleAmountMl": 120 + }' +``` + +## ๐Ÿงช Testing + +```bash +# Run all tests +./gradlew test + +# Run tests with coverage +./gradlew test jacocoTestReport + +# View coverage report +open build/reports/jacoco/test/html/index.html +``` + +## ๐Ÿ” Code Quality + +```bash +# Run all quality checks +./gradlew check + +# Individual checks +./gradlew ktlintCheck # Kotlin linting +./gradlew detekt # Static analysis +./gradlew dependencyCheckAnalyze # Security scan + +# Auto-fix formatting +./gradlew ktlintFormat +``` + +## ๐Ÿ— Architecture + +The project follows Clean Architecture principles: + +``` +src/main/kotlin/com/dpconde/sofia_tracker/ +โ”œโ”€โ”€ application/usecases/ # Business logic +โ”œโ”€โ”€ domain/ # Core entities and interfaces +โ”œโ”€โ”€ infrastructure/ # Data persistence and external services +โ””โ”€โ”€ presentation/ # REST controllers and DTOs +``` + +### Key Design Decisions + +- **Concrete Use Cases**: No interfaces for use cases to reduce complexity +- **Value Objects**: Type-safe enums for event types, sleep types, etc. +- **Domain Events**: Event sourcing-ready with versioning +- **Command Pattern**: Clear separation between commands and queries + +## ๐Ÿš€ Deployment + +The application is configured for automatic deployment on Railway: + +1. **Push to `main`** triggers GitHub Actions CI/CD pipeline +2. **Quality gates** ensure code quality and security +3. **Railway auto-deploys** after successful validation + +### Environment Variables + +The application uses Spring profiles for different environments: + +- `application.properties` - Default configuration +- `application-test.properties` - Test configuration + +## ๐Ÿ”„ CI/CD Pipeline + +GitHub Actions workflows provide comprehensive automation: + +- **CI Pipeline**: Automated testing and building +- **Security Scanning**: OWASP dependency check, CodeQL, secrets detection +- **Code Quality**: Kotlin linting, static analysis, test coverage +- **Deployment Validation**: Pre-deployment checks before Railway deploy + +## ๐Ÿ“Š Database Schema + +### Events Table + +| Column | Type | Description | +|--------|------|-------------| +| `id` | VARCHAR(255) | Primary key (UUID) | +| `local_id` | BIGINT | Client-side identifier | +| `type` | VARCHAR(50) | Event type (EAT, SLEEP, POOP) | +| `timestamp` | TIMESTAMP | When the event occurred | +| `note` | TEXT | Optional notes | +| `bottle_amount_ml` | INTEGER | For EAT events | +| `sleep_type` | VARCHAR(50) | For SLEEP events | +| `diaper_type` | VARCHAR(50) | For POOP events | +| `version` | INTEGER | Optimistic locking | +| `deleted` | BOOLEAN | Soft delete flag | +| `created_at` | TIMESTAMP | Record creation time | +| `updated_at` | TIMESTAMP | Last update time | + + +## ๐Ÿ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + + +## ๐Ÿ—บ Roadmap + +- [ ] PostgreSQL support for production +- [ ] Event analytics and reporting +- [ ] Real-time notifications +- [ ] Mobile app integration +- [ ] Data export functionality +- [ ] User authentication and multi-user support + +--- + +Built with โค๏ธ for tracking Sofia's daily activities \ No newline at end of file diff --git a/build.gradle b/build.gradle index 6ab069c..be28c5a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,14 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id 'org.jetbrains.kotlin.jvm' version '1.9.25' id 'org.jetbrains.kotlin.plugin.spring' version '1.9.25' id 'org.springframework.boot' version '3.5.6' id 'io.spring.dependency-management' version '1.1.7' + id 'org.jlleitschuh.gradle.ktlint' version '12.1.0' + id 'io.gitlab.arturbosch.detekt' version '1.23.1' + id 'jacoco' + id 'org.owasp.dependencycheck' version '9.0.7' } group = 'com.dpconde' @@ -20,19 +26,68 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.flywaydb:flyway-core' implementation 'org.jetbrains.kotlin:kotlin-reflect' + + // Swagger/OpenAPI for development (compatible with Spring Boot 3.x) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + + // Development tools + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' + testImplementation 'io.mockk:mockk:1.13.8' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } kotlin { compilerOptions { freeCompilerArgs.addAll '-Xjsr305=strict' - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_17) } } tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +ktlint { + version = "1.0.1" + filter { + exclude("**/generated/**") + exclude("**/build/**") + } +} + +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom("$projectDir/config/detekt/detekt.yml") + reports { + html.required = true + xml.required = true + txt.required = true + } +} + +dependencyCheck { + format = 'ALL' + outputDirectory = 'build/reports' + failBuildOnCVSS = 7.0 + suppressionFile = 'config/dependency-check-suppressions.xml' } diff --git a/config/dependency-check-suppressions.xml b/config/dependency-check-suppressions.xml new file mode 100644 index 0000000..7b93d88 --- /dev/null +++ b/config/dependency-check-suppressions.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..1321431 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,408 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + complexity: 2 + LongParameterList: 1 + style: 1 + comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + +processors: + active: true + +console-reports: + active: true + +output-reports: + active: true + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + UndocumentedPublicClass: + active: false + UndocumentedPublicFunction: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + ComplexMethod: + active: true + threshold: 15 + LabeledExpression: + active: false + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + MethodOverloading: + active: false + NestedBlockDepth: + active: true + threshold: 4 + StringLiteralDuplication: + active: false + TooManyFunctions: + active: true + thresholdInFiles: 11 + thresholdInClasses: 11 + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + RedundantSuspendModifier: + active: false + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + InstanceOfCheckForException: + active: false + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + SwallowedException: + active: false + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: true + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +formatting: + active: true + android: false + autoCorrect: true + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + FunctionMaxLength: + active: false + FunctionMinLength: + active: false + FunctionNaming: + active: true + functionPattern: '[a-z][a-zA-Z0-9]*' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + InvalidPackageDeclaration: + active: false + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + VariableMinLength: + active: false + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + Deprecation: + active: false + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: false + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + IgnoredReturnValue: + active: false + ImplicitDefaultLocale: + active: false + InvalidRange: + active: false + IteratorHasNextCallsNextMethod: + active: false + IteratorNotThrowingNoSuchElementException: + active: false + LateinitUsage: + active: false + MapGetWithNotNullAssertionOperator: + active: false + MissingWhenCase: + active: false + RedundantElseInWhen: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullOperator: + active: false + UnnecessarySafeCall: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: false + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: false + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunction: + active: false + DataClassShouldBeImmutable: + active: false + EqualsNullCall: + active: false + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + ForbiddenComment: + active: true + values: ['TODO:', 'FIXME:', 'STOPSHIP:'] + ForbiddenImport: + active: false + ForbiddenMethodCall: + active: false + ForbiddenPublicDataClass: + active: false + ForbiddenVoid: + active: false + FunctionOnlyReturningConstant: + active: false + LibraryCodeMustSpecifyReturnType: + active: false + LibraryEntitiesShouldNotBePublic: + active: false + LoopWithTooManyJumpStatements: + active: false + MagicNumber: + active: true + ignoreNumbers: ['-1', '0', '1', '2'] + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + MandatoryBracesIfStatements: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + MayBeConst: + active: false + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: false + RedundantExplicitType: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: "equals" + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + UnnecessaryAbstractClass: + active: false + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: false + UnnecessaryInheritance: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: false + UnusedPrivateMember: + active: false + UseArrayLiteralsInAnnotations: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + UseEmptyCounterpart: + active: false + UseIfInsteadOfWhen: + active: false + UseRequire: + active: false + UselessCallOnNotNull: + active: false + UtilityClassWithPublicConstructor: + active: false + VarCouldBeVal: + active: false + WildcardImport: + active: true \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/CreateEventUseCase.kt b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/CreateEventUseCase.kt new file mode 100644 index 0000000..87d8f14 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/CreateEventUseCase.kt @@ -0,0 +1,102 @@ +package com.dpconde.sofia_tracker.application.usecases + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import com.dpconde.sofia_tracker.domain.valueobjects.DiaperType +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.domain.valueobjects.SleepType +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class CreateEventUseCase( + private val eventRepository: EventRepository +) { + + fun execute(command: CreateEventCommand): Event { + validateCommand(command) + + val event = Event( + id = command.id, + localId = command.localId, + type = EventType.valueOf(command.type.uppercase()), + timestamp = Instant.parse(command.timestamp), + note = command.note, + bottleAmountMl = command.bottleAmountMl, + sleepType = command.sleepType?.let { SleepType.valueOf(it.uppercase()) }, + diaperType = command.diaperType?.let { DiaperType.valueOf(it.uppercase()) } + ) + + return eventRepository.save(event) + } + + private fun validateCommand(command: CreateEventCommand) { + require(command.id.isNotBlank()) { "Event ID cannot be blank" } + require(command.localId >= 0) { "Local ID must be non-negative" } + + try { + EventType.valueOf(command.type.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid event type: ${command.type}") + } + + try { + Instant.parse(command.timestamp) + } catch (e: Exception) { + throw IllegalArgumentException("Invalid timestamp format: ${command.timestamp}") + } + + if (eventRepository.existsById(command.id)) { + throw IllegalArgumentException("Event with ID ${command.id} already exists") + } + + validateTypeSpecificFields(command) + } + + private fun validateTypeSpecificFields(command: CreateEventCommand) { + val eventType = EventType.valueOf(command.type.uppercase()) + + when (eventType) { + EventType.EAT -> { + require(command.bottleAmountMl != null && command.bottleAmountMl > 0) { + "Bottle amount is required and must be positive for EAT events" + } + require(command.sleepType == null) { "Sleep type should not be specified for EAT events" } + require(command.diaperType == null) { "Diaper type should not be specified for EAT events" } + } + EventType.SLEEP -> { + require(command.sleepType != null) { "Sleep type is required for SLEEP events" } + require(command.bottleAmountMl == null) { "Bottle amount should not be specified for SLEEP events" } + require(command.diaperType == null) { "Diaper type should not be specified for SLEEP events" } + + try { + SleepType.valueOf(command.sleepType.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid sleep type: ${command.sleepType}") + } + } + EventType.POOP -> { + require(command.diaperType != null) { "Diaper type is required for POOP events" } + require(command.bottleAmountMl == null) { "Bottle amount should not be specified for POOP events" } + require(command.sleepType == null) { "Sleep type should not be specified for POOP events" } + + try { + DiaperType.valueOf(command.diaperType.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid diaper type: ${command.diaperType}") + } + } + } + } +} + +data class CreateEventCommand( + val id: String, + val localId: Long, + val type: String, + val timestamp: String, + val note: String = "", + val bottleAmountMl: Int? = null, + val sleepType: String? = null, + val diaperType: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/DeleteEventUseCase.kt b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/DeleteEventUseCase.kt new file mode 100644 index 0000000..fac17e2 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/DeleteEventUseCase.kt @@ -0,0 +1,16 @@ +package com.dpconde.sofia_tracker.application.usecases + +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import org.springframework.stereotype.Service + +@Service +class DeleteEventUseCase( + private val eventRepository: EventRepository +) { + + fun execute(id: String): Boolean { + require(id.isNotBlank()) { "Event ID cannot be blank" } + + return eventRepository.deleteById(id) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/GetEventUseCase.kt b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/GetEventUseCase.kt new file mode 100644 index 0000000..7daffdf --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/GetEventUseCase.kt @@ -0,0 +1,25 @@ +package com.dpconde.sofia_tracker.application.usecases + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import org.springframework.stereotype.Service + +@Service +class GetEventUseCase( + private val eventRepository: EventRepository +) { + + fun execute(id: String): Event? { + require(id.isNotBlank()) { "Event ID cannot be blank" } + + return eventRepository.findById(id) + ?.takeIf { !it.deleted } + } + + fun executeByLocalId(localId: Long): Event? { + require(localId >= 0) { "Local ID must be non-negative" } + + return eventRepository.findByLocalId(localId) + ?.takeIf { !it.deleted } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/ListEventsUseCase.kt b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/ListEventsUseCase.kt new file mode 100644 index 0000000..43591e8 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/ListEventsUseCase.kt @@ -0,0 +1,30 @@ +package com.dpconde.sofia_tracker.application.usecases + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ListEventsUseCase( + private val eventRepository: EventRepository +) { + + fun execute(): List { + return eventRepository.findAllActive() + .sortedByDescending { it.timestamp } + } + + fun executeByType(eventType: EventType): List { + return eventRepository.findByType(eventType) + .sortedByDescending { it.timestamp } + } + + fun executeByDateRange(start: Instant, end: Instant): List { + require(start.isBefore(end) || start == end) { "Start date must be before or equal to end date" } + + return eventRepository.findByTimestampBetween(start, end) + .sortedByDescending { it.timestamp } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/UpdateEventUseCase.kt b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/UpdateEventUseCase.kt new file mode 100644 index 0000000..4748dc3 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/application/usecases/UpdateEventUseCase.kt @@ -0,0 +1,78 @@ +package com.dpconde.sofia_tracker.application.usecases + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import com.dpconde.sofia_tracker.domain.valueobjects.DiaperType +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.domain.valueobjects.SleepType +import org.springframework.stereotype.Service + +@Service +class UpdateEventUseCase( + private val eventRepository: EventRepository +) { + + fun execute(command: UpdateEventCommand): Event? { + require(command.id.isNotBlank()) { "Event ID cannot be blank" } + + val existingEvent = eventRepository.findById(command.id) + ?.takeIf { !it.deleted } + ?: return null + + validateCommand(command, existingEvent) + + val updatedEvent = buildUpdatedEvent(existingEvent, command) + return eventRepository.save(updatedEvent) + } + + private fun validateCommand(command: UpdateEventCommand, existingEvent: Event) { + when (existingEvent.type) { + EventType.EAT -> { + if (command.bottleAmountMl != null) { + require(command.bottleAmountMl > 0) { "Bottle amount must be positive for EAT events" } + } + require(command.sleepType == null) { "Sleep type cannot be updated for EAT events" } + require(command.diaperType == null) { "Diaper type cannot be updated for EAT events" } + } + EventType.SLEEP -> { + if (command.sleepType != null) { + try { + SleepType.valueOf(command.sleepType.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid sleep type: ${command.sleepType}") + } + } + require(command.bottleAmountMl == null) { "Bottle amount cannot be updated for SLEEP events" } + require(command.diaperType == null) { "Diaper type cannot be updated for SLEEP events" } + } + EventType.POOP -> { + if (command.diaperType != null) { + try { + DiaperType.valueOf(command.diaperType.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid diaper type: ${command.diaperType}") + } + } + require(command.bottleAmountMl == null) { "Bottle amount cannot be updated for POOP events" } + require(command.sleepType == null) { "Sleep type cannot be updated for POOP events" } + } + } + } + + private fun buildUpdatedEvent(existingEvent: Event, command: UpdateEventCommand): Event { + return existingEvent.copy( + note = command.note ?: existingEvent.note, + bottleAmountMl = command.bottleAmountMl ?: existingEvent.bottleAmountMl, + sleepType = command.sleepType?.let { SleepType.valueOf(it.uppercase()) } ?: existingEvent.sleepType, + diaperType = command.diaperType?.let { DiaperType.valueOf(it.uppercase()) } ?: existingEvent.diaperType + ).incrementVersion() + } +} + +data class UpdateEventCommand( + val id: String, + val note: String? = null, + val bottleAmountMl: Int? = null, + val sleepType: String? = null, + val diaperType: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/domain/entities/Event.kt b/src/main/kotlin/com/dpconde/sofia_tracker/domain/entities/Event.kt new file mode 100644 index 0000000..cca96fa --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/domain/entities/Event.kt @@ -0,0 +1,55 @@ +package com.dpconde.sofia_tracker.domain.entities + +import com.dpconde.sofia_tracker.domain.valueobjects.DiaperType +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.domain.valueobjects.SleepType +import java.time.Instant + +data class Event( + val id: String, + val localId: Long, + val type: EventType, + val timestamp: Instant, + val note: String = "", + val version: Int = 1, + val lastModified: Instant = Instant.now(), + val deleted: Boolean = false, + val bottleAmountMl: Int? = null, + val sleepType: SleepType? = null, + val diaperType: DiaperType? = null +) { + init { + require(id.isNotBlank()) { "Event ID cannot be blank" } + require(localId >= 0) { "Local ID must be non-negative" } + require(version > 0) { "Version must be positive" } + require(bottleAmountMl == null || bottleAmountMl > 0) { "Bottle amount must be positive when specified" } + + validateTypeSpecificFields() + } + + private fun validateTypeSpecificFields() { + when (type) { + EventType.EAT -> { + require(bottleAmountMl != null) { "Bottle amount is required for EAT events" } + require(sleepType == null) { "Sleep type should not be set for EAT events" } + require(diaperType == null) { "Diaper type should not be set for EAT events" } + } + EventType.SLEEP -> { + require(sleepType != null) { "Sleep type is required for SLEEP events" } + require(bottleAmountMl == null) { "Bottle amount should not be set for SLEEP events" } + require(diaperType == null) { "Diaper type should not be set for SLEEP events" } + } + EventType.POOP -> { + require(diaperType != null) { "Diaper type is required for POOP events" } + require(bottleAmountMl == null) { "Bottle amount should not be set for POOP events" } + require(sleepType == null) { "Sleep type should not be set for POOP events" } + } + } + } + + fun markAsDeleted(): Event = copy(deleted = true, lastModified = Instant.now()) + + fun updateNote(newNote: String): Event = copy(note = newNote, lastModified = Instant.now()) + + fun incrementVersion(): Event = copy(version = version + 1, lastModified = Instant.now()) +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/domain/repositories/EventRepository.kt b/src/main/kotlin/com/dpconde/sofia_tracker/domain/repositories/EventRepository.kt new file mode 100644 index 0000000..c0ea3f7 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/domain/repositories/EventRepository.kt @@ -0,0 +1,17 @@ +package com.dpconde.sofia_tracker.domain.repositories + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import java.time.Instant + +interface EventRepository { + fun save(event: Event): Event + fun findById(id: String): Event? + fun findByLocalId(localId: Long): Event? + fun findAll(): List + fun findAllActive(): List + fun findByType(eventType: EventType): List + fun findByTimestampBetween(start: Instant, end: Instant): List + fun existsById(id: String): Boolean + fun deleteById(id: String): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/DiaperType.kt b/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/DiaperType.kt new file mode 100644 index 0000000..9cce3d3 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/DiaperType.kt @@ -0,0 +1,7 @@ +package com.dpconde.sofia_tracker.domain.valueobjects + +enum class DiaperType { + WET, + DIRTY, + BOTH +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/EventType.kt b/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/EventType.kt new file mode 100644 index 0000000..ece6012 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/EventType.kt @@ -0,0 +1,7 @@ +package com.dpconde.sofia_tracker.domain.valueobjects + +enum class EventType { + EAT, + SLEEP, + POOP +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/SleepType.kt b/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/SleepType.kt new file mode 100644 index 0000000..f1d11cd --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/domain/valueobjects/SleepType.kt @@ -0,0 +1,6 @@ +package com.dpconde.sofia_tracker.domain.valueobjects + +enum class SleepType { + SLEEP, + WAKE_UP +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/config/SwaggerConfig.kt b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/config/SwaggerConfig.kt new file mode 100644 index 0000000..c835641 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/config/SwaggerConfig.kt @@ -0,0 +1,43 @@ +package com.dpconde.sofia_tracker.infrastructure.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("development") +class SwaggerConfig { + + @Bean + fun customOpenAPI(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Sofia Tracker API") + .description("REST API for tracking baby events like feeding, sleeping, and diaper changes") + .version("0.0.1-SNAPSHOT") + .contact( + Contact() + .name("Sofia Tracker Team") + .email("contact@sofiatracker.com") + ) + .license( + License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT") + ) + ) + .servers( + listOf( + Server() + .url("http://localhost:8080") + .description("Development server") + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/entities/EventJpaEntity.kt b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/entities/EventJpaEntity.kt new file mode 100644 index 0000000..0892b4f --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/entities/EventJpaEntity.kt @@ -0,0 +1,71 @@ +package com.dpconde.sofia_tracker.infrastructure.persistence.entities + +import jakarta.persistence.* +import java.time.Instant + +@Entity +@Table(name = "events") +data class EventJpaEntity( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "local_id", nullable = false) + val localId: Long, + + @Column(name = "type", nullable = false) + @Enumerated(EnumType.STRING) + val type: EventTypeJpa, + + @Column(name = "timestamp", nullable = false) + val timestamp: Instant, + + @Column(name = "note", columnDefinition = "TEXT") + val note: String = "", + + @Column(name = "version", nullable = false) + val version: Int = 1, + + @Column(name = "last_modified", nullable = false) + val lastModified: Instant = Instant.now(), + + @Column(name = "deleted", nullable = false) + val deleted: Boolean = false, + + @Column(name = "bottle_amount_ml") + val bottleAmountMl: Int? = null, + + @Column(name = "sleep_type") + @Enumerated(EnumType.STRING) + val sleepType: SleepTypeJpa? = null, + + @Column(name = "diaper_type") + @Enumerated(EnumType.STRING) + val diaperType: DiaperTypeJpa? = null +) { + constructor() : this( + id = "", + localId = 0, + type = EventTypeJpa.EAT, + timestamp = Instant.now(), + note = "", + version = 1, + lastModified = Instant.now(), + deleted = false, + bottleAmountMl = null, + sleepType = null, + diaperType = null + ) +} + +enum class EventTypeJpa { + EAT, SLEEP, POOP +} + +enum class SleepTypeJpa { + SLEEP, WAKE_UP +} + +enum class DiaperTypeJpa { + WET, DIRTY, BOTH +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/mappers/EventMapper.kt b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/mappers/EventMapper.kt new file mode 100644 index 0000000..387c3ad --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/mappers/EventMapper.kt @@ -0,0 +1,93 @@ +package com.dpconde.sofia_tracker.infrastructure.persistence.mappers + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.valueobjects.DiaperType +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.domain.valueobjects.SleepType +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.DiaperTypeJpa +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.EventJpaEntity +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.EventTypeJpa +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.SleepTypeJpa +import org.springframework.stereotype.Component + +@Component +class EventMapper { + + fun toDomain(jpaEntity: EventJpaEntity): Event { + return Event( + id = jpaEntity.id, + localId = jpaEntity.localId, + type = mapEventTypeToDomain(jpaEntity.type), + timestamp = jpaEntity.timestamp, + note = jpaEntity.note, + version = jpaEntity.version, + lastModified = jpaEntity.lastModified, + deleted = jpaEntity.deleted, + bottleAmountMl = jpaEntity.bottleAmountMl, + sleepType = jpaEntity.sleepType?.let { mapSleepTypeToDomain(it) }, + diaperType = jpaEntity.diaperType?.let { mapDiaperTypeToDomain(it) } + ) + } + + fun toJpaEntity(domainEntity: Event): EventJpaEntity { + return EventJpaEntity( + id = domainEntity.id, + localId = domainEntity.localId, + type = mapEventTypeToJpa(domainEntity.type), + timestamp = domainEntity.timestamp, + note = domainEntity.note, + version = domainEntity.version, + lastModified = domainEntity.lastModified, + deleted = domainEntity.deleted, + bottleAmountMl = domainEntity.bottleAmountMl, + sleepType = domainEntity.sleepType?.let { mapSleepTypeToJpa(it) }, + diaperType = domainEntity.diaperType?.let { mapDiaperTypeToJpa(it) } + ) + } + + private fun mapEventTypeToDomain(jpaType: EventTypeJpa): EventType { + return when (jpaType) { + EventTypeJpa.EAT -> EventType.EAT + EventTypeJpa.SLEEP -> EventType.SLEEP + EventTypeJpa.POOP -> EventType.POOP + } + } + + private fun mapEventTypeToJpa(domainType: EventType): EventTypeJpa { + return when (domainType) { + EventType.EAT -> EventTypeJpa.EAT + EventType.SLEEP -> EventTypeJpa.SLEEP + EventType.POOP -> EventTypeJpa.POOP + } + } + + private fun mapSleepTypeToDomain(jpaType: SleepTypeJpa): SleepType { + return when (jpaType) { + SleepTypeJpa.SLEEP -> SleepType.SLEEP + SleepTypeJpa.WAKE_UP -> SleepType.WAKE_UP + } + } + + private fun mapSleepTypeToJpa(domainType: SleepType): SleepTypeJpa { + return when (domainType) { + SleepType.SLEEP -> SleepTypeJpa.SLEEP + SleepType.WAKE_UP -> SleepTypeJpa.WAKE_UP + } + } + + private fun mapDiaperTypeToDomain(jpaType: DiaperTypeJpa): DiaperType { + return when (jpaType) { + DiaperTypeJpa.WET -> DiaperType.WET + DiaperTypeJpa.DIRTY -> DiaperType.DIRTY + DiaperTypeJpa.BOTH -> DiaperType.BOTH + } + } + + private fun mapDiaperTypeToJpa(domainType: DiaperType): DiaperTypeJpa { + return when (domainType) { + DiaperType.WET -> DiaperTypeJpa.WET + DiaperType.DIRTY -> DiaperTypeJpa.DIRTY + DiaperType.BOTH -> DiaperTypeJpa.BOTH + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventJpaRepository.kt b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventJpaRepository.kt new file mode 100644 index 0000000..28c50f1 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventJpaRepository.kt @@ -0,0 +1,28 @@ +package com.dpconde.sofia_tracker.infrastructure.persistence.repositories + +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.EventJpaEntity +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.EventTypeJpa +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.time.Instant + +@Repository +interface EventJpaRepository : JpaRepository { + + fun findByLocalId(localId: Long): EventJpaEntity? + + fun findByType(type: EventTypeJpa): List + + fun findByTimestampBetween(start: Instant, end: Instant): List + + @Query("SELECT e FROM EventJpaEntity e WHERE e.deleted = false") + fun findAllActive(): List + + @Query("SELECT e FROM EventJpaEntity e WHERE e.deleted = false AND e.type = :type") + fun findActiveByType(@Param("type") type: EventTypeJpa): List + + @Query("SELECT e FROM EventJpaEntity e WHERE e.deleted = false AND e.timestamp BETWEEN :start AND :end") + fun findActiveByTimestampBetween(@Param("start") start: Instant, @Param("end") end: Instant): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventRepositoryImpl.kt b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventRepositoryImpl.kt new file mode 100644 index 0000000..4b0249b --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventRepositoryImpl.kt @@ -0,0 +1,78 @@ +package com.dpconde.sofia_tracker.infrastructure.persistence.repositories + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.infrastructure.persistence.entities.EventTypeJpa +import com.dpconde.sofia_tracker.infrastructure.persistence.mappers.EventMapper +import org.springframework.stereotype.Repository +import java.time.Instant + +@Repository +class EventRepositoryImpl( + private val jpaRepository: EventJpaRepository, + private val mapper: EventMapper +) : EventRepository { + + override fun save(event: Event): Event { + val jpaEntity = mapper.toJpaEntity(event) + val savedEntity = jpaRepository.save(jpaEntity) + return mapper.toDomain(savedEntity) + } + + override fun findById(id: String): Event? { + return jpaRepository.findById(id) + .map { mapper.toDomain(it) } + .orElse(null) + } + + override fun findByLocalId(localId: Long): Event? { + return jpaRepository.findByLocalId(localId) + ?.let { mapper.toDomain(it) } + } + + override fun findAll(): List { + return jpaRepository.findAll() + .map { mapper.toDomain(it) } + } + + override fun findAllActive(): List { + return jpaRepository.findAllActive() + .map { mapper.toDomain(it) } + } + + override fun findByType(eventType: EventType): List { + val jpaType = mapEventTypeToJpa(eventType) + return jpaRepository.findActiveByType(jpaType) + .map { mapper.toDomain(it) } + } + + override fun findByTimestampBetween(start: Instant, end: Instant): List { + return jpaRepository.findActiveByTimestampBetween(start, end) + .map { mapper.toDomain(it) } + } + + override fun existsById(id: String): Boolean { + return jpaRepository.existsById(id) + } + + override fun deleteById(id: String): Boolean { + return jpaRepository.findById(id) + .map { jpaEntity -> + val domainEvent = mapper.toDomain(jpaEntity) + val deletedDomainEvent = domainEvent.markAsDeleted() + val deletedJpaEntity = mapper.toJpaEntity(deletedDomainEvent) + jpaRepository.save(deletedJpaEntity) + true + } + .orElse(false) + } + + private fun mapEventTypeToJpa(domainType: EventType): EventTypeJpa { + return when (domainType) { + EventType.EAT -> EventTypeJpa.EAT + EventType.SLEEP -> EventTypeJpa.SLEEP + EventType.POOP -> EventTypeJpa.POOP + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/presentation/controllers/EventController.kt b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/controllers/EventController.kt new file mode 100644 index 0000000..7e11e5a --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/controllers/EventController.kt @@ -0,0 +1,161 @@ +package com.dpconde.sofia_tracker.presentation.controllers + +import com.dpconde.sofia_tracker.application.usecases.* +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.presentation.dto.CreateEventRequestDto +import com.dpconde.sofia_tracker.presentation.dto.RemoteEventDto +import com.dpconde.sofia_tracker.presentation.dto.UpdateEventRequestDto +import com.dpconde.sofia_tracker.presentation.exception.EventNotFoundException +import com.dpconde.sofia_tracker.presentation.mappers.EventDtoMapper +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +@RestController +@RequestMapping("/api/events") +@Tag(name = "Events", description = "API for managing baby events (feeding, sleeping, diaper changes)") +class EventController( + private val createEventUseCase: CreateEventUseCase, + private val getEventUseCase: GetEventUseCase, + private val updateEventUseCase: UpdateEventUseCase, + private val deleteEventUseCase: DeleteEventUseCase, + private val listEventsUseCase: ListEventsUseCase, + private val eventDtoMapper: EventDtoMapper +) { + + @PostMapping + @Operation( + summary = "Create a new event", + description = "Creates a new baby event (feeding, sleeping, or diaper change)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "Event created successfully"), + ApiResponse(responseCode = "400", description = "Invalid request data"), + ApiResponse(responseCode = "409", description = "Event with this ID already exists") + ] + ) + fun createEvent(@Valid @RequestBody request: CreateEventRequestDto): ResponseEntity { + val command = eventDtoMapper.toCreateEventCommand(request) + val createdEvent = createEventUseCase.execute(command) + val responseDto = eventDtoMapper.toRemoteEventDto(createdEvent) + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto) + } + + @GetMapping("/{id}") + @Operation( + summary = "Get event by ID", + description = "Retrieves a specific event by its unique identifier" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Event found"), + ApiResponse(responseCode = "404", description = "Event not found") + ] + ) + fun getEvent( + @Parameter(description = "Event ID", required = true) + @PathVariable id: String + ): ResponseEntity { + val event = getEventUseCase.execute(id) + ?: throw EventNotFoundException("Event with ID $id not found") + + val responseDto = eventDtoMapper.toRemoteEventDto(event) + return ResponseEntity.ok(responseDto) + } + + @GetMapping("/by-local-id/{localId}") + fun getEventByLocalId(@PathVariable localId: Long): ResponseEntity { + val event = getEventUseCase.executeByLocalId(localId) + ?: throw EventNotFoundException("Event with local ID $localId not found") + + val responseDto = eventDtoMapper.toRemoteEventDto(event) + return ResponseEntity.ok(responseDto) + } + + @PutMapping("/{id}") + fun updateEvent( + @PathVariable id: String, + @Valid @RequestBody request: UpdateEventRequestDto + ): ResponseEntity { + val command = eventDtoMapper.toUpdateEventCommand(id, request) + val updatedEvent = updateEventUseCase.execute(command) + ?: throw EventNotFoundException("Event with ID $id not found") + + val responseDto = eventDtoMapper.toRemoteEventDto(updatedEvent) + return ResponseEntity.ok(responseDto) + } + + @DeleteMapping("/{id}") + fun deleteEvent(@PathVariable id: String): ResponseEntity { + val deleted = deleteEventUseCase.execute(id) + return if (deleted) { + ResponseEntity.noContent().build() + } else { + throw EventNotFoundException("Event with ID $id not found") + } + } + + @GetMapping + @Operation( + summary = "List events", + description = "Retrieves all events with optional filtering by type or date range" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Events retrieved successfully"), + ApiResponse(responseCode = "400", description = "Invalid query parameters") + ] + ) + fun listEvents( + @Parameter(description = "Filter by event type (EAT, SLEEP, POOP)") + @RequestParam(required = false) type: String?, + @Parameter(description = "Start date for date range filter (ISO format)") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime?, + @Parameter(description = "End date for date range filter (ISO format)") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime? + ): ResponseEntity> { + val events = when { + type != null -> { + val eventType = try { + EventType.valueOf(type.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid event type: $type. Must be EAT, SLEEP, or POOP") + } + listEventsUseCase.executeByType(eventType) + } + start != null && end != null -> { + val startInstant = start.toInstant(ZoneOffset.UTC) + val endInstant = end.toInstant(ZoneOffset.UTC) + listEventsUseCase.executeByDateRange(startInstant, endInstant) + } + start != null || end != null -> { + throw IllegalArgumentException("Both start and end dates must be provided for date range filtering") + } + else -> listEventsUseCase.execute() + } + + val responseDtos = events.map { eventDtoMapper.toRemoteEventDto(it) } + return ResponseEntity.ok(responseDtos) + } + + @GetMapping("/health") + @Operation( + summary = "Health check", + description = "Returns the health status of the Events API" + ) + @ApiResponse(responseCode = "200", description = "Service is healthy") + fun health(): ResponseEntity> { + return ResponseEntity.ok(mapOf("status" to "UP")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/presentation/dto/RemoteEventDto.kt b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/dto/RemoteEventDto.kt new file mode 100644 index 0000000..b280b43 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/dto/RemoteEventDto.kt @@ -0,0 +1,62 @@ +package com.dpconde.sofia_tracker.presentation.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.* + +data class RemoteEventDto( + val id: String = "", + @JsonProperty("localId") + val localId: Long = 0, + val type: String = "", + val timestamp: String = "", + val note: String = "", + val version: Int = 1, + @JsonProperty("lastModified") + val lastModified: Long = System.currentTimeMillis(), + val deleted: Boolean = false, + @JsonProperty("bottleAmountMl") + val bottleAmountMl: Int? = null, + @JsonProperty("sleepType") + val sleepType: String? = null, + @JsonProperty("diaperType") + val diaperType: String? = null +) + +data class CreateEventRequestDto( + @field:NotBlank(message = "Event ID is required") + val id: String, + + @field:Min(value = 0, message = "Local ID must be non-negative") + val localId: Long, + + @field:NotBlank(message = "Event type is required") + @field:Pattern(regexp = "^(EAT|SLEEP|POOP)$", message = "Event type must be EAT, SLEEP, or POOP") + val type: String, + + @field:NotBlank(message = "Timestamp is required") + val timestamp: String, + + val note: String = "", + + @field:Positive(message = "Bottle amount must be positive when specified") + val bottleAmountMl: Int? = null, + + @field:Pattern(regexp = "^(SLEEP|WAKE_UP)$", message = "Sleep type must be SLEEP or WAKE_UP") + val sleepType: String? = null, + + @field:Pattern(regexp = "^(WET|DIRTY|BOTH)$", message = "Diaper type must be WET, DIRTY, or BOTH") + val diaperType: String? = null +) + +data class UpdateEventRequestDto( + val note: String? = null, + + @field:Positive(message = "Bottle amount must be positive when specified") + val bottleAmountMl: Int? = null, + + @field:Pattern(regexp = "^(SLEEP|WAKE_UP)$", message = "Sleep type must be SLEEP or WAKE_UP") + val sleepType: String? = null, + + @field:Pattern(regexp = "^(WET|DIRTY|BOTH)$", message = "Diaper type must be WET, DIRTY, or BOTH") + val diaperType: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/presentation/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..8605103 --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/exception/GlobalExceptionHandler.kt @@ -0,0 +1,76 @@ +package com.dpconde.sofia_tracker.presentation.exception + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.context.request.WebRequest + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgumentException( + ex: IllegalArgumentException, + request: WebRequest + ): ResponseEntity { + val errorResponse = ErrorResponse( + status = HttpStatus.BAD_REQUEST.value(), + error = "Bad Request", + message = ex.message ?: "Invalid argument", + path = request.getDescription(false).removePrefix("uri=") + ) + return ResponseEntity.badRequest().body(errorResponse) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions( + ex: MethodArgumentNotValidException, + request: WebRequest + ): ResponseEntity { + val fieldErrors = ex.bindingResult.fieldErrors.map { fieldError -> + "${fieldError.field}: ${fieldError.defaultMessage}" + } + + val errorResponse = ErrorResponse( + status = HttpStatus.BAD_REQUEST.value(), + error = "Validation Failed", + message = fieldErrors.joinToString("; "), + path = request.getDescription(false).removePrefix("uri=") + ) + return ResponseEntity.badRequest().body(errorResponse) + } + + @ExceptionHandler(EventNotFoundException::class) + fun handleEventNotFoundException( + ex: EventNotFoundException, + request: WebRequest + ): ResponseEntity { + return ResponseEntity.notFound().build() + } + + @ExceptionHandler(Exception::class) + fun handleGenericException( + ex: Exception, + request: WebRequest + ): ResponseEntity { + val errorResponse = ErrorResponse( + status = HttpStatus.INTERNAL_SERVER_ERROR.value(), + error = "Internal Server Error", + message = "An unexpected error occurred", + path = request.getDescription(false).removePrefix("uri=") + ) + return ResponseEntity.internalServerError().body(errorResponse) + } +} + +data class ErrorResponse( + val status: Int, + val error: String, + val message: String, + val path: String, + val timestamp: Long = System.currentTimeMillis() +) + +class EventNotFoundException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/com/dpconde/sofia_tracker/presentation/mappers/EventDtoMapper.kt b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/mappers/EventDtoMapper.kt new file mode 100644 index 0000000..bfae3ba --- /dev/null +++ b/src/main/kotlin/com/dpconde/sofia_tracker/presentation/mappers/EventDtoMapper.kt @@ -0,0 +1,52 @@ +package com.dpconde.sofia_tracker.presentation.mappers + +import com.dpconde.sofia_tracker.application.usecases.CreateEventCommand +import com.dpconde.sofia_tracker.application.usecases.UpdateEventCommand +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.presentation.dto.CreateEventRequestDto +import com.dpconde.sofia_tracker.presentation.dto.RemoteEventDto +import com.dpconde.sofia_tracker.presentation.dto.UpdateEventRequestDto +import org.springframework.stereotype.Component + +@Component +class EventDtoMapper { + + fun toRemoteEventDto(event: Event): RemoteEventDto { + return RemoteEventDto( + id = event.id, + localId = event.localId, + type = event.type.name, + timestamp = event.timestamp.toString(), + note = event.note, + version = event.version, + lastModified = event.lastModified.toEpochMilli(), + deleted = event.deleted, + bottleAmountMl = event.bottleAmountMl, + sleepType = event.sleepType?.name, + diaperType = event.diaperType?.name + ) + } + + fun toCreateEventCommand(dto: CreateEventRequestDto): CreateEventCommand { + return CreateEventCommand( + id = dto.id, + localId = dto.localId, + type = dto.type, + timestamp = dto.timestamp, + note = dto.note, + bottleAmountMl = dto.bottleAmountMl, + sleepType = dto.sleepType, + diaperType = dto.diaperType + ) + } + + fun toUpdateEventCommand(id: String, dto: UpdateEventRequestDto): UpdateEventCommand { + return UpdateEventCommand( + id = id, + note = dto.note, + bottleAmountMl = dto.bottleAmountMl, + sleepType = dto.sleepType, + diaperType = dto.diaperType + ) + } +} \ No newline at end of file diff --git a/src/main/resources/application-development.properties b/src/main/resources/application-development.properties new file mode 100644 index 0000000..62bac0d --- /dev/null +++ b/src/main/resources/application-development.properties @@ -0,0 +1,45 @@ +# Development Profile Configuration +spring.application.name=sofia-tracker + +# H2 Database Configuration +spring.datasource.url=jdbc:h2:mem:sofia_tracker_dev_db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# H2 Console Configuration (enabled for development) +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# JPA Configuration for Development +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# Flyway Configuration +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true +spring.flyway.baseline-version=0 + +# Logging Configuration for Development +logging.level.org.flywaydb=INFO +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +logging.level.com.dpconde.sofia_tracker=DEBUG +logging.level.org.springframework.web=DEBUG + +# Server Configuration +server.port=8080 + +# Swagger/OpenAPI Configuration (enabled for development) +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true +springdoc.swagger-ui.tryItOutEnabled=true +springdoc.swagger-ui.operationsSorter=method +springdoc.swagger-ui.tagsSorter=alpha + +# Development-specific settings +spring.devtools.restart.enabled=true +spring.devtools.livereload.enabled=true \ No newline at end of file diff --git a/src/main/resources/application-production.properties b/src/main/resources/application-production.properties new file mode 100644 index 0000000..62cd759 --- /dev/null +++ b/src/main/resources/application-production.properties @@ -0,0 +1,55 @@ +# Production Profile Configuration +spring.application.name=sofia-tracker + +# Database Configuration (will be overridden by Railway environment variables) +spring.datasource.url=${DATABASE_URL:jdbc:h2:mem:sofia_tracker_prod_db} +spring.datasource.driver-class-name=${DATABASE_DRIVER:org.h2.Driver} +spring.datasource.username=${DATABASE_USERNAME:sa} +spring.datasource.password=${DATABASE_PASSWORD:} + +# H2 Console Configuration (disabled for production) +spring.h2.console.enabled=false + +# JPA Configuration for Production +spring.jpa.database-platform=${DATABASE_PLATFORM:org.hibernate.dialect.H2Dialect} +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false + +# Flyway Configuration +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true +spring.flyway.baseline-version=0 + +# Logging Configuration for Production +logging.level.org.flywaydb=WARN +logging.level.org.hibernate.SQL=WARN +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN +logging.level.com.dpconde.sofia_tracker=INFO +logging.level.org.springframework.web=WARN +logging.level.org.springframework.security=WARN + +# Server Configuration +server.port=${PORT:8080} + +# Swagger/OpenAPI Configuration (disabled for production) +springdoc.api-docs.enabled=false +springdoc.swagger-ui.enabled=false + +# Production-specific settings +spring.devtools.restart.enabled=false +spring.devtools.livereload.enabled=false + +# Security settings for production +server.error.include-message=never +server.error.include-binding-errors=never +server.error.include-stacktrace=never +server.error.include-exception=false + +# Connection pool settings for production +spring.datasource.hikari.maximum-pool-size=${DATABASE_MAX_POOL_SIZE:10} +spring.datasource.hikari.minimum-idle=${DATABASE_MIN_IDLE:5} +spring.datasource.hikari.connection-timeout=${DATABASE_CONNECTION_TIMEOUT:30000} +spring.datasource.hikari.idle-timeout=${DATABASE_IDLE_TIMEOUT:600000} +spring.datasource.hikari.max-lifetime=${DATABASE_MAX_LIFETIME:1800000} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0453db4..f97724a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,8 @@ +# Default Profile Configuration spring.application.name=sofia-tracker + +# Set default profile to development (use production profile in Railway) +spring.profiles.active=${SPRING_PROFILES_ACTIVE:development} + +# Common configuration that applies to all profiles +server.port=${PORT:8080} diff --git a/src/main/resources/db/migration/V1__Create_events_table.sql b/src/main/resources/db/migration/V1__Create_events_table.sql new file mode 100644 index 0000000..1473611 --- /dev/null +++ b/src/main/resources/db/migration/V1__Create_events_table.sql @@ -0,0 +1,42 @@ +CREATE TABLE events ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + local_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL CHECK (type IN ('EAT', 'SLEEP', 'POOP')), + timestamp TIMESTAMP WITH TIME ZONE NOT NULL, + note TEXT DEFAULT '', + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + last_modified TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + bottle_amount_ml INTEGER CHECK (bottle_amount_ml IS NULL OR bottle_amount_ml > 0), + sleep_type VARCHAR(20) CHECK (sleep_type IS NULL OR sleep_type IN ('SLEEP', 'WAKE_UP')), + diaper_type VARCHAR(20) CHECK (diaper_type IS NULL OR diaper_type IN ('WET', 'DIRTY', 'BOTH')), + + -- Constraints to ensure type-specific fields are properly set + CONSTRAINT chk_eat_event CHECK ( + type != 'EAT' OR ( + bottle_amount_ml IS NOT NULL AND + sleep_type IS NULL AND + diaper_type IS NULL + ) + ), + CONSTRAINT chk_sleep_event CHECK ( + type != 'SLEEP' OR ( + sleep_type IS NOT NULL AND + bottle_amount_ml IS NULL AND + diaper_type IS NULL + ) + ), + CONSTRAINT chk_poop_event CHECK ( + type != 'POOP' OR ( + diaper_type IS NOT NULL AND + bottle_amount_ml IS NULL AND + sleep_type IS NULL + ) + ) +); + +-- Index for performance on common queries +CREATE INDEX idx_events_timestamp ON events(timestamp); +CREATE INDEX idx_events_type ON events(type); +CREATE INDEX idx_events_deleted ON events(deleted); +CREATE INDEX idx_events_local_id ON events(local_id); \ No newline at end of file diff --git a/src/test/kotlin/com/dpconde/sofia_tracker/TestConfiguration.kt b/src/test/kotlin/com/dpconde/sofia_tracker/TestConfiguration.kt new file mode 100644 index 0000000..035cf6e --- /dev/null +++ b/src/test/kotlin/com/dpconde/sofia_tracker/TestConfiguration.kt @@ -0,0 +1,8 @@ +package com.dpconde.sofia_tracker + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.ComponentScan + +@TestConfiguration +@ComponentScan(basePackages = ["com.dpconde.sofia_tracker"]) +class TestConfiguration \ No newline at end of file diff --git a/src/test/kotlin/com/dpconde/sofia_tracker/application/usecases/CreateEventUseCaseTest.kt b/src/test/kotlin/com/dpconde/sofia_tracker/application/usecases/CreateEventUseCaseTest.kt new file mode 100644 index 0000000..8491b51 --- /dev/null +++ b/src/test/kotlin/com/dpconde/sofia_tracker/application/usecases/CreateEventUseCaseTest.kt @@ -0,0 +1,185 @@ +package com.dpconde.sofia_tracker.application.usecases + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.repositories.EventRepository +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class CreateEventUseCaseTest { + + private val eventRepository = mockk() + private val useCase = CreateEventUseCase(eventRepository) + + @Test + fun `should create valid EAT event`() { + // Given + val command = CreateEventCommand( + id = "test-id", + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z", + bottleAmountMl = 120 + ) + + val capturedEvent = slot() + every { eventRepository.existsById("test-id") } returns false + every { eventRepository.save(capture(capturedEvent)) } answers { capturedEvent.captured } + + // When + val result = useCase.execute(command) + + // Then + verify { eventRepository.existsById("test-id") } + verify { eventRepository.save(any()) } + + assertEquals("test-id", result.id) + assertEquals(1L, result.localId) + assertEquals(EventType.EAT, result.type) + assertEquals(120, result.bottleAmountMl) + assertEquals(null, result.sleepType) + assertEquals(null, result.diaperType) + } + + @Test + fun `should create valid SLEEP event`() { + // Given + val command = CreateEventCommand( + id = "test-id", + localId = 1L, + type = "SLEEP", + timestamp = "2023-10-01T10:00:00Z", + sleepType = "SLEEP" + ) + + val capturedEvent = slot() + every { eventRepository.existsById("test-id") } returns false + every { eventRepository.save(capture(capturedEvent)) } answers { capturedEvent.captured } + + // When + val result = useCase.execute(command) + + // Then + assertEquals(EventType.SLEEP, result.type) + assertNotNull(result.sleepType) + assertEquals(null, result.bottleAmountMl) + } + + @Test + fun `should throw exception when event already exists`() { + // Given + val command = CreateEventCommand( + id = "existing-id", + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z", + bottleAmountMl = 120 + ) + + every { eventRepository.existsById("existing-id") } returns true + + // When & Then + assertThrows { + useCase.execute(command) + } + + verify { eventRepository.existsById("existing-id") } + verify(exactly = 0) { eventRepository.save(any()) } + } + + @Test + fun `should throw exception for invalid event type`() { + // Given + val command = CreateEventCommand( + id = "test-id", + localId = 1L, + type = "INVALID", + timestamp = "2023-10-01T10:00:00Z" + ) + + every { eventRepository.existsById("test-id") } returns false + + // When & Then + assertThrows { + useCase.execute(command) + } + } + + @Test + fun `should throw exception for EAT event without bottle amount`() { + // Given + val command = CreateEventCommand( + id = "test-id", + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z" + ) + + every { eventRepository.existsById("test-id") } returns false + + // When & Then + assertThrows { + useCase.execute(command) + } + } + + @Test + fun `should throw exception for invalid timestamp format`() { + // Given + val command = CreateEventCommand( + id = "test-id", + localId = 1L, + type = "EAT", + timestamp = "invalid-timestamp", + bottleAmountMl = 120 + ) + + every { eventRepository.existsById("test-id") } returns false + + // When & Then + assertThrows { + useCase.execute(command) + } + } + + @Test + fun `should throw exception for blank event id`() { + // Given + val command = CreateEventCommand( + id = "", + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z", + bottleAmountMl = 120 + ) + + // When & Then + assertThrows { + useCase.execute(command) + } + } + + @Test + fun `should throw exception for negative local id`() { + // Given + val command = CreateEventCommand( + id = "test-id", + localId = -1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z", + bottleAmountMl = 120 + ) + + // When & Then + assertThrows { + useCase.execute(command) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/dpconde/sofia_tracker/domain/entities/EventTest.kt b/src/test/kotlin/com/dpconde/sofia_tracker/domain/entities/EventTest.kt new file mode 100644 index 0000000..ba69f24 --- /dev/null +++ b/src/test/kotlin/com/dpconde/sofia_tracker/domain/entities/EventTest.kt @@ -0,0 +1,159 @@ +package com.dpconde.sofia_tracker.domain.entities + +import com.dpconde.sofia_tracker.domain.valueobjects.DiaperType +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.domain.valueobjects.SleepType +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EventTest { + + @Test + fun `should create valid EAT event`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + assertEquals("test-id", event.id) + assertEquals(1L, event.localId) + assertEquals(EventType.EAT, event.type) + assertEquals(120, event.bottleAmountMl) + assertEquals(null, event.sleepType) + assertEquals(null, event.diaperType) + assertFalse(event.deleted) + } + + @Test + fun `should create valid SLEEP event`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.SLEEP, + timestamp = Instant.now(), + sleepType = SleepType.SLEEP + ) + + assertEquals(SleepType.SLEEP, event.sleepType) + assertEquals(null, event.bottleAmountMl) + assertEquals(null, event.diaperType) + } + + @Test + fun `should create valid POOP event`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.POOP, + timestamp = Instant.now(), + diaperType = DiaperType.DIRTY + ) + + assertEquals(DiaperType.DIRTY, event.diaperType) + assertEquals(null, event.bottleAmountMl) + assertEquals(null, event.sleepType) + } + + @Test + fun `should throw exception when EAT event missing bottle amount`() { + assertThrows { + Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now() + ) + } + } + + @Test + fun `should throw exception when SLEEP event missing sleep type`() { + assertThrows { + Event( + id = "test-id", + localId = 1L, + type = EventType.SLEEP, + timestamp = Instant.now() + ) + } + } + + @Test + fun `should throw exception when POOP event missing diaper type`() { + assertThrows { + Event( + id = "test-id", + localId = 1L, + type = EventType.POOP, + timestamp = Instant.now() + ) + } + } + + @Test + fun `should throw exception when bottle amount is negative`() { + assertThrows { + Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = -10 + ) + } + } + + @Test + fun `should mark event as deleted`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + val deletedEvent = event.markAsDeleted() + assertTrue(deletedEvent.deleted) + assertFalse(event.deleted) // Original should be unchanged + } + + @Test + fun `should update note`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120, + note = "Original note" + ) + + val updatedEvent = event.updateNote("Updated note") + assertEquals("Updated note", updatedEvent.note) + assertEquals("Original note", event.note) // Original should be unchanged + } + + @Test + fun `should increment version`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120, + version = 1 + ) + + val incrementedEvent = event.incrementVersion() + assertEquals(2, incrementedEvent.version) + assertEquals(1, event.version) // Original should be unchanged + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventRepositoryImplTest.kt b/src/test/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventRepositoryImplTest.kt new file mode 100644 index 0000000..531901f --- /dev/null +++ b/src/test/kotlin/com/dpconde/sofia_tracker/infrastructure/persistence/repositories/EventRepositoryImplTest.kt @@ -0,0 +1,163 @@ +package com.dpconde.sofia_tracker.infrastructure.persistence.repositories + +import com.dpconde.sofia_tracker.domain.entities.Event +import com.dpconde.sofia_tracker.domain.valueobjects.EventType +import com.dpconde.sofia_tracker.infrastructure.persistence.mappers.EventMapper +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import java.time.Instant +import kotlin.test.* + +@DataJpaTest +@ActiveProfiles("test") +@Import(EventMapper::class) +class EventRepositoryImplTest { + + @Autowired + private lateinit var jpaRepository: EventJpaRepository + + @Autowired + private lateinit var mapper: EventMapper + + private val repository by lazy { EventRepositoryImpl(jpaRepository, mapper) } + + @Test + fun `should save and retrieve event`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + repository.save(event) + val retrievedEvent = repository.findById("test-id") + + assertNotNull(retrievedEvent) + assertEquals(event.id, retrievedEvent.id) + assertEquals(event.type, retrievedEvent.type) + assertEquals(event.bottleAmountMl, retrievedEvent.bottleAmountMl) + } + + @Test + fun `should find event by local id`() { + val event = Event( + id = "test-id", + localId = 42L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + repository.save(event) + val retrievedEvent = repository.findByLocalId(42L) + + assertNotNull(retrievedEvent) + assertEquals(event.id, retrievedEvent.id) + assertEquals(42L, retrievedEvent.localId) + } + + @Test + fun `should return null for non-existent event`() { + val retrievedEvent = repository.findById("non-existent") + assertNull(retrievedEvent) + } + + @Test + fun `should find active events only`() { + val activeEvent = Event( + id = "active-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + val deletedEvent = Event( + id = "deleted-id", + localId = 2L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 150, + deleted = true + ) + + repository.save(activeEvent) + repository.save(deletedEvent) + + val activeEvents = repository.findAllActive() + assertEquals(1, activeEvents.size) + assertEquals("active-id", activeEvents[0].id) + } + + @Test + fun `should find events by type`() { + val eatEvent = Event( + id = "eat-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + val sleepEvent = Event( + id = "sleep-id", + localId = 2L, + type = EventType.SLEEP, + timestamp = Instant.now(), + sleepType = com.dpconde.sofia_tracker.domain.valueobjects.SleepType.SLEEP + ) + + repository.save(eatEvent) + repository.save(sleepEvent) + + val eatEvents = repository.findByType(EventType.EAT) + assertEquals(1, eatEvents.size) + assertEquals(EventType.EAT, eatEvents[0].type) + } + + @Test + fun `should soft delete event`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + repository.save(event) + val deleted = repository.deleteById("test-id") + + assertTrue(deleted) + + // Event should still exist in database but marked as deleted + val retrievedEvent = jpaRepository.findById("test-id").orElse(null) + assertNotNull(retrievedEvent) + assertTrue(retrievedEvent.deleted) + + // Should not be returned by normal queries + val activeEvent = repository.findById("test-id") + assertNull(activeEvent) + } + + @Test + fun `should check if event exists`() { + val event = Event( + id = "test-id", + localId = 1L, + type = EventType.EAT, + timestamp = Instant.now(), + bottleAmountMl = 120 + ) + + repository.save(event) + + assertTrue(repository.existsById("test-id")) + assertFalse(repository.existsById("non-existent")) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/dpconde/sofia_tracker/presentation/controllers/EventControllerIntegrationTest.kt b/src/test/kotlin/com/dpconde/sofia_tracker/presentation/controllers/EventControllerIntegrationTest.kt new file mode 100644 index 0000000..7320ecf --- /dev/null +++ b/src/test/kotlin/com/dpconde/sofia_tracker/presentation/controllers/EventControllerIntegrationTest.kt @@ -0,0 +1,292 @@ +package com.dpconde.sofia_tracker.presentation.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.dpconde.sofia_tracker.TestConfiguration +import com.dpconde.sofia_tracker.presentation.dto.CreateEventRequestDto +import com.dpconde.sofia_tracker.presentation.dto.UpdateEventRequestDto +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebMvc +@ActiveProfiles("test") +@Transactional +@Import(TestConfiguration::class) +class EventControllerIntegrationTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `should create and retrieve EAT event`() { + // Create event + val createRequest = CreateEventRequestDto( + id = "test-eat-event", + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z", + note = "First feeding", + bottleAmountMl = 120 + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.id").value("test-eat-event")) + .andExpect(jsonPath("$.type").value("EAT")) + .andExpect(jsonPath("$.bottleAmountMl").value(120)) + .andExpect(jsonPath("$.note").value("First feeding")) + + // Retrieve event + mockMvc.perform(get("/api/events/test-eat-event")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.id").value("test-eat-event")) + .andExpect(jsonPath("$.type").value("EAT")) + .andExpect(jsonPath("$.bottleAmountMl").value(120)) + } + + @Test + fun `should create SLEEP event`() { + val createRequest = CreateEventRequestDto( + id = "test-sleep-event", + localId = 2L, + type = "SLEEP", + timestamp = "2023-10-01T11:00:00Z", + sleepType = "SLEEP" + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.id").value("test-sleep-event")) + .andExpect(jsonPath("$.type").value("SLEEP")) + .andExpect(jsonPath("$.sleepType").value("SLEEP")) + .andExpect(jsonPath("$.bottleAmountMl").doesNotExist()) + } + + @Test + fun `should create POOP event`() { + val createRequest = CreateEventRequestDto( + id = "test-poop-event", + localId = 3L, + type = "POOP", + timestamp = "2023-10-01T12:00:00Z", + diaperType = "DIRTY" + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.id").value("test-poop-event")) + .andExpect(jsonPath("$.type").value("POOP")) + .andExpect(jsonPath("$.diaperType").value("DIRTY")) + } + + @Test + fun `should update event`() { + // Create event first + val createRequest = CreateEventRequestDto( + id = "test-update-event", + localId = 4L, + type = "EAT", + timestamp = "2023-10-01T13:00:00Z", + bottleAmountMl = 100 + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ).andExpect(status().isCreated) + + // Update event + val updateRequest = UpdateEventRequestDto( + note = "Updated note", + bottleAmountMl = 150 + ) + + mockMvc.perform( + put("/api/events/test-update-event") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.note").value("Updated note")) + .andExpect(jsonPath("$.bottleAmountMl").value(150)) + .andExpect(jsonPath("$.version").value(2)) + } + + @Test + fun `should delete event`() { + // Create event first + val createRequest = CreateEventRequestDto( + id = "test-delete-event", + localId = 5L, + type = "EAT", + timestamp = "2023-10-01T14:00:00Z", + bottleAmountMl = 120 + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ).andExpect(status().isCreated) + + // Delete event + mockMvc.perform(delete("/api/events/test-delete-event")) + .andExpect(status().isNoContent) + + // Verify event is not found + mockMvc.perform(get("/api/events/test-delete-event")) + .andExpect(status().isNotFound) + } + + @Test + fun `should list all events`() { + // Create multiple events + val event1 = CreateEventRequestDto( + id = "list-event-1", + localId = 6L, + type = "EAT", + timestamp = "2023-10-01T15:00:00Z", + bottleAmountMl = 120 + ) + + val event2 = CreateEventRequestDto( + id = "list-event-2", + localId = 7L, + type = "SLEEP", + timestamp = "2023-10-01T16:00:00Z", + sleepType = "SLEEP" + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event1)) + ).andExpect(status().isCreated) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(event2)) + ).andExpect(status().isCreated) + + // List events + mockMvc.perform(get("/api/events")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").exists()) + .andExpect(jsonPath("$[1].id").exists()) + } + + @Test + fun `should return validation error for invalid event`() { + val invalidRequest = CreateEventRequestDto( + id = "", // Invalid: blank id + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z", + bottleAmountMl = 120 + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("Validation Failed")) + } + + @Test + fun `should return error for EAT event without bottle amount`() { + val invalidRequest = CreateEventRequestDto( + id = "invalid-eat-event", + localId = 1L, + type = "EAT", + timestamp = "2023-10-01T10:00:00Z" + // Missing bottleAmountMl + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest) + } + + @Test + fun `should return 404 for non-existent event`() { + mockMvc.perform(get("/api/events/non-existent-id")) + .andExpect(status().isNotFound) + } + + @Test + fun `should filter events by type`() { + // Create events of different types + val eatEvent = CreateEventRequestDto( + id = "filter-eat-event", + localId = 8L, + type = "EAT", + timestamp = "2023-10-01T17:00:00Z", + bottleAmountMl = 120 + ) + + val sleepEvent = CreateEventRequestDto( + id = "filter-sleep-event", + localId = 9L, + type = "SLEEP", + timestamp = "2023-10-01T18:00:00Z", + sleepType = "SLEEP" + ) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(eatEvent)) + ).andExpect(status().isCreated) + + mockMvc.perform( + post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(sleepEvent)) + ).andExpect(status().isCreated) + + // Filter by EAT type + mockMvc.perform(get("/api/events?type=EAT")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].type").value("EAT")) + } + + @Test + fun `should return health status`() { + mockMvc.perform(get("/api/events/health")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.status").value("UP")) + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..646bf1d --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,28 @@ +# Test configuration +spring.application.name=sofia-tracker-test + +# H2 Database Configuration for Tests +spring.datasource.url=jdbc:h2:mem:test_db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# JPA Configuration for Tests +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false + +# Flyway Configuration for Tests +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true +spring.flyway.baseline-version=0 + +# Disable H2 console in tests +spring.h2.console.enabled=false + +# Logging Configuration for Tests +logging.level.org.flywaydb=WARN +logging.level.org.hibernate.SQL=WARN +logging.level.org.springframework=WARN +logging.level.com.dpconde.sofia_tracker=DEBUG \ No newline at end of file