Browse Source

feat: add integration tests for NoteController (#4)

Graciela Gallas 2 tuần trước cách đây
mục cha
commit
114c0f92f6

+ 15 - 0
.github/workflows/build.yml

@@ -10,6 +10,21 @@ jobs:
 
     runs-on: ubuntu-latest
 
+    services:
+      mongodb:
+        image: mongo:7.0
+        options: >-
+          --health-cmd "mongosh --quiet --eval 'db.runCommand({ping: 1})'"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 27017:27017
+
+    env:
+      mongo: mongodb://localhost:27017
+      database: knotes-test
+
     steps:
     - uses: actions/checkout@v3
     - name: Set up JDK 25

+ 16 - 0
.github/workflows/buildAndRelease.yml

@@ -16,6 +16,22 @@ jobs:
   build-and-test:
     name: Build and Test
     runs-on: ubuntu-latest
+
+    services:
+      mongodb:
+        image: mongo:7.0
+        options: >-
+          --health-cmd "mongosh --quiet --eval 'db.runCommand({ping: 1})'"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 27017:27017
+
+    env:
+      mongo: mongodb://localhost:27017
+      database: knotes-test
+
     steps:
       - uses: actions/checkout@v3
       - name: Set up JDK

+ 16 - 0
.github/workflows/test-workflow.yml

@@ -16,6 +16,22 @@ jobs:
   test-build:
     name: Test Java Build
     runs-on: ubuntu-latest
+
+    services:
+      mongodb:
+        image: mongo:7.0
+        options: >-
+          --health-cmd "mongosh --quiet --eval 'db.runCommand({ping: 1})'"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 27017:27017
+
+    env:
+      mongo: mongodb://localhost:27017
+      database: knotes-test
+
     steps:
       - uses: actions/checkout@v4
 

+ 11 - 0
build.gradle

@@ -2,6 +2,7 @@ plugins {
     id 'java'
     id 'org.springframework.boot' version '4.0.1'
     id 'io.spring.dependency-management' version '1.1.7'
+
 }
 
 group = 'com.lhamacorp'
@@ -24,12 +25,22 @@ dependencies {
 
     implementation 'com.github.f4b6a3:ulid-creator:5.2.3'
     implementation 'com.github.ben-manes.caffeine:caffeine'
+    implementation 'com.fasterxml.jackson.core:jackson-databind'
 
     testImplementation 'org.springframework.boot:spring-boot-starter-mongodb-test'
     testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
     testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+    testImplementation 'io.rest-assured:rest-assured:6.0.0'
 }
 
 tasks.named('test') {
     useJUnitPlatform()
+    testLogging {
+        events "passed", "skipped", "failed"
+
+        showStandardStreams = true
+        exceptionFormat = 'full'
+        displayGranularity = 2
+    }
 }

+ 336 - 0
src/test/java/com/lhamacorp/knotes/api/NoteControllerTest.java

@@ -0,0 +1,336 @@
+package com.lhamacorp.knotes.api;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.lhamacorp.knotes.api.dto.NoteRequest;
+import com.lhamacorp.knotes.api.dto.NoteUpdateRequest;
+import io.restassured.RestAssured;
+import io.restassured.response.Response;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.http.ContentType.JSON;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class NoteControllerTest {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    String timestamp = LocalDateTime.now().toString();
+
+    @LocalServerPort
+    private int port;
+
+    @BeforeEach
+    public void setup() {
+        RestAssured.baseURI = "http://localhost";
+        RestAssured.port = port;
+        RestAssured.basePath = "/api";
+    }
+
+    @Test
+    @DisplayName("Should Create An Empty Note")
+    public void shouldCreateAnEmptyNote() throws JsonProcessingException {
+        String id = createNoteAction(null);
+        assertNotNull(id);
+    }
+
+    @Test
+    @DisplayName("Should Create A Note With Content")
+    public void shouldCreateANoteWithContent() throws IOException {
+        String expectedContent = "Test " + timestamp;
+        String id = createNoteAction(expectedContent);
+
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(expectedContent));
+        cleanNoteAction(id);
+    }
+
+    @Test
+    @DisplayName("Should Update A Note")
+    public void shouldUpdateANote() throws IOException {
+        String expectedContent = "Test " + timestamp;
+        String updateContent = "Test Updated " + timestamp;
+
+        String id = createNoteAction(expectedContent);
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(expectedContent));
+
+        updateNoteAction(id, updateContent);
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(updateContent));
+        cleanNoteAction(id);
+    }
+
+    @Test
+    @DisplayName("Should Find A Note By Id")
+    public void shouldFindANoteById() throws IOException {
+        String expectedContent = "Test " + timestamp;
+        String id = createNoteAction(expectedContent);
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(expectedContent));
+        getANoteByIdAction(id, expectedContent);
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(expectedContent));
+        cleanNoteAction(id);
+    }
+
+    @Test
+    @DisplayName("Should Check Updated Date")
+    public void shouldCheckUpdatedDate() throws IOException {
+        String expectedContent = "Test " + timestamp;
+        String id = createNoteAction(expectedContent);
+
+        given()
+                .log().all()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(expectedContent));
+        String expectedUpdateDate1 = getUpdateDateAction(id);
+        updateNoteAction(id, expectedContent);
+        String expectedUpdateDate2 = getUpdateDateAction(id);
+        Instant updateDate1 = Instant.parse(expectedUpdateDate1);
+        Instant updateDate2 = Instant.parse(expectedUpdateDate2);
+        assertTrue(updateDate2.isAfter(updateDate1));
+    }
+
+    @Test
+    @DisplayName("Should Check Created Date")
+    public void shouldCheckCreatedDate() throws IOException {
+        String expectedContent = "Test " + timestamp;
+        String id = createNoteAction(expectedContent);
+
+        given()
+                .log().all()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(expectedContent));
+        String expectedCreatedDate1 = getCreatedDateAction(id);
+        updateNoteAction(id, expectedContent);
+        String expectedCreatedDate2 = getCreatedDateAction(id);
+        Instant updateDate1 = Instant.parse(expectedCreatedDate1);
+        Instant updateDate2 = Instant.parse(expectedCreatedDate2);
+        assertEquals(updateDate2, updateDate1);
+    }
+
+    @Test
+    @DisplayName("Should Search For A Non-Existent ID")
+    public void shouldSearchForNonExistentId() throws IOException {
+        String id = "BLAF2TM3PBC6SK5ZG8NGS3YKFS";
+        given()
+                .log().all()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .log().all()
+                .statusCode(404);
+    }
+
+
+    @Test
+    @DisplayName("Should return 415 when sending plain text instead of JSON")
+    public void shouldReturn415WhenContentTypeIsInvalid() {
+        String invalidBody = "This is just a string, not a JSON";
+
+        given()
+                .log().all()
+                .contentType("text/plain")
+                .body(invalidBody)
+                .when()
+                .post("/notes")
+                .then()
+                .statusCode(415);
+    }
+
+    @Test
+    @DisplayName("Should ensure data integrity after a failed update attempt")
+    public void shouldMaintainOriginalContentAfterFailedUpdate() throws IOException {
+        String originalContent = "Original Content " + timestamp;
+        String id = createNoteAction(originalContent);
+        String malformedJson = "{ \"content\": \"Updated Content\" ";
+
+        given()
+                .log().all()
+                .contentType(JSON)
+                .body(malformedJson)
+                .when()
+                .put("/notes/" + id)
+                .then()
+                .log().all()
+                .statusCode(400);
+        getANoteByIdAction(id, originalContent);
+        cleanNoteAction(id);
+    }
+
+    @Test
+    @DisplayName("Should handle real concurrent updates using multiple threads")
+    public void shouldHandleRealConcurrentUpdates() throws IOException, InterruptedException {
+        String id = createNoteAction("Initial State");
+        String updateA = "Update A " + timestamp;
+        String updateB = "Update B " + timestamp;
+
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        CountDownLatch latch = new CountDownLatch(1);
+
+        executor.execute(() -> {
+            try {
+                latch.await();
+                updateNoteAction(id, updateA);
+            } catch (Exception e) { e.printStackTrace(); }
+        });
+
+        executor.execute(() -> {
+            try {
+                latch.await();
+                updateNoteAction(id, updateB);
+            } catch (Exception e) { e.printStackTrace(); }
+        });
+
+        latch.countDown();
+        executor.shutdown();
+
+        boolean finished = executor.awaitTermination(10, TimeUnit.SECONDS);
+        assertTrue(finished, "Threads did not finish within the timeout period");
+
+        Response response = given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .extract()
+                .response();
+
+        String finalContent = response.path("content");
+        String expectedWinner = finalContent.contains("Update A") ? updateA : updateB;
+
+        assertNotEquals("Initial State", finalContent,
+                "The content should have changed from the initial state");
+        assertEquals(expectedWinner, finalContent,
+                "The final content does not match the expected winner of the race");
+    }
+
+    @Test
+    @DisplayName("Should persist accented characters and technical symbols correctly")
+    public void shouldHandleAccentedCharactersAndSymbols() throws IOException {
+        String complexContent = "Latin chars: áéíóú çãõ ñ | Technical symbols: @#$%^&*()_+-=[]{}|\\";
+
+        String id = createNoteAction(complexContent);
+
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .statusCode(200)
+                .body("content", equalTo(complexContent));
+        cleanNoteAction(id);
+    }
+
+    @Test
+    @DisplayName("Should return 404 when updating a non-existent note")
+    public void shouldReturn404OnUpdateNonExistent() throws JsonProcessingException {
+        String nonExistentId = "00000000000000000000000000";
+
+        given()
+                .contentType(JSON)
+                .body(objectMapper.writeValueAsString(new NoteUpdateRequest("some content")))
+                .when()
+                .put("/notes/" + nonExistentId)
+                .then()
+                .statusCode(404);
+    }
+
+    private String createNoteAction(String note) throws JsonProcessingException {
+        return given()
+                .contentType(JSON)
+                .body(objectMapper.writeValueAsString(new NoteRequest(note)))
+                .when()
+                .post("/notes")
+                .then()
+                .log().all()
+                .statusCode(200)
+                .extract()
+                .path("id");
+    }
+
+
+    private void updateNoteAction(String id, String content) throws IOException {
+        given()
+                .contentType(JSON)
+                .body(objectMapper.writeValueAsString(new NoteUpdateRequest(content)))
+                .when()
+                .put("/notes/" + id)
+                .then()
+                .log().all()
+                .statusCode(200)
+                .body("content", equalTo(content));
+    }
+
+    private void cleanNoteAction(String id) throws IOException {
+        updateNoteAction(id, "");
+    }
+
+    private void getANoteByIdAction(String id, String content) throws IOException {
+        given()
+                .when()
+                .get("/notes/" + id)
+                .then()
+                .log().all()
+                .statusCode(200)
+                .body("content", equalTo(content));
+    }
+
+    private String getUpdateDateAction(String id) throws IOException {
+        return given()
+                .get("/notes/" +id)
+                .then()
+                .statusCode(200)
+                .extract()
+                .path("modifiedAt");
+    }
+
+    private String getCreatedDateAction(String id) throws IOException {
+        return given()
+                .get("/notes/" +id)
+                .then()
+                .statusCode(200)
+                .extract()
+                .path("createdAt");
+    }
+
+}