From 2faefdcfc333d07a1cd37facf41867130fafff27 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 2 Dec 2025 17:13:39 +1100 Subject: [PATCH 01/15] test(note): add unit test for note service --- .../controller/NoteController.java | 1 - .../com/be08/smart_notes/model/Document.java | 20 +- .../be08/smart_notes/service/NoteService.java | 11 +- .../smart_notes/service/NoteServiceTest.java | 382 ++++++++++++++++++ 4 files changed, 397 insertions(+), 17 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/service/NoteServiceTest.java diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 3d03d39..de5f08d 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -52,7 +52,6 @@ public ResponseEntity getAllNotes() { return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - // @PutMapping("/{id}") @PatchMapping("/{id}") public ResponseEntity updateNote(@PathVariable int id, @RequestBody @Valid NoteUpsertRequest noteUpdateRequest) { NoteResponse note = noteService.updateNote(id, noteUpdateRequest); diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index e48afc7..89bfb18 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -5,14 +5,7 @@ import com.be08.smart_notes.enums.DocumentType; import com.fasterxml.jackson.annotation.JsonInclude; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -56,4 +49,15 @@ public class Document { @Column(name = "file_size") private Integer fileSize; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 0fee555..2f1b243 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -50,12 +50,10 @@ public NoteResponse createNote(NoteUpsertRequest newData) { .title(newData.getTitle()) .content(newData.getContent()) .type(DocumentType.NOTE) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) .build(); - documentRepository.save(newNote); - return documentMapper.toNoteResponse(newNote); + Document savedNote = documentRepository.save(newNote); + return documentMapper.toNoteResponse(savedNote); } public List getAllNotes() { @@ -100,10 +98,7 @@ public void deleteNote(int noteId) { // ------ Methods that returns entities ------ // public List getAllNotesByUserIdAndIds(int userId, List noteIds) { - // Get current user id - int currentUserId = authorizationService.getCurrentUserId(); - - return documentRepository.findAllByUserIdAndIdIn(currentUserId, noteIds); + return documentRepository.findAllByUserIdAndIdIn(userId, noteIds); } public List getAllNotesByIds(List noteIds) { diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java new file mode 100644 index 0000000..eb25423 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java @@ -0,0 +1,382 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.NoteUpsertRequest; +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.enums.DocumentType; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.DocumentMapper; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.DocumentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class NoteServiceTest { + @Mock + DocumentRepository documentRepository; + @Mock + AuthorizationService authorizationService; + @Mock + DocumentMapper documentMapper; + + @InjectMocks + NoteService noteService; + + User existingUser; + Document existingNote; + Document anotherExistingNote; + NoteResponse existingNoteResponse; + NoteResponse anotherExistingNoteResponse; + + @BeforeEach + void setUp() { + existingUser = User.builder().id(100).build(); + existingNote = Document.builder().id(1).userId(100).build(); + anotherExistingNote = Document.builder().id(2).userId(100).build(); + existingNoteResponse = NoteResponse.builder().id(1).build(); + anotherExistingNoteResponse = NoteResponse.builder().id(2).build(); + + when(authorizationService.getCurrentUserId()).thenReturn(existingUser.getId()); + } + + // --- Get note --- // + @Test + void getNote_withNonExistentId_shouldThrowException() { + // Arrange + int nonExistentId = 999; + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(nonExistentId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException actualException = assertThrows(AppException.class, () -> { + noteService.getNote(nonExistentId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, actualException.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(nonExistentId, userId); + verify(documentMapper, never()).toNoteResponse(any()); + } + + @Test + void getNote_whenNoteExists_shouldReturnNoteResponse() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentMapper.toNoteResponse(existingNote)).thenReturn(existingNoteResponse); + + // Act + NoteResponse actualResponse = noteService.getNote(noteId); + + // Assert + assertNotNull(actualResponse); + assertEquals(existingNoteResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentMapper).toNoteResponse(existingNote); + } + + // -- Get all notes -- // + @Test + void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List emptyList = Collections.emptyList(); + List expectedNoteResponseList = Collections.emptyList(); + when(documentRepository.findAllByUserId(userId)).thenReturn(emptyList); + when(documentMapper.toNoteResponseList(emptyList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertTrue(actualResponse.isEmpty()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(emptyList); + } + + @Test + void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote, anotherExistingNote); + List expectedNoteResponseList = List.of(existingNoteResponse, anotherExistingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertFalse(actualResponse.isEmpty()); + assertEquals(expectedNoteResponseList.size(), actualResponse.size()); + assertEquals(expectedNoteResponseList, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } + + @Test + void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote); + List expectedNoteResponseList = List.of(existingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertEquals(1, actualResponse.size()); + assertEquals(existingNoteResponse, actualResponse.get(0)); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } + + // --- Create note --- // + @Test + void createNote_withValidInput_shouldCreateSuccessfully() { + // Arrange + int newId = 2; + String newTitle = "Test New Note"; + String newContent = "Test New Content"; + NoteUpsertRequest newRequest = NoteUpsertRequest.builder() + .title(newTitle).content(newContent).build(); + Document savedNote = Document.builder().id(newId).userId(existingUser.getId()) + .title(newTitle).content(newContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(newId) + .title(newTitle).content(newContent).build(); + + when(documentRepository.save(any(Document.class))).thenReturn(savedNote); + when(documentMapper.toNoteResponse(savedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.createNote(newRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(savedNote); + } + + // --- Update note --- // + @Test + void updateNote_whenNoteExists_shouldUpdateSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + Document updatedNote = Document.builder().id(noteId).userId(userId) + .title(updatedTitle).content(updatedContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(noteId) + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentRepository.save(any(Document.class))).thenReturn(updatedNote); + when(documentMapper.toNoteResponse(updatedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.updateNote(noteId, updateRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(updatedNote); + } + + @Test + void updateNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.updateNote(noteId, updateRequest); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).save(any()); + verify(documentMapper, never()).toNoteResponse(any()); + } + + // --- Delete note --- // + @Test + void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + + // Act + noteService.deleteNote(noteId); + + // Assert + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).deleteById(noteId); + } + + @Test + void deleteNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.deleteNote(noteId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).deleteById(anyInt()); + } + + // ------ Methods that returns entities ------ // + @Test + void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); + List expectedNotes = List.of(existingNote, anotherExistingNote); + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertEquals(2, actualNotes.size()); + assertEquals(expectedNotes, actualNotes); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } + + @Test + void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(998, 999); + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(Collections.emptyList()); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } + + @Test + void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List emptyIds = Collections.emptyList(); + + when(documentRepository.findAllByUserIdAndIdIn(userId, emptyIds)).thenReturn(Collections.emptyList()); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, emptyIds); + + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, emptyIds); + } + + @Test + void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId()); + List expectedNotes = List.of(existingNote); + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } + + @Test + void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists + List expectedNotes = List.of(existingNote); // Only returns existing note + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } +} From 3d6a7e4c11229136b1cdd3fe4f82cc88f3fbb69d Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 8 Dec 2025 18:31:27 +1100 Subject: [PATCH 02/15] test(note): add testing for note repository and resolve potential query error --- pom.xml | 22 +- .../java/com/be08/smart_notes/model/User.java | 2 +- .../repository/DocumentRepository.java | 4 +- .../smart_notes/service/DocumentService.java | 2 +- src/main/resources/application-dev.properties | 2 +- .../helper/DocumentDataBuilder.java | 29 ++ .../repository/DocumentRepositoryTest.java | 362 ++++++++++++++++++ .../smart_notes/service/NoteServiceTest.java | 12 +- src/test/resources/application.properties | 27 ++ 9 files changed, 450 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java create mode 100644 src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java create mode 100644 src/test/resources/application.properties diff --git a/pom.xml b/pom.xml index b452060..c93002c 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,15 @@ jackson-datatype-jsr310 + + + + com.h2database + h2 + 2.2.224 + test + + @@ -167,7 +176,7 @@ jsonschema-module-jakarta-validation 4.31.1 - + @@ -194,6 +203,17 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + + + diff --git a/src/main/java/com/be08/smart_notes/model/User.java b/src/main/java/com/be08/smart_notes/model/User.java index d1b14d6..21231d6 100644 --- a/src/main/java/com/be08/smart_notes/model/User.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "user") +@Table(name = "'user'") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 3cbb367..d4045c2 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -6,8 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.be08.smart_notes.model.Document; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -15,7 +13,7 @@ public interface DocumentRepository extends JpaRepository { List findAllByUserId(Integer userId); Optional findByIdAndUserId(int documentId, int userId); - Optional findByTitleAndUserId(String title, int userId); + Optional findFirstByTitleAndUserId(String title, int userId); List findAllByIdIn(List ids); List findAllByUserIdAndIdIn(Integer userId, List ids); diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 2b9ca16..0c30307 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -67,7 +67,7 @@ public Document getDocumentIfOwned(int documentId, int userId){ * @throws AppException if system source document not found */ public Document getSystemSourceDocument(int userId){ - return documentRepository.findByTitleAndUserId(SYSTEM_SOURCE_TITLE, userId) + return documentRepository.findFirstByTitleAndUserId(SYSTEM_SOURCE_TITLE, userId) .orElseThrow(() -> new AppException(ErrorCode.SYSTEM_SOURCE_DOCUMENT_NOT_FOUND)); } } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index b2bf96e..0f327b6 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -2,7 +2,7 @@ spring.datasource.url=jdbc:mysql://localhost:${DB_PORT}/${DB_NAME} spring.datasource.username=${DB_USERNAME} spring.datasource.password=${DB_PASSWORD} -spring.datasource.diver-class-name =com.mysql.cj.jdbc.Driver +spring.datasource.driver-class-name =com.mysql.cj.jdbc.Driver # JPA Settings for Development spring.jpa.show-sql=true diff --git a/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java new file mode 100644 index 0000000..a37b44c --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java @@ -0,0 +1,29 @@ +package com.be08.smart_notes.helper; + +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.enums.DocumentType; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.User; + +import java.time.LocalDateTime; + +public class DocumentDataBuilder { + public static User.UserBuilder createUser(int userId) { + return User.builder().id(userId); + } + + public static Document.DocumentBuilder createSampleNote(int userId) { + return Document.builder() + .userId(userId).type(DocumentType.NOTE) + .title("Sample Note").content("Sample Note Content") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()); + } + + public static NoteResponse.NoteResponseBuilder createNoteResponse(Document note) { + return NoteResponse.builder().id(note.getId()) + .title(note.getTitle()).content(note.getContent()) + .createdAt(note.getCreatedAt()) + .updatedAt(note.getUpdatedAt()); + } +} diff --git a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java new file mode 100644 index 0000000..42706e5 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java @@ -0,0 +1,362 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.helper.DocumentDataBuilder; +import com.be08.smart_notes.model.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +public class DocumentRepositoryTest { + @Autowired + DocumentRepository documentRepository; + + int FIRST_USER_ID = 100; + int SECOND_USER_ID = 101; + + Document document1; + Document document2; + Document document3; + Document documentAnotherUser; + + @BeforeEach + void setUp() { + documentRepository.deleteAll(); + + document1 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); + document2 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); + document3 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); + documentAnotherUser = documentRepository.save(DocumentDataBuilder.createSampleNote(SECOND_USER_ID).build()); + } + + // --- findAllByUserId --- // + @Test + void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + + // Act + List result = documentRepository.findAllByUserId(nonExistentUserId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserId_shouldNotReturnOtherUsersDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + + // --- findByIdAndUserId --- // + @Test + void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(document1.getId(), result.get().getId()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentUserId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); + + // Assert + assertFalse(result.isPresent()); + } + + // --- findByTitleAndUserId --- // + @Test + void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { + // Arrange + String existingTitle = document1.getTitle(); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(existingTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(existingTitle, result.get().getTitle()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { + // Arrange + String nonExistentTitle = "Non Existent Title"; + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { + // Arrange + String sharedTitle = "Shared Title"; + Document doc1 = documentRepository.save( + DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() + ); + Document doc2 = documentRepository.save( + DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() + ); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(sharedTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + assertEquals(doc1.getId(), result.get().getId()); + } + + // --- findAllByIdIn --- // + @Test + void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document1.getId()))); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document2.getId()))); + } + + @Test + void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999, 998); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998, 997); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByIdIn(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { + // Arrange + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + + @Test + void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + // --- findAllByUserIdAndIdIn --- // + @Test + void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { + // Arrange - trying to get user 1's documents with user 2's ID + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(SECOND_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { + // Arrange - mixing documents from different users + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + assertEquals(FIRST_USER_ID, result.get(0).getUserId()); + } + + @Test + void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(nonExistentUserId, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } +} diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java index eb25423..aeabee4 100644 --- a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.service; +import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.dto.response.NoteResponse; import com.be08.smart_notes.enums.DocumentType; @@ -46,11 +47,12 @@ public class NoteServiceTest { @BeforeEach void setUp() { - existingUser = User.builder().id(100).build(); - existingNote = Document.builder().id(1).userId(100).build(); - anotherExistingNote = Document.builder().id(2).userId(100).build(); - existingNoteResponse = NoteResponse.builder().id(1).build(); - anotherExistingNoteResponse = NoteResponse.builder().id(2).build(); + int userId = 100; + existingUser = DocumentDataBuilder.createUser(userId).build(); + existingNote = DocumentDataBuilder.createSampleNote(userId).id(1).userId(100).build(); + anotherExistingNote = DocumentDataBuilder.createSampleNote(userId).id(2).userId(100).build(); + existingNoteResponse = DocumentDataBuilder.createNoteResponse(existingNote).build(); + anotherExistingNoteResponse = DocumentDataBuilder.createNoteResponse(anotherExistingNote).build(); when(authorizationService.getCurrentUserId()).thenReturn(existingUser.getId()); } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..361e796 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,27 @@ +# Database Configuration for Development (Local MySQL) +# H2 Database Configuration +spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL; +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=testing +spring.datasource.password=password + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.show-sql=false + +#spring.jpa.defer-datasource-initialization=true +#spring.sql.init.mode=never + +# JPA Settings for Development +#spring.jpa.properties.hibernate.format_sql=true +#spring.jpa.hibernate.ddl-auto=update + +# REDIS Configuration for Development (Local Redis - Docker) +#spring.data.redis.host=${REDIS_HOST} +#spring.data.redis.port=${REDIS_PORT} +# +## Frontend URL for Local Development +#app.frontend.url=http://localhost:3000 +# +## Logging Level for Development +#logging.level.com.be08=DEBUG \ No newline at end of file From 5efb580306bd51b2b9b8eaf036681688258b56a8 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 9 Dec 2025 21:26:15 +1100 Subject: [PATCH 03/15] test(note): reorganise test display, utilising tree structure --- .../repository/DocumentRepositoryTest.java | 643 +++++++++--------- .../smart_notes/service/NoteServiceTest.java | 591 ++++++++-------- 2 files changed, 640 insertions(+), 594 deletions(-) diff --git a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java index 42706e5..4bde439 100644 --- a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java @@ -2,8 +2,7 @@ import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.model.Document; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -15,6 +14,8 @@ import static org.junit.jupiter.api.Assertions.*; @DataJpaTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayName("Document Repository Test") public class DocumentRepositoryTest { @Autowired DocumentRepository documentRepository; @@ -38,325 +39,345 @@ void setUp() { } // --- findAllByUserId --- // - @Test - void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { - // Act - List result = documentRepository.findAllByUserId(FIRST_USER_ID); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); - assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); - } - - @Test - void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { - // Arrange - int nonExistentUserId = 999; - - // Act - List result = documentRepository.findAllByUserId(nonExistentUserId); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserId_shouldNotReturnOtherUsersDocuments() { - // Act - List result = documentRepository.findAllByUserId(FIRST_USER_ID); - - // Assert - assertNotNull(result); - assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + @Nested + @DisplayName("findAllByUserId(): List") + class FindAllByUserIdTest { + @Test + void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + + // Act + List result = documentRepository.findAllByUserId(nonExistentUserId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserId_shouldNotReturnOtherUsersDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } } // --- findByIdAndUserId --- // - @Test - void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { - // Act - Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); - - // Assert - assertTrue(result.isPresent()); - assertEquals(document1.getId(), result.get().getId()); - assertEquals(FIRST_USER_ID, result.get().getUserId()); + @Nested + @DisplayName("findByIdAndUserId(): Optional") + class FindByIdAndUserIdTest { + @Test + void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(document1.getId(), result.get().getId()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentUserId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); + + // Assert + assertFalse(result.isPresent()); + } } - @Test - void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { - // Arrange - int nonExistentId = 999; - - // Act - Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); - - // Assert - assertFalse(result.isPresent()); - } - - @Test - void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { - // Act - Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); - - // Assert - assertFalse(result.isPresent()); - } - - @Test - void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { - // Arrange - int nonExistentUserId = 999; - - // Act - Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); - - // Assert - assertFalse(result.isPresent()); - } - - // --- findByTitleAndUserId --- // - @Test - void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { - // Arrange - String existingTitle = document1.getTitle(); - - // Act - Optional result = documentRepository.findFirstByTitleAndUserId(existingTitle, FIRST_USER_ID); - - // Assert - assertTrue(result.isPresent()); - assertEquals(existingTitle, result.get().getTitle()); - assertEquals(FIRST_USER_ID, result.get().getUserId()); - } - - @Test - void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { - // Arrange - String nonExistentTitle = "Non Existent Title"; - - // Act - Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); - - // Assert - assertFalse(result.isPresent()); - } - - @Test - void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { - // Arrange - String sharedTitle = "Shared Title"; - Document doc1 = documentRepository.save( - DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() - ); - Document doc2 = documentRepository.save( - DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() - ); - - // Act - Optional result = documentRepository.findFirstByTitleAndUserId(sharedTitle, FIRST_USER_ID); - - // Assert - assertTrue(result.isPresent()); - assertEquals(FIRST_USER_ID, result.get().getUserId()); - assertEquals(doc1.getId(), result.get().getId()); + // --- findFirstByTitleAndUserId --- // + @Nested + @DisplayName("findFirstByTitleAndUserId(): Optional") + class FindFirstByTitleAndUserIdTest { + @Test + void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { + // Arrange + String existingTitle = document1.getTitle(); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(existingTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(existingTitle, result.get().getTitle()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { + // Arrange + String nonExistentTitle = "Non Existent Title"; + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { + // Arrange + String sharedTitle = "Shared Title"; + Document doc1 = documentRepository.save( + DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() + ); + Document doc2 = documentRepository.save( + DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() + ); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(sharedTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + assertEquals(doc1.getId(), result.get().getId()); + } } // --- findAllByIdIn --- // - @Test - void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { - // Arrange - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document1.getId()))); - assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document2.getId()))); - } - - @Test - void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { - // Arrange - List ids = Arrays.asList(document1.getId(), 999, 998); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - } - - @Test - void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { - // Arrange - List ids = Arrays.asList(999, 998, 997); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { - // Arrange - List emptyIds = Collections.emptyList(); - - // Act - List result = documentRepository.findAllByIdIn(emptyIds); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { - // Arrange - List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == FIRST_USER_ID)); - assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); - } - - @Test - void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { - // Arrange - List ids = Collections.singletonList(document1.getId()); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); + @Nested + @DisplayName("findAllByIdIn(): List") + class FindAllByIdInTest { + @Test + void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document1.getId()))); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document2.getId()))); + } + + @Test + void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999, 998); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998, 997); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByIdIn(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { + // Arrange + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + + @Test + void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } } // --- findAllByUserIdAndIdIn --- // - @Test - void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { - // Arrange - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); - } - - @Test - void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { - // Arrange - List ids = Arrays.asList(document1.getId(), 999); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - } - - @Test - void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { - // Arrange - List ids = Arrays.asList(999, 998); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { - // Arrange - List emptyIds = Collections.emptyList(); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { - // Arrange - trying to get user 1's documents with user 2's ID - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(SECOND_USER_ID, ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { - // Arrange - mixing documents from different users - List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - assertEquals(FIRST_USER_ID, result.get(0).getUserId()); - } - - @Test - void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { - // Arrange - List ids = Collections.singletonList(document1.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - } - - @Test - void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { - // Arrange - int nonExistentUserId = 999; - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(nonExistentUserId, ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); + @Nested + @DisplayName("findAllByUserAndIdIn(): List") + class FindAllByUserIdAndIdIn { + @Test + void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { + // Arrange - trying to get user 1's documents with user 2's ID + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(SECOND_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { + // Arrange - mixing documents from different users + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + assertEquals(FIRST_USER_ID, result.get(0).getUserId()); + } + + @Test + void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(nonExistentUserId, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } } } diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java index aeabee4..58ef9d5 100644 --- a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java @@ -10,8 +10,7 @@ import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.DocumentRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -28,6 +27,8 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayName("Note Service Test") public class NoteServiceTest { @Mock DocumentRepository documentRepository; @@ -58,327 +59,351 @@ void setUp() { } // --- Get note --- // - @Test - void getNote_withNonExistentId_shouldThrowException() { - // Arrange - int nonExistentId = 999; - int userId = existingUser.getId(); - when(documentRepository.findByIdAndUserId(nonExistentId, userId)).thenReturn(Optional.empty()); - - // Act & Assert - AppException actualException = assertThrows(AppException.class, () -> { - noteService.getNote(nonExistentId); - }); - assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, actualException.getErrorCode()); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(nonExistentId, userId); - verify(documentMapper, never()).toNoteResponse(any()); - } - - @Test - void getNote_whenNoteExists_shouldReturnNoteResponse() { - // Arrange - int noteId = existingNote.getId(); - int userId = existingUser.getId(); - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); - when(documentMapper.toNoteResponse(existingNote)).thenReturn(existingNoteResponse); - - // Act - NoteResponse actualResponse = noteService.getNote(noteId); - - // Assert - assertNotNull(actualResponse); - assertEquals(existingNoteResponse, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentMapper).toNoteResponse(existingNote); + @Nested + @DisplayName("getNote(): NoteResponse") + class GetNoteTest { + @Test + void getNote_withNonExistentId_shouldThrowException() { + // Arrange + int nonExistentId = 999; + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(nonExistentId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException actualException = assertThrows(AppException.class, () -> { + noteService.getNote(nonExistentId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, actualException.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(nonExistentId, userId); + verify(documentMapper, never()).toNoteResponse(any()); + } + + @Test + void getNote_whenNoteExists_shouldReturnNoteResponse() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentMapper.toNoteResponse(existingNote)).thenReturn(existingNoteResponse); + + // Act + NoteResponse actualResponse = noteService.getNote(noteId); + + // Assert + assertNotNull(actualResponse); + assertEquals(existingNoteResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentMapper).toNoteResponse(existingNote); + } } // -- Get all notes -- // - @Test - void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { - // Arrange - int userId = existingUser.getId(); - List emptyList = Collections.emptyList(); - List expectedNoteResponseList = Collections.emptyList(); - when(documentRepository.findAllByUserId(userId)).thenReturn(emptyList); - when(documentMapper.toNoteResponseList(emptyList)).thenReturn(expectedNoteResponseList); - - // Act - List actualResponse = noteService.getAllNotes(); - - // Assert - assertNotNull(actualResponse); - assertTrue(actualResponse.isEmpty()); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findAllByUserId(userId); - verify(documentMapper).toNoteResponseList(emptyList); - } - - @Test - void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { - // Arrange - int userId = existingUser.getId(); - List noteList = List.of(existingNote, anotherExistingNote); - List expectedNoteResponseList = List.of(existingNoteResponse, anotherExistingNoteResponse); - when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); - when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); - - // Act - List actualResponse = noteService.getAllNotes(); - - // Assert - assertNotNull(actualResponse); - assertFalse(actualResponse.isEmpty()); - assertEquals(expectedNoteResponseList.size(), actualResponse.size()); - assertEquals(expectedNoteResponseList, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findAllByUserId(userId); - verify(documentMapper).toNoteResponseList(noteList); - } - - @Test - void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { - // Arrange - int userId = existingUser.getId(); - List noteList = List.of(existingNote); - List expectedNoteResponseList = List.of(existingNoteResponse); - when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); - when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); - - // Act - List actualResponse = noteService.getAllNotes(); - - // Assert - assertNotNull(actualResponse); - assertEquals(1, actualResponse.size()); - assertEquals(existingNoteResponse, actualResponse.get(0)); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findAllByUserId(userId); - verify(documentMapper).toNoteResponseList(noteList); + @Nested + @DisplayName("getAllNotes(): List") + class GetAllNotesTest { + @Test + void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List emptyList = Collections.emptyList(); + List expectedNoteResponseList = Collections.emptyList(); + when(documentRepository.findAllByUserId(userId)).thenReturn(emptyList); + when(documentMapper.toNoteResponseList(emptyList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertTrue(actualResponse.isEmpty()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(emptyList); + } + + @Test + void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote, anotherExistingNote); + List expectedNoteResponseList = List.of(existingNoteResponse, anotherExistingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertFalse(actualResponse.isEmpty()); + assertEquals(expectedNoteResponseList.size(), actualResponse.size()); + assertEquals(expectedNoteResponseList, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } + + @Test + void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote); + List expectedNoteResponseList = List.of(existingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertEquals(1, actualResponse.size()); + assertEquals(existingNoteResponse, actualResponse.get(0)); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } } // --- Create note --- // - @Test - void createNote_withValidInput_shouldCreateSuccessfully() { - // Arrange - int newId = 2; - String newTitle = "Test New Note"; - String newContent = "Test New Content"; - NoteUpsertRequest newRequest = NoteUpsertRequest.builder() - .title(newTitle).content(newContent).build(); - Document savedNote = Document.builder().id(newId).userId(existingUser.getId()) - .title(newTitle).content(newContent).type(DocumentType.NOTE).build(); - NoteResponse expectedResponse = NoteResponse.builder().id(newId) - .title(newTitle).content(newContent).build(); - - when(documentRepository.save(any(Document.class))).thenReturn(savedNote); - when(documentMapper.toNoteResponse(savedNote)).thenReturn(expectedResponse); - - // Act - NoteResponse actualResponse = noteService.createNote(newRequest); - - // Assert - assertNotNull(actualResponse); - assertEquals(expectedResponse, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).save(any(Document.class)); - verify(documentMapper).toNoteResponse(savedNote); + @Nested + @DisplayName("createNote(): NoteResponse") + class CreateNoteTest { + @Test + void createNote_withValidInput_shouldCreateSuccessfully() { + // Arrange + int newId = 2; + String newTitle = "Test New Note"; + String newContent = "Test New Content"; + NoteUpsertRequest newRequest = NoteUpsertRequest.builder() + .title(newTitle).content(newContent).build(); + Document savedNote = Document.builder().id(newId).userId(existingUser.getId()) + .title(newTitle).content(newContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(newId) + .title(newTitle).content(newContent).build(); + + when(documentRepository.save(any(Document.class))).thenReturn(savedNote); + when(documentMapper.toNoteResponse(savedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.createNote(newRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(savedNote); + } } // --- Update note --- // - @Test - void updateNote_whenNoteExists_shouldUpdateSuccessfully() { - // Arrange - int noteId = existingNote.getId(); - int userId = existingUser.getId(); - String updatedTitle = "Test Updated Title"; - String updatedContent = "Test Updated Content"; - NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() - .title(updatedTitle).content(updatedContent).build(); - Document updatedNote = Document.builder().id(noteId).userId(userId) - .title(updatedTitle).content(updatedContent).type(DocumentType.NOTE).build(); - NoteResponse expectedResponse = NoteResponse.builder().id(noteId) - .title(updatedTitle).content(updatedContent).build(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); - when(documentRepository.save(any(Document.class))).thenReturn(updatedNote); - when(documentMapper.toNoteResponse(updatedNote)).thenReturn(expectedResponse); - - // Act - NoteResponse actualResponse = noteService.updateNote(noteId, updateRequest); - - // Assert - assertNotNull(actualResponse); - assertEquals(expectedResponse, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository).save(any(Document.class)); - verify(documentMapper).toNoteResponse(updatedNote); - } - - @Test - void updateNote_whenNoteNotFound_shouldThrowException() { - // Arrange - int noteId = 999; - int userId = existingUser.getId(); - String updatedTitle = "Test Updated Title"; - String updatedContent = "Test Updated Content"; - NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() - .title(updatedTitle).content(updatedContent).build(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); - - // Act & Assert - AppException exception = assertThrows(AppException.class, () -> { - noteService.updateNote(noteId, updateRequest); - }); - assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository, never()).save(any()); - verify(documentMapper, never()).toNoteResponse(any()); + @Nested + @DisplayName("updateNote(): NoteResponse") + class UpdateNoteTest { + @Test + void updateNote_whenNoteExists_shouldUpdateSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + Document updatedNote = Document.builder().id(noteId).userId(userId) + .title(updatedTitle).content(updatedContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(noteId) + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentRepository.save(any(Document.class))).thenReturn(updatedNote); + when(documentMapper.toNoteResponse(updatedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.updateNote(noteId, updateRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(updatedNote); + } + + @Test + void updateNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.updateNote(noteId, updateRequest); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).save(any()); + verify(documentMapper, never()).toNoteResponse(any()); + } } // --- Delete note --- // - @Test - void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { - // Arrange - int noteId = existingNote.getId(); - int userId = existingUser.getId(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); - - // Act - noteService.deleteNote(noteId); - - // Assert - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository).deleteById(noteId); - } - - @Test - void deleteNote_whenNoteNotFound_shouldThrowException() { - // Arrange - int noteId = 999; - int userId = existingUser.getId(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); - - // Act & Assert - AppException exception = assertThrows(AppException.class, () -> { + @Nested + @DisplayName("deleteNote(): void") + class DeleteNoteTest { + @Test + void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + + // Act noteService.deleteNote(noteId); - }); - assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository, never()).deleteById(anyInt()); + // Assert + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).deleteById(noteId); + } + + @Test + void deleteNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.deleteNote(noteId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).deleteById(anyInt()); + } } // ------ Methods that returns entities ------ // - @Test - void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); - List expectedNotes = List.of(existingNote, anotherExistingNote); + @Nested + @DisplayName("getAllNotesByUserIdAndIds(): List") + class GetAllNotesByUserIdAndIdsTest { + @Test + void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); + List expectedNotes = List.of(existingNote, anotherExistingNote); - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertEquals(2, actualNotes.size()); - assertEquals(expectedNotes, actualNotes); + // Assert + assertNotNull(actualNotes); + assertEquals(2, actualNotes.size()); + assertEquals(expectedNotes, actualNotes); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } - @Test - void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(998, 999); + @Test + void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(998, 999); - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(Collections.emptyList()); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(Collections.emptyList()); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertTrue(actualNotes.isEmpty()); + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } - @Test - void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { - // Arrange - int userId = existingUser.getId(); - List emptyIds = Collections.emptyList(); + @Test + void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List emptyIds = Collections.emptyList(); - when(documentRepository.findAllByUserIdAndIdIn(userId, emptyIds)).thenReturn(Collections.emptyList()); + when(documentRepository.findAllByUserIdAndIdIn(userId, emptyIds)).thenReturn(Collections.emptyList()); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, emptyIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, emptyIds); - // Assert - assertNotNull(actualNotes); - assertTrue(actualNotes.isEmpty()); + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); - verify(documentRepository).findAllByUserIdAndIdIn(userId, emptyIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, emptyIds); + } - @Test - void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(existingNote.getId()); - List expectedNotes = List.of(existingNote); + @Test + void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId()); + List expectedNotes = List.of(existingNote); - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertEquals(expectedNotes.size(), actualNotes.size()); - assertEquals(existingNote, actualNotes.get(0)); + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } - @Test - void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists - List expectedNotes = List.of(existingNote); // Only returns existing note + @Test + void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists + List expectedNotes = List.of(existingNote); // Only returns existing note - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertEquals(expectedNotes.size(), actualNotes.size()); - assertEquals(existingNote, actualNotes.get(0)); + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } } } From c07941310be3d3050c911d3132bcd4bbba851d3d Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 9 Dec 2025 21:26:59 +1100 Subject: [PATCH 04/15] test(note): add integration test for note controller with testcontainers --- README.md | 17 + pom.xml | 124 ++++++- .../java/com/be08/smart_notes/model/User.java | 2 +- .../com/be08/smart_notes/controller/Base.java | 43 +++ .../NoteControllerIntegrationTest.java | 334 ++++++++++++++++++ .../be08/smart_notes/helper/JwtBuilder.java | 15 + src/test/resources/application.properties | 36 +- src/test/resources/init-db.sql | 167 +++++++++ 8 files changed, 728 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/controller/Base.java create mode 100644 src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java create mode 100644 src/test/java/com/be08/smart_notes/helper/JwtBuilder.java create mode 100644 src/test/resources/init-db.sql diff --git a/README.md b/README.md index 2ddb51b..0b3cdfb 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,21 @@ DB_PORT= DB_NAME= DB_USERNAME= DB_PASSWORD= +``` + +## How to test +For Windows, run one of below commands + +```bash +# Run all tests +./mvnw.cmd test + +# Run specific test +./mvnw.cmd test -Dtest=NoteServiceTest + +# Run unit test only +./mvnw.cmd test -Punit-test + +# Run integration test only +./mvnw.cmd verify -Pintegration-test ``` \ No newline at end of file diff --git a/pom.xml b/pom.xml index c93002c..2518a42 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,9 @@ 17 1.18.30 1.6.3 + 1.16.2 + false + false @@ -153,6 +156,36 @@ test + + + org.springframework.security + spring-security-test + 6.5.5 + test + + + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + mysql + test + + @@ -178,6 +211,18 @@ + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + @@ -204,16 +249,75 @@ - + org.apache.maven.plugins maven-surefire-plugin + 3.5.3 + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 + + + + ${skip.unit.tests} **/*Test.java + + **/*IntegrationTest.java + + + plain + + true + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0 + + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 + + + + + ${skip.integration.tests} + + **/*IntegrationTest.java + + + plain + + true + + + + + + + + integration-test + verify + + + + @@ -231,6 +335,9 @@ test test + + false + false @@ -239,6 +346,21 @@ prod + + + + unit-test + + test + + + + integration-test + + false + true + + diff --git a/src/main/java/com/be08/smart_notes/model/User.java b/src/main/java/com/be08/smart_notes/model/User.java index 21231d6..d1b14d6 100644 --- a/src/main/java/com/be08/smart_notes/model/User.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "'user'") +@Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/test/java/com/be08/smart_notes/controller/Base.java b/src/test/java/com/be08/smart_notes/controller/Base.java new file mode 100644 index 0000000..4990117 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/controller/Base.java @@ -0,0 +1,43 @@ +package com.be08.smart_notes.controller; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +public class Base { + @Container + public static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .withInitScript("init-db.sql") + .withReuse(true); + + @Container + public static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379) + .withReuse(true); + + @DynamicPropertySource + public static void properties(DynamicPropertyRegistry registry) { + // MySQL configuration + registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + + // JPA configuration + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add("spring.jpa.show-sql", () -> "false"); + + // Redis configuration + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", redisContainer::getFirstMappedPort); + } +} diff --git a/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java new file mode 100644 index 0000000..0949d78 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java @@ -0,0 +1,334 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.request.NoteUpsertRequest; +import com.be08.smart_notes.helper.DocumentDataBuilder; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.DocumentRepository; +import com.be08.smart_notes.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.jupiter.api.*; +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.security.oauth2.jwt.JwtEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.security.KeyPair; +import java.time.LocalDateTime; + +import static com.be08.smart_notes.helper.JwtBuilder.jwtWithUserId; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayName("Note Controller Integration Test") +public class NoteControllerIntegrationTest extends Base { + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + DocumentRepository documentRepository; + @Autowired + UserRepository userRepository; + + @MockitoBean + JwtEncoder jwtEncoder; + @MockitoBean + JWKSource jwkSource; + @MockitoBean + KeyPair signingKeyPair; + + static final String BASE_URI = "/api/documents/notes"; + int TEST_USER_ID; + int OTHER_USER_ID; + Document existingNote; + Document anotherNote; + + @BeforeEach + void setUpEach() { + userRepository.deleteAll(); + documentRepository.deleteAll(); + + User testUser1 = userRepository.save(User.builder().name("Test User").email("example-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); + User testUser2 = userRepository.save(User.builder().name("Test User").email("another-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); + + TEST_USER_ID = testUser1.getId(); + OTHER_USER_ID = testUser2.getId(); + + existingNote = documentRepository.save(DocumentDataBuilder.createSampleNote(TEST_USER_ID).build()); + anotherNote = documentRepository.save(DocumentDataBuilder.createSampleNote(OTHER_USER_ID).build()); + } + + @Nested + @DisplayName("POST /api/documents/notes") + class CreateNoteIntegrationTests { + @Test + void shouldCreateNoteAndPersist() throws Exception { + // Arrange + String title = "Integration Test Note"; + String content = "Integration Test Content"; + NoteUpsertRequest request = NoteUpsertRequest.builder() + .title(title).content(content).build(); + + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + MvcResult result = mockMvc.perform( + post(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.title").value(title)) + .andExpect(jsonPath("$.data.content").value(content)) + .andExpect(jsonPath("$.data.id").exists()) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andReturn(); + + int createdNoteId = objectMapper.readTree(result.getResponse().getContentAsString()).get("data").get("id").asInt(); + Document createdNote = documentRepository.findById(createdNoteId).orElseThrow(); + + assertEquals(TEST_USER_ID, createdNote.getUserId()); + assertEquals(initialSize + 1, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldRejectRequestWithNullTitle() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title(null).content("Content").build(); + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + mockMvc.perform( + post(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + + assertEquals(initialSize, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldRejectRequestWithNullContent() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title("Title").content(null).build(); + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + mockMvc.perform( + post(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + + assertEquals(initialSize, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Arrange + NoteUpsertRequest request = NoteUpsertRequest.builder() + .title("Test Note").content("Content").build(); + + // Act & Assert + mockMvc.perform( + post(BASE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /api/documents/notes") + class GetAllNotesIntegrationTests { + @Test + void shouldRetrieveAllNotesForCurrentUser() throws Exception { + // Act & Assert + mockMvc.perform( + get(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))); + } + + @Test + void shouldReturnEmptyListWhenUserHasNoNotes() throws Exception { + // Arrange + documentRepository.deleteAll(); + + // Act & Assert + mockMvc.perform( + get(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Act & Assert + mockMvc.perform(get(BASE_URI)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("PATCH /api/documents/notes/{id}") + class UpdateNoteIntegrationTests { + @Test + void shouldUpdateExistingNote() throws Exception { + // Arrange + String updatedTitle = "Updated Title"; + String updatedContent = "Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(existingNote.getId())) + .andExpect(jsonPath("$.data.title").value(updatedTitle)) + .andExpect(jsonPath("$.data.content").value(updatedContent)); + + // Verify database + Document updated = documentRepository.findById(existingNote.getId()).orElseThrow(); + assertEquals(updatedTitle, updated.getTitle()); + assertEquals(updatedContent, updated.getContent()); + } + + @Test + void shouldReturn404WhenUpdatingNonExistentNote() throws Exception { + // Arrange + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title("Updated Title").content("Updated Content").build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/99999") + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isNotFound()); + } + + @Test + void shouldRejectUpdateWithNullTitle() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title(null).content("Updated Content").build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + + // Verify unchanged + Document unchanged = documentRepository.findById(existingNote.getId()).orElseThrow(); + assertEquals(existingNote.getTitle(), unchanged.getTitle()); + } + + @Test + void shouldRejectUpdateWithNullContent() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title("Updated Title").content(null).build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Arrange + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title("Updated Title").content("Updated Content").build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("DELETE /api/documents/notes/{id}") + class DeleteNoteIntegrationTests { + @Test + void shouldDeleteExistingNote() throws Exception { + // Arrange + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + mockMvc.perform( + delete("/api/documents/notes/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isOk()); + + // Verify deletion + assertFalse(documentRepository.existsById(existingNote.getId())); + assertEquals(initialSize - 1, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldReturn404WhenDeletingNonExistentNote() throws Exception { + // Act & Assert + mockMvc.perform( + delete("/api/documents/notes/99999") + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isNotFound()); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Act & Assert + mockMvc.perform(delete("/api/documents/notes/" + existingNote.getId())) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/com/be08/smart_notes/helper/JwtBuilder.java b/src/test/java/com/be08/smart_notes/helper/JwtBuilder.java new file mode 100644 index 0000000..a31b011 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/JwtBuilder.java @@ -0,0 +1,15 @@ +package com.be08.smart_notes.helper; + +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; + +public class JwtBuilder { + public static SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtWithUserId(int userId) { + return jwt().jwt(builder -> builder + .issuer("smart-notes-auth-server") + .subject(String.valueOf(userId)) + .claim("email", "example@gmail.com") + .claim("scope", "USER").build()); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 361e796..3acab71 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -17,11 +17,31 @@ spring.jpa.show-sql=false #spring.jpa.hibernate.ddl-auto=update # REDIS Configuration for Development (Local Redis - Docker) -#spring.data.redis.host=${REDIS_HOST} -#spring.data.redis.port=${REDIS_PORT} -# -## Frontend URL for Local Development -#app.frontend.url=http://localhost:3000 -# -## Logging Level for Development -#logging.level.com.be08=DEBUG \ No newline at end of file +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} + +# Frontend URL for Local Development +app.frontend.url=http://localhost:3000 + +# AI Inference Configuration +ai.api.url=${API_URL} +ai.api.token=${API_TOKEN} +ai.api.model=${MODEL} + +ai.api.user-role=${AI_API_USER_ROLE:user} +ai.api.system-role=${AI_API_SYSTEM_ROLE:system} +ai.api.temperature=${AI_API_TEMPERATURE:0.7} +ai.api.top-p=${AI_API_TOP_P:0.9} + +# JWT Configuration +jwt.access-token.expiration-minutes=15 +jwt.refresh-token.expiration-days=7 + +# JWT KeyStore Configuration +jwt.keystore.location=classpath:keys/jwt-keystore.jks +jwt.keystore.password=test +jwt.key.password=test + +# JWT Key Rotation Configuration +jwt.signing.key.alias=jwtkey-2025 +jwt.verification.key.aliases=jwtkey-2025 \ No newline at end of file diff --git a/src/test/resources/init-db.sql b/src/test/resources/init-db.sql new file mode 100644 index 0000000..66b81d1 --- /dev/null +++ b/src/test/resources/init-db.sql @@ -0,0 +1,167 @@ +CREATE DATABASE IF NOT EXISTS `testdb`; +USE `testdb`; + +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` +( + `id` int NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `password` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `avatar_url` tinytext, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +); + +DROP TABLE IF EXISTS `tag`; +CREATE TABLE `tag` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `name` varchar(50) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_tags_user_id` (`user_id`), + CONSTRAINT `fk_tags_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +); + +DROP TABLE IF EXISTS `document`; +CREATE TABLE `document` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `type` enum ('NOTE','PDF') NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + `content` text, + `file_url` tinytext, + `file_size` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_documents_user_id` (`user_id`), + CONSTRAINT `fk_documents_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`), + CONSTRAINT `chk_document_type_content_file` CHECK ((((`type` = _utf8mb4'NOTE') and (`content` is not null)) or + ((`type` = _utf8mb4'PDF') and (`file_url` is not null) and + (`file_size` is not null)))) +); + +DROP TABLE IF EXISTS `document_tag`; +CREATE TABLE `document_tag` +( + `id` int NOT NULL AUTO_INCREMENT, + `document_id` int NOT NULL, + `tag_id` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_document_tags_document_id` (`document_id`), + KEY `fk_document_tags_tag_id` (`tag_id`), + CONSTRAINT `fk_document_tags_document_id` FOREIGN KEY (`document_id`) REFERENCES `document` (`id`), + CONSTRAINT `fk_document_tags_tag_id` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) +); + +DROP TABLE IF EXISTS `flashcard_set`; +CREATE TABLE `flashcard_set` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `origin_type` enum ('AI','USER','DEFAULT') NOT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcard_sets_user_id` (`user_id`), + CONSTRAINT `fk_flashcard_sets_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +); + +DROP TABLE IF EXISTS `flashcard`; +CREATE TABLE `flashcard` +( + `id` int NOT NULL AUTO_INCREMENT, + `flashcard_set_id` int NOT NULL, + `front_content` text NOT NULL, + `back_content` text NOT NULL, + `source_document_id` int DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcards_flashcard_set_id` (`flashcard_set_id`), + KEY `fk_flashcards_source_document_id` (`source_document_id`), + CONSTRAINT `fk_flashcards_flashcard_set_id` FOREIGN KEY (`flashcard_set_id`) REFERENCES `flashcard_set` (`id`), + CONSTRAINT `fk_flashcards_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +); + +DROP TABLE IF EXISTS `quiz_set`; +CREATE TABLE `quiz_set` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `origin_type` enum ('AI','USER','DEFAULT') NOT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_quiz_set_user_id_idx` (`user_id`), + CONSTRAINT `fk_quiz_set_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +); + +DROP TABLE IF EXISTS `quiz`; +CREATE TABLE `quiz` +( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_set_id` int NOT NULL, + `source_document_id` int DEFAULT NULL, + `title` varchar(255) NOT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_quiz_set_id_idx` (`quiz_set_id`), + KEY `fk_quiz_src_document_id` (`source_document_id`), + CONSTRAINT `fk_quiz_set_id` FOREIGN KEY (`quiz_set_id`) REFERENCES `quiz_set` (`id`), + CONSTRAINT `fk_quiz_src_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +); + +DROP TABLE IF EXISTS `question`; +CREATE TABLE `question` +( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `question_text` varchar(255) NOT NULL, + `option_a` varchar(255) NOT NULL, + `option_b` varchar(255) NOT NULL, + `option_c` varchar(255) NOT NULL, + `option_d` varchar(255) NOT NULL, + `correct_answer` char(1) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_questions_quiz_id` (`quiz_id`), + CONSTRAINT `fk_questions_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) +); + + + +DROP TABLE IF EXISTS `attempt`; +CREATE TABLE `attempt` +( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `attempt_at` datetime NOT NULL, + `total_question` int NOT NULL, + `score` int DEFAULT '0', + PRIMARY KEY (`id`), + KEY `fk_attempts_quiz_id_idx` (`quiz_id`), + CONSTRAINT `fk_attempts_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) +); + +DROP TABLE IF EXISTS `attempt_detail`; +CREATE TABLE `attempt_detail` +( + `id` int NOT NULL AUTO_INCREMENT, + `attempt_id` int NOT NULL, + `question_id` int NOT NULL, + `user_answer` char(1) DEFAULT NULL, + `is_correct` tinyint(1) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_attempt_details_attempt_id` (`attempt_id`), + KEY `fk_attempt_details_question_id_idx` (`question_id`), + CONSTRAINT `fk_attempt_details_attempt_id` FOREIGN KEY (`attempt_id`) REFERENCES `attempt` (`id`), + CONSTRAINT `fk_attempt_details_question_id` FOREIGN KEY (`question_id`) REFERENCES `question` (`id`) +); \ No newline at end of file From 32b6ac87848e8cc213159be71ad8de68a09e6a66 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 9 Dec 2025 23:53:07 +1100 Subject: [PATCH 05/15] test(note): replace Testcontainers and Containers annotations with BeforeAll --- .../com/be08/smart_notes/controller/Base.java | 25 ++++++++----- src/test/resources/application.properties | 24 ++----------- src/test/resources/init-db.sql | 35 ++++++------------- 3 files changed, 30 insertions(+), 54 deletions(-) diff --git a/src/test/java/com/be08/smart_notes/controller/Base.java b/src/test/java/com/be08/smart_notes/controller/Base.java index 4990117..94b7794 100644 --- a/src/test/java/com/be08/smart_notes/controller/Base.java +++ b/src/test/java/com/be08/smart_notes/controller/Base.java @@ -1,25 +1,23 @@ package com.be08.smart_notes.controller; +import lombok.Getter; +import org.junit.jupiter.api.BeforeAll; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -@Testcontainers +@Getter public class Base { - @Container - public static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") .withDatabaseName("testdb") .withUsername("test") .withPassword("test") .withInitScript("init-db.sql") .withReuse(true); - @Container - public static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + private static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) .withExposedPorts(6379) .withReuse(true); @@ -32,12 +30,21 @@ public static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); // JPA configuration - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "update"); registry.add("spring.jpa.show-sql", () -> "false"); // Redis configuration registry.add("spring.data.redis.host", redisContainer::getHost); registry.add("spring.data.redis.port", redisContainer::getFirstMappedPort); } + + @BeforeAll + static void initContainers() { + if (!mysqlContainer.isRunning()) { + mysqlContainer.start(); + } + if (!redisContainer.isRunning()) { + redisContainer.start(); + } + } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 3acab71..c2caac1 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,29 +1,11 @@ -# Database Configuration for Development (Local MySQL) -# H2 Database Configuration -spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL; -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=testing -spring.datasource.password=password - -spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -spring.jpa.show-sql=false - -#spring.jpa.defer-datasource-initialization=true -#spring.sql.init.mode=never - -# JPA Settings for Development -#spring.jpa.properties.hibernate.format_sql=true -#spring.jpa.hibernate.ddl-auto=update - -# REDIS Configuration for Development (Local Redis - Docker) +## REDIS Configuration spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} -# Frontend URL for Local Development +## Frontend URL for Local Development app.frontend.url=http://localhost:3000 -# AI Inference Configuration +## AI Inference Configuration ai.api.url=${API_URL} ai.api.token=${API_TOKEN} ai.api.model=${MODEL} diff --git a/src/test/resources/init-db.sql b/src/test/resources/init-db.sql index 66b81d1..4bdab8c 100644 --- a/src/test/resources/init-db.sql +++ b/src/test/resources/init-db.sql @@ -1,8 +1,7 @@ CREATE DATABASE IF NOT EXISTS `testdb`; USE `testdb`; -DROP TABLE IF EXISTS `user`; -CREATE TABLE `user` +CREATE TABLE IF NOT EXISTS `user` ( `id` int NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, @@ -15,8 +14,7 @@ CREATE TABLE `user` UNIQUE KEY `email` (`email`) ); -DROP TABLE IF EXISTS `tag`; -CREATE TABLE `tag` +CREATE TABLE IF NOT EXISTS `tag` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -26,8 +24,7 @@ CREATE TABLE `tag` CONSTRAINT `fk_tags_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ); -DROP TABLE IF EXISTS `document`; -CREATE TABLE `document` +CREATE TABLE IF NOT EXISTS `document` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -46,8 +43,7 @@ CREATE TABLE `document` (`file_size` is not null)))) ); -DROP TABLE IF EXISTS `document_tag`; -CREATE TABLE `document_tag` +CREATE TABLE IF NOT EXISTS `document_tag` ( `id` int NOT NULL AUTO_INCREMENT, `document_id` int NOT NULL, @@ -59,8 +55,7 @@ CREATE TABLE `document_tag` CONSTRAINT `fk_document_tags_tag_id` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) ); -DROP TABLE IF EXISTS `flashcard_set`; -CREATE TABLE `flashcard_set` +CREATE TABLE IF NOT EXISTS `flashcard_set` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -73,8 +68,7 @@ CREATE TABLE `flashcard_set` CONSTRAINT `fk_flashcard_sets_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ); -DROP TABLE IF EXISTS `flashcard`; -CREATE TABLE `flashcard` +CREATE TABLE IF NOT EXISTS `flashcard` ( `id` int NOT NULL AUTO_INCREMENT, `flashcard_set_id` int NOT NULL, @@ -90,8 +84,7 @@ CREATE TABLE `flashcard` CONSTRAINT `fk_flashcards_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) ); -DROP TABLE IF EXISTS `quiz_set`; -CREATE TABLE `quiz_set` +CREATE TABLE IF NOT EXISTS `quiz_set` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -104,8 +97,7 @@ CREATE TABLE `quiz_set` CONSTRAINT `fk_quiz_set_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ); -DROP TABLE IF EXISTS `quiz`; -CREATE TABLE `quiz` +CREATE TABLE IF NOT EXISTS `quiz` ( `id` int NOT NULL AUTO_INCREMENT, `quiz_set_id` int NOT NULL, @@ -120,8 +112,7 @@ CREATE TABLE `quiz` CONSTRAINT `fk_quiz_src_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) ); -DROP TABLE IF EXISTS `question`; -CREATE TABLE `question` +CREATE TABLE IF NOT EXISTS `question` ( `id` int NOT NULL AUTO_INCREMENT, `quiz_id` int NOT NULL, @@ -136,10 +127,7 @@ CREATE TABLE `question` CONSTRAINT `fk_questions_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ); - - -DROP TABLE IF EXISTS `attempt`; -CREATE TABLE `attempt` +CREATE TABLE IF NOT EXISTS `attempt` ( `id` int NOT NULL AUTO_INCREMENT, `quiz_id` int NOT NULL, @@ -151,8 +139,7 @@ CREATE TABLE `attempt` CONSTRAINT `fk_attempts_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ); -DROP TABLE IF EXISTS `attempt_detail`; -CREATE TABLE `attempt_detail` +CREATE TABLE IF NOT EXISTS `attempt_detail` ( `id` int NOT NULL AUTO_INCREMENT, `attempt_id` int NOT NULL, From 12f91ee6eb8cb4a2ad3e16af2df24acb5a86c3e9 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 10 Dec 2025 00:27:04 +1100 Subject: [PATCH 06/15] test(note): reorganise test structure --- pom.xml | 12 ++++++++---- .../controller/BaseIntegration.java} | 4 ++-- .../controller/NoteControllerIntegrationTest.java | 4 ++-- .../repository/DocumentRepositoryTest.java | 3 ++- .../{ => unit}/service/NoteServiceTest.java | 4 +++- 5 files changed, 17 insertions(+), 10 deletions(-) rename src/test/java/com/be08/smart_notes/{controller/Base.java => integration/controller/BaseIntegration.java} (95%) rename src/test/java/com/be08/smart_notes/{ => integration}/controller/NoteControllerIntegrationTest.java (99%) rename src/test/java/com/be08/smart_notes/{ => integration}/repository/DocumentRepositoryTest.java (99%) rename src/test/java/com/be08/smart_notes/{ => unit}/service/NoteServiceTest.java (99%) diff --git a/pom.xml b/pom.xml index 2518a42..bbf615a 100644 --- a/pom.xml +++ b/pom.xml @@ -265,10 +265,10 @@ ${skip.unit.tests} - **/*Test.java + **/unit/**/*Test.java - **/*IntegrationTest.java + **/integration/** plain @@ -298,8 +298,11 @@ ${skip.integration.tests} - **/*IntegrationTest.java + **/integration/**/*Test.java + + **/unit/** + plain @@ -351,7 +354,8 @@ unit-test - test + false + true diff --git a/src/test/java/com/be08/smart_notes/controller/Base.java b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java similarity index 95% rename from src/test/java/com/be08/smart_notes/controller/Base.java rename to src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java index 94b7794..6f3eea2 100644 --- a/src/test/java/com/be08/smart_notes/controller/Base.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.controller; +package com.be08.smart_notes.integration.controller; import lombok.Getter; import org.junit.jupiter.api.BeforeAll; @@ -9,7 +9,7 @@ import org.testcontainers.utility.DockerImageName; @Getter -public class Base { +public class BaseIntegration { private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") .withDatabaseName("testdb") .withUsername("test") diff --git a/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java similarity index 99% rename from src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java rename to src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java index 0949d78..3c703cd 100644 --- a/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.controller; +package com.be08.smart_notes.integration.controller; import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.helper.DocumentDataBuilder; @@ -35,7 +35,7 @@ @Transactional @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @DisplayName("Note Controller Integration Test") -public class NoteControllerIntegrationTest extends Base { +public class NoteControllerIntegrationTest extends BaseIntegration { @Autowired MockMvc mockMvc; @Autowired diff --git a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java similarity index 99% rename from src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java rename to src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java index 4bde439..b44220f 100644 --- a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -1,7 +1,8 @@ -package com.be08.smart_notes.repository; +package com.be08.smart_notes.integration.repository; import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.repository.DocumentRepository; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java similarity index 99% rename from src/test/java/com/be08/smart_notes/service/NoteServiceTest.java rename to src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java index 58ef9d5..c5de2ee 100644 --- a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.service; +package com.be08.smart_notes.unit.service; import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.dto.request.NoteUpsertRequest; @@ -10,6 +10,8 @@ import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.DocumentRepository; +import com.be08.smart_notes.service.AuthorizationService; +import com.be08.smart_notes.service.NoteService; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; From 70ae97e8beafd201362e0635de288cdaa72f4996 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 10 Dec 2025 12:03:28 +1100 Subject: [PATCH 07/15] test(note): add comments and documentation for existing tests --- README.md | 14 +++-- .../helper/DocumentDataBuilder.java | 21 ++++--- .../smart_notes/helper/UserDataBuilder.java | 30 +++++++++ .../NoteControllerIntegrationTest.java | 14 ++--- .../repository/DocumentRepositoryTest.java | 62 +++++++++---------- .../unit/service/NoteServiceTest.java | 43 ++++++------- src/test/resources/application.properties | 18 +++--- 7 files changed, 121 insertions(+), 81 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java diff --git a/README.md b/README.md index 0b3cdfb..eec3ed7 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,20 @@ DB_PASSWORD= ``` ## How to test -For Windows, run one of below commands +For Windows, run one of below commands. -```bash -# Run all tests -./mvnw.cmd test +Replace `test` with `clean test` to rebuild if there are changes in test code, similar with `verify`. +```bash # Run specific test ./mvnw.cmd test -Dtest=NoteServiceTest -# Run unit test only +# Run all unit tests ./mvnw.cmd test -Punit-test -# Run integration test only +# Run all integration tests ./mvnw.cmd verify -Pintegration-test + +# Run all tests (unit and integration) +./mvnw.cmd verify -Ptest ``` \ No newline at end of file diff --git a/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java index a37b44c..fec700e 100644 --- a/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java +++ b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java @@ -3,16 +3,17 @@ import com.be08.smart_notes.dto.response.NoteResponse; import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.model.Document; -import com.be08.smart_notes.model.User; import java.time.LocalDateTime; public class DocumentDataBuilder { - public static User.UserBuilder createUser(int userId) { - return User.builder().id(userId); - } - - public static Document.DocumentBuilder createSampleNote(int userId) { + /** + * Create a sample mock document (note) with given information. + * This method should not be used with real database interaction + * @param userId mock user ID + * @return Document.DocumentBuilder + */ + public static Document.DocumentBuilder createMockNote(int userId) { return Document.builder() .userId(userId).type(DocumentType.NOTE) .title("Sample Note").content("Sample Note Content") @@ -20,7 +21,13 @@ public static Document.DocumentBuilder createSampleNote(int userId) { .updatedAt(LocalDateTime.now()); } - public static NoteResponse.NoteResponseBuilder createNoteResponse(Document note) { + /** + * Create a sample mock user object with given information. + * This method should not be used with real database interaction + * @param note a note object to be parsed to note response + * @return NoteResponse.NoteResponseBuilder + */ + public static NoteResponse.NoteResponseBuilder createMockNoteResponse(Document note) { return NoteResponse.builder().id(note.getId()) .title(note.getTitle()).content(note.getContent()) .createdAt(note.getCreatedAt()) diff --git a/src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java b/src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java new file mode 100644 index 0000000..98bbff3 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java @@ -0,0 +1,30 @@ +package com.be08.smart_notes.helper; + +import com.be08.smart_notes.model.User; + +import java.time.LocalDateTime; + +public class UserDataBuilder { + /** + * Create a sample mock user object with given information. + * This method should not be used with real database interaction + * @param userId mock user ID + * @return User.UserBuilder + */ + public static User.UserBuilder createMockUser(int userId) { + return User.builder().id(userId); + } + + /** + * Create a user object with all required fields to be saved in database. + * Use the returned builder object to override any custom value (if needed), + * then chain .build() to build the object + * @param email unique email for new user + * @return User.UserBuilder + */ + public static User.UserBuilder createUser(String email) { + return User.builder() + .name("Test User").email(email) + .password("123456").createdAt(LocalDateTime.now()); + } +} diff --git a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java index 3c703cd..b9883f2 100644 --- a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -2,6 +2,7 @@ import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.helper.DocumentDataBuilder; +import com.be08.smart_notes.helper.UserDataBuilder; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.DocumentRepository; @@ -21,7 +22,6 @@ import org.springframework.transaction.annotation.Transactional; import java.security.KeyPair; -import java.time.LocalDateTime; import static com.be08.smart_notes.helper.JwtBuilder.jwtWithUserId; import static org.hamcrest.Matchers.hasSize; @@ -33,7 +33,7 @@ @SpringBootTest @AutoConfigureMockMvc @Transactional -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Controller Integration Test") public class NoteControllerIntegrationTest extends BaseIntegration { @Autowired @@ -52,7 +52,7 @@ public class NoteControllerIntegrationTest extends BaseIntegration { @MockitoBean KeyPair signingKeyPair; - static final String BASE_URI = "/api/documents/notes"; + final String BASE_URI = "/api/documents/notes"; int TEST_USER_ID; int OTHER_USER_ID; Document existingNote; @@ -63,14 +63,14 @@ void setUpEach() { userRepository.deleteAll(); documentRepository.deleteAll(); - User testUser1 = userRepository.save(User.builder().name("Test User").email("example-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); - User testUser2 = userRepository.save(User.builder().name("Test User").email("another-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); + User testUser1 = userRepository.save(UserDataBuilder.createUser("example-user@gmail.com").build()); + User testUser2 = userRepository.save(UserDataBuilder.createUser("another-user@gmail.com").build()); TEST_USER_ID = testUser1.getId(); OTHER_USER_ID = testUser2.getId(); - existingNote = documentRepository.save(DocumentDataBuilder.createSampleNote(TEST_USER_ID).build()); - anotherNote = documentRepository.save(DocumentDataBuilder.createSampleNote(OTHER_USER_ID).build()); + existingNote = documentRepository.save(DocumentDataBuilder.createMockNote(TEST_USER_ID).build()); + anotherNote = documentRepository.save(DocumentDataBuilder.createMockNote(OTHER_USER_ID).build()); } @Nested diff --git a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java index b44220f..d2fb0be 100644 --- a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*; @DataJpaTest -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Document Repository Test") public class DocumentRepositoryTest { @Autowired @@ -33,10 +33,10 @@ public class DocumentRepositoryTest { void setUp() { documentRepository.deleteAll(); - document1 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); - document2 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); - document3 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); - documentAnotherUser = documentRepository.save(DocumentDataBuilder.createSampleNote(SECOND_USER_ID).build()); + document1 = documentRepository.save(DocumentDataBuilder.createMockNote(FIRST_USER_ID).build()); + document2 = documentRepository.save(DocumentDataBuilder.createMockNote(FIRST_USER_ID).build()); + document3 = documentRepository.save(DocumentDataBuilder.createMockNote(FIRST_USER_ID).build()); + documentAnotherUser = documentRepository.save(DocumentDataBuilder.createMockNote(SECOND_USER_ID).build()); } // --- findAllByUserId --- // @@ -44,7 +44,7 @@ void setUp() { @DisplayName("findAllByUserId(): List") class FindAllByUserIdTest { @Test - void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { + void shouldReturnAllDocumentsWhenUserHasDocuments() { // Act List result = documentRepository.findAllByUserId(FIRST_USER_ID); @@ -55,7 +55,7 @@ void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { } @Test - void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenUserHasNoDocuments() { // Arrange int nonExistentUserId = 999; @@ -68,7 +68,7 @@ void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { } @Test - void findAllByUserId_shouldNotReturnOtherUsersDocuments() { + void shouldNotReturnOtherUsersDocuments() { // Act List result = documentRepository.findAllByUserId(FIRST_USER_ID); @@ -83,7 +83,7 @@ void findAllByUserId_shouldNotReturnOtherUsersDocuments() { @DisplayName("findByIdAndUserId(): Optional") class FindByIdAndUserIdTest { @Test - void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { + void shouldReturnDocumentWhenDocumentExists() { // Act Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); @@ -94,7 +94,7 @@ void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { } @Test - void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { + void shouldReturnEmptyWhenDocumentNotExists() { // Arrange int nonExistentId = 999; @@ -106,7 +106,7 @@ void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { } @Test - void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { + void shouldReturnEmptyWhenDocumentExistsButWrongUser() { // Act Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); @@ -115,7 +115,7 @@ void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { } @Test - void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { + void shouldReturnEmptyWhenUserNotExists() { // Arrange int nonExistentUserId = 999; @@ -132,7 +132,7 @@ void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { @DisplayName("findFirstByTitleAndUserId(): Optional") class FindFirstByTitleAndUserIdTest { @Test - void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { + void shouldReturnDocumentWhenDocumentExists() { // Arrange String existingTitle = document1.getTitle(); @@ -146,7 +146,7 @@ void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { } @Test - void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { + void shouldReturnEmptyWhenTitleNotExists() { // Arrange String nonExistentTitle = "Non Existent Title"; @@ -158,14 +158,14 @@ void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { } @Test - void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { + void shouldReturnOnlyRequestedUserWhenMultipleUsersHaveSameTitle() { // Arrange String sharedTitle = "Shared Title"; Document doc1 = documentRepository.save( - DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() + DocumentDataBuilder.createMockNote(FIRST_USER_ID).title(sharedTitle).build() ); Document doc2 = documentRepository.save( - DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() + DocumentDataBuilder.createMockNote(SECOND_USER_ID).title(sharedTitle).build() ); // Act @@ -183,7 +183,7 @@ void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRe @DisplayName("findAllByIdIn(): List") class FindAllByIdInTest { @Test - void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { + void shouldReturnAllDocumentsWhenAllIdsExist() { // Arrange List ids = Arrays.asList(document1.getId(), document2.getId()); @@ -198,7 +198,7 @@ void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { } @Test - void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { + void shouldReturnOnlyExistingDocumentsWhenSomeIdsNotExist() { // Arrange List ids = Arrays.asList(document1.getId(), 999, 998); @@ -212,7 +212,7 @@ void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { } @Test - void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenNoIdsExist() { // Arrange List ids = Arrays.asList(999, 998, 997); @@ -225,7 +225,7 @@ void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { } @Test - void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenGivenEmptyIdList() { // Arrange List emptyIds = Collections.emptyList(); @@ -238,7 +238,7 @@ void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { } @Test - void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { + void shouldReturnDocumentsFromMultipleUsers() { // Arrange List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); @@ -253,7 +253,7 @@ void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { } @Test - void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { + void shouldReturnSingleDocumentWhenGivenSingleId() { // Arrange List ids = Collections.singletonList(document1.getId()); @@ -272,7 +272,7 @@ void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { @DisplayName("findAllByUserAndIdIn(): List") class FindAllByUserIdAndIdIn { @Test - void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { + void shouldReturnAllDocumentsWhenAllIdsExistForUser() { // Arrange List ids = Arrays.asList(document1.getId(), document2.getId()); @@ -286,7 +286,7 @@ void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { } @Test - void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { + void shouldReturnOnlyExistingWhenSomeIdsNotExistForUser() { // Arrange List ids = Arrays.asList(document1.getId(), 999); @@ -300,7 +300,7 @@ void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting( } @Test - void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenNoIdsExistForUser() { // Arrange List ids = Arrays.asList(999, 998); @@ -313,7 +313,7 @@ void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { } @Test - void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenGivenEmptyIdList() { // Arrange List emptyIds = Collections.emptyList(); @@ -326,7 +326,7 @@ void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { } @Test - void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenDocumentsBelongToAnotherUser() { // Arrange - trying to get user 1's documents with user 2's ID List ids = Arrays.asList(document1.getId(), document2.getId()); @@ -339,7 +339,7 @@ void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyLi } @Test - void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { + void shouldReturnOnlyRequestedUserDocumentsWhenGivenMixedUserDocuments() { // Arrange - mixing documents from different users List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); @@ -354,7 +354,7 @@ void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUser } @Test - void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { + void shouldReturnSingleDocumentWhenGivenSingleId() { // Arrange List ids = Collections.singletonList(document1.getId()); @@ -368,7 +368,7 @@ void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { } @Test - void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenUserNotExists() { // Arrange int nonExistentUserId = 999; List ids = Arrays.asList(document1.getId(), document2.getId()); diff --git a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java index c5de2ee..62d837c 100644 --- a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -6,6 +6,7 @@ import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.helper.UserDataBuilder; import com.be08.smart_notes.mapper.DocumentMapper; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; @@ -29,7 +30,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Service Test") public class NoteServiceTest { @Mock @@ -51,11 +52,11 @@ public class NoteServiceTest { @BeforeEach void setUp() { int userId = 100; - existingUser = DocumentDataBuilder.createUser(userId).build(); - existingNote = DocumentDataBuilder.createSampleNote(userId).id(1).userId(100).build(); - anotherExistingNote = DocumentDataBuilder.createSampleNote(userId).id(2).userId(100).build(); - existingNoteResponse = DocumentDataBuilder.createNoteResponse(existingNote).build(); - anotherExistingNoteResponse = DocumentDataBuilder.createNoteResponse(anotherExistingNote).build(); + existingUser = UserDataBuilder.createMockUser(userId).build(); + existingNote = DocumentDataBuilder.createMockNote(userId).id(1).userId(100).build(); + anotherExistingNote = DocumentDataBuilder.createMockNote(userId).id(2).userId(100).build(); + existingNoteResponse = DocumentDataBuilder.createMockNoteResponse(existingNote).build(); + anotherExistingNoteResponse = DocumentDataBuilder.createMockNoteResponse(anotherExistingNote).build(); when(authorizationService.getCurrentUserId()).thenReturn(existingUser.getId()); } @@ -65,7 +66,7 @@ void setUp() { @DisplayName("getNote(): NoteResponse") class GetNoteTest { @Test - void getNote_withNonExistentId_shouldThrowException() { + void shouldThrowExceptionWhenGivenNonExistentId() { // Arrange int nonExistentId = 999; int userId = existingUser.getId(); @@ -83,7 +84,7 @@ void getNote_withNonExistentId_shouldThrowException() { } @Test - void getNote_whenNoteExists_shouldReturnNoteResponse() { + void shouldReturnNoteResponseWhenNoteExists() { // Arrange int noteId = existingNote.getId(); int userId = existingUser.getId(); @@ -108,7 +109,7 @@ void getNote_whenNoteExists_shouldReturnNoteResponse() { @DisplayName("getAllNotes(): List") class GetAllNotesTest { @Test - void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { + void shouldReturnEmptyNoteResponseListWhenListIsEmpty() { // Arrange int userId = existingUser.getId(); List emptyList = Collections.emptyList(); @@ -129,7 +130,7 @@ void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { } @Test - void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { + void shouldReturnNoteResponseListWhenListIsNotEmpty() { // Arrange int userId = existingUser.getId(); List noteList = List.of(existingNote, anotherExistingNote); @@ -152,7 +153,7 @@ void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { } @Test - void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { + void shouldReturnSingleNoteResponseListWhenGivenSingleNote() { // Arrange int userId = existingUser.getId(); List noteList = List.of(existingNote); @@ -179,7 +180,7 @@ void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { @DisplayName("createNote(): NoteResponse") class CreateNoteTest { @Test - void createNote_withValidInput_shouldCreateSuccessfully() { + void shouldCreateSuccessfullyWhenGivenValidInput() { // Arrange int newId = 2; String newTitle = "Test New Note"; @@ -212,7 +213,7 @@ void createNote_withValidInput_shouldCreateSuccessfully() { @DisplayName("updateNote(): NoteResponse") class UpdateNoteTest { @Test - void updateNote_whenNoteExists_shouldUpdateSuccessfully() { + void shouldUpdateSuccessfullyWhenNoteExists() { // Arrange int noteId = existingNote.getId(); int userId = existingUser.getId(); @@ -243,7 +244,7 @@ void updateNote_whenNoteExists_shouldUpdateSuccessfully() { } @Test - void updateNote_whenNoteNotFound_shouldThrowException() { + void shouldThrowExceptionWhenNoteNotFound() { // Arrange int noteId = 999; int userId = existingUser.getId(); @@ -272,7 +273,7 @@ void updateNote_whenNoteNotFound_shouldThrowException() { @DisplayName("deleteNote(): void") class DeleteNoteTest { @Test - void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { + void shouldDeleteSuccessfullyWhenNoteExists() { // Arrange int noteId = existingNote.getId(); int userId = existingUser.getId(); @@ -289,7 +290,7 @@ void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { } @Test - void deleteNote_whenNoteNotFound_shouldThrowException() { + void shouldThrowExceptionWhenNoteNotFound() { // Arrange int noteId = 999; int userId = existingUser.getId(); @@ -313,7 +314,7 @@ void deleteNote_whenNoteNotFound_shouldThrowException() { @DisplayName("getAllNotesByUserIdAndIds(): List") class GetAllNotesByUserIdAndIdsTest { @Test - void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { + void shouldReturnMatchingNotesWhenNotesExist() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); @@ -333,7 +334,7 @@ void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { } @Test - void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenNoMatches() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(998, 999); @@ -351,7 +352,7 @@ void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { } @Test - void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenGivenEmptyIdList() { // Arrange int userId = existingUser.getId(); List emptyIds = Collections.emptyList(); @@ -369,7 +370,7 @@ void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { } @Test - void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { + void shouldReturnSingleNoteWhenGivenSingleId() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(existingNote.getId()); @@ -389,7 +390,7 @@ void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { } @Test - void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { + void shouldReturnOnlyMatchingNotesWhenGivenPartialMatchedInputs() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index c2caac1..fa3b89d 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,19 +1,19 @@ ## REDIS Configuration -spring.data.redis.host=${REDIS_HOST:localhost} -spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.host=localhost +spring.data.redis.port=6379 ## Frontend URL for Local Development app.frontend.url=http://localhost:3000 ## AI Inference Configuration -ai.api.url=${API_URL} -ai.api.token=${API_TOKEN} -ai.api.model=${MODEL} +ai.api.url=placeholder-url +ai.api.token=placeholder-token +ai.api.model=placeholder-model -ai.api.user-role=${AI_API_USER_ROLE:user} -ai.api.system-role=${AI_API_SYSTEM_ROLE:system} -ai.api.temperature=${AI_API_TEMPERATURE:0.7} -ai.api.top-p=${AI_API_TOP_P:0.9} +ai.api.user-role=user +ai.api.system-role=system +ai.api.temperature=0.7 +ai.api.top-p=0.9 # JWT Configuration jwt.access-token.expiration-minutes=15 From 784b9227f9f6ba2adbd869762a15dd60bea58813 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 10 Dec 2025 14:11:06 +1100 Subject: [PATCH 08/15] docs: update README --- README.md | 152 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index eec3ed7..670fbf1 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,83 @@ # SmartNotes Backend -Personal project for smart note taking application +Backend service for **SmartNotes**, a personal project that enables smart note-taking with AI integration to +help individuals have a better experience in note-taking, organizing, and revising knowledge. + +**SmartNotes Frontend Repository:** [https://github.com/pvdev1805/SmartNotes](https://github.com/pvdev1805/SmartNotes) + +## Table of Contents + +- [Project Info](#project-info) + - [Branching Strategy](#branching-strategy) + - [Tech Stack](#tech-stack) +- [Supported Features](#supported-features) + - [User Authentication](#1-user-authentication) + - [AI-Powered Quiz Generation](#2-ai-powered-quiz-generation) + - [CRUD Management Features](#3-crud-management-features) +- [Project Setup](#project-setup) + - [Prerequisites](#prerequisites) + - [HuggingFace API](#huggingface-api) + - [Environment Variables](#environment-variables) +- [How to Test](#how-to-test) + - [Test Commands](#test-commands-windows) + - [Test Structure](#test-structure) + - [Test Notes](#test-notes) +- [Contributors](#contributors) + +## Project Info +### Branching Strategy +- **main**: stable, production‑ready code +- **develop**: integration branch with the latest completed features +- **feature/***: branches for individual features or fixes, merged into `develop` via pull requests + +### Tech Stack +- **Java 17** + **Spring Boot 3.5.5** for backend services +- **Maven** for build and dependency management +- **HuggingFace API** for AI quiz generation +- **MySQL** for database and persistence +- **Redis** for caching and session management +- **Docker** for containerization +- **Cloud and Deployment**: AWS (planned) + +[Back to top](#smartnotes-backend) ## Supported Features -| Module | Method | API | Description | -| -------- | -------- | -------- | ------- | -| Authentication | POST | `/api/auth/register` | Register new user | -| | POST | `/api/auth/login` | Login with your account | -| | POST | `/api/auth/logout` | Logout of current session | -| | POST | `/api/auth/refresh` | Refresh your authentication token | -| | GET | `/api/users/me` | Get your information (logged in) | -| Document | GET | `/api/documents` | List all available documents | -| | DELETE | `/api/documents/{id}` | Delete document using its id | -| Note | POST | `/api/documents/notes` | Create new note | -| | GET | `/api/documents/notes/{id}` | Get note content using its id | -| | PUT | `/api/documents/notes/{id}` | Update note content using its id | -| | DELETE | `/api/documents/notes/{id}` | Delete note content using its id | -| AI | GET | `/api/ai/generateQuiz/sample` | Get a sample quiz from sample response, this API does not require API TOKEN | -| | GET | `/api/ai/generateQuiz/{noteId}` | Generate relevant quizzes based on 1 note, this feature **requires HuggingFace API TOKEN** to run | +### 1. User Authentication +- Register, login, logout, and refresh tokens +- JWT-based secure authentication +- Secure access to user profile information + +### 2. AI-Powered Quiz Generation +- Generate quizzes from notes using HuggingFace models +- Sample endpoint for testing without authentication + +### 3. CRUD Management Features +**Document Management (CRUD)**: All types of documents including notes, flashcards, and quizzes: +- List all documents by user, or delete specific document + +**Note Management**: +- Create, update, retrieve, and delete notes + +**Flashcard & Flashcard Sets Management**: +- Create, update, retrieve, and delete flashcards +- Organize flashcards into sets + +**Quiz & Quiz Sets Management**: +- Create, update, retrieve, and delete quizzes +- Organize quizzes into sets +- Track quiz attempts and scores + +[Back to top](#smartnotes-backend) ## Project Setup +### Prerequisites +- **Java 17** or higher +- **MySQL** +- **Docker** + - For integration tests with Testcontainers + - For Redis (optional for local development on Windows) +- **HuggingFace Account** (for AI features) + ### HuggingFace API - Create a HuggingFace account - Create Access Token: @@ -33,6 +91,7 @@ Personal project for smart note taking application ### Environment variables In project root directory, create `.env` file and paste your **ACCESS TOKEN** here ``` +# AI INFERENCE API_TOKEN= API_URL= MODEL= @@ -41,29 +100,68 @@ AI_API_USER_ROLE=user AI_API_SYSTEM_ROLE=system AI_API_TEMPERATURE= AI_API_TOP_P= - + # Database DB_PORT= DB_NAME= DB_USERNAME= DB_PASSWORD= + +# JWT Config +JWT_KEYSTORE_PASSWORD= +JWT_KEY_PASSWORD= + +# Redis Config +REDIS_HOST= +REDIS_PORT= ``` -## How to test -For Windows, run one of below commands. +[Back to top](#smartnotes-backend) -Replace `test` with `clean test` to rebuild if there are changes in test code, similar with `verify`. +## How to test +### Test commands (Windows) ```bash # Run specific test ./mvnw.cmd test -Dtest=NoteServiceTest -# Run all unit tests -./mvnw.cmd test -Punit-test +# Run all unit tests (service tests with mocks) +./mvnw.cmd test -Punit-tests + +# Run all integration tests (repository + controller with containers) +./mvnw.cmd verify -Pintegration-tests + +# Run all tests (unit + integration) +./mvnw.cmd verify +``` + +### Test Structure +``` +src/test/java/ +├── unit/ # Fast tests (Surefire) +│ └── service/ # Service unit tests with mocked dependencies +│ +└── integration/ # Slower tests (Failsafe) + ├── repository/ # Repository tests with H2 + └── controller/ # Full-stack tests with MySQL + Redis containers +``` + +### Test Notes +- **Unit tests** run with Surefire and use mocked dependencies +- **Integration tests** run with Failsafe and use Testcontainers +- Docker must be running for integration tests (uses MySQL and Redis containers) +- First integration test run may take longer (downloads container images) + +[Back to top](#smartnotes-backend) + +## Contributors +**Project Maintainers:** This project (both frontend and backend) is developed and maintained by: + +- **Alice Tat** ([@TUT888](https://github.com/TUT888)) +- **Phu Vo** ([@pvdev1805](https://github.com/pvdev1805)) -# Run all integration tests -./mvnw.cmd verify -Pintegration-test +**Project Repositories:** +- Backend: [SmartNotes Backend](https://github.com/TUT888/SmartNotes) +- Frontend: [SmartNotes Frontend](https://github.com/pvdev1805/SmartNotes) -# Run all tests (unit and integration) -./mvnw.cmd verify -Ptest -``` \ No newline at end of file +[Back to top](#smartnotes-backend) \ No newline at end of file From 56f9b41ff7ca66afbdadb9cd2e349ebba742b2f9 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 16 Dec 2025 20:54:00 +1100 Subject: [PATCH 09/15] test(note): add junit tag for feature-based testing --- README.md | 25 +++++++++++++++---- .../NoteControllerIntegrationTest.java | 1 + .../repository/DocumentRepositoryTest.java | 2 ++ .../unit/service/NoteServiceTest.java | 1 + 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 670fbf1..406c6a8 100644 --- a/README.md +++ b/README.md @@ -120,19 +120,34 @@ REDIS_PORT= ## How to test ### Test commands (Windows) +Run all tests +```bash +# Run all tests (unit + integration) +./mvnw.cmd verify +``` +Run specific type of test ```bash -# Run specific test +# Run specific test file ./mvnw.cmd test -Dtest=NoteServiceTest # Run all unit tests (service tests with mocks) -./mvnw.cmd test -Punit-tests +./mvnw.cmd test -Punit-test # Run all integration tests (repository + controller with containers) -./mvnw.cmd verify -Pintegration-tests +./mvnw.cmd verify -Pintegration-test +``` -# Run all tests (unit + integration) -./mvnw.cmd verify +Run specific test group with tag, use **boolean expression** for tags combination +```bash +# Run test with "note" tag +./mvnw.cmd test -Dgroups="note" + +# Run tests with "note" or "document" tags +./mvnw.cmd test -Dgroups="note | document" + +# Combine with test type: run all unit tests with "note" tag +./mvnw.cmd test -Punit-test -Dgroups="note | document" ``` ### Test Structure diff --git a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java index b9883f2..6810a23 100644 --- a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -35,6 +35,7 @@ @Transactional @DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Controller Integration Test") +@Tag("note") public class NoteControllerIntegrationTest extends BaseIntegration { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java index d2fb0be..04014cf 100644 --- a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -17,6 +17,8 @@ @DataJpaTest @DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Document Repository Test") +@Tag("document") +@Tag("note") public class DocumentRepositoryTest { @Autowired DocumentRepository documentRepository; diff --git a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java index 62d837c..532ce38 100644 --- a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -32,6 +32,7 @@ @MockitoSettings(strictness = Strictness.LENIENT) @DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Service Test") +@Tag("note") public class NoteServiceTest { @Mock DocumentRepository documentRepository; From 2b47d24df41eba309550843a4d16654bd8f03f37 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 16 Dec 2025 21:09:31 +1100 Subject: [PATCH 10/15] feature(workflow): add auto test run on note feature branch --- .github/scripts/detect-branch-name.sh | 10 ++++++ .github/workflows/README.md | 18 ++++++++++ .github/workflows/_reusable-test.yml | 47 +++++++++++++++++++++++++++ .github/workflows/feature-test.yml | 34 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 .github/scripts/detect-branch-name.sh create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/_reusable-test.yml create mode 100644 .github/workflows/feature-test.yml diff --git a/.github/scripts/detect-branch-name.sh b/.github/scripts/detect-branch-name.sh new file mode 100644 index 0000000..a63d919 --- /dev/null +++ b/.github/scripts/detect-branch-name.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exit immediately if any command fails +set -e + +echo "Detecting Branch Name..." + +if [[ "$BRANCH_NAME" == *"note"* ]]; then + echo "test_tag=note" >> $GITHUB_OUTPUT +fi \ No newline at end of file diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..a62a922 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,18 @@ +# CI/CD workflows + +## Convention and Standard +### Naming +- Each workflow should have a unique name and filename. + - Name the **main** workflows as `workflow-filename.yml` + - Name the **reusable** workflows as `_reusable-workflow.yml` (with `_` prefix) +- A workflow’s name should **not** be changed once it has been committed to the repository, as this may cause history mismatches and unexpected errors. +Therefore, it is recommended to consider changes carefully before modifying existing workflows. + +### Directory structure +- `.github/workflows`: All **runnable workflows** must be placed in this directory, as required by the official GitHub Actions documentation. Otherwise, they may not execute correctly. +- `.github/scripts`: Place **reusable or lengthy bash scripts** in this directory so they can be referenced and reused in workflows. + +## Reference +- Official documentation: [GitHub Actions Docs - How To](https://docs.github.com/en/actions/how-tos) +- Advanced customization: [Mert Mengü - Guide to GitHub Actions for Advanced CI/CD Workflows](https://medium.com/@mertmengu/guide-to-github-actions-for-advanced-ci-cd-workflows-1e494271ac22) +- GitHub Actions with Spring Boot: [Pudding Entertainment - GitHub Actions: Spring Boot Application Build and Deployment](https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9) \ No newline at end of file diff --git a/.github/workflows/_reusable-test.yml b/.github/workflows/_reusable-test.yml new file mode 100644 index 0000000..1ec35ba --- /dev/null +++ b/.github/workflows/_reusable-test.yml @@ -0,0 +1,47 @@ +name: Reusable Test Workflow + +on: + # Workflow only runs when being called by others + workflow_call: + inputs: + level: # select between [unit, integration, all] + required: true + type: string + test-tag: # not required, but will automatically run all tests if left empty + required: false + type: string + +jobs: + build-and-test: + name: Build and Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + + - name: Build with Maven + run: mvn clean install + + - name: Run All Unit Tests + run: | + if [ -n "${{ inputs.test-tag }}" ]; then + mvn test -Punit-test -Dgroups="${{ inputs.test-tag }}" + else + mvn test -Punit-test + fi + + - name: Run All Integration Tests + if: inputs.level != 'unit' + run: | + if [ -n "${{ inputs.test-tag }}" ]; then + mvn verify -Pintegration-test -Dgroups="${{ inputs.test-tag }}" + else + mvn verify -Pintegration-test + fi \ No newline at end of file diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml new file mode 100644 index 0000000..eb4fe5c --- /dev/null +++ b/.github/workflows/feature-test.yml @@ -0,0 +1,34 @@ +name: Feature Branch Test + +on: + # Workflow runs on any changes on the target feature branch + push: + branches: + - "feature/*note*" + - "fix/*note*" + +jobs: + # Detect feature changes based on branch name + detect-changes: + name: Detect Feature Changes + runs-on: ubuntu-latest + outputs: + test_tag: ${{ steps.detect.outputs.test_tag }} + steps: + - name: Detect Changes + id: detect + env: + BRANCH_NAME: ${{ github.ref_name }} + run: | + chmod +x .github/scripts/detect-branch-name.sh + ./.github/scripts/detect-branch-name.sh + + # Build and run test based on the branch detection result + build-and-test: + name: Build and Run Tests + needs: detect-changes + uses: ./.github/workflows/_reusable-test.yml + secrets: inherit + with: + level: 'unit' + test-tag: ${{ needs.detect-changes.outputs.test_tag }} \ No newline at end of file From 7bd8d78c0ced21b0dd79eed67f9c3ab5930d01bc Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 16 Dec 2025 21:12:18 +1100 Subject: [PATCH 11/15] feature(workflow): fix missing checkout repository --- .github/workflows/feature-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml index eb4fe5c..7d60a4e 100644 --- a/.github/workflows/feature-test.yml +++ b/.github/workflows/feature-test.yml @@ -15,6 +15,9 @@ jobs: outputs: test_tag: ${{ steps.detect.outputs.test_tag }} steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Detect Changes id: detect env: From 6c3ec3c56dbf76db4e5a1c21237b66f36bf42982 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 00:06:55 +1100 Subject: [PATCH 12/15] feature(workflow): add workflow for develop branch --- .github/workflows/README.md | 18 ++++++++++++++++- .github/workflows/_reusable-test.yml | 4 ++-- .github/workflows/ci-cd-develop.yml | 29 ++++++++++++++++++++++++++++ .github/workflows/feature-test.yml | 6 ++++-- 4 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci-cd-develop.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a62a922..486d2ce 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,7 +12,23 @@ Therefore, it is recommended to consider changes carefully before modifying exis - `.github/workflows`: All **runnable workflows** must be placed in this directory, as required by the official GitHub Actions documentation. Otherwise, they may not execute correctly. - `.github/scripts`: Place **reusable or lengthy bash scripts** in this directory so they can be referenced and reused in workflows. +### Workflows (Planned) +- Changes on `feature` branch: Run unit tests, linting, security check, etc +- PR on `develop` branch (for integration/staging): + - Unit + Integration tests (API, DB, UI, etc) + - Build and push staging images + - Deploy to staging environment and test (optional) +- PR on `main` branch (for production): Final build verification +- Completed merge on `main` branch (for production): + - Build and push production-ready images with version tags + - Deploy to production environment + ## Reference +### GitHub Actions Workflows - Official documentation: [GitHub Actions Docs - How To](https://docs.github.com/en/actions/how-tos) - Advanced customization: [Mert Mengü - Guide to GitHub Actions for Advanced CI/CD Workflows](https://medium.com/@mertmengu/guide-to-github-actions-for-advanced-ci-cd-workflows-1e494271ac22) -- GitHub Actions with Spring Boot: [Pudding Entertainment - GitHub Actions: Spring Boot Application Build and Deployment](https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9) \ No newline at end of file +- GitHub Actions with Spring Boot: [Pudding Entertainment - GitHub Actions: Spring Boot Application Build and Deployment](https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9) + +### Branching Strategy +- Different branching strategies, including using develop and main branch: [Devtron - Branching Strategy for CI/CD](https://devtron.ai/blog/best-branching-strategy-for-ci-cd/) +- Run tests on feature and master branch: [TeamCity - Branching Strategy for CI/CD](https://www.jetbrains.com/teamcity/ci-cd-guide/concepts/branching-strategy/) \ No newline at end of file diff --git a/.github/workflows/_reusable-test.yml b/.github/workflows/_reusable-test.yml index 1ec35ba..91c4eed 100644 --- a/.github/workflows/_reusable-test.yml +++ b/.github/workflows/_reusable-test.yml @@ -4,7 +4,7 @@ on: # Workflow only runs when being called by others workflow_call: inputs: - level: # select between [unit, integration, all] + test-level: # select between [unit, integration, all] required: true type: string test-tag: # not required, but will automatically run all tests if left empty @@ -38,7 +38,7 @@ jobs: fi - name: Run All Integration Tests - if: inputs.level != 'unit' + if: inputs.test-level != 'unit' run: | if [ -n "${{ inputs.test-tag }}" ]; then mvn verify -Pintegration-test -Dgroups="${{ inputs.test-tag }}" diff --git a/.github/workflows/ci-cd-develop.yml b/.github/workflows/ci-cd-develop.yml new file mode 100644 index 0000000..cbcadf5 --- /dev/null +++ b/.github/workflows/ci-cd-develop.yml @@ -0,0 +1,29 @@ +name: CI/CD on Develop Branch + +on: + # Workflow runs whenever a new PR to develop is created + pull_request: + branches: + - develop + +jobs: + # Build and run test based on the branch detection result + build-and-test: + name: Build and Run Tests + uses: ./.github/workflows/_reusable-test.yml + secrets: inherit + with: + test-level: 'all' + + # Build and push image (placeholder only since the app is not completed yet) + build-images: + name: Build Images and Deploy to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build and push + run: | + echo "Building images..." + echo "Published to Docker Hub!" \ No newline at end of file diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml index 7d60a4e..8e5a739 100644 --- a/.github/workflows/feature-test.yml +++ b/.github/workflows/feature-test.yml @@ -1,11 +1,13 @@ name: Feature Branch Test on: - # Workflow runs on any changes on the target feature branch + # Workflow runs whenever the source code changes on the target feature branch push: branches: - "feature/*note*" - "fix/*note*" + paths: + - "src/**" jobs: # Detect feature changes based on branch name @@ -33,5 +35,5 @@ jobs: uses: ./.github/workflows/_reusable-test.yml secrets: inherit with: - level: 'unit' + test-level: 'unit' test-tag: ${{ needs.detect-changes.outputs.test_tag }} \ No newline at end of file From 270887ae78a8475a9951563cfe726221043ae19e Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 00:17:20 +1100 Subject: [PATCH 13/15] feature(workflow): fix ci develop requiring successful tests before publishing images --- .github/workflows/ci-cd-develop.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd-develop.yml b/.github/workflows/ci-cd-develop.yml index cbcadf5..1301563 100644 --- a/.github/workflows/ci-cd-develop.yml +++ b/.github/workflows/ci-cd-develop.yml @@ -9,7 +9,7 @@ on: jobs: # Build and run test based on the branch detection result build-and-test: - name: Build and Run Tests + name: Build and Run All Tests uses: ./.github/workflows/_reusable-test.yml secrets: inherit with: @@ -18,6 +18,7 @@ jobs: # Build and push image (placeholder only since the app is not completed yet) build-images: name: Build Images and Deploy to Docker Hub + needs: build-and-test runs-on: ubuntu-latest steps: - name: Checkout repository From 4907df58473f416859822bf969f92fdf5e860cea Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 11:02:12 +1100 Subject: [PATCH 14/15] feat(workflow): change trigger condition of feature test workflow --- .github/workflows/feature-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml index 8e5a739..bad1d8b 100644 --- a/.github/workflows/feature-test.yml +++ b/.github/workflows/feature-test.yml @@ -4,8 +4,8 @@ on: # Workflow runs whenever the source code changes on the target feature branch push: branches: - - "feature/*note*" - - "fix/*note*" + - "feature/**" + - "fix/**" paths: - "src/**" From 18dfe3e89153b20dead589a809b83819189a4b62 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 11:43:27 +1100 Subject: [PATCH 15/15] docs: update main README with CI/CD info --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 406c6a8..c270833 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # SmartNotes Backend +> **Active development** happens on [develop](https://github.com/TUT888/SmartNotes/tree/develop) branch. +> The [main](https://github.com/TUT888/SmartNotes/tree/main) branch contains **stable release code**. Backend service for **SmartNotes**, a personal project that enables smart note-taking with AI integration to help individuals have a better experience in note-taking, organizing, and revising knowledge. @@ -8,8 +10,8 @@ help individuals have a better experience in note-taking, organizing, and revisi ## Table of Contents - [Project Info](#project-info) - - [Branching Strategy](#branching-strategy) - [Tech Stack](#tech-stack) + - [Branching Strategy with CI/CD](#branching-strategy-with-cicd) - [Supported Features](#supported-features) - [User Authentication](#1-user-authentication) - [AI-Powered Quiz Generation](#2-ai-powered-quiz-generation) @@ -25,11 +27,6 @@ help individuals have a better experience in note-taking, organizing, and revisi - [Contributors](#contributors) ## Project Info -### Branching Strategy -- **main**: stable, production‑ready code -- **develop**: integration branch with the latest completed features -- **feature/***: branches for individual features or fixes, merged into `develop` via pull requests - ### Tech Stack - **Java 17** + **Spring Boot 3.5.5** for backend services - **Maven** for build and dependency management @@ -38,6 +35,14 @@ help individuals have a better experience in note-taking, organizing, and revisi - **Redis** for caching and session management - **Docker** for containerization - **Cloud and Deployment**: AWS (planned) +- **CI/CD**: GitHub Actions + +### Branching Strategy with CI/CD +> For detail documentation about CI/CD process, please refer to another [dedicated README](./.github/workflows/README.md) + +- **main**: stable, production‑ready code +- **develop**: integration branch with the latest completed features +- **feature/***: branches for individual features or fixes, merged into `develop` via pull requests [Back to top](#smartnotes-backend)