feat: implement VirtualEntity and DispatchLog models with corresponding tests and configuration
This commit is contained in:
@@ -45,7 +45,7 @@ employee is an AI-powered entity that:
|
|||||||
|------|-----------------------------------------|-------------|
|
|------|-----------------------------------------|-------------|
|
||||||
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
||||||
| 1 | Scaffold monorepo structure | ✅ Done |
|
| 1 | Scaffold monorepo structure | ✅ Done |
|
||||||
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
| 2 | Domain model (JPA entities) | ✅ Done |
|
||||||
| 3 | Repositories | ⬜ Pending |
|
| 3 | Repositories | ⬜ Pending |
|
||||||
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
|
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
|
||||||
| 5 | Prompt Builder Service | ⬜ 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."
|
> `VirtualEntity`. Make the tests in `EntityMappingTest` pass."
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
- [ ] `EntityMappingTest.kt` exists with all 5 tests.
|
- [x] `EntityMappingTest.kt` exists with all 5 tests.
|
||||||
- [ ] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
- [x] Both entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
|
||||||
- [ ] `./gradlew test` is green.
|
- [x] `./gradlew test` is green.
|
||||||
- [ ] Tables are auto-created by Hibernate on startup (`ddl-auto: create-drop` in dev).
|
- [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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED
|
org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED
|
||||||
|
org.gradle.java.home=C:/Program Files/Java/jdk-21.0.10
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Exception> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/test/resources/application.yml
Normal file
40
backend/src/test/resources/application.yml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user