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