diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5460130
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,34 @@
+name: Timeless API - Integration Tests
+
+on:
+ push:
+ branches: [ "main" ]
+ paths:
+ - 'timeless-api/**'
+ pull_request:
+ branches: [ "main" ]
+ paths:
+ - 'timeless-api/**'
+
+jobs:
+ test:
+ name: Run Tests (Testcontainers)
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: timeless-api
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Setup JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Maven Tests
+ run: mvn -B -ntp formatter:validate impsort:check verify
diff --git a/timeless-api/pom.xml b/timeless-api/pom.xml
index 11e4ba0..c3a2d45 100644
--- a/timeless-api/pom.xml
+++ b/timeless-api/pom.xml
@@ -115,16 +115,37 @@
${assertj.version}
test
+
+ org.instancio
+ instancio-junit
+ 5.3.0
+ test
+
io.quarkus
quarkus-junit5
test
+
+ org.testcontainers
+ postgresql
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
io.rest-assured
rest-assured
test
+
+ io.quarkus
+ quarkus-test-security-jwt
+ test
+
diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/RecordRepository.java b/timeless-api/src/main/java/dev/matheuscruz/domain/RecordRepository.java
index a8a18d0..fe10bf4 100644
--- a/timeless-api/src/main/java/dev/matheuscruz/domain/RecordRepository.java
+++ b/timeless-api/src/main/java/dev/matheuscruz/domain/RecordRepository.java
@@ -2,17 +2,50 @@
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import io.quarkus.logging.Log;
+import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Parameters;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.Tuple;
+import java.math.BigDecimal;
import java.util.List;
@ApplicationScoped
public class RecordRepository implements PanacheRepository {
+ @Inject
+ EntityManager em;
+
public List getRecordsWithAmountAndTypeOnlyByUser(String userId) {
Log.info("Getting balance for user ID: " + userId);
return find("select r.amount, r.transaction from Record as r where userId = :userId",
Parameters.with("userId", userId)).project(AmountAndTypeOnly.class).list();
}
+ public RecordSummary getRecordSummary(String userId, int page, int limit) {
+
+ Tuple aggregates = em
+ .createQuery("select count(r), " + "sum(case when r.transaction = :out then r.amount else 0 end), "
+ + "sum(case when r.transaction = :in then r.amount else 0 end) "
+ + "from Record r where r.userId = :userId", Tuple.class)
+ .setParameter("userId", userId).setParameter("out", Transactions.OUT)
+ .setParameter("in", Transactions.IN).getSingleResult();
+
+ long totalRecords = aggregates.get(0, Long.class);
+ BigDecimal totalExpenses = aggregates.get(1, BigDecimal.class);
+ BigDecimal totalIncome = aggregates.get(2, BigDecimal.class);
+
+ BigDecimal[] verificationTotal = verificationTotal(totalExpenses, totalIncome);
+
+ List records = find("userId = :userId", Parameters.with("userId", userId)).page(Page.of(page, limit))
+ .list();
+
+ return new RecordSummary(records, totalRecords, verificationTotal[0], verificationTotal[1]);
+ }
+
+ private BigDecimal[] verificationTotal(BigDecimal expenses, BigDecimal income) {
+ return new BigDecimal[] { expenses == null ? BigDecimal.ZERO : expenses,
+ income == null ? BigDecimal.ZERO : income };
+ }
}
diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/RecordSummary.java b/timeless-api/src/main/java/dev/matheuscruz/domain/RecordSummary.java
new file mode 100644
index 0000000..d837c65
--- /dev/null
+++ b/timeless-api/src/main/java/dev/matheuscruz/domain/RecordSummary.java
@@ -0,0 +1,7 @@
+package dev.matheuscruz.domain;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public record RecordSummary(List records, Long totalRecords, BigDecimal totalExpenses, BigDecimal totalIncome) {
+}
diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java
index 58bc6ef..0a73287 100644
--- a/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java
+++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java
@@ -1,9 +1,8 @@
package dev.matheuscruz.presentation;
-import dev.matheuscruz.domain.AmountAndTypeOnly;
import dev.matheuscruz.domain.Record;
import dev.matheuscruz.domain.RecordRepository;
-import dev.matheuscruz.domain.Transactions;
+import dev.matheuscruz.domain.RecordSummary;
import dev.matheuscruz.domain.User;
import dev.matheuscruz.domain.UserRepository;
import dev.matheuscruz.presentation.data.CreateRecordRequest;
@@ -75,33 +74,20 @@ public Response createRecord(@Valid CreateRecordRequest req) {
@GET
public Response getRecords(@RestQuery("page") String p, @RestQuery("limit") String l) {
+ int page = Integer.parseInt(Optional.ofNullable(p).orElse("0"));
+ int limit = Integer.parseInt(Optional.ofNullable(l).orElse("10"));
- int page = Integer.parseInt(Optional.of(p).orElse("0"));
- int limit = Integer.parseInt(Optional.of(l).orElse("10"));
+ RecordSummary summary = recordRepository.getRecordSummary(upn, page, limit);
- // TODO: https://github.com/mcruzdev/timeless/issues/125
- long totalRecords = recordRepository.count("userId = :userId", Parameters.with("userId", upn));
+ List output = summary.records().stream().map(record -> {
+ String format = record.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).toLocalDate()
+ .format(formatter);
+ return new RecordItemResponse(record.getId(), record.getAmount(), record.getDescription(),
+ record.getTransaction().name(), format, record.getCategory().name());
+ }).toList();
- // pagination
- List output = recordRepository.find("userId = :userId", Parameters.with("userId", upn))
- .page(Page.of(page, limit)).list().stream().map(record -> {
- String format = record.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).toLocalDate()
- .format(formatter);
- return new RecordItemResponse(record.getId(), record.getAmount(), record.getDescription(),
- record.getTransaction().name(), format, record.getCategory().name());
- }).toList();
-
- // calculate total expenses and total in
- List amountAndType = recordRepository.getRecordsWithAmountAndTypeOnlyByUser(upn);
- Optional totalExpenses = amountAndType.stream()
- .filter(item -> item.getTransaction().equals(Transactions.OUT)).map(AmountAndTypeOnly::getAmount)
- .reduce(BigDecimal::add);
-
- Optional totalIn = amountAndType.stream()
- .filter(item -> item.getTransaction().equals(Transactions.IN)).map(AmountAndTypeOnly::getAmount)
- .reduce(BigDecimal::add);
-
- return Response.ok(new PageRecord(output, totalRecords, totalExpenses.orElse(BigDecimal.ZERO),
- totalIn.orElse(BigDecimal.ZERO))).build();
+ return Response
+ .ok(new PageRecord(output, summary.totalRecords(), summary.totalExpenses(), summary.totalIncome()))
+ .build();
}
}
diff --git a/timeless-api/src/test/java/dev/matheuscruz/domain/RecordRepositoryTest.java b/timeless-api/src/test/java/dev/matheuscruz/domain/RecordRepositoryTest.java
new file mode 100644
index 0000000..7922624
--- /dev/null
+++ b/timeless-api/src/test/java/dev/matheuscruz/domain/RecordRepositoryTest.java
@@ -0,0 +1,77 @@
+package dev.matheuscruz.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.inject.Inject;
+import jakarta.transaction.Transactional;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import org.instancio.Instancio;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class RecordRepositoryTest {
+
+ @Inject
+ RecordRepository recordRepository;
+
+ @BeforeEach
+ @Transactional
+ void setUp() {
+ recordRepository.deleteAll();
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("Should return record summary correctly for a given user")
+ void shouldReturnRecordSummaryCorrectly() {
+
+ String userId = "user-" + Instancio.create(String.class);
+
+ List recordsToPersist = new ArrayList<>();
+
+ for (int i = 0; i < 3; i++) {
+ recordsToPersist.add(
+ new Record.Builder().userId(userId).transaction(Transactions.OUT).amount(new BigDecimal("10.00"))
+ .description(Instancio.create(String.class)).category(Categories.GENERAL).build());
+ }
+
+ for (int i = 0; i < 2; i++) {
+ recordsToPersist.add(
+ new Record.Builder().userId(userId).transaction(Transactions.IN).amount(new BigDecimal("50.00"))
+ .description(Instancio.create(String.class)).category(Categories.NONE).build());
+ }
+
+ for (int i = 0; i < 5; i++) {
+ recordsToPersist.add(new Record.Builder().userId("other-" + userId).transaction(Transactions.OUT)
+ .amount(new BigDecimal("5.00")).description("Other " + i).category(Categories.FIXED_COSTS).build());
+ }
+
+ recordsToPersist.forEach(recordRepository::persist);
+
+ RecordSummary summary = recordRepository.getRecordSummary(userId, 0, 10);
+
+ assertThat(summary).isNotNull();
+ assertThat(summary.totalRecords()).isEqualTo(5);
+ assertThat(summary.totalExpenses()).isEqualByComparingTo(new BigDecimal("30.00"));
+ assertThat(summary.totalIncome()).isEqualByComparingTo(new BigDecimal("100.00"));
+ assertThat(summary.records()).hasSize(5);
+ }
+
+ @Test
+ @Transactional
+ @DisplayName("Should return zeroed summary when user has no records")
+ void shouldReturnZeroedSummaryWhenNoRecords() {
+ RecordSummary summary = recordRepository.getRecordSummary("empty-" + Instancio.create(String.class), 0, 10);
+
+ assertThat(summary).isNotNull();
+ assertThat(summary.totalRecords()).isZero();
+ assertThat(summary.totalExpenses()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(summary.totalIncome()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(summary.records()).isEmpty();
+ }
+}
diff --git a/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java b/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java
new file mode 100644
index 0000000..eedcca6
--- /dev/null
+++ b/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java
@@ -0,0 +1,145 @@
+package dev.matheuscruz.presentation;
+
+import static io.restassured.RestAssured.given;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import dev.matheuscruz.domain.Categories;
+import dev.matheuscruz.domain.Record;
+import dev.matheuscruz.domain.RecordRepository;
+import dev.matheuscruz.domain.Transactions;
+import dev.matheuscruz.domain.User;
+import dev.matheuscruz.domain.UserRepository;
+import dev.matheuscruz.presentation.data.CreateRecordRequest;
+import dev.matheuscruz.presentation.data.PageRecord;
+import io.quarkus.narayana.jta.QuarkusTransaction;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.security.TestSecurity;
+import io.quarkus.test.security.jwt.Claim;
+import io.quarkus.test.security.jwt.JwtSecurity;
+import io.restassured.http.ContentType;
+import jakarta.inject.Inject;
+import java.math.BigDecimal;
+import java.util.List;
+import org.instancio.Instancio;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class RecordResourceTest {
+
+ @Inject
+ RecordRepository recordRepository;
+
+ @Inject
+ UserRepository userRepository;
+
+ @BeforeEach
+ void setUp() {
+ QuarkusTransaction.requiringNew().run(() -> {
+ recordRepository.deleteAll();
+ userRepository.deleteAll();
+ });
+ }
+
+ @Test
+ @TestSecurity(user = "test-user", roles = "user")
+ @JwtSecurity(claims = { @Claim(key = "upn", value = "test-user-id") })
+ @DisplayName("Should return empty list when user has no records")
+ void should_returnEmptyList_when_userHasNoRecords() {
+ var response = given().when().get("/api/records").then().statusCode(200).extract().as(PageRecord.class);
+
+ assertThat(response.items()).isEmpty();
+ assertThat(response.totalRecords()).isZero();
+ }
+
+ @Test
+ @TestSecurity(user = "test-user", roles = "user")
+ @JwtSecurity(claims = { @Claim(key = "upn", value = "test-user-id") })
+ @DisplayName("Should return records when user has records")
+ void should_returnRecords_when_userHasRecords() throws Exception {
+ String userId = "test-user-id";
+ User user = createUser(userId, "+5511999999999");
+
+ QuarkusTransaction.requiringNew().run(() -> {
+ userRepository.persist(user);
+ recordRepository.persist(new Record.Builder().userId(userId).amount(new BigDecimal("50.00"))
+ .description("Test record").transaction(Transactions.OUT).category(Categories.GENERAL).build());
+ });
+
+ var response = given().when().get("/api/records").then().statusCode(200).extract().as(PageRecord.class);
+
+ assertThat(response.items()).hasSize(1);
+ assertThat(response.totalRecords()).isEqualTo(1L);
+ assertThat(response.totalExpenses()).isEqualByComparingTo("50.00");
+ }
+
+ @Test
+ @TestSecurity(user = "test-user", roles = "user")
+ @JwtSecurity(claims = { @Claim(key = "upn", value = "test-user-id") })
+ @DisplayName("Should create record successfully when valid request and matching user")
+ void should_createRecord_when_validRequestAndMatchingUser() throws Exception {
+ String userId = "test-user-id";
+ String phoneNumber = "+5511999999999";
+ User user = createUser(userId, phoneNumber);
+
+ QuarkusTransaction.requiringNew().run(() -> userRepository.persist(user));
+
+ CreateRecordRequest req = new CreateRecordRequest(new BigDecimal("100.00"), "New Record", Transactions.OUT,
+ phoneNumber, Categories.GENERAL);
+
+ given().contentType(ContentType.JSON).body(req).when().post("/api/records").then().statusCode(201);
+
+ List records = recordRepository.list("userId", userId);
+ assertThat(records).hasSize(1);
+ assertThat(records.get(0).getDescription()).isEqualTo("New Record");
+ assertThat(records.get(0).getAmount()).isEqualByComparingTo("100.00");
+ }
+
+ @Test
+ @TestSecurity(user = "test-user", roles = "user")
+ @JwtSecurity(claims = { @Claim(key = "upn", value = "test-user-id") })
+ @DisplayName("Should return forbidden when creating record for another user")
+ void should_returnForbidden_when_creatingRecordForAnotherUser() throws Exception {
+ String otherUserId = "other-user-id";
+ String otherPhoneNumber = "+5511888888888";
+ User otherUser = createUser(otherUserId, otherPhoneNumber);
+
+ QuarkusTransaction.requiringNew().run(() -> userRepository.persist(otherUser));
+
+ CreateRecordRequest req = new CreateRecordRequest(new BigDecimal("100.00"), "Other Record", Transactions.OUT,
+ otherPhoneNumber, Categories.GENERAL);
+
+ given().contentType(ContentType.JSON).body(req).when().post("/api/records").then().statusCode(403);
+ }
+
+ @Test
+ @TestSecurity(user = "test-user", roles = "user")
+ @JwtSecurity(claims = { @Claim(key = "upn", value = "test-user-id") })
+ @DisplayName("Should delete record successfully when owned by user")
+ void should_deleteRecord_when_ownedByUser() throws Exception {
+ String userId = "test-user-id";
+ User user = createUser(userId, "+5511999999999");
+
+ Long recordId = QuarkusTransaction.requiringNew().call(() -> {
+ userRepository.persist(user);
+ Record record = new Record.Builder().userId(userId).amount(new BigDecimal("50.00"))
+ .description("To be deleted").transaction(Transactions.OUT).category(Categories.GENERAL).build();
+ recordRepository.persist(record);
+ return record.getId();
+ });
+
+ given().when().delete("/api/records/" + recordId).then().statusCode(204);
+
+ assertThat(recordRepository.findById(recordId)).isNull();
+ }
+
+ private User createUser(String id, String phoneNumber) throws Exception {
+ User user = User.create("test-" + id + "@test.com", "password", "First", "Last", phoneNumber);
+ java.lang.reflect.Field idField = User.class.getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(user, id);
+ return user;
+ }
+}
diff --git a/timeless-api/src/test/resources/application.properties b/timeless-api/src/test/resources/application.properties
new file mode 100644
index 0000000..4ca68ce
--- /dev/null
+++ b/timeless-api/src/test/resources/application.properties
@@ -0,0 +1,27 @@
+# Quarkus Test Properties
+# Dev Services for Database (requires Docker)
+quarkus.datasource.db-kind=postgresql
+quarkus.datasource.devservices.enabled=true
+quarkus.datasource.devservices.image-name=postgres:17-alpine
+
+# Hibernate Settings for Test
+quarkus.hibernate-orm.schema-management.strategy=drop-and-create
+quarkus.hibernate-orm.log.sql=true
+
+# AWS dummy config for tests to avoid startup failures
+quarkus.sqs.aws.region=us-east-1
+quarkus.sqs.aws.credentials.type=static
+quarkus.sqs.aws.credentials.static-provider.access-key-id=test
+quarkus.sqs.aws.credentials.static-provider.secret-access-key=test
+whatsapp.incoming-message.queue-url=http://localhost:4566/000000000000/incoming-test.fifo
+whatsapp.recognized-message.queue-url=http://localhost:4566/000000000000/recognized-test.fifo
+
+# AI dummy config for tests
+quarkus.langchain4j.openai.api-key=dummy-key
+quarkus.langchain4j.openai.gpt-4-turbo.api-key=dummy-key
+
+# Security and JWT dummy config for tests
+security.sensible.secret=YS0xNi1ieXRlLXNlY3JldA==
+mp.jwt.verify.publickey=dummy-public-key
+mp.jwt.verify.issuer=https://timelessapp.platformoon.com/issuer
+smallrye.jwt.sign.key=dummy-private-key