"
+ .andExpect(content().string(matchesRegex("(?s).*Datetime now is \\d{4}-\\d{2}-\\d{2}T[\\d:.]+.*")));
+ }
+
+ @Test
+ void stringOperationEndpointRendersTheStreamShowcase() throws Exception {
+ mockMvc.perform(get("/topic/string/operation"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("Joining All String ID's with JOIN method: ")))
+ // The distinct-and-sorted characters and the ":"-split are both sorted, so they are
+ // stable even if another test has re-ordered the singleton's topic list.
+ .andExpect(content().string(containsString(":acgijnprstv")))
+ .andExpect(content().string(containsString("java:javascript")))
+ .andExpect(content().string(containsString("[spring]")));
+ }
+
+ @Test
+ void fileOperationEndpointRendersTheNioShowcase() throws Exception {
+ mockMvc.perform(get("/topic/file/operation"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("Find all files in path and sort:")))
+ .andExpect(content().string(containsString("pom.xml")))
+ .andExpect(content().string(containsString("Read \"temp.txt\" file with stream functions")))
+ .andExpect(content().string(not(containsString("Error in IO"))))
+ .andExpect(content().string(not(containsString("IO exception"))));
+ }
+}
diff --git a/src/test/java/hello/controller/TopicControllerWebTest.java b/src/test/java/hello/controller/TopicControllerWebTest.java
new file mode 100644
index 0000000..05147be
--- /dev/null
+++ b/src/test/java/hello/controller/TopicControllerWebTest.java
@@ -0,0 +1,125 @@
+package hello.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.not;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+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.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * End-to-end coverage of the JSON contract of the topic endpoints.
+ *
+ * {@code TopicService} is a singleton holding a mutable list, so anything that writes to it leaks
+ * into the next test. Rather than depending on test order, the two tests that mutate the list
+ * ({@code /topic} writes and the in-place sort performed by {@code /topic/sort}) drop the context
+ * afterwards with {@link DirtiesContext}; every other test can then assume the seeded state.
+ *
+ *
Assertions go through {@code jsonPath} rather than through model getters on purpose: the JSON
+ * field names are the actual contract and they survive the ongoing conversion of the models to records.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+class TopicControllerWebTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void getAllTopicsReturnsTheSeededTopics() throws Exception {
+ mockMvc.perform(get("/topic"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$", hasSize(3)))
+ .andExpect(jsonPath("$[*].id", containsInAnyOrder("spring", "java", "javascript")))
+ .andExpect(jsonPath("$[?(@.id=='spring')].subjectName", contains("Spring Framework")))
+ .andExpect(jsonPath("$[?(@.id=='spring')].subjectDescription", contains("Spring Framework Description")));
+ }
+
+ @Test
+ void aTopicSerialisesWithExactlyIdSubjectNameAndSubjectDescription() throws Exception {
+ mockMvc.perform(get("/topic/java"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.*", hasSize(3)))
+ .andExpect(jsonPath("$.id").value("java"))
+ .andExpect(jsonPath("$.subjectName").value("Core Java"))
+ .andExpect(jsonPath("$.subjectDescription").value("Java Description"));
+ }
+
+ @Test
+ @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
+ void supportsTheFullCreateReadUpdateDeleteLifecycle() throws Exception {
+ mockMvc.perform(post("/topic")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"id":"kotlin","subjectName":"Kotlin","subjectDescription":"Kotlin Description"}"""))
+ .andExpect(status().isOk());
+
+ mockMvc.perform(get("/topic"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(4)))
+ .andExpect(jsonPath("$[*].id", containsInAnyOrder("spring", "java", "javascript", "kotlin")));
+
+ mockMvc.perform(get("/topic/kotlin"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.subjectName").value("Kotlin"))
+ .andExpect(jsonPath("$.subjectDescription").value("Kotlin Description"));
+
+ mockMvc.perform(put("/topic/kotlin")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"id":"kotlin","subjectName":"Kotlin Updated","subjectDescription":"Updated Description"}"""))
+ .andExpect(status().isOk());
+
+ mockMvc.perform(get("/topic/kotlin"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.subjectName").value("Kotlin Updated"))
+ .andExpect(jsonPath("$.subjectDescription").value("Updated Description"));
+
+ mockMvc.perform(delete("/topic/kotlin"))
+ .andExpect(status().isOk());
+
+ mockMvc.perform(get("/topic"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(3)))
+ .andExpect(jsonPath("$[*].id", not(hasItem("kotlin"))));
+ }
+
+ @Test
+ @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
+ void sortEndpointReturnsTopicsOrderedById() throws Exception {
+ mockMvc.perform(get("/topic/sort"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(3)))
+ .andExpect(jsonPath("$[*].id", contains("java", "javascript", "spring")));
+ }
+
+ @Test
+ void minimumLengthEndpointFiltersOnIdLength() throws Exception {
+ mockMvc.perform(get("/topic/minimum/length/4"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[*].id", containsInAnyOrder("spring", "javascript")));
+
+ mockMvc.perform(get("/topic/minimum/length/6"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[*].id", contains("javascript")));
+
+ mockMvc.perform(get("/topic/minimum/length/20"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+ }
+}
diff --git a/src/test/java/hello/controller/TopicNotFoundStatusTest.java b/src/test/java/hello/controller/TopicNotFoundStatusTest.java
new file mode 100644
index 0000000..86e888b
--- /dev/null
+++ b/src/test/java/hello/controller/TopicNotFoundStatusTest.java
@@ -0,0 +1,43 @@
+package hello.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Requesting an unknown topic id is the one place where the HTTP status carries real information, and
+ * MockMvc rethrows unhandled exceptions instead of running the error dispatch, so this one case is
+ * driven over a real connection.
+ *
+ *
{@code TopicService.getTopicWithId} returns an empty {@link java.util.Optional} on a miss, which
+ * the controller translates into a 404.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class TopicNotFoundStatusTest {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void unknownTopicIdReturnsNotFound() {
+ ResponseEntity response = restTemplate.getForEntity("/topic/does-not-exist", String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ void knownTopicIdReturnsOkWithTheUnchangedJsonShape() {
+ ResponseEntity response = restTemplate.getForEntity("/topic/spring", String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody())
+ .contains("\"id\":\"spring\"")
+ .contains("\"subjectName\":\"Spring Framework\"")
+ .contains("\"subjectDescription\":\"Spring Framework Description\"");
+ }
+}
diff --git a/src/test/java/hello/declaration/TimeClientTest.java b/src/test/java/hello/declaration/TimeClientTest.java
new file mode 100644
index 0000000..3778f26
--- /dev/null
+++ b/src/test/java/hello/declaration/TimeClientTest.java
@@ -0,0 +1,80 @@
+package hello.declaration;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import org.junit.jupiter.api.Test;
+
+import hello.model.SimpleTimeClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Covers the {@code static} and {@code default} methods on the interface and the
+ * {@link SimpleTimeClient} implementation of them.
+ */
+class TimeClientTest {
+
+ @Test
+ void getZoneIdResolvesAKnownZone() {
+ assertThat(TimeClient.getZoneId("Canada/Central")).isEqualTo(ZoneId.of("Canada/Central"));
+ }
+
+ @Test
+ void getZoneIdFallsBackToTheSystemZoneForAnInvalidZone() {
+ assertThat(TimeClient.getZoneId("Not/A/Zone")).isEqualTo(ZoneId.systemDefault());
+ assertThat(TimeClient.getZoneId("")).isEqualTo(ZoneId.systemDefault());
+ }
+
+ @Test
+ void getZonedDateTimeCombinesTheLocalDateTimeWithTheRequestedZone() {
+ SimpleTimeClient client = new SimpleTimeClient();
+ client.setDateAndTime(2020, 5, 17, 13, 45, 30);
+
+ ZonedDateTime zoned = client.getZonedDateTime("Canada/Central");
+
+ assertThat(zoned.toLocalDateTime()).isEqualTo(LocalDateTime.of(2020, 5, 17, 13, 45, 30));
+ assertThat(zoned.getZone()).isEqualTo(ZoneId.of("Canada/Central"));
+ }
+
+ @Test
+ void getZonedDateTimeUsesTheSystemZoneWhenTheZoneIsInvalid() {
+ SimpleTimeClient client = new SimpleTimeClient();
+
+ assertThat(client.getZonedDateTime("Middle/Earth").getZone()).isEqualTo(ZoneId.systemDefault());
+ }
+
+ @Test
+ void newClientIsInitialisedWithTheCurrentDateTime() {
+ LocalDateTime before = LocalDateTime.now().minusMinutes(1);
+
+ SimpleTimeClient client = new SimpleTimeClient();
+
+ assertThat(client.getLocalDateTime()).isAfter(before);
+ assertThat(client.toString()).isEqualTo(client.getLocalDateTime().toString());
+ }
+
+ @Test
+ void setTimeKeepsTheDateAndReplacesTheTime() {
+ SimpleTimeClient client = new SimpleTimeClient();
+ client.setDateAndTime(2020, 5, 17, 1, 2, 3);
+
+ client.setTime(23, 59, 58);
+
+ assertThat(client.getLocalDateTime()).isEqualTo(LocalDateTime.of(2020, 5, 17, 23, 59, 58));
+ }
+
+ @Test
+ void setDateKeepsTheTimeAndReplacesTheDate() {
+ SimpleTimeClient client = new SimpleTimeClient();
+ client.setDateAndTime(2020, 5, 17, 8, 30, 0);
+
+ // setDate/setDateAndTime declare their parameters as (day, month, year) but delegate to
+ // LocalDate.of(..) positionally, so the first argument is really the year. Asserted as
+ // implemented rather than as named.
+ client.setDate(2021, 12, 25);
+
+ assertThat(client.getLocalDateTime()).isEqualTo(LocalDateTime.of(2021, 12, 25, 8, 30, 0));
+ }
+}
diff --git a/src/test/java/hello/service/TopicServiceTest.java b/src/test/java/hello/service/TopicServiceTest.java
new file mode 100644
index 0000000..3bae0f7
--- /dev/null
+++ b/src/test/java/hello/service/TopicServiceTest.java
@@ -0,0 +1,181 @@
+package hello.service;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import hello.model.Topic;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * {@link TopicService} keeps its topics in a mutable instance field, so as a Spring singleton its
+ * state leaks between tests. These unit tests therefore build a brand new service in {@link BeforeEach}
+ * instead of injecting the bean: every test starts from the three seeded topics and nothing has to be
+ * undone afterwards.
+ */
+class TopicServiceTest {
+
+ private TopicService topicService;
+
+ @BeforeEach
+ void createFreshService() {
+ topicService = new TopicService();
+ }
+
+ /**
+ * Single place where a {@link Topic} accessor is used, so the parallel conversion of the models to
+ * records only has to touch one line in this class.
+ */
+ private static List idsOf(List topics) {
+ return topics.stream().map(Topic::id).collect(Collectors.toList());
+ }
+
+ @Test
+ void seedsThreeTopics() {
+ assertThat(idsOf(topicService.getAllTopics()))
+ .containsExactly("spring", "java", "javascript");
+ }
+
+ @Test
+ void getTopicWithIdReturnsTheMatchingTopic() {
+ Topic topic = topicService.getTopicWithId("java").orElseThrow();
+
+ assertThat(idsOf(List.of(topic))).containsExactly("java");
+ assertThat(topic.subjectName()).isEqualTo("Core Java");
+ }
+
+ @Test
+ void getTopicWithIdReportsAMissingTopicWithoutThrowing() {
+ assertThatCode(() -> topicService.getTopicWithId("does-not-exist")).doesNotThrowAnyException();
+ assertThat(topicService.getTopicWithId("does-not-exist")).isEmpty();
+ }
+
+ @Test
+ void addTopicAppendsToTheList() {
+ topicService.addTopic(new Topic("kotlin", "Kotlin", "Kotlin Description"));
+
+ assertThat(idsOf(topicService.getAllTopics())).containsExactly("spring", "java", "javascript", "kotlin");
+ }
+
+ @Test
+ void updateTopicReplacesTheTopicInPlace() {
+ topicService.updateTopic("java", new Topic("java", "Updated Java", "Updated Description"));
+
+ List topics = topicService.getAllTopics();
+ assertThat(idsOf(topics)).containsExactly("spring", "java", "javascript");
+ assertThat(topics.get(1).subjectName()).isEqualTo("Updated Java");
+ }
+
+ @Test
+ void updateTopicIsANoOpForAnUnknownId() {
+ topicService.updateTopic("kotlin", new Topic("kotlin", "Kotlin", "Kotlin Description"));
+
+ assertThat(idsOf(topicService.getAllTopics())).containsExactly("spring", "java", "javascript");
+ }
+
+ @Test
+ void deleteTopicRemovesOnlyTheMatchingTopic() {
+ topicService.deleteTopic("java");
+
+ assertThat(idsOf(topicService.getAllTopics())).containsExactly("spring", "javascript");
+ }
+
+ @Test
+ void deleteTopicIsANoOpForAnUnknownId() {
+ topicService.deleteTopic("kotlin");
+
+ assertThat(idsOf(topicService.getAllTopics())).hasSize(3);
+ }
+
+ @Test
+ void filterMinimumLengthForIdKeepsIdsStrictlyLongerThanTheGivenLength() {
+ assertThat(idsOf(topicService.filterMinimumLengthForId(4)))
+ .containsExactly("spring", "javascript");
+ assertThat(idsOf(topicService.filterMinimumLengthForId(6)))
+ .containsExactly("javascript");
+ assertThat(topicService.filterMinimumLengthForId(20)).isEmpty();
+ }
+
+ @Test
+ void sortTopicsWithIDSortsTheUnderlyingListById() {
+ assertThat(idsOf(topicService.sortTopicsWithID()))
+ .containsExactly("java", "javascript", "spring");
+
+ // The sort mutates the service's own list, which is exactly why these tests use a fresh instance.
+ assertThat(idsOf(topicService.getAllTopics()))
+ .containsExactly("java", "javascript", "spring");
+ }
+
+ @Test
+ void returnAllTopicIDWithStringSlicingJoinsIdsWithAColon() {
+ assertThat(topicService.returnAllTopicIDWithStringSlicing()).isEqualTo("spring:java:javascript");
+ }
+
+ @Test
+ void makeDistinctAndSortCharactersReturnsSortedDistinctCharacters() {
+ assertThat(topicService.makeDistinctAndSortCharacters("spring:java:javascript"))
+ .isEqualTo(":acgijnprstv");
+ assertThat(topicService.makeDistinctAndSortCharacters("")).isEmpty();
+ }
+
+ @Test
+ void splitAllIdWithColonSelectIDWithJavaKeywordThenSortThenJoinKeepsOnlyJavaIds() {
+ assertThat(topicService.splitAllIdWithColonSelectIDWithJavaKeywordThenSortThenJoin("spring:java:javascript"))
+ .isEqualTo("java:javascript");
+ assertThat(topicService.splitAllIdWithColonSelectIDWithJavaKeywordThenSortThenJoin("spring:kotlin"))
+ .isEmpty();
+ }
+
+ @Test
+ void findIdHavingCharacterMatchesIdsContainingG() {
+ assertThat(topicService.findIdHavingCharacter()).isEqualTo("[spring]");
+ }
+
+ // The file operations below walk the process working directory (the module base directory when run
+ // by Surefire) and read temp.txt from it. Their exact output changes whenever a file is added or
+ // removed from the repository, so they are asserted loosely on purpose.
+
+ @Test
+ void findAllFilesInPathAndSortListsVisibleFilesInTheWorkingDirectory() {
+ String listed = topicService.findAllFilesInPathAndSort();
+
+ assertThat(listed).isNotNull().isNotEqualTo(" Error in IO");
+ assertThat(listed).contains("pom.xml");
+ assertThat(listed).doesNotContain(".git;");
+ }
+
+ @Test
+ void findParticularFileInPathAndSortReturnsAStringWithoutFailing() {
+ assertThat(topicService.findParticularFileInPathAndSort())
+ .isNotNull()
+ .isNotEqualTo(" IO exception ");
+ }
+
+ @Test
+ void findParticularFileInPathAndSortWithWalkFunctionReturnsAStringWithoutFailing() {
+ assertThat(topicService.findParticularFileInPathAndSortWithWalkFunction())
+ .isNotNull()
+ .isNotEqualTo(" IO exception ");
+ }
+
+ @Test
+ void readFileWithStreamFunctionReturnsTheLinesStartingWithPrint() {
+ Assumptions.assumeTrue(Files.exists(Paths.get("temp.txt")), "temp.txt is read relative to the working directory");
+
+ String read = topicService.readFileWithStreamFunction();
+
+ assertThat(read).isNotNull().isNotEqualTo(" IO exception ");
+ assertThat(read).contains(" Hello");
+ assertThat(read).doesNotContain("print");
+ }
+}
diff --git a/target/classes/hello/Application.class b/target/classes/hello/Application.class
deleted file mode 100644
index 245a020..0000000
Binary files a/target/classes/hello/Application.class and /dev/null differ
diff --git a/target/classes/hello/controller/GreetingController.class b/target/classes/hello/controller/GreetingController.class
deleted file mode 100644
index 1149660..0000000
Binary files a/target/classes/hello/controller/GreetingController.class and /dev/null differ
diff --git a/target/classes/hello/controller/HelloController.class b/target/classes/hello/controller/HelloController.class
deleted file mode 100644
index 79b82ce..0000000
Binary files a/target/classes/hello/controller/HelloController.class and /dev/null differ
diff --git a/target/classes/hello/controller/TopicController.class b/target/classes/hello/controller/TopicController.class
deleted file mode 100644
index c32cad5..0000000
Binary files a/target/classes/hello/controller/TopicController.class and /dev/null differ
diff --git a/target/classes/hello/declaration/CustomPredicate.class b/target/classes/hello/declaration/CustomPredicate.class
deleted file mode 100644
index e35803f..0000000
Binary files a/target/classes/hello/declaration/CustomPredicate.class and /dev/null differ
diff --git a/target/classes/hello/declaration/TimeClient.class b/target/classes/hello/declaration/TimeClient.class
deleted file mode 100644
index a077c3b..0000000
Binary files a/target/classes/hello/declaration/TimeClient.class and /dev/null differ
diff --git a/target/classes/hello/model/Customer.class b/target/classes/hello/model/Customer.class
deleted file mode 100644
index 06e7307..0000000
Binary files a/target/classes/hello/model/Customer.class and /dev/null differ
diff --git a/target/classes/hello/model/Greeting.class b/target/classes/hello/model/Greeting.class
deleted file mode 100644
index f875277..0000000
Binary files a/target/classes/hello/model/Greeting.class and /dev/null differ
diff --git a/target/classes/hello/model/Quote.class b/target/classes/hello/model/Quote.class
deleted file mode 100644
index 3ec6c6c..0000000
Binary files a/target/classes/hello/model/Quote.class and /dev/null differ
diff --git a/target/classes/hello/model/SimpleTimeClient.class b/target/classes/hello/model/SimpleTimeClient.class
deleted file mode 100644
index f9bdddd..0000000
Binary files a/target/classes/hello/model/SimpleTimeClient.class and /dev/null differ
diff --git a/target/classes/hello/model/Topic.class b/target/classes/hello/model/Topic.class
deleted file mode 100644
index 3a53aa5..0000000
Binary files a/target/classes/hello/model/Topic.class and /dev/null differ
diff --git a/target/classes/hello/model/Value.class b/target/classes/hello/model/Value.class
deleted file mode 100644
index 862a68f..0000000
Binary files a/target/classes/hello/model/Value.class and /dev/null differ
diff --git a/target/classes/hello/service/TopicService.class b/target/classes/hello/service/TopicService.class
deleted file mode 100644
index b9f4992..0000000
Binary files a/target/classes/hello/service/TopicService.class and /dev/null differ
diff --git a/target/classes/public/index.html b/target/classes/public/index.html
deleted file mode 100644
index 566549b..0000000
--- a/target/classes/public/index.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file