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..486d2ce --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,34 @@ +# 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. + +### 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) + +### 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 new file mode 100644 index 0000000..91c4eed --- /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: + test-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.test-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/ci-cd-develop.yml b/.github/workflows/ci-cd-develop.yml new file mode 100644 index 0000000..1301563 --- /dev/null +++ b/.github/workflows/ci-cd-develop.yml @@ -0,0 +1,30 @@ +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 All 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 + needs: build-and-test + 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 new file mode 100644 index 0000000..bad1d8b --- /dev/null +++ b/.github/workflows/feature-test.yml @@ -0,0 +1,39 @@ +name: Feature Branch Test + +on: + # Workflow runs whenever the source code changes on the target feature branch + push: + branches: + - "feature/**" + - "fix/**" + paths: + - "src/**" + +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: Checkout repository + uses: actions/checkout@v4 + + - 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: + test-level: 'unit' + test-tag: ${{ needs.detect-changes.outputs.test_tag }} \ No newline at end of file diff --git a/README.md b/README.md index 2ddb51b..c270833 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,88 @@ # 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**. -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) + - [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) + - [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 +### 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) +- **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) ## 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 +96,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,10 +105,83 @@ 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= -``` \ No newline at end of file + +# JWT Config +JWT_KEYSTORE_PASSWORD= +JWT_KEY_PASSWORD= + +# Redis Config +REDIS_HOST= +REDIS_PORT= +``` + +[Back to top](#smartnotes-backend) + +## 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 file +./mvnw.cmd test -Dtest=NoteServiceTest + +# Run all unit tests (service tests with mocks) +./mvnw.cmd test -Punit-test + +# Run all integration tests (repository + controller with containers) +./mvnw.cmd verify -Pintegration-test +``` + +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 +``` +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)) + +**Project Repositories:** +- Backend: [SmartNotes Backend](https://github.com/TUT888/SmartNotes) +- Frontend: [SmartNotes Frontend](https://github.com/pvdev1805/SmartNotes) + +[Back to top](#smartnotes-backend) \ No newline at end of file diff --git a/pom.xml b/pom.xml index b452060..bbf615a 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,9 @@ 17 1.18.30 1.6.3 + 1.16.2 + false + false @@ -144,6 +147,45 @@ jackson-datatype-jsr310 + + + + com.h2database + h2 + 2.2.224 + 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 + + @@ -167,7 +209,19 @@ jsonschema-module-jakarta-validation 4.31.1 - + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + @@ -194,6 +248,79 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 + + + + + ${skip.unit.tests} + + **/unit/**/*Test.java + + + **/integration/** + + + 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} + + **/integration/**/*Test.java + + + **/unit/** + + + plain + + true + + + + + + + + integration-test + verify + + + + @@ -211,6 +338,9 @@ test test + + false + false @@ -219,6 +349,22 @@ prod + + + + unit-test + + false + true + + + + integration-test + + false + true + + 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/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/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/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..fec700e --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java @@ -0,0 +1,36 @@ +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 java.time.LocalDateTime; + +public class DocumentDataBuilder { + /** + * 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") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()); + } + + /** + * 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()) + .updatedAt(note.getUpdatedAt()); + } +} 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/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/BaseIntegration.java b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java new file mode 100644 index 0000000..6f3eea2 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java @@ -0,0 +1,50 @@ +package com.be08.smart_notes.integration.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.utility.DockerImageName; + +@Getter +public class BaseIntegration { + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .withInitScript("init-db.sql") + .withReuse(true); + + private 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", () -> "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/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java new file mode 100644 index 0000000..6810a23 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -0,0 +1,335 @@ +package com.be08.smart_notes.integration.controller; + +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; +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 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.Standard.class) +@DisplayName("Note Controller Integration Test") +@Tag("note") +public class NoteControllerIntegrationTest extends BaseIntegration { + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + DocumentRepository documentRepository; + @Autowired + UserRepository userRepository; + + @MockitoBean + JwtEncoder jwtEncoder; + @MockitoBean + JWKSource jwkSource; + @MockitoBean + KeyPair signingKeyPair; + + 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(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.createMockNote(TEST_USER_ID).build()); + anotherNote = documentRepository.save(DocumentDataBuilder.createMockNote(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/integration/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java new file mode 100644 index 0000000..04014cf --- /dev/null +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -0,0 +1,386 @@ +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; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) +@DisplayName("Document Repository Test") +@Tag("document") +@Tag("note") +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.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 --- // + @Nested + @DisplayName("findAllByUserId(): List") + class FindAllByUserIdTest { + @Test + void shouldReturnAllDocumentsWhenUserHasDocuments() { + // 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 shouldReturnEmptyListWhenUserHasNoDocuments() { + // Arrange + int nonExistentUserId = 999; + + // Act + List result = documentRepository.findAllByUserId(nonExistentUserId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldNotReturnOtherUsersDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + } + + // --- findByIdAndUserId --- // + @Nested + @DisplayName("findByIdAndUserId(): Optional") + class FindByIdAndUserIdTest { + @Test + void shouldReturnDocumentWhenDocumentExists() { + // 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 shouldReturnEmptyWhenDocumentNotExists() { + // Arrange + int nonExistentId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnEmptyWhenDocumentExistsButWrongUser() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnEmptyWhenUserNotExists() { + // Arrange + int nonExistentUserId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); + + // Assert + assertFalse(result.isPresent()); + } + } + + // --- findFirstByTitleAndUserId --- // + @Nested + @DisplayName("findFirstByTitleAndUserId(): Optional") + class FindFirstByTitleAndUserIdTest { + @Test + void shouldReturnDocumentWhenDocumentExists() { + // 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 shouldReturnEmptyWhenTitleNotExists() { + // Arrange + String nonExistentTitle = "Non Existent Title"; + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnOnlyRequestedUserWhenMultipleUsersHaveSameTitle() { + // Arrange + String sharedTitle = "Shared Title"; + Document doc1 = documentRepository.save( + DocumentDataBuilder.createMockNote(FIRST_USER_ID).title(sharedTitle).build() + ); + Document doc2 = documentRepository.save( + DocumentDataBuilder.createMockNote(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 --- // + @Nested + @DisplayName("findAllByIdIn(): List") + class FindAllByIdInTest { + @Test + void shouldReturnAllDocumentsWhenAllIdsExist() { + // 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 shouldReturnOnlyExistingDocumentsWhenSomeIdsNotExist() { + // 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 shouldReturnEmptyListWhenNoIdsExist() { + // Arrange + List ids = Arrays.asList(999, 998, 997); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenGivenEmptyIdList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByIdIn(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void 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 shouldReturnSingleDocumentWhenGivenSingleId() { + // 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 --- // + @Nested + @DisplayName("findAllByUserAndIdIn(): List") + class FindAllByUserIdAndIdIn { + @Test + void shouldReturnAllDocumentsWhenAllIdsExistForUser() { + // 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 shouldReturnOnlyExistingWhenSomeIdsNotExistForUser() { + // 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 shouldReturnEmptyListWhenNoIdsExistForUser() { + // Arrange + List ids = Arrays.asList(999, 998); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenGivenEmptyIdList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenDocumentsBelongToAnotherUser() { + // 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 shouldReturnOnlyRequestedUserDocumentsWhenGivenMixedUserDocuments() { + // 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 shouldReturnSingleDocumentWhenGivenSingleId() { + // 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 shouldReturnEmptyListWhenUserNotExists() { + // 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/unit/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java new file mode 100644 index 0000000..532ce38 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -0,0 +1,413 @@ +package com.be08.smart_notes.unit.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; +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; +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; +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) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) +@DisplayName("Note Service Test") +@Tag("note") +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() { + int userId = 100; + 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()); + } + + // --- Get note --- // + @Nested + @DisplayName("getNote(): NoteResponse") + class GetNoteTest { + @Test + void shouldThrowExceptionWhenGivenNonExistentId() { + // 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 shouldReturnNoteResponseWhenNoteExists() { + // 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 -- // + @Nested + @DisplayName("getAllNotes(): List") + class GetAllNotesTest { + @Test + void shouldReturnEmptyNoteResponseListWhenListIsEmpty() { + // 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 shouldReturnNoteResponseListWhenListIsNotEmpty() { + // 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 shouldReturnSingleNoteResponseListWhenGivenSingleNote() { + // 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 --- // + @Nested + @DisplayName("createNote(): NoteResponse") + class CreateNoteTest { + @Test + void shouldCreateSuccessfullyWhenGivenValidInput() { + // 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 --- // + @Nested + @DisplayName("updateNote(): NoteResponse") + class UpdateNoteTest { + @Test + void shouldUpdateSuccessfullyWhenNoteExists() { + // 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 shouldThrowExceptionWhenNoteNotFound() { + // 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 --- // + @Nested + @DisplayName("deleteNote(): void") + class DeleteNoteTest { + @Test + void shouldDeleteSuccessfullyWhenNoteExists() { + // 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 shouldThrowExceptionWhenNoteNotFound() { + // 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 ------ // + @Nested + @DisplayName("getAllNotesByUserIdAndIds(): List") + class GetAllNotesByUserIdAndIdsTest { + @Test + void shouldReturnMatchingNotesWhenNotesExist() { + // 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 shouldReturnEmptyListWhenNoMatches() { + // 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 shouldReturnEmptyListWhenGivenEmptyIdList() { + // 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 shouldReturnSingleNoteWhenGivenSingleId() { + // 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 shouldReturnOnlyMatchingNotesWhenGivenPartialMatchedInputs() { + // 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); + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..fa3b89d --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,29 @@ +## REDIS Configuration +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=placeholder-url +ai.api.token=placeholder-token +ai.api.model=placeholder-model + +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 +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..4bdab8c --- /dev/null +++ b/src/test/resources/init-db.sql @@ -0,0 +1,154 @@ +CREATE DATABASE IF NOT EXISTS `testdb`; +USE `testdb`; + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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)))) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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