Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions timeless-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,37 @@
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.instancio</groupId>
<artifactId>instancio-junit</artifactId>
<version>5.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-jwt</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record> {

@Inject
EntityManager em;

public List<AmountAndTypeOnly> 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<Record> 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 };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dev.matheuscruz.domain;

import java.math.BigDecimal;
import java.util.List;

public record RecordSummary(List<Record> records, Long totalRecords, BigDecimal totalExpenses, BigDecimal totalIncome) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<RecordItemResponse> 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<RecordItemResponse> 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<AmountAndTypeOnly> amountAndType = recordRepository.getRecordsWithAmountAndTypeOnlyByUser(upn);
Optional<BigDecimal> totalExpenses = amountAndType.stream()
.filter(item -> item.getTransaction().equals(Transactions.OUT)).map(AmountAndTypeOnly::getAmount)
.reduce(BigDecimal::add);

Optional<BigDecimal> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<Record> 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();
}
}
Loading