Skip to content

Commit

Permalink
Expose REST API for deleting an income
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Apr 8, 2024
1 parent c5f98ee commit 061204b
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 14 deletions.
29 changes: 29 additions & 0 deletions server/src/main/java/io/myfinbox/income/IncomeDeleted.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.myfinbox.income;

import io.myfinbox.shared.DomainEvent;
import io.myfinbox.shared.PaymentType;
import lombok.Builder;

import javax.money.MonetaryAmount;
import java.time.LocalDate;
import java.util.UUID;

import static io.myfinbox.shared.Guards.notNull;

@Builder
public record IncomeDeleted(UUID incomeId,
UUID accountId,
UUID incomeSourceId,
MonetaryAmount amount,
LocalDate incomeDate,
PaymentType paymentType) implements DomainEvent {

public IncomeDeleted {
notNull(incomeId, "incomeId cannot be null.");
notNull(accountId, "accountId cannot be null.");
notNull(incomeSourceId, "incomeSourceId cannot be null.");
notNull(amount, "amount cannot be null.");
notNull(incomeDate, "incomeDate cannot be null.");
notNull(paymentType, "paymentType cannot be null.");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.myfinbox.income.adapter.web;

import io.myfinbox.income.application.CreateIncomeUseCase;
import io.myfinbox.income.application.DeleteIncomeUseCase;
import io.myfinbox.income.application.IncomeCommand;
import io.myfinbox.income.application.UpdateIncomeUseCase;
import io.myfinbox.income.domain.Income;
Expand All @@ -13,8 +14,7 @@
import java.util.UUID;

import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.ResponseEntity.created;
import static org.springframework.http.ResponseEntity.ok;
import static org.springframework.http.ResponseEntity.*;
import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest;

@RestController
Expand All @@ -24,6 +24,7 @@ final class IncomeController implements IncomesApi {

private final CreateIncomeUseCase createIncomeUseCase;
private final UpdateIncomeUseCase updateIncomeUseCase;
private final DeleteIncomeUseCase deleteIncomeUseCase;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
Expand All @@ -40,6 +41,12 @@ public ResponseEntity<?> update(@PathVariable UUID incomeId, @RequestBody Income
.fold(apiFailureHandler::handle, income -> ok().body(toResource(income)));
}

@DeleteMapping(path = "/{incomeId}", produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> delete(@PathVariable UUID incomeId) {
return deleteIncomeUseCase.delete(incomeId)
.fold(apiFailureHandler::handle, ok -> noContent().build());
}

private IncomeResource toResource(Income income) {
return new IncomeResource()
.incomeId(income.getId().id())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,19 @@ public interface IncomesApi {
ResponseEntity<?> update(@Parameter(description = "IncomeId to be updated", required = true) UUID incomeId,
@RequestBody(description = "Income Resource to be created", required = true) IncomeResource resource);

@Operation(summary = "Delete an income in the MyFinBox", description = "Delete an income in the MyFinBox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG})
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Successful Operation",
content = @Content),
@ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Income to be deleted was not found",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "Internal Server Error",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> delete(@Parameter(description = "IncomeId to be deleted", required = true) UUID incomeId);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.myfinbox.income.application;

import io.myfinbox.income.domain.Income.IncomeIdentifier;
import io.myfinbox.income.domain.Incomes;
import io.myfinbox.shared.Failure;
import io.vavr.control.Either;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

import static java.util.Objects.isNull;

@Service
@Transactional
@RequiredArgsConstructor
class DeleteIncomeService implements DeleteIncomeUseCase {

static final String INCOME_NOT_FOUND_MESSAGE = "Income was not found.";

private final Incomes incomes;

@Override
public Either<Failure, Void> delete(UUID incomeId) {
if (isNull(incomeId)) {
return Either.left(Failure.ofNotFound(INCOME_NOT_FOUND_MESSAGE));
}

var income = incomes.findById(new IncomeIdentifier(incomeId));
if (income.isEmpty()) {
return Either.left(Failure.ofNotFound(INCOME_NOT_FOUND_MESSAGE));
}

income.get().delete();

incomes.delete(income.get());

return Either.right(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.myfinbox.income.application;

import io.myfinbox.shared.Failure;
import io.vavr.control.Either;

import java.util.UUID;

/**
* Interface for deleting incomes.
*/
public interface DeleteIncomeUseCase {

/**
* Deletes an income with the provided ID.
*
* @param incomeId The ID of the income to be deleted.
* @return {@link Either} a {@link Failure} instance if the income deletion fails, or null if the deletion is successful.
*/
Either<Failure, Void> delete(UUID incomeId);

}
12 changes: 12 additions & 0 deletions server/src/main/java/io/myfinbox/income/domain/Income.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.hypersistence.utils.hibernate.type.money.MonetaryAmountType;
import io.myfinbox.income.IncomeCreated;
import io.myfinbox.income.IncomeDeleted;
import io.myfinbox.income.IncomeUpdated;
import io.myfinbox.shared.PaymentType;
import jakarta.persistence.*;
Expand Down Expand Up @@ -106,6 +107,17 @@ public void update(IncomeBuilder builder) {
.build());
}

public void delete() {
registerEvent(IncomeDeleted.builder()
.incomeId(this.id.id())
.accountId(this.account.id())
.amount(this.amount)
.incomeSourceId(this.incomeSource.getId().id())
.paymentType(this.paymentType)
.incomeDate(this.incomeDate)
.build());
}

@Embeddable
public record IncomeIdentifier(UUID id) implements Serializable {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class ExpenseControllerSpec extends Specification {
var request = newValidExpenseResource()

when: 'expense is created'
var response = postNewExpense(request)
var response = postExpense(request)

then: 'response status is created'
assert response.getStatusCode() == CREATED
Expand All @@ -78,7 +78,7 @@ class ExpenseControllerSpec extends Specification {
var request = '{}'

when: 'expense fails to create'
var response = postNewExpense(request)
var response = postExpense(request)

then: 'response has status code unprocessable entity'
assert response.getStatusCode() == UNPROCESSABLE_ENTITY
Expand All @@ -100,7 +100,7 @@ class ExpenseControllerSpec extends Specification {
)

when: 'expense is updated'
var response = putAnExpense(request)
var response = putExpense(request)

then: 'response status is ok'
assert response.getStatusCode() == OK
Expand Down Expand Up @@ -130,7 +130,7 @@ class ExpenseControllerSpec extends Specification {
)

when: 'expense fails to update'
var response = postNewExpense(request)
var response = postExpense(request)

then: 'response has status code not found'
assert response.getStatusCode() == NOT_FOUND
Expand All @@ -142,9 +142,9 @@ class ExpenseControllerSpec extends Specification {
@Sql(['/expense/web/expensecategory-create.sql', '/expense/web/expense-create.sql'])
def "should delete an expense"() {
when: 'expense is deleted'
var response = deleteAnExpense()
var response = deleteExpense()

then: 'response status is created'
then: 'response status is no content'
assert response.getStatusCode() == NO_CONTENT

and: 'expense deleted event raised'
Expand All @@ -153,7 +153,7 @@ class ExpenseControllerSpec extends Specification {

def "should fail delete when expense not found"() {
when: 'expense fails to delete'
var response = deleteAnExpense()
var response = deleteExpense()

then: 'response has status code not found'
assert response.getStatusCode() == NOT_FOUND
Expand All @@ -162,11 +162,11 @@ class ExpenseControllerSpec extends Specification {
JSONAssert.assertEquals(expectedDeleteFailure(), response.getBody(), LENIENT)
}

def postNewExpense(String req) {
def postExpense(String req) {
restTemplate.postForEntity('/v1/expenses', entityRequest(req), String.class)
}

def putAnExpense(String req) {
def putExpense(String req) {
restTemplate.exchange(
"/v1/expenses/${expenseId}",
PUT,
Expand All @@ -175,7 +175,7 @@ class ExpenseControllerSpec extends Specification {
)
}

def deleteAnExpense() {
def deleteExpense() {
restTemplate.exchange(
"/v1/expenses/${expenseId}",
DELETE,
Expand Down
4 changes: 4 additions & 0 deletions server/src/test/groovy/io/myfinbox/income/DataSamples.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,8 @@ class DataSamples {
static newValidIncomeUpdatedEvent(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(INCOME_EVENT + map) as String, IncomeUpdated.class)
}

static newValidIncomeDeletedEvent(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(INCOME_EVENT + map) as String, IncomeDeleted.class)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package io.myfinbox.income.adapter.web
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import io.myfinbox.TestServerApplication
import io.myfinbox.income.DataSamples
import io.myfinbox.income.IncomeCreated
import io.myfinbox.income.IncomeDeleted
import io.myfinbox.income.IncomeUpdated
import org.skyscreamer.jsonassert.JSONAssert
import org.springframework.beans.factory.annotation.Autowired
Expand All @@ -26,6 +26,7 @@ import spock.lang.Tag
import static io.myfinbox.income.DataSamples.*
import static org.skyscreamer.jsonassert.JSONCompareMode.LENIENT
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import static org.springframework.http.HttpMethod.DELETE
import static org.springframework.http.HttpMethod.PUT
import static org.springframework.http.HttpStatus.*
import static org.springframework.http.MediaType.APPLICATION_JSON
Expand Down Expand Up @@ -139,19 +140,52 @@ class IncomeControllerSpec extends Specification {
JSONAssert.assertEquals(expectedUpdateFailure(), response.getBody(), LENIENT)
}

@Sql(['/income/web/incomesource-create.sql', '/income/web/income-create.sql'])
def "should delete an income"() {
when: 'income is deleted'
var response = deleteIncome()

then: 'response status is no content'
assert response.getStatusCode() == NO_CONTENT

and: 'income deleted event raised'
assert events.ofType(IncomeDeleted.class).contains(newValidIncomeDeletedEvent())
}

def "should fail delete when income not found"() {
when: 'income fails to delete'
var response = deleteIncome()

then: 'response has status code not found'
assert response.getStatusCode() == NOT_FOUND

and: 'response body contains not found failure response'
JSONAssert.assertEquals(expectedDeleteFailure(), response.getBody(), LENIENT)
}

def postIncome(String req) {
restTemplate.postForEntity('/v1/incomes', entityRequest(req), String.class)
}

def putIncome(String req) {
restTemplate.exchange(
"/v1/incomes/${DataSamples.incomeId}",
"/v1/incomes/${incomeId}",
PUT,
entityRequest(req),
String.class
)
}

def deleteIncome() {
restTemplate.exchange(
"/v1/incomes/${incomeId}",
DELETE,
entityRequest(null),
String.class
)
}


def entityRequest(String req) {
var headers = new HttpHeaders()
headers.setContentType(APPLICATION_JSON)
Expand Down Expand Up @@ -194,4 +228,12 @@ class IncomeControllerSpec extends Specification {
message : "Income source not found for the provided account."
])
}

def expectedDeleteFailure() {
JsonOutput.toJson([
status : 404,
errorCode: "NOT_FOUND",
message : "Income was not found."
])
}
}
Loading

0 comments on commit 061204b

Please sign in to comment.