diff --git a/src/main/java/com/scmspain/MsFcTechTestApplication.java b/src/main/java/com/scmspain/MsFcTechTestApplication.java index 28b3538..77ca812 100644 --- a/src/main/java/com/scmspain/MsFcTechTestApplication.java +++ b/src/main/java/com/scmspain/MsFcTechTestApplication.java @@ -1,18 +1,17 @@ package com.scmspain; -import com.scmspain.configuration.InfrastructureConfiguration; -import com.scmspain.configuration.TweetConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; @Configuration @EnableAutoConfiguration -@Import({TweetConfiguration.class, InfrastructureConfiguration.class}) +@ComponentScan(basePackageClasses = { MsFcTechTestApplication.class }) public class MsFcTechTestApplication { + public static void main(String[] args) { SpringApplication.run(MsFcTechTestApplication.class, args); } + } diff --git a/src/main/java/com/scmspain/application/services/TweetMetricService.java b/src/main/java/com/scmspain/application/services/TweetMetricService.java new file mode 100644 index 0000000..d498679 --- /dev/null +++ b/src/main/java/com/scmspain/application/services/TweetMetricService.java @@ -0,0 +1,53 @@ +package com.scmspain.application.services; + +import java.util.List; + +import com.scmspain.domain.MetricService; +import com.scmspain.domain.TweetNotFoundException; +import com.scmspain.domain.TweetService; +import com.scmspain.domain.model.TweetResponse; + +/** + * Tweet service implementation that adds metric to another wrapped tweet service. + */ +public class TweetMetricService implements TweetService { + + private final MetricService metricService; + private final TweetService tweetService; + + /** + * Constructor. + * + * @param tweetService Tweet service to wrap. + * @param metricService Metrics service. + */ + public TweetMetricService(final TweetService tweetService, final MetricService metricService) { + this.tweetService = tweetService; + this.metricService = metricService; + } + + @Override + public Long publish(String publisher, String text) { + metricService.incrementPublishedTweets(); + return tweetService.publish(publisher, text); + } + + @Override + public List listAll() { + metricService.incrementTimesQueriedTweets(); + return tweetService.listAll(); + } + + @Override + public void discard(Long tweetId) throws TweetNotFoundException { + metricService.incrementDiscardedTweets(); + tweetService.discard(tweetId); + } + + @Override + public List listAllDiscarded() { + metricService.incrementTimesQueriedDiscardedTweets(); + return tweetService.listAllDiscarded(); + } + +} diff --git a/src/main/java/com/scmspain/application/services/TweetValidationService.java b/src/main/java/com/scmspain/application/services/TweetValidationService.java new file mode 100644 index 0000000..7831113 --- /dev/null +++ b/src/main/java/com/scmspain/application/services/TweetValidationService.java @@ -0,0 +1,73 @@ +package com.scmspain.application.services; + +import java.util.List; + +import com.scmspain.domain.TweetNotFoundException; +import com.scmspain.domain.TweetService; +import com.scmspain.domain.model.TweetResponse; + +/** + * Tweet service implementation that adds validation to another wrapped tweet service. + */ +public class TweetValidationService implements TweetService { + + private static final int MAX_CHARACTERS = 140; + + private final TweetService tweetService; + private final UrlRemover urlRemover; + + /** + * Constructor. + * + * @param tweetService Tweet service to wrap. + */ + public TweetValidationService(final TweetService tweetService) { + this.tweetService = tweetService; + this.urlRemover = new UrlRemover(); + } + + @Override + public Long publish(final String publisher, final String text) { + if (publisherEmpty(publisher)) { + throw new IllegalArgumentException("Publisher must not be empty"); + } + if (textEmpty(text)) { + throw new IllegalArgumentException("Tweet must not be empty"); + } + if (textTooLong(text)) { + throw new IllegalArgumentException("Tweet must not be greater than " + MAX_CHARACTERS + " characters"); + } + return this.tweetService.publish(publisher, text); + } + + @Override + public List listAll() { + return tweetService.listAll(); + } + + @Override + public void discard(Long tweetId) throws TweetNotFoundException { + if (tweetId == null) { + throw new IllegalArgumentException("Tweet identifier must not be empty"); + } + this.tweetService.discard(tweetId); + } + + @Override + public List listAllDiscarded() { + return tweetService.listAllDiscarded(); + } + + private boolean textEmpty(final String text) { + return text == null || text.isEmpty(); + } + + private boolean publisherEmpty(final String publisher) { + return publisher == null || publisher.isEmpty(); + } + + private boolean textTooLong(final String text) { + return urlRemover.removeUrls(text).length() > MAX_CHARACTERS; + } + +} diff --git a/src/main/java/com/scmspain/application/services/UrlExtractor.java b/src/main/java/com/scmspain/application/services/UrlExtractor.java new file mode 100644 index 0000000..ba0442f --- /dev/null +++ b/src/main/java/com/scmspain/application/services/UrlExtractor.java @@ -0,0 +1,73 @@ +package com.scmspain.application.services; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Extract URLs from a String for some protocols. + */ +public class UrlExtractor { + + private static final String WHITE_SPACE = " "; + private static final String HTTP_PROTOCOL = "http://"; + private static final String HTTPS_PROTOCOL = "https://"; + + private String text; + private List urls = new ArrayList<>(); + + public UrlExtractor(final String text) { + this.text = text; + this.extractUrls(); + } + + public String getText() { + return text; + } + + public List getUrls() { + return urls; + } + + private void extractUrls() { + extractUrls(HTTP_PROTOCOL); + extractUrls(HTTPS_PROTOCOL); + } + + private void extractUrls(final String protocol) { + Optional url = extractUrl(protocol); + if (url.isPresent()) { + text = text.replace(url.get(), getEscapeIndex(urls.size())); + urls.add(url.get()); + extractUrl(protocol); + } + } + + private Optional extractUrl(String protocol) { + int urlStart = text.indexOf(protocol); + if (urlStart != -1) { + int urlEnd = text.indexOf(WHITE_SPACE, urlStart); + if (urlEnd != -1) { + return Optional.of(text.substring(urlStart, urlEnd)); + } else { + return Optional.of(text.substring(urlStart)); + } + } + return Optional.empty(); + } + + public static String rebuild(String text, List urls) { + for (int i = 0; i < urls.size(); i++) { + String target = getEscapeIndex(i); + while (text.contains(target)) { + text = text.replace(target, urls.get(i)); + } + } + return text; + } + + private static String getEscapeIndex(int index) { + return "{" + index + "}"; + } + +} diff --git a/src/main/java/com/scmspain/application/services/UrlRemover.java b/src/main/java/com/scmspain/application/services/UrlRemover.java new file mode 100644 index 0000000..45cc0ba --- /dev/null +++ b/src/main/java/com/scmspain/application/services/UrlRemover.java @@ -0,0 +1,40 @@ +package com.scmspain.application.services; + +/** + * Remove URLs from a String for some protocols. + */ +class UrlRemover { + + private static final String WHITE_SPACE = " "; + private static final String HTTP_PROTOCOL = "http://"; + private static final String HTTPS_PROTOCOL = "https://"; + + /** + * Given a text removes the URLs contained on it. + * + * @param text Text. + * @return Text without URLs. + */ + String removeUrls(final String text) { + String cleanText = removeUrls(text, HTTP_PROTOCOL); + return removeUrls(cleanText, HTTPS_PROTOCOL); + } + + private String removeUrls(final String text, final String protocol) { + if (text.contains(protocol)) { + String newText = removeUrl(text, protocol); + return removeUrls(newText, protocol); + } + return text; + } + + private String removeUrl(String text, String protocol) { + int urlStart = text.indexOf(protocol); + int urlEnd = text.indexOf(WHITE_SPACE, urlStart); + if (urlEnd == -1) { + return text.substring(0, urlStart); + } + return text.substring(0, urlStart) + text.substring(urlEnd + 1); + } + +} diff --git a/src/main/java/com/scmspain/configuration/TweetConfiguration.java b/src/main/java/com/scmspain/configuration/TweetConfiguration.java deleted file mode 100644 index fbb0dbd..0000000 --- a/src/main/java/com/scmspain/configuration/TweetConfiguration.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.scmspain.configuration; - -import com.scmspain.controller.TweetController; -import com.scmspain.services.TweetService; -import org.springframework.boot.actuate.metrics.writer.MetricWriter; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import javax.persistence.EntityManager; - -@Configuration -public class TweetConfiguration { - @Bean - public TweetService getTweetService(EntityManager entityManager, MetricWriter metricWriter) { - return new TweetService(entityManager, metricWriter); - } - - @Bean - public TweetController getTweetConfiguration(TweetService tweetService) { - return new TweetController(tweetService); - } -} diff --git a/src/main/java/com/scmspain/controller/TweetController.java b/src/main/java/com/scmspain/controller/TweetController.java deleted file mode 100644 index 55ce7cd..0000000 --- a/src/main/java/com/scmspain/controller/TweetController.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.scmspain.controller; - -import com.scmspain.controller.command.PublishTweetCommand; -import com.scmspain.entities.Tweet; -import com.scmspain.services.TweetService; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.CREATED; - -@RestController -public class TweetController { - private TweetService tweetService; - - public TweetController(TweetService tweetService) { - this.tweetService = tweetService; - } - - @GetMapping("/tweet") - public List listAllTweets() { - return this.tweetService.listAllTweets(); - } - - @PostMapping("/tweet") - @ResponseStatus(CREATED) - public void publishTweet(@RequestBody PublishTweetCommand publishTweetCommand) { - this.tweetService.publishTweet(publishTweetCommand.getPublisher(), publishTweetCommand.getTweet()); - } - - @ExceptionHandler(IllegalArgumentException.class) - @ResponseStatus(BAD_REQUEST) - @ResponseBody - public Object invalidArgumentException(IllegalArgumentException ex) { - return new Object() { - public String message = ex.getMessage(); - public String exceptionClass = ex.getClass().getSimpleName(); - }; - } -} diff --git a/src/main/java/com/scmspain/controller/command/PublishTweetCommand.java b/src/main/java/com/scmspain/controller/command/PublishTweetCommand.java deleted file mode 100644 index 543897b..0000000 --- a/src/main/java/com/scmspain/controller/command/PublishTweetCommand.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.scmspain.controller.command; - -public class PublishTweetCommand { - private String publisher; - private String tweet; - - public String getPublisher() { - return publisher; - } - - public void setPublisher(String publisher) { - this.publisher = publisher; - } - - public String getTweet() { - return tweet; - } - - public void setTweet(String tweet) { - this.tweet = tweet; - } -} diff --git a/src/main/java/com/scmspain/domain/MetricService.java b/src/main/java/com/scmspain/domain/MetricService.java new file mode 100644 index 0000000..a351e5f --- /dev/null +++ b/src/main/java/com/scmspain/domain/MetricService.java @@ -0,0 +1,28 @@ +package com.scmspain.domain; + +/** + * Metric service. + */ +public interface MetricService { + + /** + * Increment by one the published tweets metrics. + */ + void incrementPublishedTweets(); + + /** + * Increment by one the times queried tweets metrics. + */ + void incrementTimesQueriedTweets(); + + /** + * Increment by one the discarded tweets metrics. + */ + void incrementDiscardedTweets(); + + /** + * Increment by one the times queried discarded tweets metrics. + */ + void incrementTimesQueriedDiscardedTweets(); + +} diff --git a/src/main/java/com/scmspain/domain/TweetNotFoundException.java b/src/main/java/com/scmspain/domain/TweetNotFoundException.java new file mode 100644 index 0000000..f7a9f6f --- /dev/null +++ b/src/main/java/com/scmspain/domain/TweetNotFoundException.java @@ -0,0 +1,11 @@ +package com.scmspain.domain; + +import com.scmspain.domain.command.CommandException; + +public class TweetNotFoundException extends CommandException { + + public TweetNotFoundException(Long id) { + super(String.format("Tweet with identifier %s not found", id)); + } + +} diff --git a/src/main/java/com/scmspain/domain/TweetService.java b/src/main/java/com/scmspain/domain/TweetService.java new file mode 100644 index 0000000..5e58e0c --- /dev/null +++ b/src/main/java/com/scmspain/domain/TweetService.java @@ -0,0 +1,43 @@ +package com.scmspain.domain; + +import java.util.List; + +import com.scmspain.domain.model.TweetResponse; + +/** + * Tweet service. + */ +public interface TweetService { + + /** + * Publish a new tweet. + * + * @param publisher Creator of the tweet. + * @param text Content of the tweet. + * @return Identifier of the published tweet. + */ + Long publish(String publisher, String text); + + /** + * List all available tweets sorted by publication date in descending order. + * + * @return List of tweets. + */ + List listAll(); + + /** + * Discard a tweet. + * + * @param tweetId Tweet identifier. + * @throws TweetNotFoundException if no tweet found for the specified identifier. + */ + void discard(Long tweetId) throws TweetNotFoundException; + + /** + * List all discarded tweets sorted by the date it was discarded on in descending order.. + * + * @return List of discarded tweets. + */ + List listAllDiscarded(); + +} diff --git a/src/main/java/com/scmspain/domain/command/Command.java b/src/main/java/com/scmspain/domain/command/Command.java new file mode 100644 index 0000000..7effc54 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/Command.java @@ -0,0 +1,8 @@ +package com.scmspain.domain.command; + +/** + * Command interface to be implemented by domain commands. + * + * @param type of the return value. + */ +public interface Command { } diff --git a/src/main/java/com/scmspain/domain/command/CommandBus.java b/src/main/java/com/scmspain/domain/command/CommandBus.java new file mode 100644 index 0000000..87146e6 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/CommandBus.java @@ -0,0 +1,18 @@ +package com.scmspain.domain.command; + +/** + * Command bus able to execute commands. + * Given a command will pass it to the proper handler. + */ +public interface CommandBus { + + /** + * Look for the proper handler of the given command. + * + * @param command Command to execute. + * @param type of return value. + * @param type of the command. + */ + > R execute(C command) throws CommandException; + +} diff --git a/src/main/java/com/scmspain/domain/command/CommandException.java b/src/main/java/com/scmspain/domain/command/CommandException.java new file mode 100644 index 0000000..1704f60 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/CommandException.java @@ -0,0 +1,9 @@ +package com.scmspain.domain.command; + +public class CommandException extends Exception { + + public CommandException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/scmspain/domain/command/CommandHandler.java b/src/main/java/com/scmspain/domain/command/CommandHandler.java new file mode 100644 index 0000000..c903b75 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/CommandHandler.java @@ -0,0 +1,20 @@ +package com.scmspain.domain.command; + +/** + * A handler for a {@link Command}. + * + * @param type of return value. + * @param type of the command. + */ +public interface CommandHandler> { + + /** + * Handles the command. + * + * @param command Command to handle. + * @return Return value as specified in {@link Command}. + * {@link Void} if none value returned. + */ + R handle(C command) throws CommandException; + +} diff --git a/src/main/java/com/scmspain/domain/command/DiscardTweetCommand.java b/src/main/java/com/scmspain/domain/command/DiscardTweetCommand.java new file mode 100644 index 0000000..038edaf --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/DiscardTweetCommand.java @@ -0,0 +1,18 @@ +package com.scmspain.domain.command; + +/** + * Command to discard a tweet. + */ +public class DiscardTweetCommand implements Command { + + private Long tweet; + + /** + * Gets the tweet identifier to discard. + * @return Tweet identifier. + */ + public Long getTweet() { + return tweet; + } + +} diff --git a/src/main/java/com/scmspain/domain/command/DiscardTweetCommandHandler.java b/src/main/java/com/scmspain/domain/command/DiscardTweetCommandHandler.java new file mode 100644 index 0000000..0dd70a3 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/DiscardTweetCommandHandler.java @@ -0,0 +1,28 @@ +package com.scmspain.domain.command; + +import com.scmspain.domain.TweetNotFoundException; +import com.scmspain.domain.TweetService; + +/** + * Handler for the discard tweet command. + */ +public class DiscardTweetCommandHandler implements CommandHandler { + + private final TweetService tweetService; + + /** + * Constructor. + * + * @param tweetService Tweet service. + */ + public DiscardTweetCommandHandler(final TweetService tweetService) { + this.tweetService = tweetService; + } + + @Override + public Void handle(DiscardTweetCommand command) throws TweetNotFoundException { + tweetService.discard(command.getTweet()); + return null; + } + +} diff --git a/src/main/java/com/scmspain/domain/command/ListAllDiscardedTweetsCommand.java b/src/main/java/com/scmspain/domain/command/ListAllDiscardedTweetsCommand.java new file mode 100644 index 0000000..ab241c6 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/ListAllDiscardedTweetsCommand.java @@ -0,0 +1,10 @@ +package com.scmspain.domain.command; + +import java.util.List; + +import com.scmspain.domain.model.TweetResponse; + +/** + * Command for list all discarded tweets. + */ +public class ListAllDiscardedTweetsCommand implements Command> { } \ No newline at end of file diff --git a/src/main/java/com/scmspain/domain/command/ListAllDiscardedTweetsCommandHandler.java b/src/main/java/com/scmspain/domain/command/ListAllDiscardedTweetsCommandHandler.java new file mode 100644 index 0000000..3652953 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/ListAllDiscardedTweetsCommandHandler.java @@ -0,0 +1,29 @@ +package com.scmspain.domain.command; + +import java.util.List; + +import com.scmspain.domain.TweetService; +import com.scmspain.domain.model.TweetResponse; + +/** + * Handler for the list all discarded tweets command. + */ +public class ListAllDiscardedTweetsCommandHandler implements CommandHandler, ListAllDiscardedTweetsCommand> { + + private final TweetService tweetService; + + /** + * Constructor. + * + * @param tweetService Tweet service. + */ + public ListAllDiscardedTweetsCommandHandler(final TweetService tweetService) { + this.tweetService = tweetService; + } + + @Override + public List handle(ListAllDiscardedTweetsCommand command) { + return this.tweetService.listAllDiscarded(); + } + +} diff --git a/src/main/java/com/scmspain/domain/command/ListAllTweetsCommand.java b/src/main/java/com/scmspain/domain/command/ListAllTweetsCommand.java new file mode 100644 index 0000000..57b8bf2 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/ListAllTweetsCommand.java @@ -0,0 +1,10 @@ +package com.scmspain.domain.command; + +import java.util.List; + +import com.scmspain.domain.model.TweetResponse; + +/** + * Command for list all available tweets. + */ +public class ListAllTweetsCommand implements Command> { } diff --git a/src/main/java/com/scmspain/domain/command/ListAllTweetsCommandHandler.java b/src/main/java/com/scmspain/domain/command/ListAllTweetsCommandHandler.java new file mode 100644 index 0000000..7d4a83e --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/ListAllTweetsCommandHandler.java @@ -0,0 +1,29 @@ +package com.scmspain.domain.command; + +import java.util.List; + +import com.scmspain.domain.TweetService; +import com.scmspain.domain.model.TweetResponse; + +/** + * Handler for the list all tweets command. + */ +public class ListAllTweetsCommandHandler implements CommandHandler, ListAllTweetsCommand> { + + private final TweetService tweetService; + + /** + * Constructor. + * + * @param tweetService Tweet service. + */ + public ListAllTweetsCommandHandler(final TweetService tweetService) { + this.tweetService = tweetService; + } + + @Override + public List handle(ListAllTweetsCommand command) { + return this.tweetService.listAll(); + } + +} diff --git a/src/main/java/com/scmspain/domain/command/PublishTweetCommand.java b/src/main/java/com/scmspain/domain/command/PublishTweetCommand.java new file mode 100644 index 0000000..490b625 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/PublishTweetCommand.java @@ -0,0 +1,29 @@ +package com.scmspain.domain.command; + +/** + * Command for publish a tweet. + */ +public class PublishTweetCommand implements Command { + + private String publisher; + private String tweet; + + /** + * Gets the publisher of the tweet to be published. + * + * @return Publisher. + */ + public String getPublisher() { + return publisher; + } + + /** + * Gets the tweet text of the tweet to be published. + * + * @return Tweet text. + */ + public String getTweet() { + return tweet; + } + +} diff --git a/src/main/java/com/scmspain/domain/command/PublishTweetCommandHandler.java b/src/main/java/com/scmspain/domain/command/PublishTweetCommandHandler.java new file mode 100644 index 0000000..077acd5 --- /dev/null +++ b/src/main/java/com/scmspain/domain/command/PublishTweetCommandHandler.java @@ -0,0 +1,26 @@ +package com.scmspain.domain.command; + +import com.scmspain.domain.TweetService; + +/** + * Handler for the publish tweet command. + */ +public class PublishTweetCommandHandler implements CommandHandler { + + private final TweetService tweetService; + + /** + * Constructor. + * + * @param tweetService Tweet service. + */ + public PublishTweetCommandHandler(final TweetService tweetService) { + this.tweetService = tweetService; + } + + @Override + public Long handle(PublishTweetCommand command) { + return tweetService.publish(command.getPublisher(), command.getTweet()); + } + +} diff --git a/src/main/java/com/scmspain/domain/model/TweetResponse.java b/src/main/java/com/scmspain/domain/model/TweetResponse.java new file mode 100644 index 0000000..743e474 --- /dev/null +++ b/src/main/java/com/scmspain/domain/model/TweetResponse.java @@ -0,0 +1,63 @@ +package com.scmspain.domain.model; + +/** + * Tweet response. + */ +public class TweetResponse { + + private final Long id; + private final String publisher; + private final String tweet; + private final Long pre2015MigrationStatus; + + /** + * Constructor. + * + * @param id Identifier of the tweet. + * @param publisher Creator of the tweet. + * @param tweet Content of the tweet. + */ + public TweetResponse(final Long id, final String publisher, final String tweet, final Long pre2015MigrationStatus) { + this.id = id; + this.publisher = publisher; + this.tweet = tweet; + this.pre2015MigrationStatus = pre2015MigrationStatus; + } + + /** + * Gets the identifier of the tweet. + * + * @return Identifier of the tweet. + */ + public Long getId() { + return this.id; + } + + /** + * Gets the creator of the tweet. + * + * @return Creator of the tweet. + */ + public String getPublisher() { + return publisher; + } + + /** + * Gets the content of the tweet. + * + * @return Content of the tweet. + */ + public String getTweet() { + return tweet; + } + + /** + * Gets the pre 2015 migration status. + * + * @return Pre 2015 migration status. + */ + public Long getPre2015MigrationStatus() { + return pre2015MigrationStatus; + } + +} diff --git a/src/main/java/com/scmspain/entities/Tweet.java b/src/main/java/com/scmspain/entities/Tweet.java deleted file mode 100644 index 3616a94..0000000 --- a/src/main/java/com/scmspain/entities/Tweet.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.scmspain.entities; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; - -@Entity -public class Tweet { - @Id - @GeneratedValue - private Long id; - @Column(nullable = false) - private String publisher; - @Column(nullable = false, length = 140) - private String tweet; - @Column (nullable=true) - private Long pre2015MigrationStatus = 0L; - - public Tweet() { - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getPublisher() { - return publisher; - } - - public void setPublisher(String publisher) { - this.publisher = publisher; - } - - public String getTweet() { - return tweet; - } - - public void setTweet(String tweet) { - this.tweet = tweet; - } - - public Long getPre2015MigrationStatus() { - return pre2015MigrationStatus; - } - - public void setPre2015MigrationStatus(Long pre2015MigrationStatus) { - this.pre2015MigrationStatus = pre2015MigrationStatus; - } - -} diff --git a/src/main/java/com/scmspain/infrastructure/commandbus/CommandProvider.java b/src/main/java/com/scmspain/infrastructure/commandbus/CommandProvider.java new file mode 100644 index 0000000..f0e318c --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/commandbus/CommandProvider.java @@ -0,0 +1,37 @@ +package com.scmspain.infrastructure.commandbus; + +import org.springframework.context.ApplicationContext; + +import com.scmspain.domain.command.CommandHandler; + +/** + * Basic implementation of a command handler provider. + * Returns a registered bean handler from the Spring framework application context. + * + * @param type of handler + */ +class CommandProvider> { + + private final ApplicationContext applicationContext; + private final Class type; + + /** + * Constructor. + * + * @param applicationContext Spring framework application context. + * @param type Type of handler. + */ + CommandProvider(ApplicationContext applicationContext, Class type) { + this.applicationContext = applicationContext; + this.type = type; + } + + /** + * Gets a bean of the proper type from the Spring framework application context. + * @return Spring bean. + */ + H get() { + return applicationContext.getBean(type); + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/commandbus/Registry.java b/src/main/java/com/scmspain/infrastructure/commandbus/Registry.java new file mode 100644 index 0000000..78077f7 --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/commandbus/Registry.java @@ -0,0 +1,57 @@ +package com.scmspain.infrastructure.commandbus; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.core.GenericTypeResolver; +import org.springframework.stereotype.Component; + +import com.scmspain.domain.command.Command; +import com.scmspain.domain.command.CommandHandler; + +/** + * Basic implementation of a registry with the mapping between commands and handlers based on the Spring framework + * application context. + */ +@Component +public class Registry { + + private final Map, CommandProvider> providerMap = new HashMap<>(); + + /** + * Constructor. + * + * @param applicationContext Spring framework application context. + */ + @Autowired + public Registry(ApplicationContext applicationContext) { + String[] names = applicationContext.getBeanNamesForType(CommandHandler.class); + for (String name : names) { + register(applicationContext, name); + } + } + + @SuppressWarnings("unchecked") + private void register(ApplicationContext applicationContext, String name){ + Class> handlerClass = (Class>) applicationContext.getType(name); + Class[] generics = GenericTypeResolver.resolveTypeArguments(handlerClass, CommandHandler.class); + Class commandType = (Class) generics[1]; + providerMap.put(commandType, new CommandProvider(applicationContext, handlerClass)); + } + + /** + * Gets the proper handler for the given command class. + * + * @param commandClass Command class, should implement {@link Command}. + * @param type of return value. + * @param type of the command. + * @return Handler for the command. + */ + @SuppressWarnings("unchecked") + > CommandHandler get(Class commandClass) { + return providerMap.get(commandClass).get(); + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/commandbus/SpringCommandBus.java b/src/main/java/com/scmspain/infrastructure/commandbus/SpringCommandBus.java new file mode 100644 index 0000000..156c30b --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/commandbus/SpringCommandBus.java @@ -0,0 +1,36 @@ +package com.scmspain.infrastructure.commandbus; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.scmspain.domain.command.Command; +import com.scmspain.domain.command.CommandBus; +import com.scmspain.domain.command.CommandException; +import com.scmspain.domain.command.CommandHandler; + +/** + * Basic implementation of a command bus based on Spring framework application context. + */ +@Component +public class SpringCommandBus implements CommandBus { + + private final Registry registry; + + /** + * Creates a new instance with the given registry. + * + * @param registry registry + */ + @Autowired + public SpringCommandBus(Registry registry) { + this.registry = registry; + } + + @Override + @SuppressWarnings("unchecked") + public > R execute(C command) throws CommandException { + CommandHandler commandHandler = (CommandHandler) registry.get(command.getClass()); + return commandHandler.handle(command); + } + +} diff --git a/src/main/java/com/scmspain/configuration/InfrastructureConfiguration.java b/src/main/java/com/scmspain/infrastructure/configuration/InfrastructureConfiguration.java similarity index 70% rename from src/main/java/com/scmspain/configuration/InfrastructureConfiguration.java rename to src/main/java/com/scmspain/infrastructure/configuration/InfrastructureConfiguration.java index a0a2e48..26b6151 100644 --- a/src/main/java/com/scmspain/configuration/InfrastructureConfiguration.java +++ b/src/main/java/com/scmspain/infrastructure/configuration/InfrastructureConfiguration.java @@ -1,4 +1,4 @@ -package com.scmspain.configuration; +package com.scmspain.infrastructure.configuration; import org.springframework.boot.actuate.autoconfigure.ExportMetricWriter; import org.springframework.boot.actuate.metrics.jmx.JmxMetricWriter; @@ -7,10 +7,16 @@ import org.springframework.context.annotation.Configuration; import org.springframework.jmx.export.MBeanExporter; +/** + * Configuration for the infrastructure. + */ @Configuration public class InfrastructureConfiguration { - @Bean @ExportMetricWriter - public MetricWriter getMetricWriter(MBeanExporter exporter) { + + @Bean + @ExportMetricWriter + public MetricWriter getMetricWriter(final MBeanExporter exporter) { return new JmxMetricWriter(exporter); } + } diff --git a/src/main/java/com/scmspain/infrastructure/configuration/TweetConfiguration.java b/src/main/java/com/scmspain/infrastructure/configuration/TweetConfiguration.java new file mode 100644 index 0000000..3b88237 --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/configuration/TweetConfiguration.java @@ -0,0 +1,48 @@ +package com.scmspain.infrastructure.configuration; + +import com.scmspain.domain.MetricService; +import com.scmspain.application.services.TweetMetricService; +import com.scmspain.application.services.TweetValidationService; +import com.scmspain.domain.TweetService; +import com.scmspain.domain.command.DiscardTweetCommandHandler; +import com.scmspain.domain.command.ListAllDiscardedTweetsCommandHandler; +import com.scmspain.domain.command.ListAllTweetsCommandHandler; +import com.scmspain.domain.command.PublishTweetCommandHandler; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for the tweet application. + */ +@Configuration +public class TweetConfiguration { + + @Bean + public TweetService mainTweetService(final @Qualifier("tweetRepository") TweetService tweetService, final MetricService metricService) { + TweetService tweetValidationService = new TweetValidationService(tweetService); + return new TweetMetricService(tweetValidationService, metricService); + } + + @Bean + public PublishTweetCommandHandler getPublishTweetCommandHandler(@Qualifier("mainTweetService") final TweetService tweetService) { + return new PublishTweetCommandHandler(tweetService); + } + + @Bean + public DiscardTweetCommandHandler getDiscardTweetCommandHandler(@Qualifier("mainTweetService") final TweetService tweetService) { + return new DiscardTweetCommandHandler(tweetService); + } + + @Bean + public ListAllTweetsCommandHandler getListAllTweetsCommandHandler(@Qualifier("mainTweetService") final TweetService tweetService) { + return new ListAllTweetsCommandHandler(tweetService); + } + + @Bean + public ListAllDiscardedTweetsCommandHandler getListAllDiscardedTweetsCommandHandler(@Qualifier("mainTweetService") final TweetService tweetService) { + return new ListAllDiscardedTweetsCommandHandler(tweetService); + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/controller/GlobalExceptionHandler.java b/src/main/java/com/scmspain/infrastructure/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..990a750 --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/controller/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.scmspain.infrastructure.controller; + +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +import com.scmspain.domain.TweetNotFoundException; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +/** + * Controller to manage exceptions. + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(BAD_REQUEST) + @ResponseBody + public Object invalidArgumentException(IllegalArgumentException ex) { + return getExceptionObject(ex); + } + + @ExceptionHandler(TweetNotFoundException.class) + @ResponseStatus(NOT_FOUND) + @ResponseBody + public Object tweetNotFoundException(TweetNotFoundException ex) { + return getExceptionObject(ex); + } + + private Object getExceptionObject(Exception ex) { + return new Object() { + public String message = ex.getMessage(); + public String exceptionClass = ex.getClass().getSimpleName(); + }; + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/controller/TweetController.java b/src/main/java/com/scmspain/infrastructure/controller/TweetController.java new file mode 100644 index 0000000..9e44815 --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/controller/TweetController.java @@ -0,0 +1,59 @@ +package com.scmspain.infrastructure.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.scmspain.domain.command.CommandBus; +import com.scmspain.domain.command.CommandException; +import com.scmspain.domain.command.DiscardTweetCommand; +import com.scmspain.domain.command.ListAllDiscardedTweetsCommand; +import com.scmspain.domain.command.ListAllTweetsCommand; +import com.scmspain.domain.command.PublishTweetCommand; +import com.scmspain.domain.model.TweetResponse; + +import static org.springframework.http.HttpStatus.CREATED; + +/** + * Rest controller for tweet API. + */ +@RestController +public class TweetController { + + private final CommandBus commandBus; + + /** + * Constructor. + * + * @param commandBus Command bus. + */ + public TweetController(final CommandBus commandBus) { + this.commandBus = commandBus; + } + + @PostMapping("/tweet") + @ResponseStatus(CREATED) + public void publishTweet(@RequestBody PublishTweetCommand publishTweetCommand) throws CommandException { + this.commandBus.execute(publishTweetCommand); + } + + @GetMapping("/tweet") + public List listAllTweets() throws CommandException { + return this.commandBus.execute(new ListAllTweetsCommand()); + } + + @PostMapping("/discarded") + public void discardTweet(@RequestBody DiscardTweetCommand discardTweetCommand) throws CommandException { + this.commandBus.execute(discardTweetCommand); + } + + @GetMapping("/discarded") + public List listAllDiscardedTweets() throws CommandException { + return this.commandBus.execute(new ListAllDiscardedTweetsCommand()); + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/metrics/SpringActuatorMetricService.java b/src/main/java/com/scmspain/infrastructure/metrics/SpringActuatorMetricService.java new file mode 100644 index 0000000..f2c2977 --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/metrics/SpringActuatorMetricService.java @@ -0,0 +1,55 @@ +package com.scmspain.infrastructure.metrics; + +import org.springframework.boot.actuate.metrics.writer.Delta; +import org.springframework.boot.actuate.metrics.writer.MetricWriter; +import org.springframework.stereotype.Service; + +import com.scmspain.domain.MetricService; + +/** + * Metric service implementation based on spring actuator metric writer. + */ +@Service +public class SpringActuatorMetricService implements MetricService { + + private static final String PUBLISHED_TWEETS = "published-tweets"; + private static final String TIMES_QUERIED_TWEETS = "times-queried-tweets"; + private static final String DISCARDED_TWEETS = "discarded-tweets"; + private static final String TIMES_QUERIED_DISCARDED_TWEETS = "times-queried-discarded-tweets"; + + private final MetricWriter metricWriter; + + /** + * Constructor. + * + * @param metricWriter Metric writer. + */ + public SpringActuatorMetricService(MetricWriter metricWriter) { + this.metricWriter = metricWriter; + } + + @Override + public void incrementPublishedTweets() { + metricWriter.increment(deltaOne(PUBLISHED_TWEETS)); + } + + @Override + public void incrementTimesQueriedTweets() { + metricWriter.increment(deltaOne(TIMES_QUERIED_TWEETS)); + } + + @Override + public void incrementDiscardedTweets() { + metricWriter.increment(deltaOne(DISCARDED_TWEETS)); + } + + @Override + public void incrementTimesQueriedDiscardedTweets() { + metricWriter.increment(deltaOne(TIMES_QUERIED_DISCARDED_TWEETS)); + } + + private Delta deltaOne(String name) { + return new Delta<>(name, 1); + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/repository/TweetRepository.java b/src/main/java/com/scmspain/infrastructure/repository/TweetRepository.java new file mode 100644 index 0000000..4b4b9b4 --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/repository/TweetRepository.java @@ -0,0 +1,124 @@ +package com.scmspain.infrastructure.repository; + +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.persistence.EntityManager; +import javax.persistence.TypedQuery; +import javax.transaction.Transactional; + +import org.springframework.stereotype.Repository; + +import com.scmspain.application.services.UrlExtractor; +import com.scmspain.domain.TweetService; +import com.scmspain.domain.TweetNotFoundException; +import com.scmspain.domain.model.TweetResponse; +import com.scmspain.infrastructure.repository.entities.Tweet; +import com.scmspain.infrastructure.repository.entities.TweetUrl; + +/** + * Repository implementation for tweets with an entity manager. + */ +@Repository +@Transactional +public class TweetRepository implements TweetService { + + private final EntityManager entityManager; + + /** + * Constructor. + * + * @param entityManager Entity manager to access the persistence context. + */ + public TweetRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Long publish(String publisher, String text) { + UrlExtractor urlExtractor = new UrlExtractor(text); + Tweet tweet = new Tweet(publisher, urlExtractor.getText(), new Date()); + for (String url : urlExtractor.getUrls()) { + tweet.getUrls().add(new TweetUrl(tweet, url)); + } + this.entityManager.persist(tweet); + return tweet.getId(); + } + + @Override + public void discard(final Long tweetId) throws TweetNotFoundException { + Tweet tweet = getTweet(tweetId); + tweet.setDiscarded(); + tweet.setDiscardedDate(new Date()); + this.entityManager.merge(tweet); + } + + @Override + public List listAll() { + return listTweets("SELECT id FROM Tweet WHERE pre2015MigrationStatus<>99 AND discarded = false ORDER BY publicationDate DESC"); + } + + @Override + public List listAllDiscarded() { + return listTweets("SELECT id FROM Tweet WHERE pre2015MigrationStatus<>99 AND discarded = true ORDER BY discardedDate DESC"); + } + + private List listTweets(String qlString) { + TypedQuery query = + this.entityManager + .createQuery(qlString, Long.class); + + return + query + .getResultList() + .stream() + .map(this::getTweetResponse) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * Recover tweet from repository. + * + * @param id Identifier of the Tweet to retrieve + * @return Retrieved tweet response. + */ + private Optional getTweetResponse(final Long id) { + try { + Tweet tweet = getTweet(id); + String text = rebuildText(tweet); + return Optional.of(new TweetResponse(id, tweet.getPublisher(), text, tweet.getPre2015MigrationStatus())); + } catch (TweetNotFoundException e) { + return Optional.empty(); + } + } + + /** + * Recover tweet from repository. + * + * @param id Identifier of the Tweet to retrieve + * @return Retrieved tweet. + */ + public Tweet getTweet(final Long id) throws TweetNotFoundException { + Tweet tweet = this.entityManager.find(Tweet.class, id); + if (tweet == null) { + throw new TweetNotFoundException(id); + } + return tweet; + } + + private String rebuildText(Tweet tweet) { + List urls = + tweet + .getUrls() + .stream() + .map(TweetUrl::getUrl) + .collect(Collectors.toList()); + + return UrlExtractor.rebuild(tweet.getText(), urls); + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/repository/entities/Tweet.java b/src/main/java/com/scmspain/infrastructure/repository/entities/Tweet.java new file mode 100644 index 0000000..0436a8c --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/repository/entities/Tweet.java @@ -0,0 +1,134 @@ +package com.scmspain.infrastructure.repository.entities; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +/** + * Tweet entity. + */ +@Entity +public class Tweet { + + @Id + @GeneratedValue + private Long id; + + @Column(nullable = false) + private String publisher; + + @Column(name = "tweet", nullable = false) + private String text; + + @Column + private Long pre2015MigrationStatus = 0L; + + @Column + private Date publicationDate; + + @Column + private Date discardedDate; + + @Column + private Boolean discarded = Boolean.FALSE; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "tweet", fetch = FetchType.EAGER) + private List urls = new ArrayList<>(); + + /** + * Constructor to help the persistence framework to instantiate the entity. + */ + private Tweet() { } + + /** + * Constructor with parameters. + * + * @param publisher Creator of the tweet. + * @param text Content of the tweet. + * @param publicationDate Publication date of the tweet. + */ + public Tweet(final String publisher, final String text, final Date publicationDate) { + this.publisher = publisher; + this.text = text; + this.publicationDate = publicationDate; + } + + /** + * Gets de identifier of the tweet. + * + * @return Identifier. + */ + public Long getId() { + return id; + } + + /** + * Gets the publisher of the tweet. + * + * @return Publisher. + */ + public String getPublisher() { + return publisher; + } + + /** + * Gets the text of the tweet. + * + * @return Text. + */ + public String getText() { + return text; + } + + /** + * Gets the pre 2015 migration status. + * + * @return Pre 2015 migration status. + */ + public Long getPre2015MigrationStatus() { + return pre2015MigrationStatus; + } + + /** + * Gets the disabled mark or the tweet. + * + * @return True if tweet is disabled. + */ + public Boolean getDiscarded() { + return discarded; + } + + /** + * Mark the tweet as discarded. + */ + public void setDiscarded() { + this.discarded = true; + } + + /** + * Sets the discarded date. + * + * @param discardedDate Discarded date. + */ + public void setDiscardedDate(Date discardedDate) { + this.discardedDate = discardedDate; + } + + /** + * Gets the URLs of the tweet. + * + * @return List of URLs of the tweet. + */ + public List getUrls() { + return urls; + } + +} diff --git a/src/main/java/com/scmspain/infrastructure/repository/entities/TweetUrl.java b/src/main/java/com/scmspain/infrastructure/repository/entities/TweetUrl.java new file mode 100644 index 0000000..36461cc --- /dev/null +++ b/src/main/java/com/scmspain/infrastructure/repository/entities/TweetUrl.java @@ -0,0 +1,50 @@ +package com.scmspain.infrastructure.repository.entities; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.ManyToOne; + +/** + * Tweet URL entity. + */ +@Entity +public class TweetUrl { + + @Id + @GeneratedValue + private Long id; + + @Column + private String url; + + @ManyToOne + private Tweet tweet; + + /** + * Constructor to help the persistence framework to instantiate the entity. + */ + private TweetUrl() { } + + /** + * Constructor with parameters. + * + * @param tweet Tweet that owns the URL. + * @param url URL included at a tweet. + */ + public TweetUrl(final Tweet tweet, final String url) { + this.url = url; + this.tweet = tweet; + } + + /** + * Gets an URL included at a tweet. + * + * @return URL. + */ + public String getUrl() { + return url; + } + +} diff --git a/src/main/java/com/scmspain/services/TweetService.java b/src/main/java/com/scmspain/services/TweetService.java deleted file mode 100644 index d61bc9d..0000000 --- a/src/main/java/com/scmspain/services/TweetService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.scmspain.services; - -import com.scmspain.entities.Tweet; -import org.springframework.boot.actuate.metrics.writer.Delta; -import org.springframework.boot.actuate.metrics.writer.MetricWriter; -import org.springframework.stereotype.Service; - -import javax.persistence.EntityManager; -import javax.persistence.TypedQuery; -import javax.transaction.Transactional; -import java.util.ArrayList; -import java.util.List; - -@Service -@Transactional -public class TweetService { - private EntityManager entityManager; - private MetricWriter metricWriter; - - public TweetService(EntityManager entityManager, MetricWriter metricWriter) { - this.entityManager = entityManager; - this.metricWriter = metricWriter; - } - - /** - Push tweet to repository - Parameter - publisher - creator of the Tweet - Parameter - text - Content of the Tweet - Result - recovered Tweet - */ - public void publishTweet(String publisher, String text) { - if (publisher != null && publisher.length() > 0 && text != null && text.length() > 0 && text.length() < 140) { - Tweet tweet = new Tweet(); - tweet.setTweet(text); - tweet.setPublisher(publisher); - - this.metricWriter.increment(new Delta("published-tweets", 1)); - this.entityManager.persist(tweet); - } else { - throw new IllegalArgumentException("Tweet must not be greater than 140 characters"); - } - } - - /** - Recover tweet from repository - Parameter - id - id of the Tweet to retrieve - Result - retrieved Tweet - */ - public Tweet getTweet(Long id) { - return this.entityManager.find(Tweet.class, id); - } - - /** - Recover tweet from repository - Parameter - id - id of the Tweet to retrieve - Result - retrieved Tweet - */ - public List listAllTweets() { - List result = new ArrayList(); - this.metricWriter.increment(new Delta("times-queried-tweets", 1)); - TypedQuery query = this.entityManager.createQuery("SELECT id FROM Tweet AS tweetId WHERE pre2015MigrationStatus<>99 ORDER BY id DESC", Long.class); - List ids = query.getResultList(); - for (Long id : ids) { - result.add(getTweet(id)); - } - return result; - } -} diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/CommandBusITCase.java b/src/test/java/com/scmspain/infrastructure/commandbus/CommandBusITCase.java new file mode 100644 index 0000000..8b72b91 --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/CommandBusITCase.java @@ -0,0 +1,40 @@ +package com.scmspain.infrastructure.commandbus; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import com.scmspain.domain.command.CommandBus; +import com.scmspain.domain.command.CommandException; +import com.scmspain.infrastructure.commandbus.command.ByeCommand; +import com.scmspain.infrastructure.commandbus.command.HelloCommand; +import com.scmspain.infrastructure.commandbus.handler.MessageCollector; +import com.scmspain.infrastructure.configuration.TestConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TestConfiguration.class) +public class CommandBusITCase { + + @Autowired + private CommandBus commandBus; + + @Autowired + private MessageCollector messageCollector; + + @Test + public void executeHandlersForGivenCommands() throws CommandException { + String actualStringReturnValue = commandBus.execute(new HelloCommand("Schibsted")); + Void actualVoidReturnValue = commandBus.execute(new ByeCommand("AEM")); + + Assertions.assertThat(messageCollector.getMessages()).contains("Hello Schibsted", "Bye AEM"); + + assertThat(actualStringReturnValue).isEqualTo("Hello Schibsted"); + assertThat(actualVoidReturnValue).isNull(); + } + +} \ No newline at end of file diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/RegistryTest.java b/src/test/java/com/scmspain/infrastructure/commandbus/RegistryTest.java new file mode 100644 index 0000000..427146f --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/RegistryTest.java @@ -0,0 +1,43 @@ +package com.scmspain.infrastructure.commandbus; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.context.ApplicationContext; + +import com.scmspain.domain.command.CommandHandler; +import com.scmspain.infrastructure.commandbus.command.HelloCommand; +import com.scmspain.infrastructure.commandbus.handler.HelloCommandHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class RegistryTest { + + private static final String HELLO_COMMAND_HANDLER = "helloCommandHandler"; + + @Mock + private ApplicationContext applicationContext; + + @Mock + private HelloCommandHandler helloCommandHandler; + + @Test + public void shouldReturnRegisteredHandlerForCommands() { + String[] commandHandlers = new String[] { HELLO_COMMAND_HANDLER }; + when(applicationContext.getBeanNamesForType(CommandHandler.class)).thenReturn(commandHandlers); + + Class type = HelloCommandHandler.class; + when(applicationContext.getType(HELLO_COMMAND_HANDLER)).thenReturn(type); + + when(applicationContext.getBean(HelloCommandHandler.class)).thenReturn(helloCommandHandler); + + Registry registry = new Registry(applicationContext); + CommandHandler handler = registry.get(HelloCommand.class); + + assertThat(handler).isInstanceOf(HelloCommandHandler.class); + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/SpringCommandBusTest.java b/src/test/java/com/scmspain/infrastructure/commandbus/SpringCommandBusTest.java new file mode 100644 index 0000000..cb9f5df --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/SpringCommandBusTest.java @@ -0,0 +1,38 @@ +package com.scmspain.infrastructure.commandbus; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import com.scmspain.domain.command.CommandException; +import com.scmspain.domain.command.CommandHandler; +import com.scmspain.infrastructure.commandbus.command.HelloCommand; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class SpringCommandBusTest { + + @Mock + private Registry registry; + + @Mock + private CommandHandler handler; + + @InjectMocks + private SpringCommandBus commandBus; + + @Test + public void shouldExecuteHandlerForCommand() throws CommandException { + when(registry.get(HelloCommand.class)).thenReturn(handler); + + HelloCommand command = new HelloCommand("Schibsted"); + commandBus.execute(command); + + verify(handler).handle(command); + } + +} \ No newline at end of file diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/command/ByeCommand.java b/src/test/java/com/scmspain/infrastructure/commandbus/command/ByeCommand.java new file mode 100644 index 0000000..323acce --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/command/ByeCommand.java @@ -0,0 +1,17 @@ +package com.scmspain.infrastructure.commandbus.command; + +import com.scmspain.domain.command.Command; + +public class ByeCommand implements Command { + + private final String name; + + public ByeCommand(String name) { + this.name = name; + } + + public String getName() { + return name; + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/command/HelloCommand.java b/src/test/java/com/scmspain/infrastructure/commandbus/command/HelloCommand.java new file mode 100644 index 0000000..3cdbd74 --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/command/HelloCommand.java @@ -0,0 +1,17 @@ +package com.scmspain.infrastructure.commandbus.command; + +import com.scmspain.domain.command.Command; + +public class HelloCommand implements Command { + + private final String name; + + public HelloCommand(String name) { + this.name = name; + } + + public String getName() { + return name; + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/handler/ByeCommandHandler.java b/src/test/java/com/scmspain/infrastructure/commandbus/handler/ByeCommandHandler.java new file mode 100644 index 0000000..dd01e70 --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/handler/ByeCommandHandler.java @@ -0,0 +1,25 @@ +package com.scmspain.infrastructure.commandbus.handler; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.scmspain.domain.command.CommandHandler; +import com.scmspain.infrastructure.commandbus.command.ByeCommand; + +@Component +public class ByeCommandHandler implements CommandHandler { + + private MessageCollector messageCollector; + + @Autowired + public ByeCommandHandler(MessageCollector messageCollector) { + this.messageCollector = messageCollector; + } + + @Override + public Void handle(ByeCommand command) { + messageCollector.add("Bye " + command.getName()); + return null; + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/handler/HelloCommandHandler.java b/src/test/java/com/scmspain/infrastructure/commandbus/handler/HelloCommandHandler.java new file mode 100644 index 0000000..420daff --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/handler/HelloCommandHandler.java @@ -0,0 +1,28 @@ +package com.scmspain.infrastructure.commandbus.handler; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.scmspain.domain.command.CommandHandler; +import com.scmspain.infrastructure.commandbus.command.HelloCommand; + +@Component +public class HelloCommandHandler implements CommandHandler { + + private MessageCollector messageCollector; + + @Autowired + public HelloCommandHandler(MessageCollector messageCollector) { + this.messageCollector = messageCollector; + } + + @Override + public String handle(HelloCommand command) { + String message = "Hello " + command.getName(); + if (messageCollector != null) { + messageCollector.add(message); + } + return message; + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/commandbus/handler/MessageCollector.java b/src/test/java/com/scmspain/infrastructure/commandbus/handler/MessageCollector.java new file mode 100644 index 0000000..f79fdde --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/commandbus/handler/MessageCollector.java @@ -0,0 +1,21 @@ +package com.scmspain.infrastructure.commandbus.handler; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +@Component +public class MessageCollector { + + private List messages = new ArrayList<>(); + + void add(String message) { + messages.add(message); + } + + public List getMessages() { + return messages; + } + +} diff --git a/src/test/java/com/scmspain/configuration/TestConfiguration.java b/src/test/java/com/scmspain/infrastructure/configuration/TestConfiguration.java similarity index 90% rename from src/test/java/com/scmspain/configuration/TestConfiguration.java rename to src/test/java/com/scmspain/infrastructure/configuration/TestConfiguration.java index 28a6657..bb1ef9a 100644 --- a/src/test/java/com/scmspain/configuration/TestConfiguration.java +++ b/src/test/java/com/scmspain/infrastructure/configuration/TestConfiguration.java @@ -1,4 +1,4 @@ -package com.scmspain.configuration; +package com.scmspain.infrastructure.configuration; import com.scmspain.MsFcTechTestApplication; import org.springframework.context.annotation.Bean; @@ -11,8 +11,10 @@ @Configuration @Import({MsFcTechTestApplication.class}) public class TestConfiguration { + @Bean public MBeanExporter mockExporter() { return mock(MBeanExporter.class); } + } diff --git a/src/test/java/com/scmspain/infrastructure/controller/DiscardTweetCommandITCase.java b/src/test/java/com/scmspain/infrastructure/controller/DiscardTweetCommandITCase.java new file mode 100644 index 0000000..070a0e6 --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/controller/DiscardTweetCommandITCase.java @@ -0,0 +1,66 @@ +package com.scmspain.infrastructure.controller; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.web.context.WebApplicationContext; + +import com.scmspain.infrastructure.configuration.TestConfiguration; +import com.scmspain.infrastructure.repository.TweetRepository; +import com.scmspain.infrastructure.repository.entities.Tweet; + +import static java.lang.String.format; +import static junit.framework.TestCase.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TestConfiguration.class) +public class DiscardTweetCommandITCase { + + @Autowired + private WebApplicationContext context; + + @Autowired + private TweetRepository tweetRepository; + + private MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = webAppContextSetup(this.context).build(); + } + + @Test + public void shouldReturn400WhenDiscardingANullTweet() throws Exception { + mockMvc.perform(discardTweet(null)).andExpect(status().is(400)); + } + + @Test + public void shouldReturn404WhenDiscardingANonExistentTweet() throws Exception { + mockMvc.perform(discardTweet(Long.valueOf("9999"))).andExpect(status().is(404)); + } + + @Test + public void shouldReturn200WhenDiscardingTweet() throws Exception { + Long tweetId = tweetRepository.publish("Prospect", "Breaking the law"); + mockMvc.perform(discardTweet(tweetId)).andExpect(status().is(HttpStatus.OK.value())); + Tweet tweet = tweetRepository.getTweet(tweetId); + assertTrue(tweet.getDiscarded()); + } + + private MockHttpServletRequestBuilder discardTweet(Long tweetId) { + return post("/discarded") + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(format("{\"tweet\": \"%s\"}", tweetId)); + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/controller/ListAllDiscardedTweetsCommandITCase.java b/src/test/java/com/scmspain/infrastructure/controller/ListAllDiscardedTweetsCommandITCase.java new file mode 100644 index 0000000..94962d4 --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/controller/ListAllDiscardedTweetsCommandITCase.java @@ -0,0 +1,104 @@ +package com.scmspain.infrastructure.controller; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scmspain.infrastructure.configuration.TestConfiguration; +import com.scmspain.infrastructure.repository.TweetRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.annotation.DirtiesContext.ClassMode.BEFORE_CLASS; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TestConfiguration.class) +@DirtiesContext(classMode = BEFORE_CLASS) +public class ListAllDiscardedTweetsCommandITCase { + + @Autowired + private WebApplicationContext context; + + @Autowired + private TweetRepository tweetRepository; + + private MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = webAppContextSetup(this.context).build(); + } + + @Test + public void shouldListAllDiscardedTweetsSortedByDiscardedDateDescendingOrder() throws Exception { + List testTweets = testTweets(); + this.tweetRepository.discard(testTweets.get(1).tweetId); + this.tweetRepository.discard(testTweets.get(5).tweetId); + this.tweetRepository.discard(testTweets.get(3).tweetId); + + MvcResult getResult = + mockMvc + .perform(get("/discarded")) + .andExpect(status().is(HttpStatus.OK.value())) + .andReturn(); + + String content = getResult.getResponse().getContentAsString(); + List tweets = new ObjectMapper().readValue(content, List.class); + + assertThat(tweets).containsExactly( + testTweets.get(3).tweet, + testTweets.get(5).tweet, + testTweets.get(1).tweet); + } + + private List testTweets() { + List tweets = new ArrayList<>(); + tweets.add(newTestTweet("First", "First tweet")); + tweets.add(newTestTweet("Second", "Second tweet")); + tweets.add(newTestTweet("Third", "Third tweet")); + tweets.add(newTestTweet("Fourth", "Fourth tweet")); + tweets.add(newTestTweet("Fifth", "Fifth tweet")); + tweets.add(newTestTweet("Sixth", "Sixth tweet")); + return tweets; + } + + private TestTweet newTestTweet(final String publisher, final String tweet) { + Long tweetId = this.tweetRepository.publish(publisher, tweet); + + Map map = new LinkedHashMap<>(); + map.put("id", tweetId.intValue()); + map.put("publisher", publisher); + map.put("tweet", tweet); + map.put("pre2015MigrationStatus", 0); + + return new TestTweet(tweetId, map); + } + + private static class TestTweet { + + final Long tweetId; + final Map tweet; + + private TestTweet(Long tweetId, Map tweet) { + this.tweetId = tweetId; + this.tweet = tweet; + } + } + +} diff --git a/src/test/java/com/scmspain/infrastructure/controller/ListAllTweetsCommandITCase.java b/src/test/java/com/scmspain/infrastructure/controller/ListAllTweetsCommandITCase.java new file mode 100644 index 0000000..1838bba --- /dev/null +++ b/src/test/java/com/scmspain/infrastructure/controller/ListAllTweetsCommandITCase.java @@ -0,0 +1,104 @@ +package com.scmspain.infrastructure.controller; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.scmspain.infrastructure.configuration.TestConfiguration; +import com.scmspain.infrastructure.repository.TweetRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.annotation.DirtiesContext.ClassMode.BEFORE_CLASS; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TestConfiguration.class) +@DirtiesContext(classMode = BEFORE_CLASS) +public class ListAllTweetsCommandITCase { + + @Autowired + private WebApplicationContext context; + + @Autowired + private TweetRepository tweetRepository; + + private MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = webAppContextSetup(this.context).build(); + } + + @Test + public void shouldListNotDiscardedTweetsSortedByPublicationDateDescendingOrder() throws Exception { + List testTweets = testTweets(); + this.tweetRepository.discard(testTweets.get(1).tweetId); + this.tweetRepository.discard(testTweets.get(3).tweetId); + + MvcResult getResult = + mockMvc + .perform(get("/tweet")) + .andExpect(status().is(HttpStatus.OK.value())) + .andReturn(); + + String content = getResult.getResponse().getContentAsString(); + List tweets = new ObjectMapper().readValue(content, List.class); + + assertThat(tweets).containsExactly( + testTweets.get(5).tweet, + testTweets.get(4).tweet, + testTweets.get(2).tweet, + testTweets.get(0).tweet); + } + + private List testTweets() { + List tweets = new ArrayList<>(); + tweets.add(newTestTweet("First", "First tweet https://www.google.com")); + tweets.add(newTestTweet("Second", "Second https://www.google.com tweet")); + tweets.add(newTestTweet("Third", "https://www.google.com Third tweet")); + tweets.add(newTestTweet("Fourth", "Fourth tweet http://www.google.com")); + tweets.add(newTestTweet("Fifth", "Fifth http://www.google.com tweet")); + tweets.add(newTestTweet("Sixth", "http://www.google.com Sixth tweet")); + return tweets; + } + + private TestTweet newTestTweet(final String publisher, final String tweet) { + Long tweetId = this.tweetRepository.publish(publisher, tweet); + + Map map = new LinkedHashMap<>(); + map.put("id", tweetId.intValue()); + map.put("publisher", publisher); + map.put("tweet", tweet); + map.put("pre2015MigrationStatus", 0); + + return new TestTweet(tweetId, map); + } + + private static class TestTweet { + + final Long tweetId; + final Map tweet; + + private TestTweet(Long tweetId, Map tweet) { + this.tweetId = tweetId; + this.tweet = tweet; + } + } + +} diff --git a/src/test/java/com/scmspain/controller/TweetControllerTest.java b/src/test/java/com/scmspain/infrastructure/controller/PublishTweetCommandITCase.java similarity index 56% rename from src/test/java/com/scmspain/controller/TweetControllerTest.java rename to src/test/java/com/scmspain/infrastructure/controller/PublishTweetCommandITCase.java index 4368add..5553408 100644 --- a/src/test/java/com/scmspain/controller/TweetControllerTest.java +++ b/src/test/java/com/scmspain/infrastructure/controller/PublishTweetCommandITCase.java @@ -1,33 +1,31 @@ -package com.scmspain.controller; +package com.scmspain.infrastructure.controller; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.scmspain.configuration.TestConfiguration; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; -import java.util.List; +import com.scmspain.infrastructure.configuration.TestConfiguration; import static java.lang.String.format; -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @RunWith(SpringRunner.class) @SpringBootTest(classes = TestConfiguration.class) -public class TweetControllerTest { +public class PublishTweetCommandITCase { + @Autowired private WebApplicationContext context; + private MockMvc mockMvc; @Before @@ -37,27 +35,23 @@ public void setUp() { @Test public void shouldReturn200WhenInsertingAValidTweet() throws Exception { - mockMvc.perform(newTweet("Prospect", "Breaking the law")) - .andExpect(status().is(201)); + mockMvc + .perform(newTweet("Prospect", "Breaking the law")) + .andExpect(status().is(HttpStatus.CREATED.value())); } @Test - public void shouldReturn400WhenInsertingAnInvalidTweet() throws Exception { - mockMvc.perform(newTweet("Schibsted Spain", "We are Schibsted Spain (look at our home page http://www.schibsted.es/), we own Vibbo, InfoJobs, fotocasa, coches.net and milanuncios. Welcome!")) - .andExpect(status().is(400)); + public void shouldReturn200WhenInsertingAValidTweetWithUrls() throws Exception { + mockMvc + .perform(newTweet("Prospect", "Breaking the law: http://www.judaspriest.com http://www.judaspriest.com http://www.judaspriest.com http://www.judaspriest.com http://www.judaspriest.com http://www.judaspriest.com")) + .andExpect(status().is(HttpStatus.CREATED.value())); } @Test - public void shouldReturnAllPublishedTweets() throws Exception { - mockMvc.perform(newTweet("Yo", "How are you?")) - .andExpect(status().is(201)); - - MvcResult getResult = mockMvc.perform(get("/tweet")) - .andExpect(status().is(200)) - .andReturn(); - - String content = getResult.getResponse().getContentAsString(); - assertThat(new ObjectMapper().readValue(content, List.class).size()).isEqualTo(1); + public void shouldReturn400WhenInsertingAnInvalidTweet() throws Exception { + mockMvc + .perform(newTweet("Schibsted Spain", "We are Schibsted Spain (look at our home page http://www.schibsted.es/), we own Vibbo, InfoJobs, fotocasa, coches.net and milanuncios. Welcome! Text added to make it fail.")) + .andExpect(status().is(HttpStatus.BAD_REQUEST.value())); } private MockHttpServletRequestBuilder newTweet(String publisher, String tweet) { diff --git a/src/test/java/com/scmspain/services/TweetServiceTest.java b/src/test/java/com/scmspain/services/TweetServiceTest.java index ac88fe5..5428b18 100644 --- a/src/test/java/com/scmspain/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/services/TweetServiceTest.java @@ -1,38 +1,141 @@ package com.scmspain.services; -import com.scmspain.entities.Tweet; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import javax.persistence.EntityManager; +import javax.persistence.TypedQuery; + import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.springframework.boot.actuate.metrics.writer.Delta; import org.springframework.boot.actuate.metrics.writer.MetricWriter; -import javax.persistence.EntityManager; +import com.scmspain.application.services.TweetMetricService; +import com.scmspain.application.services.TweetValidationService; +import com.scmspain.domain.MetricService; +import com.scmspain.domain.TweetService; +import com.scmspain.domain.model.TweetResponse; +import com.scmspain.infrastructure.repository.TweetRepository; +import com.scmspain.infrastructure.repository.entities.Tweet; +import com.scmspain.infrastructure.metrics.SpringActuatorMetricService; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Matchers.any; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class TweetServiceTest { + + private static final String GUYBRUSH = "Guybrush Threepwood"; + private static final String PIRATE = "Pirate"; + private static final String VALID_MESSAGE = "I am Guybrush Threepwood, mighty pirate."; + private static final String TOO_LONG_MESSAGE = "LeChuck? He's the guy that went to the Governor's for dinner and never wanted to leave. He fell for her in a big way, but she told him to drop dead. So he did. Then things really got ugly."; + private static final String VALID_MESSAGE_WITH_URLS = "Link http 1 http://www.foogle.com - link https 1 https://www.foogle.com - link http 2 http://www.foogle.com - link https 2 https://www.foogle.com"; + private EntityManager entityManager; private MetricWriter metricWriter; private TweetService tweetService; @Before - public void setUp() throws Exception { + public void setUp() { this.entityManager = mock(EntityManager.class); + TweetService tweetRepository = new TweetRepository(entityManager); + this.metricWriter = mock(MetricWriter.class); + MetricService metricService = new SpringActuatorMetricService(metricWriter); - this.tweetService = new TweetService(entityManager, metricWriter); + TweetService tweetValidationService = new TweetValidationService(tweetRepository); + + this.tweetService = new TweetMetricService(tweetValidationService, metricService); } @Test - public void shouldInsertANewTweet() throws Exception { - tweetService.publishTweet("Guybrush Threepwood", "I am Guybrush Threepwood, mighty pirate."); + public void shouldInsertANewTweet() { + this.tweetService.publish(GUYBRUSH, VALID_MESSAGE); + InOrder inOrder = inOrder(metricWriter, entityManager); + inOrder.verify(metricWriter).increment(any(Delta.class)); + inOrder.verify(entityManager).persist(any(Tweet.class)); + } - verify(entityManager).persist(any(Tweet.class)); + @Test(expected = IllegalArgumentException.class) + public void shouldThrowAnExceptionWhenTweetPublisherIsNull() { + this.tweetService.publish(null, VALID_MESSAGE); } @Test(expected = IllegalArgumentException.class) - public void shouldThrowAnExceptionWhenTweetLengthIsInvalid() throws Exception { - tweetService.publishTweet("Pirate", "LeChuck? He's the guy that went to the Governor's for dinner and never wanted to leave. He fell for her in a big way, but she told him to drop dead. So he did. Then things really got ugly."); + public void shouldThrowAnExceptionWhenTweetPublisherIsEmpty() { + this.tweetService.publish("", VALID_MESSAGE); } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowAnExceptionWhenTweetTextIsNull() { + this.tweetService.publish(PIRATE, null); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowAnExceptionWhenTweetTextIsEmpty() { + this.tweetService.publish(PIRATE, ""); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowAnExceptionWhenTweetTextLengthIsInvalid() { + this.tweetService.publish(PIRATE, TOO_LONG_MESSAGE); + } + + @Test + public void shouldNotCountUrlsForTweetLength() { + this.tweetService.publish(GUYBRUSH, VALID_MESSAGE_WITH_URLS); + InOrder inOrder = inOrder(metricWriter, entityManager); + ArgumentCaptor deltaArgument = ArgumentCaptor.forClass(Delta.class); + inOrder.verify(metricWriter).increment(deltaArgument.capture()); + assertThat("published-tweets").isEqualTo(deltaArgument.getValue().getName()); + assertThat(1).isEqualTo(deltaArgument.getValue().getValue()); + inOrder.verify(entityManager).persist(any(Tweet.class)); + } + + @Test + public void shouldListAllAvailableTweetsWithInvalidTweetIdentifiers() { + Long idTweet1 = 9997L; + Long idTweet2 = 9998L; + Long idTweet3 = 9999L; + mockEntityManagerQuery(idTweet1, idTweet2, idTweet3); + + Tweet tweet1 = tweet("publisher 9997", "content 9997"); + Tweet tweet3 = tweet("publisher 9999", "content 9999"); + mockEntityManagerFind(idTweet1, tweet1); + mockEntityManagerFind(idTweet2, null); + mockEntityManagerFind(idTweet3, tweet3); + + List tweets = this.tweetService.listAll(); + assertThat(tweets) + .hasSize(2) + .extracting(TweetResponse::getPublisher) + .containsExactly("publisher 9997", "publisher 9999"); + + ArgumentCaptor deltaArgument = ArgumentCaptor.forClass(Delta.class); + verify(metricWriter).increment(deltaArgument.capture()); + assertThat("times-queried-tweets").isEqualTo(deltaArgument.getValue().getName()); + assertThat(1).isEqualTo(deltaArgument.getValue().getValue()); + } + + private void mockEntityManagerQuery(Long... idTweet) { + TypedQuery mockedQuery = mock(TypedQuery.class); + when(mockedQuery.getResultList()).thenReturn(Arrays.asList(idTweet)); + when(entityManager.createQuery(any(String.class), any(Class.class))).thenReturn(mockedQuery); + } + + private void mockEntityManagerFind(Long tweetId, Tweet tweet) { + when(entityManager.find(Tweet.class, tweetId)).thenReturn(tweet); + } + + private Tweet tweet(String publisher, String content) { + return new Tweet(publisher, content, new Date()); + } + }