From 7f5f66ebe9a569b69ab47bd8b63d5cb575a9ea81 Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Thu, 26 Mar 2026 16:23:12 -0300 Subject: [PATCH] docs: enhance TDD guidelines in CLAUDE.md and INSTRUCTIONS.md --- CLAUDE.md | 44 ++++- INSTRUCTIONS.md | 488 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 397 insertions(+), 135 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de6fff5..a5a2b56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,29 @@ right knowledge. --- +## Default Workflow: Test-Driven Development (TDD) + +> **Every implementation step in this project follows TDD. This is non-negotiable.** + +The cycle for every step is: + +| Phase | Action | Gate | +|------------|-------------------------------------------------------------------------|------------------------------------| +| **Red** | Write test file(s) for the step. Run the test suite. | New tests must **fail**. | +| **Green** | Write the minimum implementation to make all tests pass. | All tests must **pass**. | +| **Refactor** | Clean up the implementation. | Tests must stay **green**. | +| **Done** | Mark step ✅ only when the full build is green. | `./gradlew build` / `npm run build && npm run test` | + +**Rules:** +- Never write implementation code before the test file exists and the tests fail. +- Never mark a step Done unless the full test suite passes. +- Test method names (Kotlin/JUnit): `should_[expectedBehavior]_when_[condition]`. +- Backend mocking: **MockK** only (not Mockito). +- Backend integration tests: `@SpringBootTest` with **H2 in-memory** database. +- Frontend tests: **Vitest** + **React Testing Library**, mocked Axios. + +--- + ## Project Overview - **Type:** Monorepo (backend + frontend in the same repository) @@ -361,17 +384,32 @@ docker compose down ## Testing Guidelines +> **This project follows strict TDD.** For every implementation step, tests are written +> first (Red), then the implementation is added until all tests pass (Green), then code is +> cleaned up (Refactor). Never write implementation code before the tests exist and fail. + +### TDD Workflow (apply to every step) +1. **Red** — Write the test file(s). Run `./gradlew test` (backend) or `npm run test` + (frontend) and confirm the new tests **fail** (compile errors are acceptable at this stage). +2. **Green** — Write the minimum implementation needed to make all tests pass. +3. **Refactor** — Clean up the implementation while keeping tests green. +4. A step is only ✅ Done when `./gradlew build` (backend) or + `npm run build && npm run test` (frontend) is fully green. + ### Backend -- Every service class must have a corresponding unit test class. +- Every service class must have a corresponding unit test class **written before the service**. - Use **MockK** for mocking (not Mockito). - Integration tests use `@SpringBootTest` and an **H2 in-memory** database. - Test method names follow: `should_[expectedBehavior]_when_[condition]`. - Minimum 80% code coverage for service classes. +- Test files live in `src/test/kotlin/` mirroring the `src/main/kotlin/` package structure. ### Frontend -- Every page component must have at least a smoke test (renders without crashing). +- Every page component must have at least a smoke test (renders without crashing), + **written before the component**. - API layer functions must be tested with mocked Axios responses. - Use **Vitest** as the test runner and **React Testing Library** for component tests. +- Test files live in `src/__tests__/` mirroring the `src/` structure. --- @@ -458,6 +496,8 @@ BODY: - Branch naming: `feature/`, `fix/`, `chore/` - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, `test:` - Scope your commits: `feat(backend):`, `feat(frontend):`, `chore(docker):` +- **TDD commit order per step:** first `test(): add failing tests for `, then + `feat(): implement — all tests passing`. - PRs require all CI checks to pass before merging. - Never commit directly to `main`. diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 2f34eb6..3c82f54 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -27,9 +27,16 @@ employee is an AI-powered entity that: - Follow steps **in order** — each step builds on the previous one. - At the start of each AI session, share the contents of `CLAUDE.md` for context. +- **Each step follows TDD: write the tests first, then implement until all tests pass.** - After completing each step, mark it ✅ **Done** and note any decisions made. - If anything changes (new library, schema change, etc.), update `CLAUDE.md` too. +### TDD Workflow Per Step +1. **Red** — AI writes the test file(s) for the step. `./gradlew test` or `npm run test` must fail (or show the new tests failing). +2. **Green** — AI writes the implementation until all tests pass. +3. **Refactor** — Clean up while keeping tests green. +4. Mark the step ✅ Done only when `./gradlew build` (backend) or `npm run build && npm run test` (frontend) is fully green. + --- ## Progress Tracker @@ -48,10 +55,11 @@ employee is an AI-powered entity that: | 9 | REST Controllers & DTOs | ⬜ Pending | | 10 | Authentication (JWT login) | ⬜ Pending | | 11 | React Frontend | ⬜ Pending | -| 12 | Unit & Integration Tests | ⬜ Pending | -| 13 | Docker Compose (dev + prod) | ⬜ Pending | -| 14 | All-in-one Docker image | ⬜ Pending | -| 15 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending | +| 12 | Docker Compose (dev + prod) | ⬜ Pending | +| 13 | All-in-one Docker image | ⬜ Pending | +| 14 | CI/CD — GitHub Actions + Docker Hub | ⬜ Pending | + +> ⚠️ **Steps 2–11 each follow TDD.** The AI writes failing tests first, then implements until green. See "TDD Workflow Per Step" above. --- @@ -219,14 +227,35 @@ A record of every AI generation + email send attempt. | `error_message` | String | Nullable — stores failure reason if FAILED | | `dispatched_at` | LocalDateTime | When the dispatch was triggered | -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create the two JPA entities: `VirtualEntity` and `DispatchLog`. -> Place them in the `model/` package. Use UUIDs as primary keys and proper JPA annotations. -> `DispatchLog` has a `@ManyToOne` relationship to `VirtualEntity`." +### TDD — Write tests first + +**Test file:** `src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_persistVirtualEntity_when_allFieldsProvided +// should_enforceUniqueEmail_when_duplicateEmailInserted +// should_persistDispatchLog_when_linkedToVirtualEntity +// should_setCreatedAtAutomatically_when_virtualEntitySaved +// should_defaultActiveToTrue_when_virtualEntityCreated +``` + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write an integration test class `EntityMappingTest` in +> `src/test/kotlin/com/condado/newsletter/model/`. Use `@DataJpaTest` with H2. Write tests +> that verify: VirtualEntity persists all fields, email is unique, DispatchLog links to +> VirtualEntity, createdAt is auto-set, active defaults to true. Do NOT create the entities +> yet — tests should fail to compile." + +**Prompt — Phase 2 (implementation):** +> "Now create the two JPA entities `VirtualEntity` and `DispatchLog` in `model/`. Use UUIDs +> as primary keys and proper JPA annotations. `DispatchLog` has a `@ManyToOne` to +> `VirtualEntity`. Make the tests in `EntityMappingTest` pass." **Done when:** +- [ ] `EntityMappingTest.kt` exists with all 5 tests. - [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`. -- [ ] `./gradlew build` compiles cleanly. +- [ ] `./gradlew test` is green. - [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev). --- @@ -238,15 +267,34 @@ A record of every AI generation + email send attempt. | Repository | Entity | Custom queries needed | |--------------------------|-----------------|----------------------------------------------------------| | `VirtualEntityRepository`| `VirtualEntity` | `findAllByActiveTrue()`, `findByEmail()` | -| `DispatchLogRepository` | `DispatchLog` | `findAllByEntityId()`, `findTopByEntityIdOrderByDispatchedAtDesc()` | +| `DispatchLogRepository` | `DispatchLog` | `findAllByVirtualEntity()`, `findTopByVirtualEntityOrderByDispatchedAtDesc()` | -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create the two JPA repositories in the `repository/` package. -> Each must extend `JpaRepository` and include the custom query methods listed in the instructions." +### TDD — Write tests first + +**Test file:** `src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_returnOnlyActiveEntities_when_findAllByActiveTrueCalled +// should_findEntityByEmail_when_emailExists +// should_returnEmptyOptional_when_emailNotFound +// should_returnAllLogsForEntity_when_findAllByVirtualEntityCalled +// should_returnMostRecentLog_when_findTopByVirtualEntityOrderByDispatchedAtDescCalled +``` + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write a `@DataJpaTest` class `RepositoryTest` in +> `src/test/kotlin/com/condado/newsletter/repository/`. Test all custom query methods for +> both repositories using H2. Do NOT create the repositories yet." + +**Prompt — Phase 2 (implementation):** +> "Create `VirtualEntityRepository` and `DispatchLogRepository` in `repository/`, each +> extending `JpaRepository`, with the custom query methods needed to make `RepositoryTest` pass." **Done when:** +- [ ] `RepositoryTest.kt` exists with all 5 tests. - [ ] Both repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`. -- [ ] `./gradlew build` compiles cleanly. +- [ ] `./gradlew test` is green. --- @@ -277,16 +325,33 @@ data class EmailContext( ) ``` -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create the `EmailReaderService` class in `service/`. It must -> connect to an IMAP server using env vars (`IMAP_HOST`, `IMAP_PORT`, `MAIL_USERNAME`, -> `MAIL_PASSWORD`, `IMAP_INBOX_FOLDER`), fetch emails from the last N days, and return them -> as a list of `EmailContext` data objects sorted chronologically. Use Jakarta Mail." +### TDD — Write tests first + +**Test file:** `src/test/kotlin/com/condado/newsletter/service/EmailReaderServiceTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_returnEmailsSortedChronologically_when_multipleEmailsFetched +// should_returnEmptyList_when_imapConnectionFails +// should_filterEmailsOlderThanContextWindow_when_windowIs3Days +// should_stripHtml_when_emailBodyContainsHtmlTags +``` + +The IMAP `Session`/`Store` must be injected or overridable so tests can mock it with MockK. + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write a MockK unit test class `EmailReaderServiceTest` in +> `src/test/kotlin/com/condado/newsletter/service/`. Mock the Jakarta Mail `Store` and +> `Folder`. Write the 4 tests listed. Do NOT create the service yet." + +**Prompt — Phase 2 (implementation):** +> "Create `EmailContext` data class and `EmailReaderService` in `service/`. Use Jakarta Mail +> for IMAP. The Store must be injectable/mockable. Make all `EmailReaderServiceTest` tests pass." **Done when:** -- [ ] `EmailReaderService.kt` exists in `service/`. -- [ ] `EmailContext.kt` data class exists. -- [ ] Service reads real emails when tested against a live IMAP account. +- [ ] `EmailReaderServiceTest.kt` exists with all 4 tests. +- [ ] `EmailReaderService.kt` and `EmailContext.kt` exist in their packages. +- [ ] `./gradlew test` is green. - [ ] Returns empty list (not exception) on connection failure. --- @@ -308,15 +373,37 @@ It must be followed exactly, with the entity fields and email context filled in fun buildPrompt(entity: VirtualEntity, emailContext: List): String ``` -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create `PromptBuilderService` in `service/`. It must implement -> `buildPrompt(entity, emailContext)` following the prompt template defined in CLAUDE.md exactly. -> This is the only class allowed to build prompt strings." +### TDD — Write tests first + +**Test file:** `src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_containEntityName_when_buildPromptCalled +// should_containEntityJobTitle_when_buildPromptCalled +// should_containEntityPersonality_when_buildPromptCalled +// should_containContextWindowDays_when_buildPromptCalled +// should_containEachEmailSenderAndSubject_when_emailContextProvided +// should_containFormatInstruction_when_buildPromptCalled // verifies SUBJECT:/BODY: instruction +// should_returnPromptWithNoEmails_when_emailContextIsEmpty +``` + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write a pure unit test class `PromptBuilderServiceTest` in +> `src/test/kotlin/com/condado/newsletter/service/`. No mocks needed — just build a +> `VirtualEntity` and `List` and assert the output string. Write all 7 tests. +> Do NOT create the service yet." + +**Prompt — Phase 2 (implementation):** +> "Create `PromptBuilderService` in `service/`. It must implement `buildPrompt(entity, +> emailContext)` following the prompt template in CLAUDE.md exactly. Make all +> `PromptBuilderServiceTest` tests pass." **Done when:** +- [ ] `PromptBuilderServiceTest.kt` exists with all 7 tests. - [ ] `PromptBuilderService.kt` exists in `service/`. -- [ ] Output prompt matches the template from `CLAUDE.md` with fields correctly substituted. -- [ ] Unit test verifies the prompt structure. +- [ ] `./gradlew test` is green. +- [ ] Output prompt matches the template from `CLAUDE.md` with all fields correctly substituted. --- @@ -331,8 +418,7 @@ fun buildPrompt(entity: VirtualEntity, emailContext: List): String - Use the model configured in `OPENAI_MODEL` env var (default: `gpt-4o`). - Send the prompt as a `user` message. - Return the AI's response as a plain `String`. -- Parse the response to extract `subject` and `body` — the AI should be instructed to return - them in a structured format: +- Parse the response to extract `subject` and `body` — the AI is instructed to return: ``` SUBJECT: BODY: @@ -342,15 +428,34 @@ fun buildPrompt(entity: VirtualEntity, emailContext: List): String **HTTP Client:** Use Spring's `RestClient` (Spring Boot 3.2+) — do NOT use WebClient or Feign. -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create `AiService` in `service/`. It must call the OpenAI -> Chat Completions API using Spring's `RestClient`, using the `OPENAI_API_KEY` and `OPENAI_MODEL` -> env vars. Return the AI's text response. Also implement a `parseResponse(raw: String)` method -> that extracts the subject and body from a response formatted as `SUBJECT: ...\nBODY:\n...`." +### TDD — Write tests first + +**Test file:** `src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_returnAiResponseText_when_apiCallSucceeds +// should_throwAiServiceException_when_apiReturnsError +// should_extractSubjectAndBody_when_responseIsWellFormatted +// should_throwParseException_when_responseIsMissingSubjectLine +// should_throwParseException_when_responseIsMissingBodySection +``` + +Mock `RestClient` with MockK. `parseResponse()` can be tested without mocking. + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write a MockK unit test class `AiServiceTest` in +> `src/test/kotlin/com/condado/newsletter/service/`. Mock Spring's `RestClient` chain. +> Write all 5 tests listed. Do NOT create the service yet." + +**Prompt — Phase 2 (implementation):** +> "Create `AiService` in `service/` using Spring `RestClient` for the OpenAI API. Implement +> `parseResponse(raw: String)` that extracts SUBJECT and BODY. Make all `AiServiceTest` tests pass." **Done when:** +- [ ] `AiServiceTest.kt` exists with all 5 tests. - [ ] `AiService.kt` exists in `service/`. -- [ ] Calling the service with a real API key returns a valid AI-generated email. +- [ ] `./gradlew test` is green. - [ ] `parseResponse()` correctly extracts subject and body. --- @@ -367,21 +472,37 @@ fun send(from: String, to: List, subject: String, body: String) ``` - `from` is the `VirtualEntity.email` (the fictional employee's address). -- `to` is the list of all real participants' emails (loaded from config or DB — TBD in Step 9). +- `to` is the list of all real participants' emails (from `app.recipients` config). - `body` may be plain text or simple HTML — send as both `text/plain` and `text/html` (multipart). - Log every send attempt. -**Note on recipients:** For now, the list of recipients (the real friends' emails) will be stored -as a comma-separated string in `application.yml` under `app.recipients`. This can be made dynamic later. +### TDD — Write tests first -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create `EmailSenderService` in `service/`. It must use -> `JavaMailSender` to send emails. The `from` address comes from the entity, the `to` list -> comes from `app.recipients` in config, and the body is sent as both plain text and HTML." +**Test file:** `src/test/kotlin/com/condado/newsletter/service/EmailSenderServiceTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_callJavaMailSenderWithCorrectFromAddress_when_sendCalled +// should_sendToAllRecipients_when_multipleRecipientsConfigured +// should_sendMultipartMessage_when_sendCalled // verifies both text/plain and text/html parts +// should_logSendAttempt_when_sendCalled +``` + +Mock `JavaMailSender` and `MimeMessage` with MockK. + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write a MockK unit test class `EmailSenderServiceTest` in +> `src/test/kotlin/com/condado/newsletter/service/`. Mock `JavaMailSender`. Write all 4 tests. +> Do NOT create the service yet." + +**Prompt — Phase 2 (implementation):** +> "Create `EmailSenderService` in `service/` using `JavaMailSender`. Send emails as multipart +> (text/plain + text/html). Make all `EmailSenderServiceTest` tests pass." **Done when:** +- [ ] `EmailSenderServiceTest.kt` exists with all 4 tests. - [ ] `EmailSenderService.kt` exists in `service/`. -- [ ] Email is received in a real inbox when tested with valid SMTP credentials. +- [ ] `./gradlew test` is green. --- @@ -392,29 +513,47 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca **Class:** `EntityScheduler` in `scheduler/` package. **Approach:** -- Spring's `@Scheduled` with a fixed-rate tick (every 60 seconds) checks which entities are due. -- Use a `ScheduledTaskRegistrar` (or dynamic scheduling) to register a task per entity using its - `schedule_cron` expression. +- Use `SchedulingConfigurer` to register a cron task per active entity on startup. +- A `@Scheduled(fixedRate = 60_000)` refresh method re-reads entities and re-registers tasks + when entities are added/updated. - When triggered, orchestrate the full pipeline: 1. Read emails via `EmailReaderService` (using `entity.contextWindowDays`). 2. Build prompt via `PromptBuilderService`. 3. Call AI via `AiService`. 4. Parse AI response (subject + body). 5. Send email via `EmailSenderService`. - 6. Save a `DispatchLog` record with status SENT or FAILED. + 6. Save a `DispatchLog` with status SENT. - If the pipeline fails at any step, save a `DispatchLog` with status FAILED and the error message. -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create `EntityScheduler` in `scheduler/`. It must dynamically -> schedule a cron job per active `VirtualEntity` using `SchedulingConfigurer`. When triggered, -> it must run the full pipeline: read emails → build prompt → call AI → send email → save -> `DispatchLog`. Handle failures gracefully and always write a `DispatchLog`." +### TDD — Write tests first + +**Test file:** `src/test/kotlin/com/condado/newsletter/scheduler/EntitySchedulerTest.kt` + +Tests to write (all should **fail** before implementation): +```kotlin +// should_runFullPipeline_when_entityIsTriggered +// should_saveDispatchLogWithStatusSent_when_pipelineSucceeds +// should_saveDispatchLogWithStatusFailed_when_aiServiceThrows +// should_saveDispatchLogWithStatusFailed_when_emailSenderThrows +// should_notTrigger_when_entityIsInactive +``` + +Mock all five services with MockK. + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write a MockK unit test class `EntitySchedulerTest` in +> `src/test/kotlin/com/condado/newsletter/scheduler/`. Mock all five service dependencies. +> Write all 5 tests listed. Do NOT create the scheduler yet." + +**Prompt — Phase 2 (implementation):** +> "Create `EntityScheduler` in `scheduler/` using `SchedulingConfigurer`. Orchestrate the full +> pipeline and always persist a `DispatchLog`. Make all `EntitySchedulerTest` tests pass." **Done when:** +- [ ] `EntitySchedulerTest.kt` exists with all 5 tests. - [ ] `EntityScheduler.kt` exists in `scheduler/`. -- [ ] `@EnableScheduling` is present on the main app or a config class. -- [ ] An active entity triggers at its scheduled time and a `DispatchLog` record is created. -- [ ] End-to-end test: entity fires → email arrives in inbox. +- [ ] `./gradlew test` is green. +- [ ] `@EnableScheduling` is on `CondadoApplication`. --- @@ -425,14 +564,14 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca **Controllers:** ### `VirtualEntityController` — `/api/v1/virtual-entities` -| Method | Path | Description | -|--------|---------------|--------------------------------------| -| POST | `/` | Create a new virtual entity | -| GET | `/` | List all virtual entities | -| GET | `/{id}` | Get one entity by ID | -| PUT | `/{id}` | Update an entity | -| DELETE | `/{id}` | Deactivate an entity (soft delete) | -| POST | `/{id}/trigger` | Manually trigger the entity pipeline | +| Method | Path | Description | +|--------|-------------------|--------------------------------------| +| POST | `/` | Create a new virtual entity | +| GET | `/` | List all virtual entities | +| GET | `/{id}` | Get one entity by ID | +| PUT | `/{id}` | Update an entity | +| DELETE | `/{id}` | Deactivate an entity (soft delete) | +| POST | `/{id}/trigger` | Manually trigger the entity pipeline | ### `DispatchLogController` — `/api/v1/dispatch-logs` | Method | Path | Description | @@ -440,21 +579,52 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca | GET | `/` | List all dispatch logs | | GET | `/entity/{id}` | List logs for a specific entity | -**DTOs to create (in `dto/` package):** +**DTOs (in `dto/` package):** - `VirtualEntityCreateDto` — name, email, jobTitle, personality, scheduleCron, contextWindowDays - `VirtualEntityUpdateDto` — same fields, all optional - `VirtualEntityResponseDto` — full entity fields + id + createdAt - `DispatchLogResponseDto` — all DispatchLog fields -**Prompt to use with AI:** -> "Using the CLAUDE.md context, create `VirtualEntityController` and `DispatchLogController` in -> `controller/`, and all DTOs in `dto/`. Controllers return `ResponseEntity`. The `/trigger` -> endpoint manually runs the entity pipeline. DTOs use validation annotations." +### TDD — Write tests first + +**Test files:** +- `src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt` +- `src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt` + +Tests to write — `VirtualEntityControllerTest` (all should **fail** before implementation): +```kotlin +// should_return201AndBody_when_postWithValidPayload +// should_return400_when_postWithMissingRequiredField +// should_return200AndList_when_getAllEntities +// should_return200AndEntity_when_getById +// should_return404_when_getByIdNotFound +// should_return200_when_putWithValidPayload +// should_return200AndDeactivated_when_delete +// should_return200_when_triggerEndpointCalled +``` + +Tests to write — `DispatchLogControllerTest`: +```kotlin +// should_return200AndAllLogs_when_getAllLogs +// should_return200AndFilteredLogs_when_getByEntityId +``` + +Use `@SpringBootTest` + `MockMvc` + H2. Mock `EntityScheduler` so trigger tests don't run the pipeline. + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write `@SpringBootTest` integration test classes +> `VirtualEntityControllerTest` and `DispatchLogControllerTest` using MockMvc and H2. Write +> all tests listed. Do NOT create the controllers or DTOs yet." + +**Prompt — Phase 2 (implementation):** +> "Create `VirtualEntityController`, `DispatchLogController`, and all DTOs in their packages. +> Controllers return `ResponseEntity`. Make all controller tests pass." **Done when:** +- [ ] Both test files exist with all tests listed above. - [ ] All controller and DTO files exist. +- [ ] `./gradlew test` is green. - [ ] Swagger UI shows all endpoints. -- [ ] CRUD via Swagger UI or Postman works end-to-end. --- @@ -464,34 +634,59 @@ as a comma-separated string in `application.yml` under `app.recipients`. This ca **Approach:** - `POST /api/auth/login` accepts `{ "password": "..." }`, validates against `APP_PASSWORD` env var. -- On success, generates a JWT (signed with `JWT_SECRET`, expiry from `JWT_EXPIRATION_MS`) and - sets it as an `httpOnly` cookie in the response. -- Spring Security `JwtAuthFilter` (extends `OncePerRequestFilter`) validates the cookie on every - protected request. -- Public paths: `POST /api/auth/login`, `/swagger-ui.html`, `/v3/api-docs/**`. +- On success, generates a JWT signed with `JWT_SECRET` and sets it as an `httpOnly` cookie. +- `JwtAuthFilter` (`OncePerRequestFilter`) validates the cookie on every protected request. +- Public paths: `POST /api/auth/login`, `GET /api/auth/me`, `/swagger-ui/**`, `/v3/api-docs/**`. - There is **no user table** — the password lives only in the environment variable. **Classes to create:** -- `AuthController` — `POST /api/auth/login` endpoint +- `AuthController` — `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout` - `AuthService` — validates password, generates JWT - `JwtService` — signs and validates JWT tokens - `JwtAuthFilter` — reads cookie, validates JWT, sets `SecurityContext` -- `SecurityConfig` — Spring Security HTTP config (permit login + swagger, protect everything else) +- `SecurityConfig` — Spring Security HTTP config **DTOs:** - `LoginRequest` — `{ "password": String }` -- `AuthResponse` — `{ "message": String }` (cookie is set on the response; no token in body) +- `AuthResponse` — `{ "message": String }` -**Prompt to use with AI:** -> "Using the CLAUDE.md context, implement JWT authentication for the single-admin model. -> Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`. -> `POST /api/auth/login` validates against `APP_PASSWORD` env var and returns a JWT in an -> httpOnly cookie. All other endpoints require the JWT cookie. Swagger UI is public." +### TDD — Write tests first + +**Test files:** +- `src/test/kotlin/com/condado/newsletter/service/AuthServiceTest.kt` +- `src/test/kotlin/com/condado/newsletter/controller/AuthControllerTest.kt` + +Tests to write — `AuthServiceTest` (all should **fail** before implementation): +```kotlin +// should_returnJwtToken_when_correctPasswordProvided +// should_throwUnauthorizedException_when_wrongPasswordProvided +// should_returnValidClaims_when_jwtTokenParsed +// should_returnFalse_when_expiredTokenValidated +``` + +Tests to write — `AuthControllerTest`: +```kotlin +// should_return200AndSetCookie_when_correctPasswordPosted +// should_return401_when_wrongPasswordPosted +// should_return200_when_getMeWithValidCookie +// should_return401_when_getMeWithNoCookie +// should_return401_when_protectedEndpointAccessedWithoutCookie +``` + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write MockK unit tests for `AuthService` and a `@SpringBootTest` +> integration test `AuthControllerTest` using MockMvc and H2. Write all tests listed. +> Do NOT create the auth classes yet." + +**Prompt — Phase 2 (implementation):** +> "Create `AuthController`, `AuthService`, `JwtService`, `JwtAuthFilter`, and `SecurityConfig`. +> `POST /api/auth/login` validates against `APP_PASSWORD`, returns JWT in an httpOnly cookie. +> Make all auth tests pass." **Done when:** -- [ ] `POST /api/auth/login` with correct password sets an `httpOnly` JWT cookie and returns `200`. -- [ ] `POST /api/auth/login` with wrong password returns `401`. -- [ ] All `/api/v1/**` endpoints return `401` without a valid JWT cookie. +- [ ] Both test files exist with all tests listed above. +- [ ] All auth class files exist. +- [ ] `./gradlew test` is green. - [ ] Swagger UI remains accessible without auth. - [ ] Password and JWT secret are never hardcoded. @@ -539,61 +734,88 @@ router/ - Login form submits to `POST /api/auth/login` — on success React Query invalidates and React Router navigates to `/`. -**Prompt to use with AI:** -> "Using the CLAUDE.md context, build the React frontend. Create all four pages (Login, -> Dashboard, Entities, Logs) with React Query for data fetching, shadcn/ui for components, -> and React Router for navigation. Implement `ProtectedRoute` using `GET /api/auth/me`. -> All API calls must go through the `src/api/` layer." +### TDD — Write tests first + +**Test files:** +- `src/__tests__/api/authApi.test.ts` +- `src/__tests__/api/entitiesApi.test.ts` +- `src/__tests__/api/logsApi.test.ts` +- `src/__tests__/pages/LoginPage.test.tsx` +- `src/__tests__/pages/EntitiesPage.test.tsx` +- `src/__tests__/pages/DashboardPage.test.tsx` +- `src/__tests__/pages/LogsPage.test.tsx` +- `src/__tests__/components/ProtectedRoute.test.tsx` + +Tests to write — **API layer** (all should **fail** before implementation): +```typescript +// authApi.test.ts +// should_callLoginEndpoint_when_loginCalled +// should_callLogoutEndpoint_when_logoutCalled +// should_callMeEndpoint_when_getMeCalled + +// entitiesApi.test.ts +// should_callGetEndpoint_when_getAllEntitiesCalled +// should_callPostEndpoint_when_createEntityCalled +// should_callPutEndpoint_when_updateEntityCalled +// should_callDeleteEndpoint_when_deleteEntityCalled +// should_callTriggerEndpoint_when_triggerEntityCalled + +// logsApi.test.ts +// should_callGetAllLogsEndpoint_when_getAllLogsCalled +// should_callGetByEntityEndpoint_when_getLogsByEntityCalled +``` + +Tests to write — **Pages & Components** (all should **fail** before implementation): +```typescript +// LoginPage.test.tsx +// should_renderLoginForm_when_pageLoads +// should_callLoginApi_when_formSubmitted +// should_showErrorMessage_when_loginFails +// should_redirectToDashboard_when_loginSucceeds + +// EntitiesPage.test.tsx +// should_renderEntityList_when_entitiesLoaded +// should_openCreateDialog_when_addButtonClicked +// should_callDeleteApi_when_deleteConfirmed + +// DashboardPage.test.tsx +// should_renderEntityCount_when_pageLoads +// should_renderRecentLogs_when_pageLoads + +// LogsPage.test.tsx +// should_renderLogTable_when_logsLoaded +// should_filterLogsByEntity_when_filterSelected + +// ProtectedRoute.test.tsx +// should_renderChildren_when_sessionIsValid +// should_redirectToLogin_when_sessionIsInvalid +``` + +**Prompt — Phase 1 (tests):** +> "Using the CLAUDE.md context, write Vitest + React Testing Library test files for the +> frontend. Create tests for all three API modules (`authApi`, `entitiesApi`, `logsApi`) — +> mock Axios and assert that each function calls the correct endpoint. Create smoke tests for +> all four pages and for `ProtectedRoute`. Mock React Query and the API layer. Write all tests +> listed above. Do NOT create any implementation files yet — tests should fail." + +**Prompt — Phase 2 (implementation):** +> "Using the CLAUDE.md context, implement the full React frontend. Create all API modules in +> `src/api/`, all components in `src/components/`, all pages in `src/pages/`, and the router +> in `src/router/index.tsx`. Use React Query for all server state, shadcn/ui for UI components, +> and React Router for navigation. Make all frontend tests pass." **Done when:** +- [ ] All test files exist with all tests listed above. +- [ ] `npm run test` is green (all tests pass). - [ ] `npm run build` succeeds with no TypeScript errors. - [ ] `npm run dev` serves the app and login flow works end-to-end. - [ ] Unauthenticated users are redirected to `/login`. - [ ] Entities can be created, edited, toggled active, and deleted via the UI. - [ ] Dispatch logs are visible and filterable by entity. -- [ ] All pages have at least a smoke test (`npm run test` passes). --- -## Step 12 — Unit & Integration Tests - -**Goal:** Test every service class and one integration test per controller. - -### Backend tests - -| Test Class | Type | Covers | -|---------------------------------|-------------|----------------------------------------------| -| `EmailReaderServiceTest` | Unit | IMAP fetch, empty list on error | -| `PromptBuilderServiceTest` | Unit | Prompt matches template, fields substituted | -| `AiServiceTest` | Unit | API call mocked, `parseResponse` parsing | -| `EmailSenderServiceTest` | Unit | `JavaMailSender` called with correct args | -| `AuthServiceTest` | Unit | Correct/incorrect password, JWT generation | -| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint | -| `DispatchLogControllerTest` | Integration | List all, list by entity | - -### Frontend tests - -| Test File | Covers | -|---------------------------------|--------------------------------------------------------| -| `LoginPage.test.tsx` | Renders, submits form, shows error on wrong password | -| `EntitiesPage.test.tsx` | Lists entities, opens create dialog, handles delete | -| `authApi.test.ts` | `login()` calls correct endpoint with correct payload | -| `entitiesApi.test.ts` | CRUD functions call correct endpoints | - -**Prompt to use with AI:** -> "Using the CLAUDE.md context, generate unit tests for all backend service classes using MockK, -> integration tests for controllers using `@SpringBootTest` with H2, and frontend component and -> API layer tests using Vitest + React Testing Library. Follow naming convention -> `should_[expectedBehavior]_when_[condition]` for backend tests." - -**Done when:** -- [ ] `./gradlew test` passes all backend tests green. -- [ ] Backend service class coverage ≥ 80%. -- [ ] `npm run test` passes all frontend tests green. - ---- - -## Step 13 — Docker Compose (Dev + Prod) +## Step 12 — Docker Compose (Dev + Prod) **Goal:** Containerize both services and wire them together for local dev and production. @@ -629,7 +851,7 @@ condado-news-letter/ --- -## Step 14 — All-in-one Docker Image +## Step 13 — All-in-one Docker Image **Goal:** Build a single Docker image that runs the entire stack (Nginx + Spring Boot + PostgreSQL) under Supervisor, deployable with a single `docker run` command. @@ -710,7 +932,7 @@ docker run -d \ --- -## Step 15 — CI/CD (GitHub Actions + Docker Hub) +## Step 14 — CI/CD (GitHub Actions + Docker Hub) **Goal:** Automate testing on every PR and publish the all-in-one image to Docker Hub on every merge to `main`.