diff --git a/.env.example b/.env.example index 137cbff..9a27e45 100644 --- a/.env.example +++ b/.env.example @@ -30,4 +30,4 @@ OPENAI_MODEL=gpt-4o APP_RECIPIENTS=friend1@example.com,friend2@example.com # ── Frontend (Vite build-time) ──────────────────────────────────────────────── -VITE_API_BASE_URL=http://localhost:6969 +VITE_API_BASE_URL=http://localhost diff --git a/CLAUDE.md b/CLAUDE.md index a5a2b56..b611d52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -501,6 +501,25 @@ BODY: - PRs require all CI checks to pass before merging. - Never commit directly to `main`. +### Commit Rules (enforced by AI) + +These rules apply to every commit made during AI-assisted implementation: + +| Rule | Detail | +|------|--------| +| **Two commits per TDD step** | 1st commit = failing tests (Red), 2nd commit = passing implementation (Green) | +| **Commit after each step** | Never accumulate multiple steps in one commit | +| **Red commit subject** | `test(): add failing tests for step ` | +| **Green commit subject** | `feat(): implement step ` | +| **Scope values** | `backend`, `frontend`, `docker`, `ci`, `config` | +| **Body** | Optional but encouraged: list what was added/changed | +| **No `--no-verify`** | Never bypass git hooks | +| **No force push** | Never use `--force` on shared branches | +| **Atomic commits** | Each commit must leave the build green (except deliberate Red-phase test commits) | +| **`chore` for housekeeping** | Config changes, dependency tweaks, file renames → `chore():` | +| **`fix` for bug fixes** | `fix(): ` | +| **`docs` for documentation** | Changes to `CLAUDE.md`, `INSTRUCTIONS.md`, `README.md` → `docs:` | + ### GitHub Actions Workflows | Workflow file | Trigger | What it does | diff --git a/Dockerfile.allinone b/Dockerfile.allinone index 397e64c..5f411d3 100644 --- a/Dockerfile.allinone +++ b/Dockerfile.allinone @@ -53,6 +53,6 @@ COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 6969 +EXPOSE 80 ENTRYPOINT ["/entrypoint.sh"] diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 3c82f54..ec31aac 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -45,7 +45,7 @@ employee is an AI-powered entity that: |------|-----------------------------------------|-------------| | 0 | Define project & write CLAUDE.md | ✅ Done | | 1 | Scaffold monorepo structure | ✅ Done | -| 2 | Domain model (JPA entities) | ⬜ Pending | +| 2 | Domain model (JPA entities) | ✅ Done | | 3 | Repositories | ⬜ Pending | | 4 | Email Reader Service (IMAP) | ⬜ Pending | | 5 | Prompt Builder Service | ⬜ Pending | @@ -253,10 +253,16 @@ Tests to write (all should **fail** before implementation): > `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 test` is green. -- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev). +- [x] `EntityMappingTest.kt` exists with all 5 tests. +- [x] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`. +- [x] `./gradlew test` is green. +- [x] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev). + +**Key decisions made:** +- Added `org.gradle.java.home=C:/Program Files/Java/jdk-21.0.10` to `gradle.properties` — the Kotlin DSL compiler embedded in Gradle 8.14.1 does not support JVM target 26, so the Gradle daemon must run under JDK 21. +- Created `src/test/resources/application.yml` to override datasource and JPA settings for tests (H2 in-memory, `ddl-auto: create-drop`), and provide placeholder values for required env vars so tests run without Docker/real services. +- `VirtualEntity` and `DispatchLog` use class-body `var` fields for `id` (`@GeneratedValue`) and `createdAt` (`@CreationTimestamp`) so Hibernate can set them; all other fields are constructor `val` properties. +- `DispatchStatus` enum: `PENDING`, `SENT`, `FAILED`. --- diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/AppConfig.kt b/backend/src/main/kotlin/com/condado/newsletter/config/AppConfig.kt new file mode 100644 index 0000000..8bb5694 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/config/AppConfig.kt @@ -0,0 +1,15 @@ +package com.condado.newsletter.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient + +/** + * Application-wide bean configuration. + */ +@Configuration +class AppConfig { + + @Bean + fun restClient(): RestClient = RestClient.create() +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt b/backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt new file mode 100644 index 0000000..4552013 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt @@ -0,0 +1,40 @@ +package com.condado.newsletter.config + +import com.condado.newsletter.service.JwtService +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +/** + * Reads the JWT from the `jwt` cookie on each request. + * If the token is valid, sets an authenticated [SecurityContext]. + */ +@Component +class JwtAuthFilter(private val jwtService: JwtService) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val token = request.cookies + ?.firstOrNull { it.name == "jwt" } + ?.value + + if (token != null && jwtService.validateToken(token)) { + val auth = UsernamePasswordAuthenticationToken( + "admin", + null, + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + SecurityContextHolder.getContext().authentication = auth + } + + filterChain.doFilter(request, response) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt b/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt new file mode 100644 index 0000000..67d1b5d --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt @@ -0,0 +1,34 @@ +package com.condado.newsletter.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig(private val jwtAuthFilter: JwtAuthFilter) { + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .exceptionHandling { it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) } + .authorizeHttpRequests { auth -> + auth + .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + } + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java) + return http.build() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt new file mode 100644 index 0000000..872270d --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt @@ -0,0 +1,54 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.AuthResponse +import com.condado.newsletter.dto.LoginRequest +import com.condado.newsletter.service.AuthService +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Handles authentication — login, logout, and session check. + */ +@RestController +@RequestMapping("/api/auth") +class AuthController(private val authService: AuthService) { + + /** Validates the password and sets a JWT cookie on success. */ + @PostMapping("/login") + fun login( + @RequestBody request: LoginRequest, + response: HttpServletResponse + ): ResponseEntity { + val token = authService.login(request.password) + val cookie = Cookie("jwt", token).apply { + isHttpOnly = true + path = "/" + maxAge = 86400 // 24 hours + } + response.addCookie(cookie) + return ResponseEntity.ok(AuthResponse("Login successful")) + } + + /** Returns 200 if the JWT cookie is valid (checked by JwtAuthFilter). */ + @GetMapping("/me") + fun me(): ResponseEntity = + ResponseEntity.ok(AuthResponse("Authenticated")) + + /** Clears the JWT cookie. */ + @PostMapping("/logout") + fun logout(response: HttpServletResponse): ResponseEntity { + val cookie = Cookie("jwt", "").apply { + isHttpOnly = true + path = "/" + maxAge = 0 + } + response.addCookie(cookie) + return ResponseEntity.ok(AuthResponse("Logged out")) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt new file mode 100644 index 0000000..ab48e63 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/DispatchLogController.kt @@ -0,0 +1,37 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.DispatchLogResponseDto +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * REST controller for read-only access to [com.condado.newsletter.model.DispatchLog] resources. + */ +@RestController +@RequestMapping("/api/v1/dispatch-logs") +class DispatchLogController( + private val dispatchLogRepository: DispatchLogRepository, + private val virtualEntityRepository: VirtualEntityRepository +) { + + /** Lists all dispatch logs. */ + @GetMapping + fun getAll(): ResponseEntity> = + ResponseEntity.ok(dispatchLogRepository.findAll().map { DispatchLogResponseDto.from(it) }) + + /** Lists dispatch logs for a specific entity. */ + @GetMapping("/entity/{entityId}") + fun getByEntityId(@PathVariable entityId: UUID): ResponseEntity> { + val entity = virtualEntityRepository.findById(entityId).orElse(null) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok( + dispatchLogRepository.findAllByVirtualEntity(entity).map { DispatchLogResponseDto.from(it) } + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt new file mode 100644 index 0000000..58479d4 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/VirtualEntityController.kt @@ -0,0 +1,72 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.VirtualEntityCreateDto +import com.condado.newsletter.dto.VirtualEntityResponseDto +import com.condado.newsletter.dto.VirtualEntityUpdateDto +import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.EntityService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * REST controller for managing [com.condado.newsletter.model.VirtualEntity] resources. + */ +@RestController +@RequestMapping("/api/v1/virtual-entities") +class VirtualEntityController( + private val entityService: EntityService, + private val entityScheduler: EntityScheduler +) { + + /** Creates a new virtual entity. */ + @PostMapping + fun create(@Valid @RequestBody dto: VirtualEntityCreateDto): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED).body(entityService.create(dto)) + + /** Lists all virtual entities. */ + @GetMapping + fun getAll(): ResponseEntity> = + ResponseEntity.ok(entityService.findAll()) + + /** Returns one entity by ID. */ + @GetMapping("/{id}") + fun getById(@PathVariable id: UUID): ResponseEntity { + val entity = entityService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entity) + } + + /** Updates an entity. */ + @PutMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody dto: VirtualEntityUpdateDto + ): ResponseEntity { + val entity = entityService.update(id, dto) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entity) + } + + /** Soft-deletes an entity (sets active = false). */ + @DeleteMapping("/{id}") + fun deactivate(@PathVariable id: UUID): ResponseEntity { + val entity = entityService.deactivate(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(entity) + } + + /** Manually triggers the email pipeline for a specific entity. */ + @PostMapping("/{id}/trigger") + fun trigger(@PathVariable id: UUID): ResponseEntity { + val entity = entityService.findRawById(id) ?: return ResponseEntity.notFound().build() + entityScheduler.runPipeline(entity) + return ResponseEntity.ok().build() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt new file mode 100644 index 0000000..03e4dc5 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt @@ -0,0 +1,7 @@ +package com.condado.newsletter.dto + +/** Request body for POST /api/auth/login. */ +data class LoginRequest(val password: String) + +/** Generic response body for auth operations. */ +data class AuthResponse(val message: String) diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt new file mode 100644 index 0000000..bb5d612 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/DispatchLogResponseDto.kt @@ -0,0 +1,35 @@ +package com.condado.newsletter.dto + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.DispatchStatus +import java.time.LocalDateTime +import java.util.UUID + +/** DTO returned by the API for a [com.condado.newsletter.model.DispatchLog]. */ +data class DispatchLogResponseDto( + val id: UUID?, + val entityId: UUID?, + val entityName: String, + val promptSent: String?, + val aiResponse: String?, + val emailSubject: String?, + val emailBody: String?, + val status: DispatchStatus, + val errorMessage: String?, + val dispatchedAt: LocalDateTime +) { + companion object { + fun from(log: DispatchLog) = DispatchLogResponseDto( + id = log.id, + entityId = log.virtualEntity.id, + entityName = log.virtualEntity.name, + promptSent = log.promptSent, + aiResponse = log.aiResponse, + emailSubject = log.emailSubject, + emailBody = log.emailBody, + status = log.status, + errorMessage = log.errorMessage, + dispatchedAt = log.dispatchedAt + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt new file mode 100644 index 0000000..10a0738 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityCreateDto.kt @@ -0,0 +1,14 @@ +package com.condado.newsletter.dto + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +/** DTO for creating a new [com.condado.newsletter.model.VirtualEntity]. */ +data class VirtualEntityCreateDto( + @field:NotBlank val name: String, + @field:NotBlank @field:Email val email: String, + @field:NotBlank val jobTitle: String, + val personality: String? = null, + val scheduleCron: String? = null, + val contextWindowDays: Int = 3 +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt new file mode 100644 index 0000000..c379b8a --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityResponseDto.kt @@ -0,0 +1,32 @@ +package com.condado.newsletter.dto + +import com.condado.newsletter.model.VirtualEntity +import java.time.LocalDateTime +import java.util.UUID + +/** DTO returned by the API for a [com.condado.newsletter.model.VirtualEntity]. */ +data class VirtualEntityResponseDto( + val id: UUID?, + val name: String, + val email: String, + val jobTitle: String, + val personality: String?, + val scheduleCron: String?, + val contextWindowDays: Int, + val active: Boolean, + val createdAt: LocalDateTime? +) { + companion object { + fun from(entity: VirtualEntity) = VirtualEntityResponseDto( + id = entity.id, + name = entity.name, + email = entity.email, + jobTitle = entity.jobTitle, + personality = entity.personality, + scheduleCron = entity.scheduleCron, + contextWindowDays = entity.contextWindowDays, + active = entity.active, + createdAt = entity.createdAt + ) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt new file mode 100644 index 0000000..28b9a41 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/VirtualEntityUpdateDto.kt @@ -0,0 +1,12 @@ +package com.condado.newsletter.dto + +/** DTO for updating an existing [com.condado.newsletter.model.VirtualEntity]. All fields are optional. */ +data class VirtualEntityUpdateDto( + val name: String? = null, + val email: String? = null, + val jobTitle: String? = null, + val personality: String? = null, + val scheduleCron: String? = null, + val contextWindowDays: Int? = null, + val active: Boolean? = null +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/DispatchLog.kt b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchLog.kt new file mode 100644 index 0000000..db2b0aa --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchLog.kt @@ -0,0 +1,53 @@ +package com.condado.newsletter.model + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * Records every AI generation and email send attempt for a given [VirtualEntity]. + * Stores the prompt sent, the AI response, parsed subject/body, send status, and timestamp. + */ +@Entity +@Table(name = "dispatch_logs") +class DispatchLog( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "entity_id", nullable = false) + val virtualEntity: VirtualEntity, + + @Column(name = "prompt_sent", columnDefinition = "TEXT") + val promptSent: String? = null, + + @Column(name = "ai_response", columnDefinition = "TEXT") + val aiResponse: String? = null, + + @Column(name = "email_subject") + val emailSubject: String? = null, + + @Column(name = "email_body", columnDefinition = "TEXT") + val emailBody: String? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val status: DispatchStatus = DispatchStatus.PENDING, + + @Column(name = "error_message") + val errorMessage: String? = null, + + @Column(name = "dispatched_at", nullable = false) + val dispatchedAt: LocalDateTime = LocalDateTime.now() +) { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID? = null +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/DispatchStatus.kt b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchStatus.kt new file mode 100644 index 0000000..07d9c38 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/DispatchStatus.kt @@ -0,0 +1,10 @@ +package com.condado.newsletter.model + +/** + * Represents the dispatch status of an AI-generated email send attempt. + */ +enum class DispatchStatus { + PENDING, + SENT, + FAILED +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/EmailContext.kt b/backend/src/main/kotlin/com/condado/newsletter/model/EmailContext.kt new file mode 100644 index 0000000..21a0dfc --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/EmailContext.kt @@ -0,0 +1,14 @@ +package com.condado.newsletter.model + +import java.time.LocalDateTime + +/** + * A snapshot of a single email read from the shared company IMAP inbox. + * Used as context when building the AI prompt. + */ +data class EmailContext( + val from: String, + val subject: String, + val body: String, + val receivedAt: LocalDateTime +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/ParsedAiResponse.kt b/backend/src/main/kotlin/com/condado/newsletter/model/ParsedAiResponse.kt new file mode 100644 index 0000000..e59301c --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/ParsedAiResponse.kt @@ -0,0 +1,9 @@ +package com.condado.newsletter.model + +/** + * The parsed result of an AI-generated email response. + */ +data class ParsedAiResponse( + val subject: String, + val body: String +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/VirtualEntity.kt b/backend/src/main/kotlin/com/condado/newsletter/model/VirtualEntity.kt new file mode 100644 index 0000000..c9c3067 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/VirtualEntity.kt @@ -0,0 +1,49 @@ +package com.condado.newsletter.model + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import java.time.LocalDateTime +import java.util.UUID + +/** + * Represents a fictional employee of "Condado Abaixo da Média SA". + * Each entity has a scheduled time to send AI-generated emails, a personality description, + * and a context window for reading recent emails via IMAP. + */ +@Entity +@Table(name = "virtual_entities") +class VirtualEntity( + @Column(nullable = false) + val name: String, + + @Column(unique = true, nullable = false) + val email: String, + + @Column(name = "job_title", nullable = false) + val jobTitle: String, + + @Column(columnDefinition = "TEXT") + val personality: String? = null, + + @Column(name = "schedule_cron") + val scheduleCron: String? = null, + + @Column(name = "context_window_days") + val contextWindowDays: Int = 3, + + @Column(nullable = false) + val active: Boolean = true +) { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID? = null + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + var createdAt: LocalDateTime? = null +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/repository/DispatchLogRepository.kt b/backend/src/main/kotlin/com/condado/newsletter/repository/DispatchLogRepository.kt new file mode 100644 index 0000000..0d27e92 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/repository/DispatchLogRepository.kt @@ -0,0 +1,19 @@ +package com.condado.newsletter.repository + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.VirtualEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional +import java.util.UUID + +/** + * Repository for [DispatchLog] with custom query methods. + */ +interface DispatchLogRepository : JpaRepository { + + /** Returns all dispatch logs for the given [VirtualEntity]. */ + fun findAllByVirtualEntity(virtualEntity: VirtualEntity): List + + /** Returns the most recent dispatch log for the given [VirtualEntity], or empty if none exist. */ + fun findTopByVirtualEntityOrderByDispatchedAtDesc(virtualEntity: VirtualEntity): Optional +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/repository/VirtualEntityRepository.kt b/backend/src/main/kotlin/com/condado/newsletter/repository/VirtualEntityRepository.kt new file mode 100644 index 0000000..9e4c263 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/repository/VirtualEntityRepository.kt @@ -0,0 +1,18 @@ +package com.condado.newsletter.repository + +import com.condado.newsletter.model.VirtualEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional +import java.util.UUID + +/** + * Repository for [VirtualEntity] with custom query methods. + */ +interface VirtualEntityRepository : JpaRepository { + + /** Returns all entities where [VirtualEntity.active] is true. */ + fun findAllByActiveTrue(): List + + /** Finds an entity by its unique email address. */ + fun findByEmail(email: String): Optional +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/scheduler/EntityScheduler.kt b/backend/src/main/kotlin/com/condado/newsletter/scheduler/EntityScheduler.kt new file mode 100644 index 0000000..98abc35 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/scheduler/EntityScheduler.kt @@ -0,0 +1,75 @@ +package com.condado.newsletter.scheduler + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.DispatchStatus +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.service.AiService +import com.condado.newsletter.service.EmailReaderService +import com.condado.newsletter.service.EmailSenderService +import com.condado.newsletter.service.PromptBuilderService +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.config.ScheduledTaskRegistrar +import org.springframework.scheduling.support.CronTrigger +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap + +/** + * Registers and manages per-entity scheduled tasks using [SchedulingConfigurer]. + * Refreshes task registrations every minute to pick up changes to active entities. + */ +@Component +class EntityScheduler( + private val emailReaderService: EmailReaderService, + private val promptBuilderService: PromptBuilderService, + private val aiService: AiService, + private val emailSenderService: EmailSenderService, + private val dispatchLogRepository: DispatchLogRepository, + @Value("\${app.recipients:}") val recipients: String, + @Value("\${imap.inbox-folder:INBOX}") val inboxFolder: String +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Runs the full email generation + send pipeline for the given [entity]. + * If the entity is inactive, returns immediately. + * Always persists a [DispatchLog] with SENT or FAILED status. + */ + fun runPipeline(entity: VirtualEntity) { + if (!entity.active) return + + val recipientList = recipients.split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + + try { + val emails = emailReaderService.readEmails(inboxFolder, entity.contextWindowDays) + val prompt = promptBuilderService.buildPrompt(entity, emails) + val aiResponse = aiService.generate(prompt) + emailSenderService.send(entity.email, recipientList, aiResponse.subject, aiResponse.body) + + dispatchLogRepository.save( + DispatchLog( + virtualEntity = entity, + promptSent = prompt, + aiResponse = "${aiResponse.subject}\n${aiResponse.body}", + emailSubject = aiResponse.subject, + emailBody = aiResponse.body, + status = DispatchStatus.SENT + ) + ) + log.info("Pipeline succeeded for entity '${entity.name}'") + } catch (e: Exception) { + log.error("Pipeline failed for entity '${entity.name}': ${e.message}", e) + dispatchLogRepository.save( + DispatchLog( + virtualEntity = entity, + status = DispatchStatus.FAILED, + errorMessage = e.message + ) + ) + } + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/AiExceptions.kt b/backend/src/main/kotlin/com/condado/newsletter/service/AiExceptions.kt new file mode 100644 index 0000000..d0120b6 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/AiExceptions.kt @@ -0,0 +1,7 @@ +package com.condado.newsletter.service + +/** Thrown when the OpenAI API call fails. */ +class AiServiceException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + +/** Thrown when the AI response cannot be parsed into SUBJECT/BODY format. */ +class AiParseException(message: String) : RuntimeException(message) diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/AiService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/AiService.kt new file mode 100644 index 0000000..49b7290 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/AiService.kt @@ -0,0 +1,92 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.model.ParsedAiResponse +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient + +/** + * Calls the OpenAI Chat Completions API to generate email content. + * Returns a [ParsedAiResponse] with the extracted subject and body. + */ +@Service +class AiService( + private val restClient: RestClient, + @Value("\${openai.api-key}") private val apiKey: String, + @Value("\${openai.model:gpt-4o}") private val model: String +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Sends [prompt] to the OpenAI API and returns the parsed subject + body. + * @throws [AiServiceException] on API errors. + * @throws [AiParseException] if the response format is unexpected. + */ + fun generate(prompt: String): ParsedAiResponse { + val rawText = try { + val request = ChatRequest( + model = model, + messages = listOf(ChatMessage(role = "user", content = prompt)) + ) + val json = restClient.post() + .uri("https://api.openai.com/v1/chat/completions") + .header("Authorization", "Bearer $apiKey") + .body(request) + .retrieve() + .body(String::class.java) + ?: throw AiServiceException("OpenAI returned an empty response") + + extractContent(json) + } catch (e: AiServiceException) { + throw e + } catch (e: Exception) { + log.error("OpenAI API call failed: ${e.message}", e) + throw AiServiceException("OpenAI API call failed: ${e.message}", e) + } + + return parseResponse(rawText) + } + + /** + * Parses the raw AI output into a [ParsedAiResponse]. + * Expected format: + * ``` + * SUBJECT: + * BODY: + * + * ``` + */ + fun parseResponse(raw: String): ParsedAiResponse { + val lines = raw.trim().lines() + + val subjectLine = lines.firstOrNull { it.startsWith("SUBJECT:", ignoreCase = true) } + ?: throw AiParseException("AI response missing SUBJECT line. Raw response: $raw") + + val bodyStart = lines.indexOfFirst { it.trim().equals("BODY:", ignoreCase = true) } + if (bodyStart == -1) throw AiParseException("AI response missing BODY: section. Raw response: $raw") + + val subject = subjectLine.replaceFirst(Regex("^SUBJECT:\\s*", RegexOption.IGNORE_CASE), "").trim() + val body = lines.drop(bodyStart + 1).joinToString("\n").trim() + + return ParsedAiResponse(subject = subject, body = body) + } + + // ── internal JSON helpers ──────────────────────────────────────────────── + + private fun extractContent(json: String): String { + val mapper = com.fasterxml.jackson.databind.ObjectMapper() + val tree = mapper.readTree(json) + return tree["choices"]?.firstOrNull()?.get("message")?.get("content")?.asText() + ?.replace("\\n", "\n") + ?: throw AiServiceException("Unexpected OpenAI response structure: $json") + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class ChatRequest(val model: String, val messages: List) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class ChatMessage(val role: String, val content: String) +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt new file mode 100644 index 0000000..fafb0be --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt @@ -0,0 +1,26 @@ +package com.condado.newsletter.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +/** + * Handles the single-admin authentication flow. + * There is no user table — the password lives only in the [appPassword] environment variable. + */ +@Service +class AuthService( + private val jwtService: JwtService, + @Value("\${app.password}") private val appPassword: String +) { + /** + * Validates the given [password] against [appPassword]. + * @return A signed JWT token string. + * @throws [UnauthorizedException] if the password is incorrect. + */ + fun login(password: String): String { + if (password != appPassword) { + throw UnauthorizedException("Invalid password") + } + return jwtService.generateToken() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/EmailReaderService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/EmailReaderService.kt new file mode 100644 index 0000000..f8ff6f8 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/EmailReaderService.kt @@ -0,0 +1,117 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.model.EmailContext +import jakarta.mail.Folder +import jakarta.mail.Message +import jakarta.mail.Session +import jakarta.mail.Store +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Date +import java.util.Properties + +/** + * Reads recent emails from the shared IMAP inbox to use as AI context. + * Returns emails sorted chronologically (oldest first). + * On any error, logs the exception and returns an empty list. + */ +@Service +class EmailReaderService( + @Value("\${imap.host:localhost}") private val imapHost: String = "localhost", + @Value("\${imap.port:993}") private val imapPort: Int = 993, + @Value("\${spring.mail.username:}") private val username: String = "", + @Value("\${spring.mail.password:}") private val password: String = "", + /** Factory function — can be replaced in tests to inject a mock Store */ + val storeFactory: ((Properties, String, String) -> Store)? = null +) { + private val log = LoggerFactory.getLogger(javaClass) + + constructor(storeFactory: (Properties, String, String) -> Store) : this( + imapHost = "localhost", + imapPort = 993, + username = "", + password = "", + storeFactory = storeFactory + ) + + /** Test-only constructor accepting a pre-built mock Store */ + internal constructor(storeFactory: () -> Store) : this( + imapHost = "localhost", + imapPort = 993, + username = "", + password = "", + storeFactory = { _, _, _ -> storeFactory() } + ) + + /** + * Reads emails from [folderName] received within the last [contextWindowDays] days. + * + * @return list of [EmailContext] objects sorted oldest-first, or empty list on error. + */ + fun readEmails(folderName: String, contextWindowDays: Int): List { + return try { + val store = openStore() + val folder = store.getFolder(folderName) + folder.open(Folder.READ_ONLY) + val cutoff = Date(System.currentTimeMillis() - contextWindowDays.toLong() * 24 * 60 * 60 * 1000) + + folder.getMessages() + .filter { msg -> + val date = msg.receivedDate ?: msg.sentDate + date != null && date.after(cutoff) + } + .sortedBy { it.receivedDate ?: it.sentDate } + .map { msg -> msg.toEmailContext() } + } catch (e: Exception) { + log.error("Failed to read emails from IMAP folder '$folderName': ${e.message}", e) + emptyList() + } + } + + private fun openStore(): Store { + val props = Properties().apply { + put("mail.store.protocol", "imaps") + put("mail.imaps.host", imapHost) + put("mail.imaps.port", imapPort.toString()) + } + return if (storeFactory != null) { + storeFactory!!.invoke(props, username, password) + } else { + val session = Session.getInstance(props) + session.getStore("imaps").also { it.connect(imapHost, imapPort, username, password) } + } + } + + private fun Message.toEmailContext(): EmailContext { + val from = from?.firstOrNull()?.toString() ?: "unknown" + val date = (receivedDate ?: sentDate ?: Date()) + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + val bodyText = extractText(content, contentType) + return EmailContext( + from = from, + subject = subject ?: "(no subject)", + body = bodyText, + receivedAt = date + ) + } + + private fun extractText(content: Any?, contentType: String?): String { + val raw = when (content) { + is String -> content + else -> content?.toString() ?: "" + } + val isHtml = contentType?.contains("html", ignoreCase = true) == true || + raw.contains(Regex("<[a-zA-Z][^>]*>")) + return if (isHtml) stripHtml(raw) else raw + } + + private fun stripHtml(html: String): String = + html.replace(Regex("<[^>]+>"), " ") + .replace(Regex("\\s{2,}"), " ") + .trim() +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/EmailSenderService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/EmailSenderService.kt new file mode 100644 index 0000000..e175541 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/EmailSenderService.kt @@ -0,0 +1,41 @@ +package com.condado.newsletter.service + +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import org.slf4j.LoggerFactory +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.stereotype.Service + +/** + * Sends AI-generated emails via SMTP using Spring's [JavaMailSender]. + * Each email is sent as multipart (text/plain + text/html). + */ +@Service +class EmailSenderService(private val mailSender: JavaMailSender) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Sends an email from a virtual entity to all configured recipients. + * + * @param from Sender email address (the virtual entity's email). + * @param to List of recipient email addresses. + * @param subject Email subject line. + * @param body Email body (may be plain text or simple HTML). + */ + fun send(from: String, to: List, subject: String, body: String) { + log.info("Sending email from='$from' to=$to subject='$subject'") + val message: MimeMessage = mailSender.createMimeMessage() + val helper = MimeMessageHelper(message, true, "UTF-8") + helper.setFrom(InternetAddress(from)) + helper.setTo(to.toTypedArray()) + helper.setSubject(subject) + // Send as both plain text and HTML for maximum compatibility + val plainText = body.replace(Regex("<[^>]+>"), "").trim() + val htmlBody = if (body.contains(Regex("<[a-zA-Z]"))) body else "
$body
" + helper.setText(plainText, htmlBody) + mailSender.send(message) + log.info("Email sent successfully from='$from' subject='$subject'") + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt new file mode 100644 index 0000000..225938e --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/EntityService.kt @@ -0,0 +1,79 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.dto.VirtualEntityCreateDto +import com.condado.newsletter.dto.VirtualEntityResponseDto +import com.condado.newsletter.dto.VirtualEntityUpdateDto +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Service for managing [VirtualEntity] lifecycle: create, read, update, and soft-delete. + */ +@Service +class EntityService( + private val virtualEntityRepository: VirtualEntityRepository, + private val dispatchLogRepository: DispatchLogRepository +) { + + /** Returns all virtual entities. */ + fun findAll(): List = + virtualEntityRepository.findAll().map { VirtualEntityResponseDto.from(it) } + + /** Returns one entity by ID, or null if not found. */ + fun findById(id: UUID): VirtualEntityResponseDto? = + virtualEntityRepository.findById(id).map { VirtualEntityResponseDto.from(it) }.orElse(null) + + /** Creates a new virtual entity. */ + @Transactional + fun create(dto: VirtualEntityCreateDto): VirtualEntityResponseDto { + val entity = VirtualEntity( + name = dto.name, + email = dto.email, + jobTitle = dto.jobTitle, + personality = dto.personality, + scheduleCron = dto.scheduleCron, + contextWindowDays = dto.contextWindowDays + ) + return VirtualEntityResponseDto.from(virtualEntityRepository.save(entity)) + } + + /** Applies partial updates to an existing entity. Returns null if not found. */ + @Transactional + fun update(id: UUID, dto: VirtualEntityUpdateDto): VirtualEntityResponseDto? { + val existing = virtualEntityRepository.findById(id).orElse(null) ?: return null + val updated = VirtualEntity( + name = dto.name ?: existing.name, + email = dto.email ?: existing.email, + jobTitle = dto.jobTitle ?: existing.jobTitle, + personality = dto.personality ?: existing.personality, + scheduleCron = dto.scheduleCron ?: existing.scheduleCron, + contextWindowDays = dto.contextWindowDays ?: existing.contextWindowDays, + active = dto.active ?: existing.active + ).apply { this.id = existing.id } + return VirtualEntityResponseDto.from(virtualEntityRepository.save(updated)) + } + + /** Soft-deletes an entity by setting active = false. Returns null if not found. */ + @Transactional + fun deactivate(id: UUID): VirtualEntityResponseDto? { + val existing = virtualEntityRepository.findById(id).orElse(null) ?: return null + val deactivated = VirtualEntity( + name = existing.name, + email = existing.email, + jobTitle = existing.jobTitle, + personality = existing.personality, + scheduleCron = existing.scheduleCron, + contextWindowDays = existing.contextWindowDays, + active = false + ).apply { this.id = existing.id } + return VirtualEntityResponseDto.from(virtualEntityRepository.save(deactivated)) + } + + /** Finds the raw entity for use in the scheduler pipeline. Returns null if not found. */ + fun findRawById(id: UUID): VirtualEntity? = + virtualEntityRepository.findById(id).orElse(null) +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt new file mode 100644 index 0000000..4266251 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt @@ -0,0 +1,46 @@ +package com.condado.newsletter.service + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.Date + +/** + * Handles JWT token creation and validation using JJWT 0.12.x. + * The secret and expiration are read from environment variables. + */ +@Service +class JwtService( + @Value("\${app.jwt.secret}") val secret: String, + @Value("\${app.jwt.expiration-ms}") val expirationMs: Long +) { + private val signingKey by lazy { + Keys.hmacShaKeyFor(secret.toByteArray(Charsets.UTF_8)) + } + + /** Generates a new signed JWT token valid for [expirationMs] milliseconds. */ + fun generateToken(): String { + val now = Date() + return Jwts.builder() + .subject("admin") + .issuedAt(now) + .expiration(Date(now.time + expirationMs)) + .signWith(signingKey) + .compact() + } + + /** + * Validates a JWT token. + * @return `true` if the token is valid and not expired; `false` otherwise. + */ + fun validateToken(token: String): Boolean = try { + Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token) + true + } catch (e: ExpiredJwtException) { + false + } catch (e: Exception) { + false + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/PromptBuilderService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/PromptBuilderService.kt new file mode 100644 index 0000000..ffec939 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/PromptBuilderService.kt @@ -0,0 +1,61 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.model.EmailContext +import com.condado.newsletter.model.VirtualEntity +import org.springframework.stereotype.Service + +/** + * The single authoritative place in the codebase where AI prompts are constructed. + * No other class may build or modify prompt strings. + */ +@Service +class PromptBuilderService { + + /** + * Builds the full prompt to be sent to the AI, based on the entity's profile + * and the list of recent emails from the company inbox. + * + * @param entity The virtual employee whose turn it is to send an email. + * @param emailContext Recent emails from the IMAP inbox (oldest-first). + * @return The complete prompt string ready to send to the OpenAI API. + */ + fun buildPrompt(entity: VirtualEntity, emailContext: List): String { + val emailSection = if (emailContext.isEmpty()) { + "(No recent emails in the inbox.)" + } else { + emailContext.joinToString("\n\n") { email -> + """ + |From: ${email.from} + |Subject: ${email.subject} + |Received: ${email.receivedAt} + | + |${email.body} + """.trimMargin() + } + } + + return """ + |You are ${entity.name}, ${entity.jobTitle} at "Condado Abaixo da Média SA". + | + |Your personality: ${entity.personality ?: "Professional and formal."} + | + |IMPORTANT TONE RULE: You must write in an extremely formal, bureaucratic, corporate tone — + |as if writing an official memo. However, the actual content of the email must be completely + |casual, trivial, or nonsensical — as if talking to close friends about mundane things. + |The contrast between the formal tone and the casual content is intentional and essential. + | + |Here are the most recent emails from the company inbox (last ${entity.contextWindowDays} days) + |for context: + | + |$emailSection + | + |Write a new email to be sent to the company group, continuing the conversation naturally. + |Reply or react to the recent emails if relevant. Sign off as ${entity.name}, ${entity.jobTitle}. + | + |Format your response exactly as: + |SUBJECT: + |BODY: + | + """.trimMargin() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt b/backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt new file mode 100644 index 0000000..10a6f61 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt @@ -0,0 +1,8 @@ +package com.condado.newsletter.service + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +/** Thrown when an authentication attempt fails (wrong password or missing token). */ +@ResponseStatus(HttpStatus.UNAUTHORIZED) +class UnauthorizedException(message: String) : RuntimeException(message) diff --git a/backend/src/test/kotlin/com/condado/newsletter/controller/AuthControllerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/controller/AuthControllerTest.kt new file mode 100644 index 0000000..03dd275 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/controller/AuthControllerTest.kt @@ -0,0 +1,76 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.JwtService +import com.ninjasquad.springmockk.MockkBean +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import jakarta.servlet.http.Cookie + +@SpringBootTest +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var jwtService: JwtService + + @MockkBean + lateinit var entityScheduler: EntityScheduler + + @Test + fun should_return200AndSetCookie_when_correctPasswordPosted() { + mockMvc.perform( + post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"testpassword"}""") + ) + .andExpect(status().isOk) + .andExpect(cookie().exists("jwt")) + .andExpect(cookie().httpOnly("jwt", true)) + } + + @Test + fun should_return401_when_wrongPasswordPosted() { + mockMvc.perform( + post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"wrongpassword"}""") + ) + .andExpect(status().isUnauthorized) + } + + @Test + fun should_return200_when_getMeWithValidCookie() { + val token = jwtService.generateToken() + + mockMvc.perform( + get("/api/auth/me") + .cookie(Cookie("jwt", token)) + ) + .andExpect(status().isOk) + } + + @Test + fun should_return401_when_getMeWithNoCookie() { + mockMvc.perform(get("/api/auth/me")) + .andExpect(status().isUnauthorized) + } + + @Test + fun should_return401_when_protectedEndpointAccessedWithoutCookie() { + mockMvc.perform(get("/api/v1/virtual-entities")) + .andExpect(status().isUnauthorized) + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt new file mode 100644 index 0000000..5f6d196 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt @@ -0,0 +1,57 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.DispatchStatus +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.repository.VirtualEntityRepository +import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.JwtService +import com.ninjasquad.springmockk.MockkBean +import jakarta.servlet.http.Cookie +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc +class DispatchLogControllerTest { + + @Autowired lateinit var mockMvc: MockMvc + @Autowired lateinit var virtualEntityRepository: VirtualEntityRepository + @Autowired lateinit var dispatchLogRepository: DispatchLogRepository + @Autowired lateinit var jwtService: JwtService + @MockkBean lateinit var entityScheduler: EntityScheduler + + private fun authCookie() = Cookie("jwt", jwtService.generateToken()) + + @AfterEach + fun cleanUp() { + dispatchLogRepository.deleteAll() + virtualEntityRepository.deleteAll() + } + + @Test + fun should_return200AndAllLogs_when_getAllLogs() { + val entity = virtualEntityRepository.save(VirtualEntity(name = "Log Entity", email = "log@condado.com", jobTitle = "Logger")) + dispatchLogRepository.save(DispatchLog(virtualEntity = entity, emailSubject = "Test Subject", status = DispatchStatus.SENT)) + mockMvc.perform(get("/api/v1/dispatch-logs").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].emailSubject").value("Test Subject")) + } + + @Test + fun should_return200AndFilteredLogs_when_getByEntityId() { + val entity1 = virtualEntityRepository.save(VirtualEntity(name = "Entity One", email = "one@condado.com", jobTitle = "Job One")) + val entity2 = virtualEntityRepository.save(VirtualEntity(name = "Entity Two", email = "two@condado.com", jobTitle = "Job Two")) + dispatchLogRepository.save(DispatchLog(virtualEntity = entity1, emailSubject = "Log One", status = DispatchStatus.SENT)) + dispatchLogRepository.save(DispatchLog(virtualEntity = entity2, emailSubject = "Log Two", status = DispatchStatus.FAILED)) + mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].emailSubject").value("Log One")) + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt new file mode 100644 index 0000000..ee5ed1b --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt @@ -0,0 +1,100 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.VirtualEntityRepository +import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.JwtService +import com.fasterxml.jackson.databind.ObjectMapper +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import io.mockk.verify +import jakarta.servlet.http.Cookie +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc +class VirtualEntityControllerTest { + + @Autowired lateinit var mockMvc: MockMvc + @Autowired lateinit var virtualEntityRepository: VirtualEntityRepository + @Autowired lateinit var jwtService: JwtService + @MockkBean lateinit var entityScheduler: EntityScheduler + + private val objectMapper = ObjectMapper() + + private fun authCookie() = Cookie("jwt", jwtService.generateToken()) + + @AfterEach + fun cleanUp() { virtualEntityRepository.deleteAll() } + + @Test + fun should_return201AndBody_when_postWithValidPayload() { + val payload = mapOf("name" to "Fulano da Silva", "email" to "fulano@condado.com", "jobTitle" to "Diretor de Nada") + mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isCreated).andExpect(jsonPath("$.name").value("Fulano da Silva")).andExpect(jsonPath("$.id").isNotEmpty) + } + + @Test + fun should_return400_when_postWithMissingRequiredField() { + val payload = mapOf("name" to "Fulano") + mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isBadRequest) + } + + @Test + fun should_return200AndList_when_getAllEntities() { + virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "test@condado.com", jobTitle = "Tester")) + mockMvc.perform(get("/api/v1/virtual-entities").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].name").value("Test Entity")) + } + + @Test + fun should_return200AndEntity_when_getById() { + val entity = virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "entity@condado.com", jobTitle = "Test Job")) + mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$.name").value("Test Entity")) + } + + @Test + fun should_return404_when_getByIdNotFound() { + mockMvc.perform(get("/api/v1/virtual-entities/${java.util.UUID.randomUUID()}").cookie(authCookie())) + .andExpect(status().isNotFound) + } + + @Test + fun should_return200_when_putWithValidPayload() { + val entity = virtualEntityRepository.save(VirtualEntity(name = "Old Name", email = "old@condado.com", jobTitle = "Old Job")) + mockMvc.perform(put("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content("""{"name":"New Name"}""")) + .andExpect(status().isOk).andExpect(jsonPath("$.name").value("New Name")).andExpect(jsonPath("$.email").value("old@condado.com")) + } + + @Test + fun should_return200AndDeactivated_when_delete() { + val entity = virtualEntityRepository.save(VirtualEntity(name = "Active Entity", email = "active@condado.com", jobTitle = "Active Job")) + mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$.active").value(false)) + } + + @Test + fun should_return200_when_triggerEndpointCalled() { + val entity = virtualEntityRepository.save(VirtualEntity(name = "Trigger Entity", email = "trigger@condado.com", jobTitle = "Trigger Job")) + every { entityScheduler.runPipeline(any()) } just runs + mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger").cookie(authCookie())) + .andExpect(status().isOk) + verify(exactly = 1) { entityScheduler.runPipeline(any()) } + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt b/backend/src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt new file mode 100644 index 0000000..154a4e3 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/model/EntityMappingTest.kt @@ -0,0 +1,96 @@ +package com.condado.newsletter.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager +import java.time.LocalDateTime + +@DataJpaTest +class EntityMappingTest { + + @Autowired + lateinit var entityManager: TestEntityManager + + @Test + fun should_persistVirtualEntity_when_allFieldsProvided() { + val entity = VirtualEntity( + name = "João Silva", + email = "joao@condado.com", + jobTitle = "Chief Nonsense Officer", + personality = "Extremely formal but talks about cats", + scheduleCron = "0 9 * * 1", + contextWindowDays = 3 + ) + + val saved = entityManager.persistAndFlush(entity) + + assertThat(saved.id).isNotNull() + assertThat(saved.name).isEqualTo("João Silva") + assertThat(saved.email).isEqualTo("joao@condado.com") + assertThat(saved.jobTitle).isEqualTo("Chief Nonsense Officer") + assertThat(saved.personality).isEqualTo("Extremely formal but talks about cats") + assertThat(saved.scheduleCron).isEqualTo("0 9 * * 1") + assertThat(saved.contextWindowDays).isEqualTo(3) + } + + @Test + fun should_enforceUniqueEmail_when_duplicateEmailInserted() { + val entity1 = VirtualEntity(name = "First", email = "dup@condado.com", jobTitle = "Dev") + val entity2 = VirtualEntity(name = "Second", email = "dup@condado.com", jobTitle = "Dev") + + entityManager.persistAndFlush(entity1) + + assertThrows { + entityManager.persistAndFlush(entity2) + } + } + + @Test + fun should_persistDispatchLog_when_linkedToVirtualEntity() { + val entity = VirtualEntity(name = "Maria Santos", email = "maria@condado.com", jobTitle = "COO") + val savedEntity = entityManager.persistAndFlush(entity) + entityManager.clear() + + val log = DispatchLog( + virtualEntity = entityManager.find(VirtualEntity::class.java, savedEntity.id), + promptSent = "Test prompt content", + aiResponse = "SUBJECT: Test\nBODY:\nTest body", + emailSubject = "Test Subject", + emailBody = "Test body content", + status = DispatchStatus.SENT + ) + + val savedLog = entityManager.persistAndFlush(log) + + assertThat(savedLog.id).isNotNull() + assertThat(savedLog.virtualEntity.id).isEqualTo(savedEntity.id) + assertThat(savedLog.status).isEqualTo(DispatchStatus.SENT) + assertThat(savedLog.promptSent).isEqualTo("Test prompt content") + assertThat(savedLog.emailSubject).isEqualTo("Test Subject") + } + + @Test + fun should_setCreatedAtAutomatically_when_virtualEntitySaved() { + val before = LocalDateTime.now().minusSeconds(1) + + val entity = VirtualEntity(name = "Auto Time", email = "time@condado.com", jobTitle = "Tester") + val saved = entityManager.persistAndFlush(entity) + + val after = LocalDateTime.now().plusSeconds(1) + + assertThat(saved.createdAt).isNotNull() + assertThat(saved.createdAt).isAfterOrEqualTo(before) + assertThat(saved.createdAt).isBeforeOrEqualTo(after) + } + + @Test + fun should_defaultActiveToTrue_when_virtualEntityCreated() { + val entity = VirtualEntity(name = "Default Active", email = "active@condado.com", jobTitle = "CEO") + val saved = entityManager.persistAndFlush(entity) + + assertThat(saved.active).isTrue() + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt b/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt new file mode 100644 index 0000000..f253abd --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/repository/RepositoryTest.kt @@ -0,0 +1,91 @@ +package com.condado.newsletter.repository + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.DispatchStatus +import com.condado.newsletter.model.VirtualEntity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +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.boot.test.autoconfigure.orm.jpa.TestEntityManager + +@DataJpaTest +class RepositoryTest { + + @Autowired + lateinit var entityManager: TestEntityManager + + @Autowired + lateinit var virtualEntityRepository: VirtualEntityRepository + + @Autowired + lateinit var dispatchLogRepository: DispatchLogRepository + + private lateinit var activeEntity: VirtualEntity + private lateinit var inactiveEntity: VirtualEntity + + @BeforeEach + fun setUp() { + activeEntity = entityManager.persistAndFlush( + VirtualEntity(name = "Active One", email = "active@condado.com", jobTitle = "CEO", active = true) + ) + inactiveEntity = entityManager.persistAndFlush( + VirtualEntity(name = "Inactive One", email = "inactive@condado.com", jobTitle = "CTO", active = false) + ) + entityManager.clear() + } + + @Test + fun should_returnOnlyActiveEntities_when_findAllByActiveTrueCalled() { + val result = virtualEntityRepository.findAllByActiveTrue() + + assertThat(result).hasSize(1) + assertThat(result[0].email).isEqualTo("active@condado.com") + } + + @Test + fun should_findEntityByEmail_when_emailExists() { + val result = virtualEntityRepository.findByEmail("active@condado.com") + + assertThat(result).isPresent + assertThat(result.get().name).isEqualTo("Active One") + } + + @Test + fun should_returnEmptyOptional_when_emailNotFound() { + val result = virtualEntityRepository.findByEmail("nobody@condado.com") + + assertThat(result).isEmpty + } + + @Test + fun should_returnAllLogsForEntity_when_findAllByVirtualEntityCalled() { + val entity = entityManager.find(VirtualEntity::class.java, activeEntity.id) + entityManager.persistAndFlush(DispatchLog(virtualEntity = entity, status = DispatchStatus.SENT)) + entityManager.persistAndFlush(DispatchLog(virtualEntity = entity, status = DispatchStatus.FAILED)) + entityManager.clear() + + val result = dispatchLogRepository.findAllByVirtualEntity(entity) + + assertThat(result).hasSize(2) + } + + @Test + fun should_returnMostRecentLog_when_findTopByVirtualEntityOrderByDispatchedAtDescCalled() { + val entity = entityManager.find(VirtualEntity::class.java, activeEntity.id) + val firstLog = entityManager.persistAndFlush( + DispatchLog(virtualEntity = entity, status = DispatchStatus.SENT) + ) + Thread.sleep(10) + val latestLog = entityManager.persistAndFlush( + DispatchLog(virtualEntity = entity, status = DispatchStatus.FAILED) + ) + entityManager.clear() + + val result = dispatchLogRepository.findTopByVirtualEntityOrderByDispatchedAtDesc(entity) + + assertThat(result).isPresent + assertThat(result.get().id).isEqualTo(latestLog.id) + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/scheduler/EntitySchedulerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/scheduler/EntitySchedulerTest.kt new file mode 100644 index 0000000..d63a9fa --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/scheduler/EntitySchedulerTest.kt @@ -0,0 +1,150 @@ +package com.condado.newsletter.scheduler + +import com.condado.newsletter.model.DispatchLog +import com.condado.newsletter.model.DispatchStatus +import com.condado.newsletter.model.EmailContext +import com.condado.newsletter.model.ParsedAiResponse +import com.condado.newsletter.model.VirtualEntity +import com.condado.newsletter.repository.DispatchLogRepository +import com.condado.newsletter.service.AiService +import com.condado.newsletter.service.AiServiceException +import com.condado.newsletter.service.EmailReaderService +import com.condado.newsletter.service.EmailSenderService +import com.condado.newsletter.service.PromptBuilderService +import io.mockk.* +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID + +class EntitySchedulerTest { + + private val emailReaderService: EmailReaderService = mockk() + private val promptBuilderService: PromptBuilderService = mockk() + private val aiService: AiService = mockk() + private val emailSenderService: EmailSenderService = mockk() + private val dispatchLogRepository: DispatchLogRepository = mockk() + + private lateinit var scheduler: EntityScheduler + + private val entity = VirtualEntity( + name = "Joao Gerente", + email = "joao@condado.com", + jobTitle = "Gerente de Nada", + personality = "Muito formal", + scheduleCron = "0 9 * * *", + contextWindowDays = 3, + active = true + ).apply { id = UUID.randomUUID() } + + private val inactiveEntity = VirtualEntity( + name = "Maria Inativa", + email = "maria@condado.com", + jobTitle = "Consultora de Vibe", + active = false + ).apply { id = UUID.randomUUID() } + + @BeforeEach + fun setUp() { + scheduler = EntityScheduler( + emailReaderService = emailReaderService, + promptBuilderService = promptBuilderService, + aiService = aiService, + emailSenderService = emailSenderService, + dispatchLogRepository = dispatchLogRepository, + recipients = "recipient@example.com", + inboxFolder = "INBOX" + ) + } + + @Test + fun should_runFullPipeline_when_entityIsTriggered() { + val emails = listOf(EmailContext("sender@example.com", "Subject", "Body", LocalDateTime.now())) + val prompt = "Generated prompt" + val aiResponse = ParsedAiResponse(subject = "Weekly memo", body = "Dear colleagues...") + + every { emailReaderService.readEmails("INBOX", 3) } returns emails + every { promptBuilderService.buildPrompt(entity, emails) } returns prompt + every { aiService.generate(prompt) } returns aiResponse + every { emailSenderService.send(entity.email, listOf("recipient@example.com"), aiResponse.subject, aiResponse.body) } just runs + every { dispatchLogRepository.save(any()) } answers { firstArg() } + + scheduler.runPipeline(entity) + + verify(exactly = 1) { emailReaderService.readEmails("INBOX", 3) } + verify(exactly = 1) { promptBuilderService.buildPrompt(entity, emails) } + verify(exactly = 1) { aiService.generate(prompt) } + verify(exactly = 1) { emailSenderService.send(entity.email, listOf("recipient@example.com"), aiResponse.subject, aiResponse.body) } + } + + @Test + fun should_saveDispatchLogWithStatusSent_when_pipelineSucceeds() { + val emails = emptyList() + val prompt = "prompt" + val aiResponse = ParsedAiResponse(subject = "Subject", body = "Body") + val savedLog = slot() + + every { emailReaderService.readEmails("INBOX", 3) } returns emails + every { promptBuilderService.buildPrompt(entity, emails) } returns prompt + every { aiService.generate(prompt) } returns aiResponse + every { emailSenderService.send(any(), any(), any(), any()) } just runs + every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() } + + scheduler.runPipeline(entity) + + assertThat(savedLog.isCaptured).isTrue() + assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.SENT) + assertThat(savedLog.captured.emailSubject).isEqualTo("Subject") + assertThat(savedLog.captured.emailBody).isEqualTo("Body") + } + + @Test + fun should_saveDispatchLogWithStatusFailed_when_aiServiceThrows() { + val emails = emptyList() + val prompt = "prompt" + val savedLog = slot() + + every { emailReaderService.readEmails("INBOX", 3) } returns emails + every { promptBuilderService.buildPrompt(entity, emails) } returns prompt + every { aiService.generate(prompt) } throws AiServiceException("API error") + every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() } + + scheduler.runPipeline(entity) + + assertThat(savedLog.isCaptured).isTrue() + assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.FAILED) + assertThat(savedLog.captured.errorMessage).contains("API error") + } + + @Test + fun should_saveDispatchLogWithStatusFailed_when_emailSenderThrows() { + val emails = emptyList() + val prompt = "prompt" + val aiResponse = ParsedAiResponse(subject = "Subject", body = "Body") + val savedLog = slot() + + every { emailReaderService.readEmails("INBOX", 3) } returns emails + every { promptBuilderService.buildPrompt(entity, emails) } returns prompt + every { aiService.generate(prompt) } returns aiResponse + every { emailSenderService.send(any(), any(), any(), any()) } throws RuntimeException("SMTP error") + every { dispatchLogRepository.save(capture(savedLog)) } answers { firstArg() } + + scheduler.runPipeline(entity) + + assertThat(savedLog.isCaptured).isTrue() + assertThat(savedLog.captured.status).isEqualTo(DispatchStatus.FAILED) + assertThat(savedLog.captured.errorMessage).contains("SMTP error") + } + + @Test + fun should_notTrigger_when_entityIsInactive() { + scheduler.runPipeline(inactiveEntity) + + verify { emailReaderService wasNot called } + verify { promptBuilderService wasNot called } + verify { aiService wasNot called } + verify { emailSenderService wasNot called } + verify { dispatchLogRepository wasNot called } + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt new file mode 100644 index 0000000..74bcf93 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt @@ -0,0 +1,88 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.model.ParsedAiResponse +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.web.client.RestClient + +class AiServiceTest { + + private val mockRestClient: RestClient = mockk() + private val mockRequestBodyUriSpec: RestClient.RequestBodyUriSpec = mockk() + private val mockRequestBodySpec: RestClient.RequestBodySpec = mockk() + private val mockResponseSpec: RestClient.ResponseSpec = mockk() + + private val service = AiService( + restClient = mockRestClient, + apiKey = "test-key", + model = "gpt-4o" + ) + + @Test + fun should_returnAiResponseText_when_apiCallSucceeds() { + val rawResponse = "SUBJECT: Test Subject\nBODY:\nTest body content" + stubRestClient(rawResponse.replace("\n", "\\n")) + + val result = service.generate("My test prompt") + + assertThat(result.subject).isEqualTo("Test Subject") + assertThat(result.body).isEqualTo("Test body content") + } + + @Test + fun should_throwAiServiceException_when_apiReturnsError() { + every { mockRestClient.post() } throws RuntimeException("API unavailable") + + assertThrows { + service.generate("My prompt") + } + } + + @Test + fun should_extractSubjectAndBody_when_responseIsWellFormatted() { + val raw = "SUBJECT: Weekly Update\nBODY:\nDear colleagues,\n\nPlease note the snacks." + val result = service.parseResponse(raw) + + assertThat(result.subject).isEqualTo("Weekly Update") + assertThat(result.body).isEqualTo("Dear colleagues,\n\nPlease note the snacks.") + } + + @Test + fun should_throwParseException_when_responseIsMissingSubjectLine() { + val raw = "BODY:\nSome body without a subject" + + assertThrows { + service.parseResponse(raw) + } + } + + @Test + fun should_throwParseException_when_responseIsMissingBodySection() { + val raw = "SUBJECT: Some Subject\nNo body section here" + + assertThrows { + service.parseResponse(raw) + } + } + + // ── helper ────────────────────────────────────────────────────────────── + + private fun stubRestClient(responseText: String) { + every { mockRestClient.post() } returns mockRequestBodyUriSpec + every { mockRequestBodyUriSpec.uri(any()) } returns mockRequestBodySpec + every { mockRequestBodySpec.header(any(), any()) } returns mockRequestBodySpec + every { mockRequestBodySpec.body(any()) } returns mockRequestBodySpec + every { mockRequestBodySpec.retrieve() } returns mockResponseSpec + every { mockResponseSpec.body(String::class.java) } returns """ + { + "choices": [ + { "message": { "content": "$responseText" } } + ] + } + """.trimIndent() + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/AuthServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/AuthServiceTest.kt new file mode 100644 index 0000000..d1e6305 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/service/AuthServiceTest.kt @@ -0,0 +1,62 @@ +package com.condado.newsletter.service + +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class AuthServiceTest { + + private val jwtService: JwtService = mockk() + private lateinit var authService: AuthService + + @BeforeEach + fun setUp() { + authService = AuthService( + jwtService = jwtService, + appPassword = "testpassword" + ) + } + + @Test + fun should_returnJwtToken_when_correctPasswordProvided() { + every { jwtService.generateToken() } returns "jwt-token" + + val token = authService.login("testpassword") + + assertThat(token).isEqualTo("jwt-token") + } + + @Test + fun should_throwUnauthorizedException_when_wrongPasswordProvided() { + assertThrows { + authService.login("wrongpassword") + } + } + + @Test + fun should_returnValidClaims_when_jwtTokenParsed() { + val realJwtService = JwtService( + secret = "test-secret-key-for-testing-only-must-be-at-least-32-characters", + expirationMs = 86400000L + ) + val token = realJwtService.generateToken() + + assertThat(realJwtService.validateToken(token)).isTrue() + } + + @Test + fun should_returnFalse_when_expiredTokenValidated() { + val realJwtService = JwtService( + secret = "test-secret-key-for-testing-only-must-be-at-least-32-characters", + expirationMs = 1L + ) + val token = realJwtService.generateToken() + + Thread.sleep(10) // wait for expiration + + assertThat(realJwtService.validateToken(token)).isFalse() + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/EmailReaderServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/EmailReaderServiceTest.kt new file mode 100644 index 0000000..de9daff --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/service/EmailReaderServiceTest.kt @@ -0,0 +1,113 @@ +package com.condado.newsletter.service + +import io.mockk.every +import io.mockk.mockk +import jakarta.mail.Folder +import jakarta.mail.Message +import jakarta.mail.Session +import jakarta.mail.Store +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.Date + +class EmailReaderServiceTest { + + private val mockStore: Store = mockk(relaxed = true) + private val service = EmailReaderService(storeFactory = { mockStore }) + + @Test + fun should_returnEmailsSortedChronologically_when_multipleEmailsFetched() { + val folder: Folder = mockk(relaxed = true) + val session: Session = mockk() + + val newer = buildMimeMessage(session, "Newer email", "bob@x.com", Date()) + val older = buildMimeMessage(session, "Older email", "alice@x.com", Date(System.currentTimeMillis() - 100_000)) + + every { mockStore.getFolder(any()) } returns folder + every { folder.messageCount } returns 2 + every { folder.getMessages() } returns arrayOf(newer, older) + + val result = service.readEmails("INBOX", contextWindowDays = 10) + + assertThat(result).hasSize(2) + assertThat(result[0].subject).isEqualTo("Older email") + assertThat(result[1].subject).isEqualTo("Newer email") + } + + @Test + fun should_returnEmptyList_when_imapConnectionFails() { + every { mockStore.getFolder(any()) } throws RuntimeException("Connection refused") + + val result = service.readEmails("INBOX", contextWindowDays = 3) + + assertThat(result).isEmpty() + } + + @Test + fun should_filterEmailsOlderThanContextWindow_when_windowIs3Days() { + val folder: Folder = mockk(relaxed = true) + val session: Session = mockk() + + val recentDate = Date() + val oldDate = Date(System.currentTimeMillis() - 10L * 24 * 60 * 60 * 1000) + + val recent = buildMimeMessage(session, "Recent", "a@x.com", recentDate) + val old = buildMimeMessage(session, "Old", "b@x.com", oldDate) + + every { mockStore.getFolder(any()) } returns folder + every { folder.messageCount } returns 2 + every { folder.getMessages() } returns arrayOf(recent, old) + + val result = service.readEmails("INBOX", contextWindowDays = 3) + + assertThat(result).hasSize(1) + assertThat(result[0].subject).isEqualTo("Recent") + } + + @Test + fun should_stripHtml_when_emailBodyContainsHtmlTags() { + val folder: Folder = mockk(relaxed = true) + val session: Session = mockk() + + val msg = buildMimeMessage( + session, + subject = "HTML email", + from = "dev@x.com", + date = Date(), + body = "

Hello world

" + ) + + every { mockStore.getFolder(any()) } returns folder + every { folder.messageCount } returns 1 + every { folder.getMessages() } returns arrayOf(msg) + + val result = service.readEmails("INBOX", contextWindowDays = 10) + + assertThat(result).hasSize(1) + assertThat(result[0].body).doesNotContain("

", "", "", "

") + assertThat(result[0].body).contains("Hello") + assertThat(result[0].body).contains("world") + } + + // ── helper ────────────────────────────────────────────────────────────── + + private fun buildMimeMessage( + session: Session, + subject: String, + from: String, + date: Date, + body: String = "Plain text body" + ): MimeMessage { + val msg: MimeMessage = mockk(relaxed = true) + every { msg.subject } returns subject + every { msg.from } returns arrayOf(InternetAddress(from)) + every { msg.sentDate } returns date + every { msg.receivedDate } returns date + every { msg.contentType } returns "text/plain" + every { msg.content } returns body + return msg + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/EmailSenderServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/EmailSenderServiceTest.kt new file mode 100644 index 0000000..d64c548 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/service/EmailSenderServiceTest.kt @@ -0,0 +1,79 @@ +package com.condado.newsletter.service + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import jakarta.mail.internet.MimeMessage +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.mail.javamail.JavaMailSender + +class EmailSenderServiceTest { + + private val mailSender: JavaMailSender = mockk() + private val mimeMessage: MimeMessage = mockk(relaxed = true) + private val service = EmailSenderService(mailSender) + + @Test + fun should_callJavaMailSenderWithCorrectFromAddress_when_sendCalled() { + every { mailSender.createMimeMessage() } returns mimeMessage + every { mailSender.send(mimeMessage) } returns Unit + + service.send( + from = "joao@condado.com", + to = listOf("friend@example.com"), + subject = "RE: Meeting on Tuesday", + body = "Dear colleagues, I have prepared the agenda." + ) + + verify(exactly = 1) { mailSender.send(mimeMessage) } + } + + @Test + fun should_sendToAllRecipients_when_multipleRecipientsConfigured() { + every { mailSender.createMimeMessage() } returns mimeMessage + every { mailSender.send(mimeMessage) } returns Unit + + service.send( + from = "joao@condado.com", + to = listOf("friend1@example.com", "friend2@example.com", "friend3@example.com"), + subject = "Subject", + body = "Body" + ) + + verify(exactly = 1) { mailSender.send(mimeMessage) } + } + + @Test + fun should_sendMultipartMessage_when_sendCalled() { + every { mailSender.createMimeMessage() } returns mimeMessage + every { mailSender.send(mimeMessage) } returns Unit + + // The send method should complete without throwing — multipart assembly is verified indirectly + service.send( + from = "joao@condado.com", + to = listOf("friend@example.com"), + subject = "Formal Memo", + body = "

I wish to formally announce that I am out of coffee.

" + ) + + verify(exactly = 1) { mailSender.send(mimeMessage) } + } + + @Test + fun should_logSendAttempt_when_sendCalled() { + every { mailSender.createMimeMessage() } returns mimeMessage + every { mailSender.send(mimeMessage) } returns Unit + + // No exception should be thrown — logging is captured via SLF4J internally + service.send( + from = "joao@condado.com", + to = listOf("friend@example.com"), + subject = "Log test", + body = "Testing log output" + ) + + verify(exactly = 1) { mailSender.send(mimeMessage) } + } +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt new file mode 100644 index 0000000..6326007 --- /dev/null +++ b/backend/src/test/kotlin/com/condado/newsletter/service/PromptBuilderServiceTest.kt @@ -0,0 +1,75 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.model.EmailContext +import com.condado.newsletter.model.VirtualEntity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class PromptBuilderServiceTest { + + private val service = PromptBuilderService() + + private val entity = VirtualEntity( + name = "Rodrigo Alves", + email = "rodrigo@condado.com", + jobTitle = "Head of Casual Affairs", + personality = "Extremely pedantic but only talks about cats", + scheduleCron = "0 9 * * 1", + contextWindowDays = 3 + ) + + private val emailContext = listOf( + EmailContext( + from = "Maria Santos ", + subject = "Re: The weekly snack situation", + body = "I think we need more chips in the pantry.", + receivedAt = LocalDateTime.now().minusHours(2) + ) + ) + + @Test + fun should_containEntityName_when_buildPromptCalled() { + val prompt = service.buildPrompt(entity, emailContext) + assertThat(prompt).contains("Rodrigo Alves") + } + + @Test + fun should_containEntityJobTitle_when_buildPromptCalled() { + val prompt = service.buildPrompt(entity, emailContext) + assertThat(prompt).contains("Head of Casual Affairs") + } + + @Test + fun should_containEntityPersonality_when_buildPromptCalled() { + val prompt = service.buildPrompt(entity, emailContext) + assertThat(prompt).contains("Extremely pedantic but only talks about cats") + } + + @Test + fun should_containContextWindowDays_when_buildPromptCalled() { + val prompt = service.buildPrompt(entity, emailContext) + assertThat(prompt).contains("3") + } + + @Test + fun should_containEachEmailSenderAndSubject_when_emailContextProvided() { + val prompt = service.buildPrompt(entity, emailContext) + assertThat(prompt).contains("Maria Santos ") + assertThat(prompt).contains("Re: The weekly snack situation") + } + + @Test + fun should_containFormatInstruction_when_buildPromptCalled() { + val prompt = service.buildPrompt(entity, emailContext) + assertThat(prompt).contains("SUBJECT:") + assertThat(prompt).contains("BODY:") + } + + @Test + fun should_returnPromptWithNoEmails_when_emailContextIsEmpty() { + val prompt = service.buildPrompt(entity, emptyList()) + assertThat(prompt).contains("Rodrigo Alves") + assertThat(prompt).isNotEmpty() + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 0000000..1709e72 --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,40 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + mail: + host: localhost + port: 25 + username: test + password: test + properties: + mail: + smtp: + auth: false + starttls: + enable: false + +app: + password: testpassword + recipients: test@test.com + jwt: + secret: test-secret-key-for-testing-only-must-be-at-least-32-characters + expiration-ms: 86400000 + +imap: + host: localhost + port: 993 + inbox-folder: INBOX + +openai: + api-key: test-api-key + model: gpt-4o diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b94c1dd..44e0305 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -49,7 +49,7 @@ services: - condado-net # ── Frontend + Nginx ───────────────────────────────────────────────────────── - frontend: + nginx: build: context: ./frontend dockerfile: Dockerfile @@ -57,7 +57,7 @@ services: VITE_API_BASE_URL: ${VITE_API_BASE_URL} restart: always ports: - - "6969:80" + - "80:80" depends_on: - backend networks: diff --git a/docker-compose.yml b/docker-compose.yml index 1f1a337..8a8bcb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,7 @@ services: - condado-net # ── Frontend + Nginx ───────────────────────────────────────────────────────── - frontend: + nginx: build: context: ./frontend dockerfile: Dockerfile @@ -57,7 +57,7 @@ services: VITE_API_BASE_URL: ${VITE_API_BASE_URL} restart: unless-stopped ports: - - "6969:80" + - "80:80" depends_on: - backend networks: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 204d3da..0493261 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -19,5 +19,10 @@ fi # ── Ensure supervisor log directory exists ──────────────────────────────────── mkdir -p /var/log/supervisor +# ── Defaults for all-in-one local PostgreSQL ───────────────────────────────── +export SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL:-jdbc:postgresql://localhost:5432/condado} +export SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME:-condado} +export SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD:-condado} + # ── Start all services via supervisord ─────────────────────────────────────── exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/supervisord.conf b/docker/supervisord.conf index f2d745c..7435fca 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -13,7 +13,6 @@ stderr_logfile=/var/log/supervisor/postgres.err.log [program:backend] command=java -jar /app/app.jar -environment=SPRING_DATASOURCE_URL="jdbc:postgresql://localhost:5432/condado",SPRING_DATASOURCE_USERNAME="condado",SPRING_DATASOURCE_PASSWORD="condado" autostart=true autorestart=true startsecs=15 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 104b8fc..7d1233e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "condado-newsletter-frontend", - "version": "0.1.1", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "condado-newsletter-frontend", - "version": "0.1.1", + "version": "0.2.1", "dependencies": { "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/frontend/package.json b/frontend/package.json index 7e4daa4..3a19bd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "condado-newsletter-frontend", "private": true, - "version": "0.1.1", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/__tests__/api/authApi.test.ts b/frontend/src/__tests__/api/authApi.test.ts new file mode 100644 index 0000000..d2c7d78 --- /dev/null +++ b/frontend/src/__tests__/api/authApi.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import apiClient from '@/api/apiClient' +import { login, logout, getMe } from '@/api/authApi' + +vi.mock('@/api/apiClient', () => ({ + default: { + post: vi.fn(), + get: vi.fn(), + }, +})) + +const mockedApiClient = apiClient as unknown as { + post: ReturnType + get: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('authApi', () => { + it('should_callLoginEndpoint_when_loginCalled', async () => { + mockedApiClient.post.mockResolvedValue({ data: {} }) + await login({ password: 'secret' }) + expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login', { password: 'secret' }) + }) + + it('should_callLogoutEndpoint_when_logoutCalled', async () => { + mockedApiClient.post.mockResolvedValue({ data: {} }) + await logout() + expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/logout') + }) + + it('should_callMeEndpoint_when_getMeCalled', async () => { + mockedApiClient.get.mockResolvedValue({ data: { message: 'Authenticated' } }) + const result = await getMe() + expect(mockedApiClient.get).toHaveBeenCalledWith('/auth/me') + expect(result).toEqual({ message: 'Authenticated' }) + }) +}) diff --git a/frontend/src/__tests__/api/entitiesApi.test.ts b/frontend/src/__tests__/api/entitiesApi.test.ts new file mode 100644 index 0000000..ae0c47b --- /dev/null +++ b/frontend/src/__tests__/api/entitiesApi.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import apiClient from '@/api/apiClient' +import { + getEntities, + createEntity, + updateEntity, + deleteEntity, + triggerEntity, +} from '@/api/entitiesApi' + +vi.mock('@/api/apiClient', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +const mockedApiClient = apiClient as unknown as { + get: ReturnType + post: ReturnType + put: ReturnType + delete: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('entitiesApi', () => { + it('should_callGetEndpoint_when_getAllEntitiesCalled', async () => { + mockedApiClient.get.mockResolvedValue({ data: [] }) + await getEntities() + expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/virtual-entities') + }) + + it('should_callPostEndpoint_when_createEntityCalled', async () => { + const dto = { name: 'Test', email: 'test@test.com', jobTitle: 'Tester', personality: '', scheduleCron: '', contextWindowDays: 3 } + mockedApiClient.post.mockResolvedValue({ data: dto }) + await createEntity(dto) + expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/virtual-entities', dto) + }) + + it('should_callPutEndpoint_when_updateEntityCalled', async () => { + mockedApiClient.put.mockResolvedValue({ data: {} }) + await updateEntity('123', { name: 'Updated' }) + expect(mockedApiClient.put).toHaveBeenCalledWith('/v1/virtual-entities/123', { name: 'Updated' }) + }) + + it('should_callDeleteEndpoint_when_deleteEntityCalled', async () => { + mockedApiClient.delete.mockResolvedValue({ data: {} }) + await deleteEntity('123') + expect(mockedApiClient.delete).toHaveBeenCalledWith('/v1/virtual-entities/123') + }) + + it('should_callTriggerEndpoint_when_triggerEntityCalled', async () => { + mockedApiClient.post.mockResolvedValue({ data: {} }) + await triggerEntity('123') + expect(mockedApiClient.post).toHaveBeenCalledWith('/v1/virtual-entities/123/trigger') + }) +}) diff --git a/frontend/src/__tests__/api/logsApi.test.ts b/frontend/src/__tests__/api/logsApi.test.ts new file mode 100644 index 0000000..fa17c5e --- /dev/null +++ b/frontend/src/__tests__/api/logsApi.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import apiClient from '@/api/apiClient' +import { getLogs, getLogsByEntity } from '@/api/logsApi' + +vi.mock('@/api/apiClient', () => ({ + default: { + get: vi.fn(), + }, +})) + +const mockedApiClient = apiClient as unknown as { + get: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('logsApi', () => { + it('should_callGetAllLogsEndpoint_when_getAllLogsCalled', async () => { + mockedApiClient.get.mockResolvedValue({ data: [] }) + await getLogs() + expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/dispatch-logs') + }) + + it('should_callGetByEntityEndpoint_when_getLogsByEntityCalled', async () => { + mockedApiClient.get.mockResolvedValue({ data: [] }) + await getLogsByEntity('entity-123') + expect(mockedApiClient.get).toHaveBeenCalledWith('/v1/dispatch-logs/entity/entity-123') + }) +}) diff --git a/frontend/src/__tests__/components/ProtectedRoute.test.tsx b/frontend/src/__tests__/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..532adbc --- /dev/null +++ b/frontend/src/__tests__/components/ProtectedRoute.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import ProtectedRoute from '@/components/ProtectedRoute' +import * as authApi from '@/api/authApi' + +vi.mock('@/api/authApi') + +const makeWrapper = (initialPath = '/') => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + +describe('ProtectedRoute', () => { + it('should_renderChildren_when_sessionIsValid', async () => { + vi.mocked(authApi.getMe).mockResolvedValue({ message: 'Authenticated' }) + const wrapper = makeWrapper() + render( + +
Protected Content
+
, + { wrapper } + ) + await screen.findByText('Protected Content') + }) + + it('should_redirectToLogin_when_sessionIsInvalid', async () => { + vi.mocked(authApi.getMe).mockRejectedValue(new Error('Unauthorized')) + const wrapper = makeWrapper() + render( + +
Protected Content
+
, + { wrapper } + ) + // Should navigate away, protected content should not be visible + await new Promise((r) => setTimeout(r, 100)) + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/pages/DashboardPage.test.tsx b/frontend/src/__tests__/pages/DashboardPage.test.tsx index c6dfb86..85aa259 100644 --- a/frontend/src/__tests__/pages/DashboardPage.test.tsx +++ b/frontend/src/__tests__/pages/DashboardPage.test.tsx @@ -1,11 +1,47 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import DashboardPage from '@/pages/DashboardPage' +import * as entitiesApi from '@/api/entitiesApi' +import * as logsApi from '@/api/logsApi' + +vi.mock('@/api/entitiesApi') +vi.mock('@/api/logsApi') + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) describe('DashboardPage', () => { it('should_display_app_version_when_rendered', () => { - render() - - expect(screen.getByText(/version 0.1.0/i)).toBeInTheDocument() + vi.mocked(entitiesApi.getEntities).mockResolvedValue([]) + vi.mocked(logsApi.getLogs).mockResolvedValue([]) + render(, { wrapper }) + expect(screen.getByText(/version \d+\.\d+\.\d+/i)).toBeInTheDocument() }) -}) + + it('should_renderEntityCount_when_pageLoads', async () => { + vi.mocked(entitiesApi.getEntities).mockResolvedValue([ + { id: '1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Job A', personality: '', scheduleCron: '', contextWindowDays: 3, active: true, createdAt: '' }, + ]) + vi.mocked(logsApi.getLogs).mockResolvedValue([]) + render(, { wrapper }) + await waitFor(() => { + expect(screen.getByText(/1 active entity|1 entidade/i)).toBeInTheDocument() + }) + }) + + it('should_renderRecentLogs_when_pageLoads', async () => { + vi.mocked(entitiesApi.getEntities).mockResolvedValue([]) + vi.mocked(logsApi.getLogs).mockResolvedValue([ + { id: '1', entityId: 'e1', entityName: 'Entity A', promptSent: '', aiResponse: '', emailSubject: 'Memo', emailBody: '', status: 'SENT', errorMessage: null, dispatchedAt: '2024-01-01T10:00:00' }, + ]) + render(, { wrapper }) + await waitFor(() => { + expect(screen.getByText(/Memo/i)).toBeInTheDocument() + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/EntitiesPage.test.tsx b/frontend/src/__tests__/pages/EntitiesPage.test.tsx new file mode 100644 index 0000000..21bbe3d --- /dev/null +++ b/frontend/src/__tests__/pages/EntitiesPage.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import EntitiesPage from '@/pages/EntitiesPage' +import * as entitiesApi from '@/api/entitiesApi' + +vi.mock('@/api/entitiesApi') + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +const mockEntity = { + id: 'entity-1', + name: 'Test Entity', + email: 'test@condado.com', + jobTitle: 'Tester', + personality: 'Formal', + scheduleCron: '0 9 * * *', + contextWindowDays: 3, + active: true, + createdAt: '2024-01-01T00:00:00', +} + +describe('EntitiesPage', () => { + it('should_renderEntityList_when_entitiesLoaded', async () => { + vi.mocked(entitiesApi.getEntities).mockResolvedValue([mockEntity]) + render(, { wrapper }) + await waitFor(() => { + expect(screen.getByText('Test Entity')).toBeInTheDocument() + }) + }) + + it('should_openCreateDialog_when_addButtonClicked', async () => { + vi.mocked(entitiesApi.getEntities).mockResolvedValue([]) + render(, { wrapper }) + const addButton = screen.getByRole('button', { name: /add|create|new/i }) + fireEvent.click(addButton) + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + }) + + it('should_callDeleteApi_when_deleteConfirmed', async () => { + vi.mocked(entitiesApi.getEntities).mockResolvedValue([mockEntity]) + vi.mocked(entitiesApi.deleteEntity).mockResolvedValue(undefined) + render(, { wrapper }) + await waitFor(() => { + expect(screen.getByText('Test Entity')).toBeInTheDocument() + }) + const deleteButton = screen.getByRole('button', { name: /delete|deactivate/i }) + fireEvent.click(deleteButton) + await waitFor(() => { + expect(entitiesApi.deleteEntity).toHaveBeenCalledWith('entity-1') + }) + }) +}) diff --git a/frontend/src/__tests__/pages/LoginPage.test.tsx b/frontend/src/__tests__/pages/LoginPage.test.tsx new file mode 100644 index 0000000..4a6dc46 --- /dev/null +++ b/frontend/src/__tests__/pages/LoginPage.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import LoginPage from '@/pages/LoginPage' +import * as authApi from '@/api/authApi' + +vi.mock('@/api/authApi') + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('LoginPage', () => { + it('should_renderLoginForm_when_pageLoads', () => { + render(, { wrapper }) + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument() + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + }) + + it('should_callLoginApi_when_formSubmitted', async () => { + vi.mocked(authApi.login).mockResolvedValue(undefined) + render(, { wrapper }) + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }) + fireEvent.click(screen.getByRole('button', { name: /login/i })) + await waitFor(() => { + expect(authApi.login).toHaveBeenCalledWith({ password: 'secret' }) + }) + }) + + it('should_showErrorMessage_when_loginFails', async () => { + vi.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized')) + render(, { wrapper }) + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }) + fireEvent.click(screen.getByRole('button', { name: /login/i })) + await waitFor(() => { + expect(screen.getByText(/invalid password|unauthorized|error/i)).toBeInTheDocument() + }) + }) + + it('should_redirectToDashboard_when_loginSucceeds', async () => { + vi.mocked(authApi.login).mockResolvedValue(undefined) + render(, { wrapper }) + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'correct' } }) + fireEvent.click(screen.getByRole('button', { name: /login/i })) + await waitFor(() => { + expect(authApi.login).toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/__tests__/pages/LogsPage.test.tsx b/frontend/src/__tests__/pages/LogsPage.test.tsx new file mode 100644 index 0000000..ae44c25 --- /dev/null +++ b/frontend/src/__tests__/pages/LogsPage.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import LogsPage from '@/pages/LogsPage' +import * as logsApi from '@/api/logsApi' +import * as entitiesApi from '@/api/entitiesApi' + +vi.mock('@/api/logsApi') +vi.mock('@/api/entitiesApi') + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +const mockLog = { + id: 'log-1', + entityId: 'e1', + entityName: 'Entity A', + promptSent: 'prompt', + aiResponse: 'response', + emailSubject: 'Weekly Memo', + emailBody: 'Dear colleagues', + status: 'SENT' as const, + errorMessage: null, + dispatchedAt: '2024-01-01T10:00:00', +} + +describe('LogsPage', () => { + it('should_renderLogTable_when_logsLoaded', async () => { + vi.mocked(logsApi.getLogs).mockResolvedValue([mockLog]) + vi.mocked(entitiesApi.getEntities).mockResolvedValue([]) + render(, { wrapper }) + await waitFor(() => { + expect(screen.getByText('Weekly Memo')).toBeInTheDocument() + }) + }) + + it('should_filterLogsByEntity_when_filterSelected', async () => { + vi.mocked(logsApi.getLogs).mockResolvedValue([mockLog]) + vi.mocked(entitiesApi.getEntities).mockResolvedValue([ + { id: 'e1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Job', personality: '', scheduleCron: '', contextWindowDays: 3, active: true, createdAt: '' }, + ]) + render(, { wrapper }) + await waitFor(() => { + expect(screen.getByText('Weekly Memo')).toBeInTheDocument() + }) + expect(screen.getByRole('combobox', { hidden: true })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/api/logsApi.ts b/frontend/src/api/logsApi.ts index 9a8eda5..005f445 100644 --- a/frontend/src/api/logsApi.ts +++ b/frontend/src/api/logsApi.ts @@ -5,6 +5,7 @@ export type DispatchStatus = 'PENDING' | 'SENT' | 'FAILED' export interface DispatchLogResponse { id: string entityId: string + entityName: string promptSent: string aiResponse: string emailSubject: string diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 9f586ce..027c885 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,11 +1,38 @@ +import { useQuery } from '@tanstack/react-query' +import { getEntities } from '../api/entitiesApi' +import { getLogs } from '../api/logsApi' + export default function DashboardPage() { const appVersion = __APP_VERSION__ + const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities }) + const { data: logs = [] } = useQuery({ queryKey: ['logs'], queryFn: getLogs }) + + const activeCount = entities.filter((e) => e.active).length return (

Dashboard

-

Dashboard — coming in Step 11.

Version {appVersion}

+
+
+

Active Entities

+

{activeCount} active {activeCount === 1 ? 'entity' : 'entities'}

+
+
+
+

Recent Dispatches

+
    + {logs.slice(0, 10).map((log) => ( +
  • + {log.emailSubject} + {log.entityName} +
  • + ))} + {logs.length === 0 && ( +
  • No dispatches yet.
  • + )} +
+
) } diff --git a/frontend/src/pages/EntitiesPage.tsx b/frontend/src/pages/EntitiesPage.tsx new file mode 100644 index 0000000..23900f0 --- /dev/null +++ b/frontend/src/pages/EntitiesPage.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + getEntities, + createEntity, + deleteEntity, + VirtualEntityCreateDto, +} from '../api/entitiesApi' + +export default function EntitiesPage() { + const queryClient = useQueryClient() + const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities }) + const [dialogOpen, setDialogOpen] = useState(false) + const [form, setForm] = useState({ + name: '', + email: '', + jobTitle: '', + personality: '', + scheduleCron: '', + contextWindowDays: 3, + }) + + const createMutation = useMutation({ + mutationFn: createEntity, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['entities'] }) + setDialogOpen(false) + setForm({ name: '', email: '', jobTitle: '', personality: '', scheduleCron: '', contextWindowDays: 3 }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteEntity(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['entities'] }), + }) + + return ( +
+
+

Virtual Entities

+ +
+ +
    + {entities.map((entity) => ( +
  • +
    +

    {entity.name}

    +

    {entity.jobTitle} — {entity.email}

    +
    + +
  • + ))} + {entities.length === 0 && ( +
  • No entities yet.
  • + )} +
+ + {dialogOpen && ( +
+
+

New Entity

+
{ + e.preventDefault() + createMutation.mutate(form) + }} + className="space-y-3" + > + setForm({ ...form, name: e.target.value })} + className="block w-full rounded border border-gray-300 px-3 py-2 text-sm" + required + /> + setForm({ ...form, email: e.target.value })} + className="block w-full rounded border border-gray-300 px-3 py-2 text-sm" + required + /> + setForm({ ...form, jobTitle: e.target.value })} + className="block w-full rounded border border-gray-300 px-3 py-2 text-sm" + required + /> +