diff --git a/build.sbt b/build.sbt index b395736..221d7fe 100644 --- a/build.sbt +++ b/build.sbt @@ -129,7 +129,7 @@ lazy val transactionApi = (project in file("transaction-api")) lombok ) ) - .dependsOn(security) + .dependsOn(security, itemApi) lazy val transactionImpl = (project in file("transaction-impl")) .settings(commonSettings: _*) diff --git a/transaction-api/src/main/java/com/example/auction/transaction/api/DeliveryInfo.java b/transaction-api/src/main/java/com/example/auction/transaction/api/DeliveryInfo.java index dc58017..2f45129 100644 --- a/transaction-api/src/main/java/com/example/auction/transaction/api/DeliveryInfo.java +++ b/transaction-api/src/main/java/com/example/auction/transaction/api/DeliveryInfo.java @@ -2,6 +2,10 @@ // import com.example.auction.item.api.DeliveryOption; +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Value; + +@Value public final class DeliveryInfo { private final String addressLine1; @@ -12,6 +16,7 @@ public final class DeliveryInfo { private final String country; // private final DeliveryOption selectedDeliveryOption; + @JsonCreator public DeliveryInfo(String addressLine1, String addressLine2, String city, String state, int postalCode, String country /* DeliveryOption selectedDeliveryOption*/) { this.addressLine1 = addressLine1; this.addressLine2 = addressLine2; diff --git a/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfo.java b/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfo.java index 5db90ae..4c434a4 100644 --- a/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfo.java +++ b/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfo.java @@ -1,23 +1,35 @@ package com.example.auction.transaction.api; -//import com.example.auction.item.api.Item; -import org.pcollections.PSequence; +import com.example.auction.item.api.ItemData; +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Value; +import java.util.Optional; +import java.util.UUID; + +@Value public final class TransactionInfo { - //private final Item item; - private final PSequence messages; - private final TransactionStatus status; - private final DeliveryInfo deliveryInfo; + //private final PSequence messages; + private final UUID itemId; + private final UUID creator; + private final UUID winner; + private final ItemData itemData; + private final int itemPrice; private final int deliveryPrice; - private final PaymentInfo paymentInfo; + private final Optional deliveryInfo; + private final TransactionInfoStatus status; + //private final PaymentInfo paymentInfo; - public TransactionInfo(/*Item item, */PSequence messages, TransactionStatus status, DeliveryInfo deliveryInfo, int deliveryPrice, PaymentInfo paymentInfo) { - // this.item = item; - this.messages = messages; - this.status = status; - this.deliveryInfo = deliveryInfo; + @JsonCreator + public TransactionInfo(UUID itemId, UUID creator, UUID winner, ItemData itemData, int itemPrice, int deliveryPrice, Optional deliveryInfo, TransactionInfoStatus status) { + this.itemId = itemId; + this.creator = creator; + this.winner = winner; + this.itemData = itemData; + this.itemPrice = itemPrice; this.deliveryPrice = deliveryPrice; - this.paymentInfo = paymentInfo; + this.deliveryInfo = deliveryInfo; + this.status = status; } } diff --git a/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionStatus.java b/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfoStatus.java similarity index 86% rename from transaction-api/src/main/java/com/example/auction/transaction/api/TransactionStatus.java rename to transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfoStatus.java index fb5d614..543fca1 100644 --- a/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionStatus.java +++ b/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionInfoStatus.java @@ -1,6 +1,6 @@ package com.example.auction.transaction.api; -public enum TransactionStatus { +public enum TransactionInfoStatus { /** * Negotiating delivery details. */ @@ -11,6 +11,11 @@ public enum TransactionStatus { */ PAYMENT_SUBMITTED, + /** + * Payment is rejected + */ + PAYMENT_FAILED, + /** * Payment is confirmed. */ diff --git a/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionService.java b/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionService.java index c2b499e..98c8ee2 100644 --- a/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionService.java +++ b/transaction-api/src/main/java/com/example/auction/transaction/api/TransactionService.java @@ -1,10 +1,18 @@ package com.example.auction.transaction.api; import static com.lightbend.lagom.javadsl.api.Service.named; +import static com.lightbend.lagom.javadsl.api.Service.pathCall; +import akka.Done; +import akka.NotUsed; +import com.example.auction.security.SecurityHeaderFilter; import com.lightbend.lagom.javadsl.api.Descriptor; import com.lightbend.lagom.javadsl.api.Service; +import com.lightbend.lagom.javadsl.api.ServiceCall; import com.lightbend.lagom.javadsl.api.broker.Topic; +import com.lightbend.lagom.javadsl.api.deser.PathParamSerializers; + +import java.util.UUID; /** * The transaction services. @@ -19,7 +27,7 @@ public interface TransactionService extends Service { //ServiceCall sendMessage(UUID itemId); - //ServiceCall submitDeliveryDetails(UUID itemId); + ServiceCall submitDeliveryDetails(UUID itemId); //ServiceCall setDeliveryPrice(UUID itemId); @@ -31,6 +39,8 @@ public interface TransactionService extends Service { //ServiceCall initiateRefund(UUID itemId); + ServiceCall getTransaction(UUID itemId); + /** * The transaction events topic. */ @@ -39,8 +49,11 @@ public interface TransactionService extends Service { @Override default Descriptor descriptor() { return named("transaction").withCalls( - // No pathcalls for now ... - ); + pathCall("/api/transaction/:id", this::submitDeliveryDetails), + pathCall("/api/transaction/:id", this::getTransaction) + ).withPathParamSerializer(UUID.class, PathParamSerializers.required("UUID", UUID::fromString, UUID::toString)) + .withHeaderFilter(SecurityHeaderFilter.INSTANCE); + } -} +} \ No newline at end of file diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/DeliveryData.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/DeliveryData.java new file mode 100644 index 0000000..b344a13 --- /dev/null +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/DeliveryData.java @@ -0,0 +1,24 @@ +package com.example.auction.transaction.impl; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Value; + +@Value +public class DeliveryData { + private final String addressLine1; + private final String addressLine2; + private final String city; + private final String state; + private final int postalCode; + private final String country; + + @JsonCreator + public DeliveryData(String addressLine1, String addressLine2, String city, String state, int postalCode, String country) { + this.addressLine1 = addressLine1; + this.addressLine2 = addressLine2; + this.city = city; + this.state = state; + this.postalCode = postalCode; + this.country = country; + } +} diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/Transaction.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/Transaction.java index 309dc25..934f984 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/Transaction.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/Transaction.java @@ -1,9 +1,11 @@ package com.example.auction.transaction.impl; +import com.example.auction.item.api.ItemData; import com.fasterxml.jackson.annotation.JsonCreator; import com.lightbend.lagom.serialization.Jsonable; import lombok.Value; +import java.util.Optional; import java.util.UUID; @Value @@ -12,15 +14,33 @@ public class Transaction implements Jsonable { private final UUID itemId; private final UUID creator; private final UUID winner; + private final ItemData itemData; private final int itemPrice; private final int deliveryPrice; + private final Optional deliveryData; @JsonCreator - public Transaction(UUID itemId, UUID creator, UUID winner, int itemPrice, int deliveryPrice) { + private Transaction(UUID itemId, UUID creator, UUID winner, ItemData itemData, int itemPrice, int deliveryPrice, Optional deliveryData) { this.itemId = itemId; this.creator = creator; this.winner = winner; + this.itemData = itemData; this.itemPrice = itemPrice; this.deliveryPrice = deliveryPrice; + this.deliveryData = deliveryData; + } + + public Transaction(UUID itemId, UUID creator, UUID winner, ItemData itemData, int itemPrice) { + this.itemId = itemId; + this.creator = creator; + this.winner = winner; + this.itemData = itemData; + this.itemPrice = itemPrice; + this.deliveryPrice = 0; + this.deliveryData = Optional.empty(); + } + + public Transaction withDeliveryData(DeliveryData deliveryData){ + return new Transaction(itemId, creator, winner, itemData, itemPrice, deliveryPrice, Optional.of(deliveryData)); } } diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionCommand.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionCommand.java index ba70f3f..9b07ea0 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionCommand.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionCommand.java @@ -2,16 +2,19 @@ import akka.Done; import com.fasterxml.jackson.annotation.JsonCreator; -import com.lightbend.lagom.javadsl.persistence.PersistentEntity; +import com.lightbend.lagom.javadsl.persistence.PersistentEntity.ReplyType; import com.lightbend.lagom.serialization.Jsonable; import lombok.Value; +import java.util.UUID; + /** * A transaction command. */ public interface TransactionCommand extends Jsonable { + @Value - final class StartTransaction implements TransactionCommand, PersistentEntity.ReplyType { + final class StartTransaction implements TransactionCommand, ReplyType { private final Transaction transaction; @@ -20,4 +23,26 @@ public StartTransaction(Transaction transaction) { this.transaction = transaction; } } + + @Value + final class SubmitDeliveryDetails implements TransactionCommand, ReplyType { + private final UUID userId; + private final DeliveryData deliveryData; + + @JsonCreator + public SubmitDeliveryDetails(UUID userId, DeliveryData deliveryData) { + this.userId = userId; + this.deliveryData = deliveryData; + } + } + + @Value + final class GetTransaction implements TransactionCommand, ReplyType { + private final UUID userId; + + @JsonCreator + public GetTransaction(UUID userId) { + this.userId = userId; + } + } } diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEntity.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEntity.java index 632814b..9502dfe 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEntity.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEntity.java @@ -1,13 +1,17 @@ package com.example.auction.transaction.impl; import akka.Done; +import com.lightbend.lagom.javadsl.api.transport.Forbidden; +import com.lightbend.lagom.javadsl.api.transport.NotFound; import com.lightbend.lagom.javadsl.persistence.PersistentEntity; import com.example.auction.transaction.impl.TransactionCommand.*; import com.example.auction.transaction.impl.TransactionEvent.*; + import java.util.Optional; import java.util.UUID; public class TransactionEntity extends PersistentEntity { + @Override public Behavior initialBehavior(Optional snapshotState) { if (!snapshotState.isPresent()) { @@ -19,6 +23,8 @@ public Behavior initialBehavior(Optional snapshotState) { return notStarted(state); case NEGOTIATING_DELIVERY: return negotiatingDelivery(state); + case PAYMENT_SUBMITTED: + return paymentSubmitted(state); default: throw new IllegalStateException(); } @@ -28,25 +34,69 @@ public Behavior initialBehavior(Optional snapshotState) { private Behavior notStarted(TransactionState state) { BehaviorBuilder builder = newBehaviorBuilder(state); - builder.setCommandHandler(StartTransaction.class, (start, ctx) -> - ctx.thenPersist(new TransactionStarted(entityUUID(), start.getTransaction()), (e) -> + builder.setCommandHandler(StartTransaction.class, (cmd, ctx) -> + ctx.thenPersist(new TransactionStarted(entityUUID(), cmd.getTransaction()), (e) -> ctx.reply(Done.getInstance()) ) ); - builder.setEventHandlerChangingBehavior(TransactionStarted.class, started -> - negotiatingDelivery(TransactionState.start(started.getTransaction())) + builder.setEventHandlerChangingBehavior(TransactionStarted.class, event -> + negotiatingDelivery(TransactionState.start(event.getTransaction())) ); + addGetTransactionHandler(builder); return builder.build(); } private Behavior negotiatingDelivery(TransactionState state) { BehaviorBuilder builder = newBehaviorBuilder(state); + + builder.setReadOnlyCommandHandler(StartTransaction.class, (cmd, ctx) -> + ctx.reply(Done.getInstance()) + ); + + builder.setCommandHandler(SubmitDeliveryDetails.class, (cmd, ctx) -> { + if(cmd.getUserId().equals(state().getTransaction().get().getWinner())) { + return ctx.thenPersist(new DeliveryDetailsSubmitted(entityUUID(), cmd.getDeliveryData()), (e) -> + ctx.reply(Done.getInstance()) + ); + } + else + throw new Forbidden("Only the auction winner can submit delivery details"); + }); + + builder.setEventHandler(DeliveryDetailsSubmitted.class, evt -> + state().updateDeliveryData(evt.getDeliveryData()) + ); + + addGetTransactionHandler(builder); + + return builder.build(); + } + + private Behavior paymentSubmitted(TransactionState state) { + BehaviorBuilder builder = newBehaviorBuilder(state); // WIP ... + + addGetTransactionHandler(builder); + return builder.build(); } + private void addGetTransactionHandler(BehaviorBuilder builder) { + builder.setReadOnlyCommandHandler(GetTransaction.class, (cmd, ctx) -> { + if(state().getTransaction().isPresent()) { + if (cmd.getUserId().equals(state().getTransaction().get().getCreator()) || + cmd.getUserId().equals(state().getTransaction().get().getWinner())) + ctx.reply(state()); + else + throw new Forbidden("Only the item owner and the auction winner can see transaction details"); + } + else + throw new NotFound("Transaction for item " + entityId() + " not found"); + }); + } + private UUID entityUUID() { return UUID.fromString(entityId()); } diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEvent.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEvent.java index 2a1c069..3bc1b74 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEvent.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionEvent.java @@ -32,4 +32,15 @@ public TransactionStarted(UUID itemId, Transaction transaction) { } } + @Value + final class DeliveryDetailsSubmitted implements TransactionEvent { + private final UUID itemId; + private final DeliveryData deliveryData; + + @JsonCreator + public DeliveryDetailsSubmitted(UUID itemId, DeliveryData deliveryData) { + this.itemId = itemId; + this.deliveryData = deliveryData; + } + } } diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionMappers.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionMappers.java new file mode 100644 index 0000000..eec2f20 --- /dev/null +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionMappers.java @@ -0,0 +1,48 @@ +package com.example.auction.transaction.impl; + +import com.example.auction.transaction.api.DeliveryInfo; +import com.example.auction.transaction.api.TransactionInfo; + +import java.util.Optional; + +public class TransactionMappers { + + public static Optional toApi(Optional data) { + return data.map(deliveryData -> new DeliveryInfo( + data.get().getAddressLine1(), + data.get().getAddressLine2(), + data.get().getCity(), + data.get().getState(), + data.get().getPostalCode(), + data.get().getCountry()) + ); + } + + public static DeliveryData fromApi(DeliveryInfo data) { + return new DeliveryData( + data.getAddressLine1(), + data.getAddressLine2(), + data.getCity(), + data.getState(), + data.getPostalCode(), + data.getCountry() + ); + } + + public static TransactionInfo toApi(TransactionState data) { + // TransactionEntity verifies if a transaction in TransactionState is set + // This code is called after this verification was done from TransactionServiceImpl + // We can get() safely + Transaction transaction = data.getTransaction().get(); + return new TransactionInfo( + transaction.getItemId(), + transaction.getCreator(), + transaction.getWinner(), + transaction.getItemData(), + transaction.getItemPrice(), + transaction.getDeliveryPrice(), + toApi(transaction.getDeliveryData()), + data.getStatus().transactionStatus + ); + } +} \ No newline at end of file diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionServiceImpl.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionServiceImpl.java index 59a2f59..df4efd7 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionServiceImpl.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionServiceImpl.java @@ -1,12 +1,16 @@ package com.example.auction.transaction.impl; import akka.Done; +import akka.NotUsed; import akka.stream.javadsl.Flow; +import com.example.auction.transaction.api.TransactionInfo; +import com.example.auction.transaction.impl.TransactionCommand.*; import com.example.auction.item.api.Item; import com.example.auction.item.api.ItemEvent; import com.example.auction.item.api.ItemService; -import com.example.auction.transaction.impl.TransactionCommand.*; +import com.example.auction.transaction.api.DeliveryInfo; import com.example.auction.transaction.api.TransactionService; +import com.lightbend.lagom.javadsl.api.ServiceCall; import com.lightbend.lagom.javadsl.persistence.PersistentEntityRef; import com.lightbend.lagom.javadsl.persistence.PersistentEntityRegistry; @@ -14,6 +18,8 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; +import static com.example.auction.security.ServerSecurity.authenticated; + public class TransactionServiceImpl implements TransactionService { private final PersistentEntityRegistry registry; @@ -26,14 +32,17 @@ public TransactionServiceImpl(PersistentEntityRegistry registry, ItemService ite itemService.itemEvents().subscribe().atLeastOnce(Flow.create().mapAsync(1, itemEvent -> { if (itemEvent instanceof ItemEvent.AuctionFinished) { ItemEvent.AuctionFinished auctionFinished = (ItemEvent.AuctionFinished) itemEvent; - Item item = auctionFinished.getItem(); - Transaction transaction = new Transaction(item.getId(), item.getCreator(), - item.getAuctionWinner().get(), item.getPrice(), 0); - - return entityRef(auctionFinished.getItemId()).ask(new StartTransaction(transaction)); - } else { + // If an auction doesn't have a winner, then we can't start a transaction + if(auctionFinished.getItem().getAuctionWinner().isPresent()) { + Item item = auctionFinished.getItem(); + Transaction transaction = new Transaction(item.getId(), item.getCreator(), + item.getAuctionWinner().get(), item.getItemData(), item.getPrice()); + return entityRef(auctionFinished.getItemId()).ask(new StartTransaction(transaction)); + } + else + return CompletableFuture.completedFuture(Done.getInstance()); + } else return CompletableFuture.completedFuture(Done.getInstance()); - } })); } @@ -42,6 +51,28 @@ public Topic transactionEvents() { return null; }*/ + @Override + public ServiceCall submitDeliveryDetails(UUID itemId) { + return authenticated(userId -> deliveryInfo -> { + SubmitDeliveryDetails submit = new SubmitDeliveryDetails(userId, TransactionMappers.fromApi(deliveryInfo)); + return entityRef(itemId).ask(submit); + }); + } + + @Override + public ServiceCall getTransaction(UUID itemId) { + return authenticated(userId -> request -> { + GetTransaction get = new GetTransaction(userId); + return entityRef(itemId) + .ask(get) + .thenApply(transaction -> { + TransactionInfo transactionInfo = TransactionMappers.toApi((TransactionState) transaction); + return transactionInfo; + }); + }); + + } + private PersistentEntityRef entityRef(UUID itemId) { return registry.refFor(TransactionEntity.class, itemId.toString()); } diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionState.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionState.java index 799792d..34c2a21 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionState.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionState.java @@ -5,6 +5,7 @@ import lombok.Value; import java.util.Optional; +import java.util.function.Function; /** * The transaction state. @@ -12,9 +13,6 @@ @Value public class TransactionState implements Jsonable { - /** - * The transaction details. - */ private final Optional transaction; private final TransactionStatus status; @@ -31,4 +29,13 @@ public static TransactionState notStarted() { public static TransactionState start(Transaction transaction) { return new TransactionState(Optional.of(transaction), TransactionStatus.NEGOTIATING_DELIVERY); } + + public TransactionState updateDeliveryData(DeliveryData deliveryData) { + return update(i -> i.withDeliveryData(deliveryData), status); + } + + private TransactionState update(Function updateFunction, TransactionStatus status) { + assert transaction.isPresent(); + return new TransactionState(transaction.map(updateFunction), status); + } } diff --git a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionStatus.java b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionStatus.java index c6ad8c2..f1a3064 100644 --- a/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionStatus.java +++ b/transaction-impl/src/main/java/com/example/auction/transaction/impl/TransactionStatus.java @@ -1,14 +1,22 @@ package com.example.auction.transaction.impl; +import com.example.auction.transaction.api.TransactionInfoStatus; + public enum TransactionStatus { - NOT_STARTED, - NEGOTIATING_DELIVERY, - PAYMENT_SUBMITTED, - PAYMENT_FAILED, - PAYMENT_CONFIRMED, - ITEM_DISPATCHED, - ITEM_RECEIVED, - CANCELED, - REFUNDING, - REFUNDED + NOT_STARTED(null), + NEGOTIATING_DELIVERY(TransactionInfoStatus.NEGOTIATING_DELIVERY), + PAYMENT_SUBMITTED(TransactionInfoStatus.PAYMENT_SUBMITTED), + PAYMENT_FAILED(TransactionInfoStatus.PAYMENT_FAILED), + PAYMENT_CONFIRMED(TransactionInfoStatus.PAYMENT_CONFIRMED), + ITEM_DISPATCHED(TransactionInfoStatus.ITEM_DISPATCHED), + ITEM_RECEIVED(TransactionInfoStatus.ITEM_RECEIVED), + CANCELLED(TransactionInfoStatus.CANCELLED), + REFUNDING(TransactionInfoStatus.REFUNDING), + REFUNDED(TransactionInfoStatus.REFUNDED); + + public final TransactionInfoStatus transactionStatus; + + TransactionStatus(TransactionInfoStatus transactionStatus) { + this.transactionStatus = transactionStatus; + } } diff --git a/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionEntityTest.java b/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionEntityTest.java index 6de9f5b..04b00ad 100644 --- a/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionEntityTest.java +++ b/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionEntityTest.java @@ -2,16 +2,20 @@ import akka.actor.ActorSystem; import akka.testkit.JavaTestKit; +import com.example.auction.item.api.ItemData; +import com.lightbend.lagom.javadsl.api.transport.Forbidden; import com.lightbend.lagom.javadsl.testkit.PersistentEntityTestDriver; +import com.lightbend.lagom.javadsl.testkit.PersistentEntityTestDriver.Outcome; import org.junit.*; import com.example.auction.transaction.impl.TransactionCommand.*; import com.example.auction.transaction.impl.TransactionEvent.*; + +import java.time.Duration; import java.util.Optional; import java.util.UUID; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; @@ -34,8 +38,14 @@ public static void shutdownActorSystem() { private final UUID itemId = UUID.randomUUID(); private final UUID creator = UUID.randomUUID(); private final UUID winner = UUID.randomUUID(); + private final ItemData itemData = new ItemData("title", "desc", "EUR", 1, 10, Duration.ofMinutes(10), Optional.empty()); + private final DeliveryData deliveryData = new DeliveryData("Addr1", "Addr2", "City", "State", 27, "Country"); + + private final Transaction transaction = new Transaction(itemId, creator, winner, itemData, 2000); - private final Transaction transaction = new Transaction(itemId, creator, winner, 2000, 50); + private final StartTransaction startTransaction = new StartTransaction(transaction); + private final SubmitDeliveryDetails submitDeliveryDetails = new SubmitDeliveryDetails(winner, deliveryData); + private final GetTransaction getTransaction = new GetTransaction(creator); @Before public void createTestDriver() { @@ -51,11 +61,43 @@ public void noIssues() { } @Test - public void testStartTransaction() { - PersistentEntityTestDriver.Outcome outcome = driver.run(new StartTransaction(transaction)); + public void shouldEmitEventWhenCreatingTransaction() { + Outcome outcome = driver.run(startTransaction); assertThat(outcome.state().getStatus(), equalTo(TransactionStatus.NEGOTIATING_DELIVERY)); assertThat(outcome.state().getTransaction(), equalTo(Optional.of(transaction))); assertThat(outcome.events(), hasItem(new TransactionStarted(itemId, transaction))); } + + @Test + public void shouldEmitEventWhenSubmittingDeliveryDetails(){ + driver.run(startTransaction); + Outcome outcome = driver.run(submitDeliveryDetails); + assertThat(outcome.state().getStatus(), equalTo(TransactionStatus.NEGOTIATING_DELIVERY)); + assertThat(outcome.events(), hasItem(new DeliveryDetailsSubmitted(itemId, deliveryData))); + } + + @Test(expected = Forbidden.class) + public void shouldForbidSubmittingDeliveryDetailsByNonBuyer() throws Throwable{ + driver.run(startTransaction); + UUID hacker = UUID.randomUUID(); + SubmitDeliveryDetails invalid = new SubmitDeliveryDetails(hacker, deliveryData); + driver.run(invalid); + } + + @Test + public void shouldAllowSeeTransactionByItemCreator() { + driver.run(startTransaction); + Outcome outcome = driver.run(getTransaction); + assertThat(outcome.getReplies(), hasItem(outcome.state())); + } + + @Test(expected = Forbidden.class) + public void shouldForbidSeeTransactionByNonWinnerNonCreator() throws Throwable{ + driver.run(startTransaction); + UUID hacker = UUID.randomUUID(); + GetTransaction invalid = new GetTransaction(hacker); + driver.run(invalid); + } } + diff --git a/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionServiceImplIntegrationTest.java b/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionServiceImplIntegrationTest.java new file mode 100644 index 0000000..f114c37 --- /dev/null +++ b/transaction-impl/src/test/java/com/example/auction/transaction/impl/TransactionServiceImplIntegrationTest.java @@ -0,0 +1,169 @@ +package com.example.auction.transaction.impl; + +import akka.Done; +import akka.NotUsed; +import com.example.auction.item.api.*; +import com.example.auction.pagination.PaginatedSequence; +import com.example.auction.transaction.api.DeliveryInfo; +import com.example.auction.transaction.api.TransactionInfo; +import com.example.auction.transaction.api.TransactionInfoStatus; +import com.example.auction.transaction.api.TransactionService; +import com.lightbend.lagom.javadsl.api.ServiceCall; +import com.lightbend.lagom.javadsl.api.broker.Topic; +import com.lightbend.lagom.javadsl.api.transport.NotFound; +import com.lightbend.lagom.javadsl.testkit.ProducerStub; +import com.lightbend.lagom.javadsl.testkit.ProducerStubFactory; +import com.lightbend.lagom.javadsl.testkit.ServiceTest; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import scala.concurrent.duration.FiniteDuration; + +import javax.inject.Inject; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static com.example.auction.security.ClientSecurity.authenticate; +import static com.lightbend.lagom.javadsl.testkit.ServiceTest.bind; +import static com.lightbend.lagom.javadsl.testkit.ServiceTest.defaultSetup; +import static com.lightbend.lagom.javadsl.testkit.ServiceTest.eventually; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertEquals; + +public class TransactionServiceImplIntegrationTest { + + private final static ServiceTest.Setup setup = defaultSetup().withCassandra(true) + .configureBuilder(b -> + b.configure("cassandra-query-journal.eventual-consistency-delay", "0") + .overrides(bind(ItemService.class).to(ItemStub.class)) + ); + + private static ServiceTest.TestServer testServer; + private static TransactionService transactionService; + + @BeforeClass + public static void beforeAll() { + testServer = ServiceTest.startServer(setup); + transactionService = testServer.client(TransactionService.class); + } + + @AfterClass + public static void afterAll() { + testServer.stop(); + } + + private static ProducerStub itemProducerStub; + + private final UUID itemId = UUID.randomUUID(); + private final UUID creatorId = UUID.randomUUID(); + private final UUID winnerId = UUID.randomUUID(); + private final ItemData itemData = new ItemData("title", "desc", "EUR", 1, 10, Duration.ofMinutes(10), Optional.empty()); + private final Item item = new Item(itemId, creatorId, itemData, 5000, ItemStatus.COMPLETED, Optional.of(Instant.now()), Optional.of(Instant.now()), Optional.of(winnerId)); + private final ItemEvent.AuctionFinished auctionFinished = new ItemEvent.AuctionFinished(itemId, item); + + private final DeliveryInfo deliveryInfo = new DeliveryInfo("ADDR1", "ADDR2", "CITY", "STATE", 27, "COUNTRY"); + + private final TransactionInfo transactionInfoStarted = new TransactionInfo(itemId, creatorId, winnerId, itemData, item.getPrice(), 0, Optional.empty(), TransactionInfoStatus.NEGOTIATING_DELIVERY); + private final TransactionInfo transactionInfoWithDelivery = new TransactionInfo(itemId, creatorId, winnerId, itemData, item.getPrice(), 0, Optional.of(deliveryInfo), TransactionInfoStatus.NEGOTIATING_DELIVERY); + + + @Test + public void shouldCreateTransactionOnAuctionFinished() { + itemProducerStub.send(auctionFinished); + + eventually(new FiniteDuration(10, SECONDS), () -> { + TransactionInfo retrievedTransaction = transactionService.getTransaction(itemId) + .handleRequestHeader(authenticate(creatorId)) + .invoke() + .toCompletableFuture() + .get(5, SECONDS); + assertEquals(retrievedTransaction, transactionInfoStarted); + }); + } + + @Test(expected = NotFound.class) + public void shouldNotCreateTransactionWithNoWinner() throws Throwable { + UUID itemIdWithNoWinner = UUID.randomUUID(); + Item itemWithNoWinner = new Item(itemIdWithNoWinner, creatorId, itemData, 5000, ItemStatus.COMPLETED, Optional.of(Instant.now()), Optional.of(Instant.now()), Optional.empty()); + ItemEvent.AuctionFinished auctionFinishedWithNoWinner = new ItemEvent.AuctionFinished(itemIdWithNoWinner, itemWithNoWinner); + itemProducerStub.send(auctionFinishedWithNoWinner); + + try { + transactionService.getTransaction(itemIdWithNoWinner) + .handleRequestHeader(authenticate(creatorId)) + .invoke() + .toCompletableFuture() + .get(5, SECONDS); + } + catch(ExecutionException re) { + throw re.getCause(); + } + catch (InterruptedException | TimeoutException e) { + throw e; + } + } + + @Test + public void shouldSubmitDeliveryDetails() throws Throwable { + itemProducerStub.send(auctionFinished); + + transactionService.submitDeliveryDetails(itemId) + .handleRequestHeader(authenticate(winnerId)) + .invoke(deliveryInfo) + .toCompletableFuture() + .get(5, SECONDS); + + eventually(new FiniteDuration(15, SECONDS), () -> { + TransactionInfo retrievedTransaction = transactionService.getTransaction(itemId) + .handleRequestHeader(authenticate(creatorId)) + .invoke() + .toCompletableFuture() + .get(5, SECONDS); + + assertEquals(retrievedTransaction, transactionInfoWithDelivery); + }); + } + + private static class ItemStub implements ItemService { + + @Inject + public ItemStub(ProducerStubFactory topicFactory) { + itemProducerStub = topicFactory.producer(TOPIC_ID); + } + + @Override + public ServiceCall createItem() { + return null; + } + + @Override + public ServiceCall updateItem(UUID id) { + return null; + } + + @Override + public ServiceCall startAuction(UUID id) { + return null; + } + + @Override + public ServiceCall getItem(UUID id) { + return null; + } + + @Override + public ServiceCall> getItemsForUser( + UUID id, ItemStatus status, Optional pageNo, Optional pageSize) { + return null; + } + + @Override + public Topic itemEvents() { + return itemProducerStub.topic(); + } + } +} diff --git a/web-gateway/app/Module.java b/web-gateway/app/Module.java index fe6840f..c82b623 100644 --- a/web-gateway/app/Module.java +++ b/web-gateway/app/Module.java @@ -1,6 +1,7 @@ import com.example.auction.bidding.api.BiddingService; import com.example.auction.item.api.ItemService; import com.example.auction.search.api.SearchService; +import com.example.auction.transaction.api.TransactionService; import com.example.auction.user.api.UserService; import com.lightbend.lagom.javadsl.api.ServiceAcl; import com.lightbend.lagom.javadsl.api.ServiceInfo; @@ -15,6 +16,7 @@ protected void configure() { bindClient(ItemService.class); bindClient(BiddingService.class); bindClient(SearchService.class); + bindClient(TransactionService.class); } } diff --git a/web-gateway/app/controllers/DeliveryDetailsForm.java b/web-gateway/app/controllers/DeliveryDetailsForm.java new file mode 100644 index 0000000..5d0adc0 --- /dev/null +++ b/web-gateway/app/controllers/DeliveryDetailsForm.java @@ -0,0 +1,66 @@ +package controllers; + +import play.data.validation.Constraints; + +public class DeliveryDetailsForm { + @Constraints.Required + private String addressLine1; + @Constraints.Required + private String addressLine2; + @Constraints.Required + private String city; + @Constraints.Required + private String state; + @Constraints.Required + private int postalCode; + @Constraints.Required + private String country; + + public String getAddressLine1() { + return addressLine1; + } + + public void setAddressLine1(String addressLine1) { + this.addressLine1 = addressLine1; + } + + public String getAddressLine2() { + return addressLine2; + } + + public void setAddressLine2(String addressLine2) { + this.addressLine2 = addressLine2; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public int getPostalCode() { + return postalCode; + } + + public void setPostalCode(int postalCode) { + this.postalCode = postalCode; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } +} diff --git a/web-gateway/app/controllers/TransactionController.java b/web-gateway/app/controllers/TransactionController.java new file mode 100644 index 0000000..ee1c450 --- /dev/null +++ b/web-gateway/app/controllers/TransactionController.java @@ -0,0 +1,147 @@ +package controllers; + +import com.example.auction.transaction.api.DeliveryInfo; +import com.example.auction.transaction.api.TransactionInfo; +import com.example.auction.transaction.api.TransactionInfoStatus; +import com.example.auction.transaction.api.TransactionService; +import com.example.auction.user.api.User; +import com.example.auction.user.api.UserService; +import com.lightbend.lagom.javadsl.api.transport.TransportException; +import play.Configuration; +import play.data.Form; +import play.data.FormFactory; +import play.i18n.MessagesApi; +import play.mvc.Http; +import play.mvc.Result; + +import javax.inject.Inject; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import static com.example.auction.security.ClientSecurity.authenticate; + +public class TransactionController extends AbstractController { + private final FormFactory formFactory; + private final TransactionService transactionService; + + private final Boolean showInlineInstruction; + + @Inject + public TransactionController(Configuration config, MessagesApi messagesApi, UserService userService, FormFactory formFactory, + TransactionService transactionService) { + super(messagesApi, userService); + this.formFactory = formFactory; + this.transactionService = transactionService; + + showInlineInstruction = config.getBoolean("online-auction.instruction.show"); + } + + public CompletionStage getTransaction(String id) { + return requireUser(ctx(), user -> + loadNav(user).thenCompose(nav -> { + UUID itemId = UUID.fromString(id); + CompletionStage transactionFuture = transactionService.getTransaction(itemId).handleRequestHeader(authenticate(user)).invoke(); + return transactionFuture.handle((transaction, exception) ->{ + if(exception == null) { + Optional seller = Optional.empty(); + Optional winner = Optional.empty(); + for (User u : nav.getUsers()) { + if (transaction.getCreator().equals(u.getId())) { + seller = Optional.of(u); + } + if (transaction.getWinner().equals(u.getId())) { + winner = Optional.of(u); + } + } + Currency currency = Currency.valueOf(transaction.getItemData().getCurrencyId()); + return ok(views.html.transaction.render(showInlineInstruction, Optional.of(transaction), seller, winner, Optional.of(currency), Optional.empty(), nav)); + } + else { + String msg = ((TransportException) exception.getCause()).exceptionMessage().detail(); + return ok(views.html.transaction.render(showInlineInstruction, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(msg), nav)); + } + }); + }) + ); + } + + public CompletionStage submitDeliveryDetailsForm(String id) { + return requireUser(ctx(), user -> + loadNav(user).thenCompose(nav -> { + UUID itemId = UUID.fromString(id); + CompletionStage transactionFuture = transactionService.getTransaction(itemId).handleRequestHeader(authenticate(user)).invoke(); + return transactionFuture.handle((transaction, exception) ->{ + if(exception == null) { + DeliveryDetailsForm form = new DeliveryDetailsForm(); + Optional maybeDeliveryInfo = transaction.getDeliveryInfo(); + if(maybeDeliveryInfo.isPresent()) { + form.setAddressLine1(maybeDeliveryInfo.get().getAddressLine1()); + form.setAddressLine2(maybeDeliveryInfo.get().getAddressLine2()); + form.setCity(maybeDeliveryInfo.get().getCity()); + form.setState(maybeDeliveryInfo.get().getState()); + form.setPostalCode(maybeDeliveryInfo.get().getPostalCode()); + form.setCountry(maybeDeliveryInfo.get().getCountry()); + } + return ok( + views.html.deliveryDetails.render( + showInlineInstruction, + !transaction.getCreator().equals(user), + itemId, + formFactory.form(DeliveryDetailsForm.class).fill(form), + transaction.getStatus(), + Optional.empty(), + nav) + ); + } + else { + String msg = ((TransportException) exception.getCause()).exceptionMessage().detail(); + return ok(views.html.deliveryDetails.render(showInlineInstruction, false, itemId, formFactory.form(DeliveryDetailsForm.class), TransactionInfoStatus.NEGOTIATING_DELIVERY, Optional.of(msg), nav)); + } + }); + }) + ); + } + + public CompletionStage submitDeliveryDetails(String id, String transactionStatus, boolean isBuyer) { + Http.Context ctx = ctx(); + return requireUser(ctx(), user -> { + + Form form = formFactory.form(DeliveryDetailsForm.class).bindFromRequest(ctx.request()); + UUID itemId = UUID.fromString(id); + TransactionInfoStatus status = TransactionInfoStatus.valueOf(transactionStatus); + + if (form.hasErrors()) { + return loadNav(user).thenApply(nav -> + ok(views.html.deliveryDetails.render(showInlineInstruction, isBuyer, itemId, form, status, Optional.empty(), nav)) + ); + } else { + return transactionService.submitDeliveryDetails(itemId) + .handleRequestHeader(authenticate(user)) + .invoke(fromForm(form.get())) + .handle((done, exception) -> { + if(exception == null) { + return CompletableFuture.completedFuture(redirect(routes.TransactionController.getTransaction(id))); + //return CompletableFuture.completedFuture(redirect(controllers.routes.TransactionController.submitDeliveryDetailsForm(id))); + } else { + String msg = ((TransportException) exception.getCause()).exceptionMessage().detail(); + return loadNav(user).thenApply(nav -> + ok(views.html.deliveryDetails.render(showInlineInstruction, isBuyer, itemId, form, status, Optional.of(msg), nav))); + } + }).thenCompose(x -> x); + } + }); + } + + private DeliveryInfo fromForm(DeliveryDetailsForm deliveryForm) { + return new DeliveryInfo( + deliveryForm.getAddressLine1(), + deliveryForm.getAddressLine2(), + deliveryForm.getCity(), + deliveryForm.getState(), + deliveryForm.getPostalCode(), + deliveryForm.getCountry() + ); + } +} diff --git a/web-gateway/app/views/deliveryDetails.scala.html b/web-gateway/app/views/deliveryDetails.scala.html new file mode 100644 index 0000000..9282b6a --- /dev/null +++ b/web-gateway/app/views/deliveryDetails.scala.html @@ -0,0 +1,76 @@ +@import helper._ +@import java.util.Optional +@import java.util.UUID + +@import com.example.auction.transaction.api.TransactionInfoStatus +@(showInlineInstruction: Boolean, isBuyer: Boolean, itemId: UUID, deliveryForm: Form[DeliveryDetailsForm], transactionStatus: TransactionInfoStatus, errorMessage: Optional[String])(implicit nav: Nav) + +@* TODO: Get transaction status to show or not read-only inputs *@ +@main(message("deliveryDetails")) { + +

@message("deliveryDetails")

+ + @if(showInlineInstruction == true) { +

@message("instruction.deliveryDetails")

+ } + + @foundationForm(deliveryForm, routes.TransactionController.submitDeliveryDetails(itemId.toString, transactionStatus.name, isBuyer)) { + @if(errorMessage.isPresent) { +
+ @errorMessage.get() +
+ } + +
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + @inputText(deliveryForm("addressLine1")) + } else { + @inputText(deliveryForm("addressLine1"),'readonly->'readonly) + } +
+
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + @inputText(deliveryForm("addressLine2")) + } else { + @inputText(deliveryForm("addressLine2"),'readonly -> 'readonly) + } +
+
+
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + @inputText(deliveryForm("city")) + } else { + @inputText(deliveryForm("city"),'readonly -> 'readonly) + } + +
+
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + @inputText(deliveryForm("state")) + } else { + @inputText(deliveryForm("state"),'readonly -> 'readonly) + } +
+
+ +
+
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + @inputText(deliveryForm("postalCode"), 'type -> "number") + } else { + @inputText(deliveryForm("postalCode"), 'type -> "number",'readonly -> 'readonly) + } +
+
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + @inputText(deliveryForm("country")) + } else { + @inputText(deliveryForm("country"),'readonly -> 'readonly) + } +
+
+ @if(transactionStatus.equals(TransactionInfoStatus.NEGOTIATING_DELIVERY) && isBuyer) { + + } + } +} diff --git a/web-gateway/app/views/transaction.scala.html b/web-gateway/app/views/transaction.scala.html new file mode 100644 index 0000000..90ff9ed --- /dev/null +++ b/web-gateway/app/views/transaction.scala.html @@ -0,0 +1,47 @@ +@import com.example.auction.transaction.api.TransactionInfo +@import com.example.auction.user.api.User +@import java.util.Optional +@import controllers.{Currency => Currencies} + +@(showInlineInstruction: Boolean, transaction: Optional[TransactionInfo], seller: Optional[User], winner: Optional[User], currency: Optional[Currencies], errorMessage: Optional[String])(implicit nav: Nav) +@main(message("transactionInfo")) { +

@message("transactionInfo")

+ + @if(showInlineInstruction == true) { +

@message("instruction.transactionInfo")

+ } + + @if(errorMessage.isPresent) { +
+ @errorMessage.get() +
+ } + + @if(transaction.isPresent) { +
+

@message("aboutGeneral")

+ +
@message("seller")
+
@seller.get().getName
+ +
@message("auctionWinner")
+
@winner.get().getName
+ +

@message("aboutItem")

+ +
@message("title")
+
@transaction.get().getItemData.getTitle
+ +
@message("description")
+
@transaction.get().getItemData.getDescription
+ +
@message("finalPrice")
+
@currency.get().format(transaction.get().getItemPrice)
+ +
@message("currency")
+
@currency.get().getDisplayName
+
+ + @message("viewDeliveryDetails") + } +} \ No newline at end of file diff --git a/web-gateway/conf/messages b/web-gateway/conf/messages index 9a2765f..6d2f6b9 100644 --- a/web-gateway/conf/messages +++ b/web-gateway/conf/messages @@ -99,3 +99,19 @@ instruction.auctionCancelled=The auction has been cancelled for this item. instruction.search=Here you can search for auctioned items. You can filter your search by specifying the maximum price of items to search for and the currency to search in. instruction.enableSearch=Search functionality can be enabled by downloading and running a local instance of elasticsearch. Steps for doing this can be found in {0}. If the lagom system is up and running, you don't need to restart the system after installing and running elasticsearch; the search service becomes functional once it starts. + +instruction.deliveryDetails=Here you can view/submit the delivery details for the transaction +instruction.transactionInfo=Here you can see all transaction details + +transactionInfo=Transaction information +aboutItem=About item +aboutGeneral=General information +viewDeliveryDetails=View delivery details +deliveryDetails=Delivery Details +submitDeliveryDetails=Submit +addressLine1=Adress Line 1 +addressLine2=Adress Line 2 +city=City +state=State +postalCode=Postal code +country=Country diff --git a/web-gateway/conf/routes b/web-gateway/conf/routes index 693d5a4..76b46c8 100644 --- a/web-gateway/conf/routes +++ b/web-gateway/conf/routes @@ -22,3 +22,8 @@ GET /search controllers.SearchController.searchForm( POST /search controllers.SearchController.search() GET /assets/*file controllers.Assets.at(path = "/public", file) + +# Transaction +GET /transaction/:id controllers.TransactionController.getTransaction(id) +GET /transaction/:id/delivery controllers.TransactionController.submitDeliveryDetailsForm(id) +POST /transaction/:id/delivery controllers.TransactionController.submitDeliveryDetails(id, transactionStatus, isBuyer: Boolean)