From cb5d1d8bbc18e8024001a6cbe601f20dfa2f0fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Sat, 22 Sep 2018 19:17:05 +0200 Subject: [PATCH 1/7] Create `tweetmgr` package to better contain the web service's code. --- .../scmspain/{ => tweetmgr}/MsFcTechTestApplication.java | 7 +++---- .../configuration/InfrastructureConfiguration.java | 2 +- .../{ => tweetmgr}/configuration/TweetConfiguration.java | 6 +++--- .../{ => tweetmgr}/controller/TweetController.java | 8 ++++---- .../controller/command/PublishTweetCommand.java | 2 +- .../java/com/scmspain/{ => tweetmgr}/entities/Tweet.java | 2 +- .../scmspain/{ => tweetmgr}/services/TweetService.java | 4 ++-- .../{ => tweetmgr}/configuration/TestConfiguration.java | 4 ++-- .../{ => tweetmgr}/controller/TweetControllerTest.java | 4 ++-- .../{ => tweetmgr}/services/TweetServiceTest.java | 4 ++-- 10 files changed, 21 insertions(+), 22 deletions(-) rename src/main/java/com/scmspain/{ => tweetmgr}/MsFcTechTestApplication.java (71%) rename src/main/java/com/scmspain/{ => tweetmgr}/configuration/InfrastructureConfiguration.java (92%) rename src/main/java/com/scmspain/{ => tweetmgr}/configuration/TweetConfiguration.java (79%) rename src/main/java/com/scmspain/{ => tweetmgr}/controller/TweetController.java (84%) rename src/main/java/com/scmspain/{ => tweetmgr}/controller/command/PublishTweetCommand.java (88%) rename src/main/java/com/scmspain/{ => tweetmgr}/entities/Tweet.java (96%) rename src/main/java/com/scmspain/{ => tweetmgr}/services/TweetService.java (96%) rename src/test/java/com/scmspain/{ => tweetmgr}/configuration/TestConfiguration.java (82%) rename src/test/java/com/scmspain/{ => tweetmgr}/controller/TweetControllerTest.java (96%) rename src/test/java/com/scmspain/{ => tweetmgr}/services/TweetServiceTest.java (93%) diff --git a/src/main/java/com/scmspain/MsFcTechTestApplication.java b/src/main/java/com/scmspain/tweetmgr/MsFcTechTestApplication.java similarity index 71% rename from src/main/java/com/scmspain/MsFcTechTestApplication.java rename to src/main/java/com/scmspain/tweetmgr/MsFcTechTestApplication.java index 28b3538..91781c0 100644 --- a/src/main/java/com/scmspain/MsFcTechTestApplication.java +++ b/src/main/java/com/scmspain/tweetmgr/MsFcTechTestApplication.java @@ -1,10 +1,9 @@ -package com.scmspain; +package com.scmspain.tweetmgr; -import com.scmspain.configuration.InfrastructureConfiguration; -import com.scmspain.configuration.TweetConfiguration; +import com.scmspain.tweetmgr.configuration.InfrastructureConfiguration; +import com.scmspain.tweetmgr.configuration.TweetConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; diff --git a/src/main/java/com/scmspain/configuration/InfrastructureConfiguration.java b/src/main/java/com/scmspain/tweetmgr/configuration/InfrastructureConfiguration.java similarity index 92% rename from src/main/java/com/scmspain/configuration/InfrastructureConfiguration.java rename to src/main/java/com/scmspain/tweetmgr/configuration/InfrastructureConfiguration.java index a0a2e48..a4267fd 100644 --- a/src/main/java/com/scmspain/configuration/InfrastructureConfiguration.java +++ b/src/main/java/com/scmspain/tweetmgr/configuration/InfrastructureConfiguration.java @@ -1,4 +1,4 @@ -package com.scmspain.configuration; +package com.scmspain.tweetmgr.configuration; import org.springframework.boot.actuate.autoconfigure.ExportMetricWriter; import org.springframework.boot.actuate.metrics.jmx.JmxMetricWriter; diff --git a/src/main/java/com/scmspain/configuration/TweetConfiguration.java b/src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java similarity index 79% rename from src/main/java/com/scmspain/configuration/TweetConfiguration.java rename to src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java index fbb0dbd..44a2bd4 100644 --- a/src/main/java/com/scmspain/configuration/TweetConfiguration.java +++ b/src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java @@ -1,7 +1,7 @@ -package com.scmspain.configuration; +package com.scmspain.tweetmgr.configuration; -import com.scmspain.controller.TweetController; -import com.scmspain.services.TweetService; +import com.scmspain.tweetmgr.controller.TweetController; +import com.scmspain.tweetmgr.services.TweetService; import org.springframework.boot.actuate.metrics.writer.MetricWriter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/scmspain/controller/TweetController.java b/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java similarity index 84% rename from src/main/java/com/scmspain/controller/TweetController.java rename to src/main/java/com/scmspain/tweetmgr/controller/TweetController.java index 55ce7cd..a5af00e 100644 --- a/src/main/java/com/scmspain/controller/TweetController.java +++ b/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java @@ -1,8 +1,8 @@ -package com.scmspain.controller; +package com.scmspain.tweetmgr.controller; -import com.scmspain.controller.command.PublishTweetCommand; -import com.scmspain.entities.Tweet; -import com.scmspain.services.TweetService; +import com.scmspain.tweetmgr.controller.command.PublishTweetCommand; +import com.scmspain.tweetmgr.entities.Tweet; +import com.scmspain.tweetmgr.services.TweetService; import org.springframework.web.bind.annotation.*; import java.util.List; diff --git a/src/main/java/com/scmspain/controller/command/PublishTweetCommand.java b/src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java similarity index 88% rename from src/main/java/com/scmspain/controller/command/PublishTweetCommand.java rename to src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java index 543897b..acdf475 100644 --- a/src/main/java/com/scmspain/controller/command/PublishTweetCommand.java +++ b/src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java @@ -1,4 +1,4 @@ -package com.scmspain.controller.command; +package com.scmspain.tweetmgr.controller.command; public class PublishTweetCommand { private String publisher; diff --git a/src/main/java/com/scmspain/entities/Tweet.java b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java similarity index 96% rename from src/main/java/com/scmspain/entities/Tweet.java rename to src/main/java/com/scmspain/tweetmgr/entities/Tweet.java index 3616a94..60359ec 100644 --- a/src/main/java/com/scmspain/entities/Tweet.java +++ b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java @@ -1,4 +1,4 @@ -package com.scmspain.entities; +package com.scmspain.tweetmgr.entities; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/src/main/java/com/scmspain/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java similarity index 96% rename from src/main/java/com/scmspain/services/TweetService.java rename to src/main/java/com/scmspain/tweetmgr/services/TweetService.java index d61bc9d..5a2176d 100644 --- a/src/main/java/com/scmspain/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -1,6 +1,6 @@ -package com.scmspain.services; +package com.scmspain.tweetmgr.services; -import com.scmspain.entities.Tweet; +import com.scmspain.tweetmgr.entities.Tweet; import org.springframework.boot.actuate.metrics.writer.Delta; import org.springframework.boot.actuate.metrics.writer.MetricWriter; import org.springframework.stereotype.Service; diff --git a/src/test/java/com/scmspain/configuration/TestConfiguration.java b/src/test/java/com/scmspain/tweetmgr/configuration/TestConfiguration.java similarity index 82% rename from src/test/java/com/scmspain/configuration/TestConfiguration.java rename to src/test/java/com/scmspain/tweetmgr/configuration/TestConfiguration.java index 28a6657..15ffbb2 100644 --- a/src/test/java/com/scmspain/configuration/TestConfiguration.java +++ b/src/test/java/com/scmspain/tweetmgr/configuration/TestConfiguration.java @@ -1,6 +1,6 @@ -package com.scmspain.configuration; +package com.scmspain.tweetmgr.configuration; -import com.scmspain.MsFcTechTestApplication; +import com.scmspain.tweetmgr.MsFcTechTestApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; diff --git a/src/test/java/com/scmspain/controller/TweetControllerTest.java b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java similarity index 96% rename from src/test/java/com/scmspain/controller/TweetControllerTest.java rename to src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java index 4368add..8aa3188 100644 --- a/src/test/java/com/scmspain/controller/TweetControllerTest.java +++ b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java @@ -1,7 +1,7 @@ -package com.scmspain.controller; +package com.scmspain.tweetmgr.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import com.scmspain.configuration.TestConfiguration; +import com.scmspain.tweetmgr.configuration.TestConfiguration; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/src/test/java/com/scmspain/services/TweetServiceTest.java b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java similarity index 93% rename from src/test/java/com/scmspain/services/TweetServiceTest.java rename to src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java index ac88fe5..d3c45e9 100644 --- a/src/test/java/com/scmspain/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java @@ -1,6 +1,6 @@ -package com.scmspain.services; +package com.scmspain.tweetmgr.services; -import com.scmspain.entities.Tweet; +import com.scmspain.tweetmgr.entities.Tweet; import org.junit.Before; import org.junit.Test; import org.springframework.boot.actuate.metrics.writer.MetricWriter; From 2c06920efebeaa559222c4e52e848966c24e04f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Sat, 22 Sep 2018 19:40:13 +0200 Subject: [PATCH 2/7] * A Tweet can't be empty. * A Tweet can't contain more than 140 characters. * A Tweet's Publisher name can't be empty. --- .../tweetmgr/services/TweetService.java | 4 +- .../tweetmgr/services/TweetServiceTest.java | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java index 5a2176d..3517cab 100644 --- a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -28,8 +28,8 @@ public TweetService(EntityManager entityManager, MetricWriter metricWriter) { 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) { + public Tweet 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); diff --git a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java index d3c45e9..cb3e7cb 100644 --- a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java @@ -6,6 +6,8 @@ import org.springframework.boot.actuate.metrics.writer.MetricWriter; import javax.persistence.EntityManager; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; @@ -14,16 +16,17 @@ public class TweetServiceTest { private EntityManager entityManager; private MetricWriter metricWriter; - private TweetService tweetService; + private com.scmspain.tweetmgr.services.TweetService tweetService; @Before public void setUp() throws Exception { this.entityManager = mock(EntityManager.class); this.metricWriter = mock(MetricWriter.class); - this.tweetService = new TweetService(entityManager, metricWriter); + this.tweetService = new com.scmspain.tweetmgr.services.TweetService(entityManager, metricWriter); } + @Test public void shouldInsertANewTweet() throws Exception { tweetService.publishTweet("Guybrush Threepwood", "I am Guybrush Threepwood, mighty pirate."); @@ -31,8 +34,49 @@ public void shouldInsertANewTweet() throws Exception { verify(entityManager).persist(any(Tweet.class)); } + @Test + public void shouldAllowTweetsLessThan140Characters() { + tweetService.publishTweet("Less than 140 characters", getTweetContent(139)); + verify(entityManager).persist(any(Tweet.class)); + } + + @Test + public void shouldAllow140CharacterTweets() { + tweetService.publishTweet("140 characters", getTweetContent(140)); + verify(entityManager).persist(any(Tweet.class)); + } + @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."); } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowEmptyTweets() { + tweetService.publishTweet("LeChuck", ""); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowTweetsLongerThan140Characters() { + tweetService.publishTweet("More than 140 characters", getTweetContent(141)); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowTweetsWithoutPublisher() { + tweetService.publishTweet("", "Tweet"); + } + + /** + * Generates a string of the required length, to use as the tweet contents. + * + * @param length the length of the generated string + * @return a string of the required length. + */ + private String getTweetContent(int length) { + if (length < 1) { + throw new IllegalArgumentException("More than 1 character required"); + } + + return IntStream.range(0, length).mapToObj(i -> "x").collect(Collectors.joining()); + } } From ff0b4dde1300cfcb724aef113b986fce725be426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Sat, 22 Sep 2018 21:51:25 +0200 Subject: [PATCH 3/7] * The list must be sorted by publication date in descending order. --- .../com/scmspain/tweetmgr/entities/Tweet.java | 15 +++++--- .../tweetmgr/services/TweetService.java | 4 +-- .../controller/TweetControllerTest.java | 36 +++++++++++++++++++ .../tweetmgr/services/TweetServiceTest.java | 4 +-- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java index 60359ec..c5a3307 100644 --- a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java +++ b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java @@ -1,9 +1,9 @@ package com.scmspain.tweetmgr.entities; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; +import org.hibernate.annotations.CreationTimestamp; + +import javax.persistence.*; +import java.sql.Timestamp; @Entity public class Tweet { @@ -16,6 +16,9 @@ public class Tweet { private String tweet; @Column (nullable=true) private Long pre2015MigrationStatus = 0L; + @Column + @CreationTimestamp + private Timestamp createdOn; public Tweet() { } @@ -52,4 +55,8 @@ public void setPre2015MigrationStatus(Long pre2015MigrationStatus) { this.pre2015MigrationStatus = pre2015MigrationStatus; } + public Timestamp getCreatedOn() { + return createdOn; + } + } diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java index 3517cab..fb82a35 100644 --- a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -28,7 +28,7 @@ public TweetService(EntityManager entityManager, MetricWriter metricWriter) { Parameter - text - Content of the Tweet Result - recovered Tweet */ - public Tweet publishTweet(String publisher, String text) { + 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); @@ -58,7 +58,7 @@ public Tweet getTweet(Long id) { 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); + TypedQuery query = this.entityManager.createQuery("SELECT id FROM Tweet AS tweetId WHERE pre2015MigrationStatus<>99 ORDER BY createdOn DESC", Long.class); List ids = query.getResultList(); for (Long id : ids) { result.add(getTweet(id)); diff --git a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java index 8aa3188..3b7e27a 100644 --- a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java +++ b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scmspain.tweetmgr.configuration.TestConfiguration; +import com.scmspain.tweetmgr.entities.Tweet; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -14,6 +15,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; +import java.sql.Timestamp; import java.util.List; import static java.lang.String.format; @@ -66,4 +68,38 @@ private MockHttpServletRequestBuilder newTweet(String publisher, String tweet) { .content(format("{\"publisher\": \"%s\", \"tweet\": \"%s\"}", publisher, tweet)); } + @Test + public void shouldReturnAllPublishedTweetsOrderedByDate() throws Exception { + // Publish a few tweets. + mockMvc.perform(newTweet("Guybrush", "You fight like a dairy farmer!")) + .andExpect(status().is(201)); + mockMvc.perform(newTweet("Largo", "How appropriate! You fight like a cow!")) + .andExpect(status().is(201)); + mockMvc.perform(newTweet("Guybrush", "I've spoken with apes more polite than you!")) + .andExpect(status().is(201)); + mockMvc.perform(newTweet("Largo", "I'm glad to hear you attended your family reunion!")) + .andExpect(status().is(201)); + + // Get published tweets. + MvcResult getResult = mockMvc.perform(get("/tweet")) + .andExpect(status().is(200)) + .andReturn(); + + // Get the response body. + String content = getResult.getResponse().getContentAsString(); + Tweet[] tweets = new ObjectMapper().readValue(content, Tweet[].class); + + // Make sure the tweets are listed in chronological order. + Timestamp previousTimestamp = null; + for (Tweet tweet : tweets) { + Timestamp currentTimestamp = tweet.getCreatedOn(); + // If there is a previous timestamp... + if (null != previousTimestamp) { + // Ensure that the previous tweet was published before the current one. + assertThat(currentTimestamp.before(previousTimestamp)); + } + previousTimestamp = currentTimestamp; + } + } + } diff --git a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java index cb3e7cb..ebc1629 100644 --- a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java @@ -16,14 +16,14 @@ public class TweetServiceTest { private EntityManager entityManager; private MetricWriter metricWriter; - private com.scmspain.tweetmgr.services.TweetService tweetService; + private TweetService tweetService; @Before public void setUp() throws Exception { this.entityManager = mock(EntityManager.class); this.metricWriter = mock(MetricWriter.class); - this.tweetService = new com.scmspain.tweetmgr.services.TweetService(entityManager, metricWriter); + this.tweetService = new TweetService(entityManager, metricWriter); } From 0bfc2320402b476ae0fd2265a15cd7c7dcfdb529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Sun, 23 Sep 2018 09:57:24 +0200 Subject: [PATCH 4/7] Rename the `tweet` member in the `Tweet` class as it may get confusing down the line. --- .../com/scmspain/tweetmgr/entities/Tweet.java | 17 +++++++++-------- .../tweetmgr/services/TweetService.java | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java index c5a3307..afb8785 100644 --- a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java +++ b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java @@ -10,19 +10,20 @@ public class Tweet { @Id @GeneratedValue private Long id; + @Column(nullable = false) private String publisher; + @Column(nullable = false, length = 140) - private String tweet; + private String message; + @Column (nullable=true) private Long pre2015MigrationStatus = 0L; + @Column @CreationTimestamp private Timestamp createdOn; - public Tweet() { - } - public Long getId() { return id; } @@ -39,12 +40,12 @@ public void setPublisher(String publisher) { this.publisher = publisher; } - public String getTweet() { - return tweet; + public String getMessage() { + return message; } - public void setTweet(String tweet) { - this.tweet = tweet; + public void setMessage(String message) { + this.message = message; } public Long getPre2015MigrationStatus() { diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java index fb82a35..cf03dc4 100644 --- a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -31,7 +31,7 @@ public TweetService(EntityManager entityManager, MetricWriter metricWriter) { 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.setMessage(text); tweet.setPublisher(publisher); this.metricWriter.increment(new Delta("published-tweets", 1)); From e007e0429d0c74282c4aa7c409d6d892c51bba2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Mon, 24 Sep 2018 21:27:59 +0200 Subject: [PATCH 5/7] * As a User, I want to add links to the tweet text without affecting the 140 character limit. --- .../com/scmspain/tweetmgr/entities/Tweet.java | 10 ++- .../tweetmgr/services/TweetService.java | 89 ++++++++++++++----- .../controller/TweetControllerTest.java | 2 +- .../tweetmgr/services/TweetServiceTest.java | 24 +++++ 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java index afb8785..345f6ac 100644 --- a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java +++ b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java @@ -2,7 +2,10 @@ import org.hibernate.annotations.CreationTimestamp; -import javax.persistence.*; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; import java.sql.Timestamp; @Entity @@ -14,10 +17,10 @@ public class Tweet { @Column(nullable = false) private String publisher; - @Column(nullable = false, length = 140) + @Column(nullable = false) private String message; - @Column (nullable=true) + @Column private Long pre2015MigrationStatus = 0L; @Column @@ -59,5 +62,4 @@ public void setPre2015MigrationStatus(Long pre2015MigrationStatus) { public Timestamp getCreatedOn() { return createdOn; } - } diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java index cf03dc4..30d880e 100644 --- a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -10,10 +10,18 @@ import javax.transaction.Transactional; import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @Transactional public class TweetService { + + public static final int MAX_MESSAGE_LENGTH = 140; + + private static final String URL_PATTERN = "(https?://\\S*\\s)"; + private static final Pattern pattern = Pattern.compile(URL_PATTERN, Pattern.CASE_INSENSITIVE); + private EntityManager entityManager; private MetricWriter metricWriter; @@ -23,38 +31,71 @@ public TweetService(EntityManager entityManager, 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.setMessage(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"); + * Pushes a tweet to the repository. + * @param publisher creator of the tweet + * @param message content of the tweet + * @return the stored tweet + */ + public void publishTweet(String publisher, String message) { + validate(publisher, message); + + Tweet tweet = new Tweet(); + tweet.setMessage(message); + tweet.setPublisher(publisher); + + this.metricWriter.increment(new Delta("published-tweets", 1)); + this.entityManager.persist(tweet); + } + + /** + * Validates the contents of a tweet + * @param publisher the tweet publisher + * @param message the tweet message + * @return + */ + private void validate(String publisher, String message) { + List errors = new ArrayList<>(); + + if ((null == publisher) || publisher.isEmpty()) { + errors.add("Publisher is missing"); + } + + if ((null == message) || message.isEmpty()) { + errors.add("Message is missing"); + } + + if (null != message) { + int messageLength = message.length(); + // Recalculate the message length without the links. + Matcher matcher = pattern.matcher(message); + while (matcher.find()) { + messageLength -= matcher.group().length(); + } + + // Check the length of the message. + if (messageLength > MAX_MESSAGE_LENGTH) { + errors.add("Message is longer than 140 characters"); + } + } + + if (!errors.isEmpty()) { + throw new IllegalArgumentException(String.join(" | ", errors)); } } /** - Recover tweet from repository - Parameter - id - id of the Tweet to retrieve - Result - retrieved Tweet - */ + * Recovers a tweet from the repository + * @param id identifier of the tweet to retrieve + * @return the requested tweet, null if not found + */ public Tweet getTweet(Long id) { - return this.entityManager.find(Tweet.class, id); + return this.entityManager.find(Tweet.class, id); } /** - Recover tweet from repository - Parameter - id - id of the Tweet to retrieve - Result - retrieved Tweet - */ + * Retrieves all tweets from the repository + * @return all tweets + */ public List listAllTweets() { List result = new ArrayList(); this.metricWriter.increment(new Delta("times-queried-tweets", 1)); diff --git a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java index 3b7e27a..34880e3 100644 --- a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java +++ b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java @@ -45,7 +45,7 @@ public void shouldReturn200WhenInsertingAValidTweet() throws Exception { @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!")) + mockMvc.perform(newTweet("Schibsted Spain", "We are Schibsted Spain (check our home page http://www.schibsted.es/). Currently we own Vibbo, InfoJobs, fotocasa, coches.net, motos.net, Habitaclia and milanuncios. Welcome!")) .andExpect(status().is(400)); } diff --git a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java index ebc1629..b762517 100644 --- a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -56,6 +57,11 @@ public void shouldNotAllowEmptyTweets() { tweetService.publishTweet("LeChuck", ""); } + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowNullTweets() { + tweetService.publishTweet("LeChuck", null); + } + @Test(expected = IllegalArgumentException.class) public void shouldNotAllowTweetsLongerThan140Characters() { tweetService.publishTweet("More than 140 characters", getTweetContent(141)); @@ -66,6 +72,24 @@ public void shouldNotAllowTweetsWithoutPublisher() { tweetService.publishTweet("", "Tweet"); } + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowTweetsWithNullPublisher() { + tweetService.publishTweet(null, "Tweet"); + } + + @Test + public void shouldNotCountLinksTowardsTheCharacterLimit() { + String message = "I’m selling these fine leather jackets"; + StringBuilder sb = new StringBuilder(); + while (sb.length() < TweetService.MAX_MESSAGE_LENGTH) { + sb.append(" http://stansusedjackets.com/"); + } + assertTrue(message.length() + sb.length() > 140); + + tweetService.publishTweet("Guybrush Threepwood", message + sb.toString()); + verify(entityManager).persist(any(Tweet.class)); + } + /** * Generates a string of the required length, to use as the tweet contents. * From d9c42e4cff8c3118c9db942e45f13b33fb13f807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Mon, 24 Sep 2018 22:28:13 +0200 Subject: [PATCH 6/7] * As a User, I want to discard tweets. * Tweets shall be discarded globally, we don't need user-based discarding. * Discarded tweets will not be shown in the published tweet list. * As a User, I want to view a list of discarded tweets. * The list must be sorted by the date it was discarded on in descending order. --- .../tweetmgr/controller/TweetController.java | 11 ++++ .../command/DiscardTweetCommand.java | 9 +++ .../com/scmspain/tweetmgr/entities/Tweet.java | 11 ++++ .../tweetmgr/services/TweetService.java | 37 +++++++++++- .../controller/TweetControllerTest.java | 60 ++++++++++++++++++- .../tweetmgr/services/TweetServiceTest.java | 36 ++++++++++- 6 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java diff --git a/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java b/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java index a5af00e..3875893 100644 --- a/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java +++ b/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java @@ -1,5 +1,6 @@ package com.scmspain.tweetmgr.controller; +import com.scmspain.tweetmgr.controller.command.DiscardTweetCommand; import com.scmspain.tweetmgr.controller.command.PublishTweetCommand; import com.scmspain.tweetmgr.entities.Tweet; import com.scmspain.tweetmgr.services.TweetService; @@ -29,6 +30,16 @@ public void publishTweet(@RequestBody PublishTweetCommand publishTweetCommand) { this.tweetService.publishTweet(publishTweetCommand.getPublisher(), publishTweetCommand.getTweet()); } + @GetMapping("/discarded") + public List listDiscarded() { + return tweetService.listDiscardedTweets(); + } + + @PostMapping("/discarded") + public void discardTweet(@RequestBody DiscardTweetCommand discardTweetCommand) { + this.tweetService.discardTweet(discardTweetCommand.getTweet()); + } + @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(BAD_REQUEST) @ResponseBody diff --git a/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java b/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java new file mode 100644 index 0000000..4077299 --- /dev/null +++ b/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java @@ -0,0 +1,9 @@ +package com.scmspain.tweetmgr.controller.command; + +public class DiscardTweetCommand { + private Long tweet; + + public Long getTweet() { + return tweet; + } +} diff --git a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java index 345f6ac..a5917ff 100644 --- a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java +++ b/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java @@ -27,6 +27,9 @@ public class Tweet { @CreationTimestamp private Timestamp createdOn; + @Column + private Timestamp discardedOn; + public Long getId() { return id; } @@ -62,4 +65,12 @@ public void setPre2015MigrationStatus(Long pre2015MigrationStatus) { public Timestamp getCreatedOn() { return createdOn; } + + public Timestamp getDiscardedOn() { + return discardedOn; + } + + public void setDiscardedOn(Timestamp discardedOn) { + this.discardedOn = discardedOn; + } } diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java index 30d880e..d81e0ac 100644 --- a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -8,6 +8,7 @@ import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import javax.transaction.Transactional; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; @@ -34,7 +35,6 @@ public TweetService(EntityManager entityManager, MetricWriter metricWriter) { * Pushes a tweet to the repository. * @param publisher creator of the tweet * @param message content of the tweet - * @return the stored tweet */ public void publishTweet(String publisher, String message) { validate(publisher, message); @@ -47,11 +47,28 @@ public void publishTweet(String publisher, String message) { this.entityManager.persist(tweet); } + /** + * Marks a tweet as discarded. + * @param id the tweet identifier + */ + public void discardTweet(Long id) { + Tweet tweet = getTweet(id); + if (tweet == null) { + throw new IllegalArgumentException("Tweet not found"); + } + + if (tweet.getDiscardedOn() == null) { + tweet.setDiscardedOn(new Timestamp(System.currentTimeMillis())); + } + + this.entityManager.persist(tweet); + } + /** * Validates the contents of a tweet * @param publisher the tweet publisher * @param message the tweet message - * @return + * @throws IllegalArgumentException if the input data is not valid */ private void validate(String publisher, String message) { List errors = new ArrayList<>(); @@ -99,11 +116,25 @@ public Tweet getTweet(Long id) { 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 createdOn DESC", Long.class); + TypedQuery query = this.entityManager.createQuery("SELECT id FROM Tweet AS tweetId WHERE (pre2015MigrationStatus<>99) AND (discardedOn IS NULL) ORDER BY createdOn DESC", Long.class); List ids = query.getResultList(); for (Long id : ids) { result.add(getTweet(id)); } return result; } + + /** + * Retrieves all tweets from the repository + * @return all tweets + */ + public List listDiscardedTweets() { + this.metricWriter.increment(new Delta("times-queried-tweets", 1)); + TypedQuery query = entityManager.createQuery( + "SELECT t FROM Tweet t WHERE (pre2015MigrationStatus<>99) AND (discardedOn IS NOT NULL) ORDER BY createdOn DESC", + Tweet.class + ); + + return query.getResultList(); + } } diff --git a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java index 34880e3..9888cfa 100644 --- a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java +++ b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scmspain.tweetmgr.configuration.TestConfiguration; import com.scmspain.tweetmgr.entities.Tweet; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -15,6 +16,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; +import javax.persistence.EntityManager; import java.sql.Timestamp; import java.util.List; @@ -32,9 +34,17 @@ public class TweetControllerTest { private WebApplicationContext context; private MockMvc mockMvc; + @Autowired + private EntityManager entityManager; + @Before public void setUp() { - this.mockMvc = webAppContextSetup(this.context).build(); + this.mockMvc = webAppContextSetup(context).build(); + } + + @After + public void cleanRepository() { + entityManager.clear(); } @Test @@ -62,12 +72,60 @@ public void shouldReturnAllPublishedTweets() throws Exception { assertThat(new ObjectMapper().readValue(content, List.class).size()).isEqualTo(1); } + @Test + public void shouldReturnAllDiscardedTweets() throws Exception { + // Post two tweets. + mockMvc.perform(newTweet("Yo", "How are you?")) + .andExpect(status().is(201)); + mockMvc.perform(newTweet("Yo", "How are you?")) + .andExpect(status().is(201)); + // Discard one. + mockMvc.perform(discardTweet("1")) + .andExpect(status().is(200)); + + // There should only be one discarded tweet. + MvcResult getResult = mockMvc.perform(get("/discarded")) + .andExpect(status().is(200)) + .andReturn(); + + String content = getResult.getResponse().getContentAsString(); + assertThat(new ObjectMapper().readValue(content, List.class).size()).isEqualTo(1); + } + + @Test + public void shouldAllowDiscardingTweet() throws Exception { + mockMvc.perform(newTweet("Yo", "How are you?")) + .andExpect(status().is(201)); + + mockMvc.perform(discardTweet("1")) + .andExpect(status().is(200)); + + 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(0); + } + + @Test + public void shouldNotDiscardMissingTweet() throws Exception { + mockMvc.perform(discardTweet("0")) + .andExpect(status().is(400)); + } + private MockHttpServletRequestBuilder newTweet(String publisher, String tweet) { return post("/tweet") .contentType(MediaType.APPLICATION_JSON_UTF8) .content(format("{\"publisher\": \"%s\", \"tweet\": \"%s\"}", publisher, tweet)); } + private MockHttpServletRequestBuilder discardTweet(String id) { + return post("/discarded") + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(format("{\"tweet\": \"%s\"}", id)); + } + @Test public void shouldReturnAllPublishedTweetsOrderedByDate() throws Exception { // Publish a few tweets. diff --git a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java index b762517..ed24443 100644 --- a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java @@ -6,18 +6,19 @@ import org.springframework.boot.actuate.metrics.writer.MetricWriter; import javax.persistence.EntityManager; +import java.sql.Timestamp; import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; public class TweetServiceTest { private EntityManager entityManager; private MetricWriter metricWriter; private TweetService tweetService; + private Tweet testTweet; @Before public void setUp() throws Exception { @@ -25,6 +26,12 @@ public void setUp() throws Exception { this.metricWriter = mock(MetricWriter.class); this.tweetService = new TweetService(entityManager, metricWriter); + + // Set up entity manager mock. + testTweet = new Tweet(); + testTweet.setPublisher("Guybrush Threepwood"); + testTweet.setMessage("Look behind you, a three-headed monkey!"); + when(entityManager.find(Tweet.class, 1L)).thenReturn(testTweet); } @@ -90,6 +97,29 @@ public void shouldNotCountLinksTowardsTheCharacterLimit() { verify(entityManager).persist(any(Tweet.class)); } + @Test + public void shouldAllowDiscardingTweets() { + Tweet testTweet = new Tweet(); + testTweet.setPublisher("Guybrush Threepwood"); + testTweet.setMessage("Look behind you, a three-headed monkey!"); + when(entityManager.find(Tweet.class, 1L)).thenReturn(testTweet); + + tweetService.discardTweet(1L); + + assertNotNull(testTweet.getDiscardedOn()); + } + + @Test + public void shouldNotDiscardAlreadyDiscardedTweets() { + tweetService.discardTweet(1L); + + Timestamp previousDiscardTimestamp = testTweet.getDiscardedOn(); + + tweetService.discardTweet(1L); + + assertEquals(testTweet.getDiscardedOn(), previousDiscardTimestamp); + } + /** * Generates a string of the required length, to use as the tweet contents. * From a852ffacb8f62a8246355c8e74a4f54caa9c30f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Noe=CC=81?= Date: Wed, 26 Sep 2018 21:53:50 +0200 Subject: [PATCH 7/7] Code cleanup and reorganisation. Replaced the entity manager with a JPA repository. Moved entity to the `persistence` package. Made the controller command beans immutable. --- .../configuration/TweetConfiguration.java | 8 +- .../tweetmgr/controller/TweetController.java | 21 ++-- .../command/DiscardTweetCommand.java | 18 ++- .../command/PublishTweetCommand.java | 26 ++-- .../tweetmgr/persistence/TweetRepository.java | 25 ++++ .../{ => persistence}/entities/Tweet.java | 2 +- .../tweetmgr/services/TweetService.java | 115 ++--------------- .../tweetmgr/services/TweetServiceImpl.java | 116 ++++++++++++++++++ .../controller/TweetControllerTest.java | 10 +- .../tweetmgr/services/TweetServiceTest.java | 49 ++++---- 10 files changed, 226 insertions(+), 164 deletions(-) create mode 100644 src/main/java/com/scmspain/tweetmgr/persistence/TweetRepository.java rename src/main/java/com/scmspain/tweetmgr/{ => persistence}/entities/Tweet.java (96%) create mode 100644 src/main/java/com/scmspain/tweetmgr/services/TweetServiceImpl.java diff --git a/src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java b/src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java index 44a2bd4..ffb743f 100644 --- a/src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java +++ b/src/main/java/com/scmspain/tweetmgr/configuration/TweetConfiguration.java @@ -1,18 +1,18 @@ package com.scmspain.tweetmgr.configuration; import com.scmspain.tweetmgr.controller.TweetController; +import com.scmspain.tweetmgr.persistence.TweetRepository; import com.scmspain.tweetmgr.services.TweetService; +import com.scmspain.tweetmgr.services.TweetServiceImpl; 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); + public TweetService getTweetService(TweetRepository repository, MetricWriter metricWriter) { + return new TweetServiceImpl(repository, metricWriter); } @Bean diff --git a/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java b/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java index 3875893..f285b96 100644 --- a/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java +++ b/src/main/java/com/scmspain/tweetmgr/controller/TweetController.java @@ -2,7 +2,7 @@ import com.scmspain.tweetmgr.controller.command.DiscardTweetCommand; import com.scmspain.tweetmgr.controller.command.PublishTweetCommand; -import com.scmspain.tweetmgr.entities.Tweet; +import com.scmspain.tweetmgr.persistence.entities.Tweet; import com.scmspain.tweetmgr.services.TweetService; import org.springframework.web.bind.annotation.*; @@ -13,31 +13,34 @@ @RestController public class TweetController { - private TweetService tweetService; + static final String TWEET_PATH = "/tweet"; + static final String DISCARDED_PATH = "/discarded"; + + private final TweetService tweetService; public TweetController(TweetService tweetService) { this.tweetService = tweetService; } - @GetMapping("/tweet") + @GetMapping(TWEET_PATH) public List listAllTweets() { - return this.tweetService.listAllTweets(); + return tweetService.listAllTweets(); } - @PostMapping("/tweet") + @PostMapping(TWEET_PATH) @ResponseStatus(CREATED) public void publishTweet(@RequestBody PublishTweetCommand publishTweetCommand) { - this.tweetService.publishTweet(publishTweetCommand.getPublisher(), publishTweetCommand.getTweet()); + this.tweetService.publishTweet(publishTweetCommand.getPublisher(), publishTweetCommand.getMessage()); } - @GetMapping("/discarded") + @GetMapping(DISCARDED_PATH) public List listDiscarded() { return tweetService.listDiscardedTweets(); } - @PostMapping("/discarded") + @PostMapping(DISCARDED_PATH) public void discardTweet(@RequestBody DiscardTweetCommand discardTweetCommand) { - this.tweetService.discardTweet(discardTweetCommand.getTweet()); + this.tweetService.discardTweet(discardTweetCommand.getId()); } @ExceptionHandler(IllegalArgumentException.class) diff --git a/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java b/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java index 4077299..9a691f5 100644 --- a/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java +++ b/src/main/java/com/scmspain/tweetmgr/controller/command/DiscardTweetCommand.java @@ -1,9 +1,21 @@ package com.scmspain.tweetmgr.controller.command; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request body for the /discarded end-point. + */ public class DiscardTweetCommand { - private Long tweet; - public Long getTweet() { - return tweet; + private final Long id; + + @JsonCreator + public DiscardTweetCommand(@JsonProperty("tweet") Long id) { + this.id = id; + } + + public Long getId() { + return id; } } diff --git a/src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java b/src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java index acdf475..dd604b3 100644 --- a/src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java +++ b/src/main/java/com/scmspain/tweetmgr/controller/command/PublishTweetCommand.java @@ -1,22 +1,26 @@ package com.scmspain.tweetmgr.controller.command; -public class PublishTweetCommand { - private String publisher; - private String tweet; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; - public String getPublisher() { - return publisher; - } +/** + * Request body for the /tweet end-point. + */ +public class PublishTweetCommand { + private final String publisher; + private final String message; - public void setPublisher(String publisher) { + @JsonCreator + public PublishTweetCommand(@JsonProperty("publisher") String publisher, @JsonProperty("tweet") String message) { this.publisher = publisher; + this.message = message; } - public String getTweet() { - return tweet; + public String getPublisher() { + return publisher; } - public void setTweet(String tweet) { - this.tweet = tweet; + public String getMessage() { + return message; } } diff --git a/src/main/java/com/scmspain/tweetmgr/persistence/TweetRepository.java b/src/main/java/com/scmspain/tweetmgr/persistence/TweetRepository.java new file mode 100644 index 0000000..e9845d7 --- /dev/null +++ b/src/main/java/com/scmspain/tweetmgr/persistence/TweetRepository.java @@ -0,0 +1,25 @@ +package com.scmspain.tweetmgr.persistence; + +import com.scmspain.tweetmgr.persistence.entities.Tweet; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +/** + * JPA repository of tweets + */ +public interface TweetRepository extends JpaRepository { + + /** + * @return all published, non-discarded tweets. + */ + @Query(value = "SELECT t FROM Tweet t WHERE (pre2015MigrationStatus<>99) AND (discardedOn IS NULL) ORDER BY createdOn DESC") + List findAllPublished(); + + /** + * @return all discarded tweets. + */ + @Query(value = "SELECT t FROM Tweet t WHERE (pre2015MigrationStatus<>99) AND (discardedOn IS NOT NULL) ORDER BY discardedOn DESC") + List findAllDiscarded(); +} diff --git a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java b/src/main/java/com/scmspain/tweetmgr/persistence/entities/Tweet.java similarity index 96% rename from src/main/java/com/scmspain/tweetmgr/entities/Tweet.java rename to src/main/java/com/scmspain/tweetmgr/persistence/entities/Tweet.java index a5917ff..81ac0e9 100644 --- a/src/main/java/com/scmspain/tweetmgr/entities/Tweet.java +++ b/src/main/java/com/scmspain/tweetmgr/persistence/entities/Tweet.java @@ -1,4 +1,4 @@ -package com.scmspain.tweetmgr.entities; +package com.scmspain.tweetmgr.persistence.entities; import org.hibernate.annotations.CreationTimestamp; diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java index d81e0ac..a32ae74 100644 --- a/src/main/java/com/scmspain/tweetmgr/services/TweetService.java +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetService.java @@ -1,140 +1,39 @@ package com.scmspain.tweetmgr.services; -import com.scmspain.tweetmgr.entities.Tweet; -import org.springframework.boot.actuate.metrics.writer.Delta; -import org.springframework.boot.actuate.metrics.writer.MetricWriter; -import org.springframework.stereotype.Service; +import com.scmspain.tweetmgr.persistence.entities.Tweet; -import javax.persistence.EntityManager; -import javax.persistence.TypedQuery; -import javax.transaction.Transactional; -import java.sql.Timestamp; -import java.util.ArrayList; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -@Service -@Transactional -public class TweetService { - - public static final int MAX_MESSAGE_LENGTH = 140; - - private static final String URL_PATTERN = "(https?://\\S*\\s)"; - private static final Pattern pattern = Pattern.compile(URL_PATTERN, Pattern.CASE_INSENSITIVE); - - private EntityManager entityManager; - private MetricWriter metricWriter; - - public TweetService(EntityManager entityManager, MetricWriter metricWriter) { - this.entityManager = entityManager; - this.metricWriter = metricWriter; - } +public interface TweetService { /** * Pushes a tweet to the repository. * @param publisher creator of the tweet * @param message content of the tweet */ - public void publishTweet(String publisher, String message) { - validate(publisher, message); - - Tweet tweet = new Tweet(); - tweet.setMessage(message); - tweet.setPublisher(publisher); - - this.metricWriter.increment(new Delta("published-tweets", 1)); - this.entityManager.persist(tweet); - } + void publishTweet(String publisher, String message); /** * Marks a tweet as discarded. * @param id the tweet identifier */ - public void discardTweet(Long id) { - Tweet tweet = getTweet(id); - if (tweet == null) { - throw new IllegalArgumentException("Tweet not found"); - } - - if (tweet.getDiscardedOn() == null) { - tweet.setDiscardedOn(new Timestamp(System.currentTimeMillis())); - } - - this.entityManager.persist(tweet); - } - - /** - * Validates the contents of a tweet - * @param publisher the tweet publisher - * @param message the tweet message - * @throws IllegalArgumentException if the input data is not valid - */ - private void validate(String publisher, String message) { - List errors = new ArrayList<>(); - - if ((null == publisher) || publisher.isEmpty()) { - errors.add("Publisher is missing"); - } - - if ((null == message) || message.isEmpty()) { - errors.add("Message is missing"); - } - - if (null != message) { - int messageLength = message.length(); - // Recalculate the message length without the links. - Matcher matcher = pattern.matcher(message); - while (matcher.find()) { - messageLength -= matcher.group().length(); - } - - // Check the length of the message. - if (messageLength > MAX_MESSAGE_LENGTH) { - errors.add("Message is longer than 140 characters"); - } - } - - if (!errors.isEmpty()) { - throw new IllegalArgumentException(String.join(" | ", errors)); - } - } + void discardTweet(Long id); /** * Recovers a tweet from the repository * @param id identifier of the tweet to retrieve * @return the requested tweet, null if not found */ - public Tweet getTweet(Long id) { - return this.entityManager.find(Tweet.class, id); - } + Tweet getTweet(Long id); /** * Retrieves all tweets from the repository * @return all tweets */ - 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) AND (discardedOn IS NULL) ORDER BY createdOn DESC", Long.class); - List ids = query.getResultList(); - for (Long id : ids) { - result.add(getTweet(id)); - } - return result; - } + List listAllTweets(); /** * Retrieves all tweets from the repository * @return all tweets */ - public List listDiscardedTweets() { - this.metricWriter.increment(new Delta("times-queried-tweets", 1)); - TypedQuery query = entityManager.createQuery( - "SELECT t FROM Tweet t WHERE (pre2015MigrationStatus<>99) AND (discardedOn IS NOT NULL) ORDER BY createdOn DESC", - Tweet.class - ); - - return query.getResultList(); - } + List listDiscardedTweets(); } diff --git a/src/main/java/com/scmspain/tweetmgr/services/TweetServiceImpl.java b/src/main/java/com/scmspain/tweetmgr/services/TweetServiceImpl.java new file mode 100644 index 0000000..afa2498 --- /dev/null +++ b/src/main/java/com/scmspain/tweetmgr/services/TweetServiceImpl.java @@ -0,0 +1,116 @@ +package com.scmspain.tweetmgr.services; + +import com.scmspain.tweetmgr.persistence.TweetRepository; +import com.scmspain.tweetmgr.persistence.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.transaction.Transactional; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@Transactional +public class TweetServiceImpl implements TweetService { + + static final int MAX_MESSAGE_LENGTH = 140; + + private static final String URL_PATTERN = "(https?://\\S*\\s)"; + private static final Pattern pattern = Pattern.compile(URL_PATTERN, Pattern.CASE_INSENSITIVE); + + private final TweetRepository repository; + private final MetricWriter metricWriter; + + public TweetServiceImpl(TweetRepository repository, MetricWriter metricWriter) { + this.repository = repository; + this.metricWriter = metricWriter; + } + + @Override + public void publishTweet(String publisher, String message) { + validate(publisher, message); + + Tweet tweet = new Tweet(); + tweet.setMessage(message); + tweet.setPublisher(publisher); + + metricWriter.increment(new Delta("published-tweets", 1)); + repository.save(tweet); + } + + @Override + public void discardTweet(Long id) { + Tweet tweet = getTweet(id); + if (tweet == null) { + throw new IllegalArgumentException("Tweet not found"); + } + + // If the tweet was not already discarded... + if (tweet.getDiscardedOn() == null) { + // ... discard it. + tweet.setDiscardedOn(new Timestamp(System.currentTimeMillis())); + + metricWriter.increment(new Delta("discarded-tweets", 1)); + repository.save(tweet); + } + } + + @Override + public Tweet getTweet(Long id) { + return repository.findOne(id); + } + + @Override + public List listAllTweets() { + metricWriter.increment(new Delta("times-queried-tweets", 1)); + + return repository.findAllPublished(); + } + + @Override + public List listDiscardedTweets() { + metricWriter.increment(new Delta("times-queried-discarded-tweets", 1)); + + return repository.findAllDiscarded(); + } + + /** + * Validates the contents of a tweet + * @param publisher the tweet publisher + * @param message the tweet message + * @throws IllegalArgumentException if the input data is not valid + */ + private void validate(String publisher, String message) { + List errors = new ArrayList<>(); + + if ((null == publisher) || publisher.isEmpty()) { + errors.add("Publisher is missing"); + } + + if ((null == message) || message.isEmpty()) { + errors.add("Message is missing"); + } + + if (null != message) { + int messageLength = message.length(); + // Recalculate the message length without the links. + Matcher matcher = pattern.matcher(message); + while (matcher.find()) { + messageLength -= matcher.group().length(); + } + + // Check the length of the message. + if (messageLength > MAX_MESSAGE_LENGTH) { + errors.add("Message is longer than 140 characters"); + } + } + + if (!errors.isEmpty()) { + throw new IllegalArgumentException(String.join(" | ", errors)); + } + } +} diff --git a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java index 9888cfa..9dd3460 100644 --- a/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java +++ b/src/test/java/com/scmspain/tweetmgr/controller/TweetControllerTest.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.scmspain.tweetmgr.configuration.TestConfiguration; -import com.scmspain.tweetmgr.entities.Tweet; +import com.scmspain.tweetmgr.persistence.entities.Tweet; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -24,6 +24,7 @@ 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.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @@ -98,6 +99,7 @@ public void shouldAllowDiscardingTweet() throws Exception { .andExpect(status().is(201)); mockMvc.perform(discardTweet("1")) + .andDo(print()) .andExpect(status().is(200)); MvcResult getResult = mockMvc.perform(get("/tweet")) @@ -115,13 +117,13 @@ public void shouldNotDiscardMissingTweet() throws Exception { } private MockHttpServletRequestBuilder newTweet(String publisher, String tweet) { - return post("/tweet") + return post(TweetController.TWEET_PATH) .contentType(MediaType.APPLICATION_JSON_UTF8) .content(format("{\"publisher\": \"%s\", \"tweet\": \"%s\"}", publisher, tweet)); } private MockHttpServletRequestBuilder discardTweet(String id) { - return post("/discarded") + return post(TweetController.DISCARDED_PATH) .contentType(MediaType.APPLICATION_JSON_UTF8) .content(format("{\"tweet\": \"%s\"}", id)); } @@ -154,7 +156,7 @@ public void shouldReturnAllPublishedTweetsOrderedByDate() throws Exception { // If there is a previous timestamp... if (null != previousTimestamp) { // Ensure that the previous tweet was published before the current one. - assertThat(currentTimestamp.before(previousTimestamp)); + assertThat(currentTimestamp).isBefore(previousTimestamp); } previousTimestamp = currentTimestamp; } diff --git a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java index ed24443..f395f46 100644 --- a/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java +++ b/src/test/java/com/scmspain/tweetmgr/services/TweetServiceTest.java @@ -1,61 +1,66 @@ package com.scmspain.tweetmgr.services; -import com.scmspain.tweetmgr.entities.Tweet; +import com.scmspain.tweetmgr.persistence.TweetRepository; +import com.scmspain.tweetmgr.persistence.entities.Tweet; import org.junit.Before; import org.junit.Test; import org.springframework.boot.actuate.metrics.writer.MetricWriter; -import javax.persistence.EntityManager; import java.sql.Timestamp; import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.junit.Assert.*; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.any; public class TweetServiceTest { - private EntityManager entityManager; - private MetricWriter metricWriter; + private TweetRepository repository; private TweetService tweetService; private Tweet testTweet; @Before - public void setUp() throws Exception { - this.entityManager = mock(EntityManager.class); - this.metricWriter = mock(MetricWriter.class); + public void setUp() { + repository = mock(TweetRepository.class); + MetricWriter metricWriter = mock(MetricWriter.class); - this.tweetService = new TweetService(entityManager, metricWriter); + this.tweetService = new TweetServiceImpl(repository, metricWriter); // Set up entity manager mock. testTweet = new Tweet(); testTweet.setPublisher("Guybrush Threepwood"); testTweet.setMessage("Look behind you, a three-headed monkey!"); - when(entityManager.find(Tweet.class, 1L)).thenReturn(testTweet); + when(repository.findOne(1L)).thenReturn(testTweet); } @Test - public void shouldInsertANewTweet() throws Exception { + public void shouldInsertANewTweet() { tweetService.publishTweet("Guybrush Threepwood", "I am Guybrush Threepwood, mighty pirate."); - verify(entityManager).persist(any(Tweet.class)); + verify(repository).save(any(Tweet.class)); } @Test public void shouldAllowTweetsLessThan140Characters() { tweetService.publishTweet("Less than 140 characters", getTweetContent(139)); - verify(entityManager).persist(any(Tweet.class)); + + verify(repository).save(any(Tweet.class)); } @Test public void shouldAllow140CharacterTweets() { tweetService.publishTweet("140 characters", getTweetContent(140)); - verify(entityManager).persist(any(Tweet.class)); + + verify(repository).save(any(Tweet.class)); } @Test(expected = IllegalArgumentException.class) - public void shouldThrowAnExceptionWhenTweetLengthIsInvalid() throws Exception { + public void shouldThrowAnExceptionWhenTweetLengthIsInvalid() { 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."); } @@ -88,22 +93,18 @@ public void shouldNotAllowTweetsWithNullPublisher() { public void shouldNotCountLinksTowardsTheCharacterLimit() { String message = "I’m selling these fine leather jackets"; StringBuilder sb = new StringBuilder(); - while (sb.length() < TweetService.MAX_MESSAGE_LENGTH) { + while (sb.length() < TweetServiceImpl.MAX_MESSAGE_LENGTH) { sb.append(" http://stansusedjackets.com/"); } assertTrue(message.length() + sb.length() > 140); tweetService.publishTweet("Guybrush Threepwood", message + sb.toString()); - verify(entityManager).persist(any(Tweet.class)); + + verify(repository).save(any(Tweet.class)); } @Test public void shouldAllowDiscardingTweets() { - Tweet testTweet = new Tweet(); - testTweet.setPublisher("Guybrush Threepwood"); - testTweet.setMessage("Look behind you, a three-headed monkey!"); - when(entityManager.find(Tweet.class, 1L)).thenReturn(testTweet); - tweetService.discardTweet(1L); assertNotNull(testTweet.getDiscardedOn());