├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── ng-update.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── crud-angular ├── .editorconfig ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── proxy.conf.js ├── src │ ├── app │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.routes.ts │ │ ├── courses │ │ │ ├── components │ │ │ │ ├── course-view │ │ │ │ │ ├── course-view.component.html │ │ │ │ │ ├── course-view.component.scss │ │ │ │ │ ├── course-view.component.spec.ts │ │ │ │ │ └── course-view.component.ts │ │ │ │ └── courses-list │ │ │ │ │ ├── courses-list.component.html │ │ │ │ │ ├── courses-list.component.scss │ │ │ │ │ ├── courses-list.component.spec.ts │ │ │ │ │ └── courses-list.component.ts │ │ │ ├── containers │ │ │ │ ├── course-form │ │ │ │ │ ├── course-form.component.html │ │ │ │ │ ├── course-form.component.scss │ │ │ │ │ ├── course-form.component.spec.ts │ │ │ │ │ └── course-form.component.ts │ │ │ │ └── courses │ │ │ │ │ ├── courses.component.html │ │ │ │ │ ├── courses.component.scss │ │ │ │ │ ├── courses.component.spec.ts │ │ │ │ │ └── courses.component.ts │ │ │ ├── courses.routes.ts │ │ │ ├── model │ │ │ │ ├── course-page.ts │ │ │ │ ├── course.ts │ │ │ │ └── lesson.ts │ │ │ ├── resolver │ │ │ │ ├── course.resolver.spec.ts │ │ │ │ └── course.resolver.ts │ │ │ └── services │ │ │ │ ├── courses.mock.ts │ │ │ │ ├── courses.service.spec.ts │ │ │ │ └── courses.service.ts │ │ └── shared │ │ │ ├── components │ │ │ ├── confirmation-dialog │ │ │ │ ├── confirmation-dialog.component.spec.ts │ │ │ │ └── confirmation-dialog.component.ts │ │ │ └── error-dialog │ │ │ │ ├── error-dialog.component.spec.ts │ │ │ │ └── error-dialog.component.ts │ │ │ ├── pipes │ │ │ ├── category.pipe.spec.ts │ │ │ └── category.pipe.ts │ │ │ └── services │ │ │ ├── form-utils.service.spec.ts │ │ │ └── form-utils.service.ts │ ├── assets │ │ └── .gitkeep │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── crud-spring ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── .vscode │ ├── launch.json │ └── tasks.json ├── api.http ├── mvnw ├── mvnw.cmd ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── loiane │ │ │ │ ├── CrudSpringApplication.java │ │ │ │ ├── course │ │ │ │ ├── Course.java │ │ │ │ ├── CourseController.java │ │ │ │ ├── CourseRepository.java │ │ │ │ ├── CourseService.java │ │ │ │ ├── Lesson.java │ │ │ │ ├── dto │ │ │ │ │ ├── CourseDTO.java │ │ │ │ │ ├── CoursePageDTO.java │ │ │ │ │ ├── CourseRequestDTO.java │ │ │ │ │ ├── LessonDTO.java │ │ │ │ │ └── mapper │ │ │ │ │ │ └── CourseMapper.java │ │ │ │ └── enums │ │ │ │ │ ├── Category.java │ │ │ │ │ ├── Status.java │ │ │ │ │ └── converters │ │ │ │ │ ├── CategoryConverter.java │ │ │ │ │ └── StatusConverter.java │ │ │ │ ├── exception │ │ │ │ ├── BusinessException.java │ │ │ │ └── RecordNotFoundException.java │ │ │ │ └── shared │ │ │ │ ├── controller │ │ │ │ └── ApplicationControllerAdvice.java │ │ │ │ └── validation │ │ │ │ ├── ValueOfEnum.java │ │ │ │ └── ValueOfEnumValidator.java │ │ └── resources │ │ │ ├── application-dev.properties │ │ │ ├── application-prod.properties │ │ │ ├── application-test.properties │ │ │ ├── application.properties │ │ │ └── schema.sql │ └── test │ │ └── java │ │ └── com │ │ └── loiane │ │ ├── CrudSpringApplicationTests.java │ │ ├── config │ │ └── ValidationAdvice.java │ │ └── course │ │ ├── CourseControllerTest.java │ │ ├── CourseRepositoryTest.java │ │ ├── CourseServiceTest.java │ │ └── TestData.java └── test-coverage.md └── docs ├── form.jpeg ├── main.jpeg └── view.jpeg /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "maven" # See documentation for possible values 9 | directory: "crud-spring" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "npm" # See documentation for possible values 13 | directory: "crud-angular" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: CI with Maven and Angular 10 | 11 | on: 12 | push: 13 | branches: [ "main" ] 14 | pull_request: 15 | branches: [ "main" ] 16 | 17 | jobs: 18 | java: 19 | name: Maven Build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up JDK 21 24 | uses: actions/setup-java@v3 25 | with: 26 | java-version: '21' 27 | distribution: 'temurin' 28 | cache: maven 29 | - name: Build with Maven 30 | run: mvn -B package --file crud-spring/pom.xml 31 | angular: 32 | name: Angular Build 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout the source code 36 | uses: actions/checkout@v3 37 | - name: Setup Node.js 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: 20 41 | cache: 'npm' 42 | cache-dependency-path: crud-angular/package-lock.json 43 | - name: Install dependencies 44 | run: npm ci 45 | working-directory: crud-angular 46 | - name: Run tests 47 | run: npm run test:ci 48 | working-directory: crud-angular 49 | - name: Build 50 | run: npm run build 51 | working-directory: crud-angular 52 | -------------------------------------------------------------------------------- /.github/workflows/ng-update.yml: -------------------------------------------------------------------------------- 1 | name: "Update Angular Action" 2 | on: # when the action should run. Can also be a CRON or in response to external events. see https://git.io/JeBz1 3 | schedule: 4 | - cron: '30 5 * * 1,3,5' 5 | 6 | jobs: 7 | ngxUptodate: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Updating ng dependencies # the magic happens here ! 11 | uses: fast-facts/ng-update@v1 12 | with: 13 | base-branch: main 14 | project-path: ./crud-angular 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/settings.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "java", 5 | "name": "Spring Boot-CrudSpringApplication", 6 | "request": "launch", 7 | "cwd": "${workspaceFolder}", 8 | "mainClass": "com.loiane.CrudSpringApplication", 9 | "projectName": "crud-spring", 10 | "args": "", 11 | "envFile": "${workspaceFolder}/.env" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Loiane Groner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REST API with Spring Boot and Angular 2 | 3 | ![Build](https://github.com/loiane/crud-angular-spring/actions/workflows/build.yml/badge.svg?branch=main) 4 | 5 | CRUD Angular + Spring demonstrating Has-Many relationship, with tests. 6 | 7 | This API is to showcase, especially for beginners, what a basic CRUD API that's close to being Production-ready looks like. 8 | 9 | ## 💻 Tecnologies 10 | 11 | - Java 21 12 | - Spring Boot 3 (Spring 6) 13 | - Maven 14 | - JPA + Hibernate 15 | - MySQL 16 | - JUnit 5 + Mockito (back-end tests) 17 | - Angular v19 18 | - Angular Material 19 | - Karma + Jasmine (front-end tests) 20 | 21 | ## ⌨️ Editor / IDE 22 | 23 | - Visual Studio Code 24 | - Java Extensions [link](https://marketplace.visualstudio.com/items?itemName=loiane.java-spring-extension-pack) 25 | - Angular Extensions [link](https://marketplace.visualstudio.com/items?itemName=loiane.angular-extension-pack) 26 | 27 | ## Some functionalities available in the API 28 | 29 | - ✅ Java model class with validation 30 | - ✅ JPA repository 31 | - ✅ JPA Pagination 32 | - ✅ MySQL database (you can use any database of your preference) 33 | - ✅ Controller, Service, and Repository layers 34 | - ✅ Has-Many relationships (Course-Lessons) 35 | - ✅ Java 17 Records as DTO (Data Transfer Object) 36 | - ✅ Hibernate / Jakarta Validation 37 | - ✅ Unit tests for all layers (repository, service, controller) 38 | - ✅ Test coverage for tests 39 | - ✅ Spring Docs - Swagger (https://springdoc.org/v2/) 40 | 41 | ### Not implemented (maybe in a future version) 42 | 43 | - Security (Authorization and Authentication) 44 | - Caching 45 | - Data Compression 46 | - Throttling e Rate-limiting 47 | - Profiling the app 48 | - Test Containers 49 | - Docker Build 50 | 51 | ## Some functionalities available in the front end 52 | 53 | - ✅ Angular Standalone components (Angular v16+) 54 | - ✅ Angular Material components 55 | - ✅ List of all courses with pagination 56 | - ✅ Form to update/create courses with lessons (has-many - FormArray) 57 | - ✅ View only screen 58 | - ✅ TypedForms (Angular v14+) 59 | - ✅ Presentational x Smart Components 60 | - 🚧 Unit and Integration tests for components, services, pipes, guards 61 | 62 | ## Screenshots 63 | 64 | Main Page with Pagination 65 | 66 |

67 | Main Page 68 |

69 | 70 | Form with One to Many (Course-Lessons) 71 | 72 |

73 | Form Page 74 |

75 | 76 | View Page with YouTube Player 77 | 78 |

79 | View Page 80 |

81 | 82 | ## ❗️Executing the code locally 83 | 84 | ### Executing the back-end 85 | 86 | You need to have Java and Maven installed and configured locally. 87 | 88 | Open the `crud-spring` project in your favorite IDE as a Maven project and execute it as Spring Boot application. 89 | 90 | ### Executing the front-end 91 | 92 | You need to have Node.js / NPM installed locally. 93 | 94 | 1. Install all the required dependencies: 95 | 96 | ``` 97 | npm install 98 | ``` 99 | 100 | 2. Execute the project: 101 | 102 | ``` 103 | npm run start 104 | ``` 105 | 106 | This command will run the Angular project with a proxy to the Java server, without requiring CORS. 107 | 108 | Open your browser and access **http://localhost:4200** (Angular default port). 109 | 110 | #### Upgrading Angular 111 | 112 | ``` 113 | ng update 114 | ``` 115 | 116 | Then 117 | 118 | ``` 119 | ng update @angular/cli @angular/core @angular/cdk @angular/material @angular/youtube-player --force 120 | ``` 121 | -------------------------------------------------------------------------------- /crud-angular/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /crud-angular/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /crud-angular/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": [ 4 | "loiane.angular-extension-pack", 5 | "loiane.ts-extension-pack", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /crud-angular/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "pwa-chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /crud-angular/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.rulers": [ 4 | 90 5 | ], 6 | "editor.autoIndent": "full", 7 | "editor.formatOnType": false, 8 | "editor.formatOnPaste": false, 9 | "editor.formatOnSave": true, 10 | "editor.minimap.enabled": false, 11 | "explorer.openEditors.visible": 0, 12 | "files.trimTrailingWhitespace": true, 13 | "files.autoSave": "onFocusChange", 14 | "git.confirmSync": false, 15 | "git.enableSmartCommit": true, 16 | "html.autoClosingTags": true, 17 | "cSpell.words": [ 18 | "maxlength" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /crud-angular/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /crud-angular/README.md: -------------------------------------------------------------------------------- 1 | # CrudAngular 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli). 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Updating this project to higher versions 22 | 23 | Run `ng update @angular/cli @angular/core @angular/cdk @angular/material @angular/youtube-player @angular-eslint/schematics --force` to execute the automated update using Angular CLI. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /crud-angular/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "crud-angular": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/crud-angular", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": [ 24 | "zone.js" 25 | ], 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "assets": [ 29 | "src/favicon.ico", 30 | "src/assets" 31 | ], 32 | "styles": [ 33 | "src/styles.scss" 34 | ], 35 | "scripts": [] 36 | }, 37 | "configurations": { 38 | "production": { 39 | "budgets": [ 40 | { 41 | "type": "initial", 42 | "maximumWarning": "500kb", 43 | "maximumError": "1mb" 44 | }, 45 | { 46 | "type": "anyComponentStyle", 47 | "maximumWarning": "2kb", 48 | "maximumError": "4kb" 49 | } 50 | ], 51 | "outputHashing": "all" 52 | }, 53 | "development": { 54 | "buildOptimizer": false, 55 | "optimization": false, 56 | "vendorChunk": true, 57 | "extractLicenses": false, 58 | "sourceMap": true, 59 | "namedChunks": true 60 | } 61 | }, 62 | "defaultConfiguration": "production" 63 | }, 64 | "serve": { 65 | "builder": "@angular-devkit/build-angular:dev-server", 66 | "configurations": { 67 | "production": { 68 | "buildTarget": "crud-angular:build:production" 69 | }, 70 | "development": { 71 | "buildTarget": "crud-angular:build:development" 72 | } 73 | }, 74 | "defaultConfiguration": "development" 75 | }, 76 | "extract-i18n": { 77 | "builder": "@angular-devkit/build-angular:extract-i18n", 78 | "options": { 79 | "buildTarget": "crud-angular:build" 80 | } 81 | }, 82 | "test": { 83 | "builder": "@angular-devkit/build-angular:karma", 84 | "options": { 85 | "polyfills": [ 86 | "zone.js", 87 | "zone.js/testing" 88 | ], 89 | "tsConfig": "tsconfig.spec.json", 90 | "inlineStyleLanguage": "scss", 91 | "assets": [ 92 | "src/favicon.ico", 93 | "src/assets" 94 | ], 95 | "styles": [ 96 | "src/styles.scss" 97 | ], 98 | "scripts": [] 99 | } 100 | }, 101 | } 102 | } 103 | }, 104 | "cli": { 105 | "schematicCollections": [ 106 | ] 107 | }, 108 | "schematics": { 109 | "@schematics/angular:component": { 110 | "type": "component" 111 | }, 112 | "@schematics/angular:directive": { 113 | "type": "directive" 114 | }, 115 | "@schematics/angular:service": { 116 | "type": "service" 117 | }, 118 | "@schematics/angular:guard": { 119 | "typeSeparator": "." 120 | }, 121 | "@schematics/angular:interceptor": { 122 | "typeSeparator": "." 123 | }, 124 | "@schematics/angular:module": { 125 | "typeSeparator": "." 126 | }, 127 | "@schematics/angular:pipe": { 128 | "typeSeparator": "." 129 | }, 130 | "@schematics/angular:resolver": { 131 | "typeSeparator": "." 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /crud-angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crud-angular", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --proxy-config proxy.conf.js -o", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test --browsers=ChromeHeadless", 10 | "test:ci": "ng test --no-watch --no-progress --code-coverage --browsers=ChromeHeadless", 11 | "lint": "ng lint" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "^20.0.1", 16 | "@angular/cdk": "^20.0.2", 17 | "@angular/common": "^20.0.1", 18 | "@angular/compiler": "^20.0.1", 19 | "@angular/core": "^20.0.1", 20 | "@angular/forms": "^20.0.1", 21 | "@angular/material": "^20.0.2", 22 | "@angular/platform-browser": "^20.0.1", 23 | "@angular/platform-browser-dynamic": "^20.0.1", 24 | "@angular/router": "^20.0.1", 25 | "@angular/youtube-player": "^20.0.2", 26 | "rxjs": "~7.8.2", 27 | "tslib": "^2.7.0", 28 | "zone.js": "~0.15.1" 29 | }, 30 | "devDependencies": { 31 | "@angular-devkit/build-angular": "^20.0.1", 32 | "@angular/cli": "~20.0.1", 33 | "@angular/compiler-cli": "^20.0.1", 34 | "@types/jasmine": "~5.1.8", 35 | "jasmine-core": "~5.7.1", 36 | "karma": "~6.4.4", 37 | "karma-chrome-launcher": "~3.2.0", 38 | "karma-coverage": "~2.2.1", 39 | "karma-jasmine": "~5.1.0", 40 | "karma-jasmine-html-reporter": "~2.1.0", 41 | "typescript": "~5.8.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /crud-angular/proxy.conf.js: -------------------------------------------------------------------------------- 1 | const PROXY_CONFIG = [ 2 | { 3 | context: ["/api"], 4 | target: "http://localhost:8080/", 5 | secure: false, 6 | logLevel: "debug", 7 | }, 8 | ]; 9 | 10 | module.exports = PROXY_CONFIG; 11 | -------------------------------------------------------------------------------- /crud-angular/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | 5 | import { AppComponent } from './app.component'; 6 | 7 | describe('AppComponent', () => { 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [RouterTestingModule, MatToolbarModule, AppComponent] 11 | }).compileComponents(); 12 | }); 13 | 14 | it('should create the app', () => { 15 | const fixture = TestBed.createComponent(AppComponent); 16 | const app = fixture.componentInstance; 17 | expect(app).toBeTruthy(); 18 | }); 19 | 20 | it('should render title', () => { 21 | const fixture = TestBed.createComponent(AppComponent); 22 | fixture.detectChanges(); 23 | const compiled = fixture.nativeElement; 24 | expect(compiled.querySelector('h1').textContent).toContain('Courses App'); 25 | }); 26 | 27 | it('should render toolbar', () => { 28 | const fixture = TestBed.createComponent(AppComponent); 29 | fixture.detectChanges(); 30 | const compiled = fixture.nativeElement; 31 | expect(compiled.querySelector('mat-toolbar').textContent).toBeTruthy(); 32 | }); 33 | 34 | it('should render router-outlet', () => { 35 | const fixture = TestBed.createComponent(AppComponent); 36 | fixture.detectChanges(); 37 | const compiled = fixture.nativeElement; 38 | expect(compiled.querySelector('router-outlet')).toBeTruthy(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /crud-angular/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { RouterLink, RouterOutlet } from '@angular/router'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | imports: [MatToolbarModule, RouterLink, RouterOutlet], 8 | template: ` 9 | 10 |

Courses App

11 |
12 | 13 | ` 14 | }) 15 | export class AppComponent { } 16 | -------------------------------------------------------------------------------- /crud-angular/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const APP_ROUTES: Routes = [ 4 | { path: '', pathMatch: 'full', redirectTo: 'courses' }, 5 | { 6 | path: 'courses', 7 | loadChildren: () => import('./courses/courses.routes').then(m => m.COURSES_ROUTES) 8 | } 9 | ]; 10 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/course-view/course-view.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

{{ course.name }}

5 |
6 | 7 | 8 | 12 | {{ lesson.name }} 13 | 14 | 15 | 16 |
17 | 18 |
19 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/course-view/course-view.component.scss: -------------------------------------------------------------------------------- 1 | .example-container { 2 | position: absolute; 3 | top: 60px; 4 | bottom: 60px; 5 | left: 0; 6 | right: 0; 7 | } 8 | 9 | .example-sidenav-content { 10 | display: flex; 11 | height: 100%; 12 | align-items: center; 13 | justify-content: center; 14 | } 15 | 16 | .example-sidenav { 17 | padding: 20px; 18 | } 19 | 20 | .list-with-border { 21 | border: 1px solid #f2f2f2; 22 | } 23 | 24 | .selected-lesson { 25 | font-style: italic; 26 | font-weight: bold !important; 27 | background-color: whitesmoke; 28 | } 29 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/course-view/course-view.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatListModule } from '@angular/material/list'; 5 | import { MatSidenavModule } from '@angular/material/sidenav'; 6 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 7 | import { ActivatedRoute } from '@angular/router'; 8 | import { YouTubePlayerModule } from '@angular/youtube-player'; 9 | 10 | import { coursesMock } from './../../services/courses.mock'; 11 | import { CourseViewComponent } from './course-view.component'; 12 | 13 | /* tslint:disable:no-unused-variable */ 14 | describe('CourseViewComponent', () => { 15 | let component: CourseViewComponent; 16 | let fixture: ComponentFixture; 17 | let activatedRouteMock: any; 18 | 19 | beforeEach(waitForAsync(() => { 20 | activatedRouteMock = { 21 | snapshot: { 22 | data: { 23 | course: coursesMock[0] 24 | } 25 | } 26 | }; 27 | TestBed.configureTestingModule({ 28 | providers: [{ provide: ActivatedRoute, useValue: activatedRouteMock }], 29 | imports: [ 30 | CourseViewComponent, 31 | NoopAnimationsModule, 32 | MatSidenavModule, 33 | MatButtonModule, 34 | MatListModule, 35 | YouTubePlayerModule 36 | ], 37 | schemas: [NO_ERRORS_SCHEMA] 38 | }).compileComponents(); 39 | })); 40 | 41 | beforeEach(() => { 42 | fixture = TestBed.createComponent(CourseViewComponent); 43 | component = fixture.componentInstance; 44 | fixture.detectChanges(); 45 | }); 46 | 47 | it('should create', () => { 48 | expect(component).toBeTruthy(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/course-view/course-view.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | ChangeDetectionStrategy, 4 | ChangeDetectorRef, 5 | Component, 6 | ElementRef, 7 | NO_ERRORS_SCHEMA, 8 | OnInit, 9 | ViewChild 10 | } from '@angular/core'; 11 | import { MatButtonModule } from '@angular/material/button'; 12 | import { MatSidenavModule } from '@angular/material/sidenav'; 13 | import { ActivatedRoute } from '@angular/router'; 14 | import { MatListModule } from '@angular/material/list'; 15 | 16 | import { Course } from '../../model/course'; 17 | import { NgFor, NgIf } from '@angular/common'; 18 | import { Lesson } from '../../model/lesson'; 19 | import { YouTubePlayerModule } from '@angular/youtube-player'; 20 | 21 | @Component({ 22 | selector: 'app-course-view', 23 | templateUrl: './course-view.component.html', 24 | styleUrls: ['./course-view.component.scss'], 25 | changeDetection: ChangeDetectionStrategy.OnPush, 26 | imports: [ 27 | NgIf, 28 | NgFor, 29 | MatSidenavModule, 30 | MatButtonModule, 31 | MatListModule, 32 | YouTubePlayerModule 33 | ], 34 | schemas: [NO_ERRORS_SCHEMA] 35 | }) 36 | export class CourseViewComponent implements OnInit, AfterViewInit { 37 | course!: Course; 38 | selectedLesson!: Lesson; 39 | videoHeight!: number; 40 | videoWidth!: number; 41 | 42 | @ViewChild('youTubePlayer') youTubePlayer!: ElementRef; 43 | 44 | constructor( 45 | private route: ActivatedRoute, 46 | private changeDetectorRef: ChangeDetectorRef) { } 47 | 48 | ngOnInit() { 49 | this.course = this.route.snapshot.data['course']; 50 | if (this.course.lessons) this.selectedLesson = this.course.lessons[0]; 51 | 52 | // This code loads the IFrame Player API code asynchronously, according to the instructions at 53 | // https://developers.google.com/youtube/iframe_api_reference#Getting_Started 54 | const tag = document.createElement('script'); 55 | tag.src = 'https://www.youtube.com/iframe_api'; 56 | document.body.appendChild(tag); 57 | } 58 | 59 | ngAfterViewInit(): void { 60 | this.onResize(); 61 | window.addEventListener('resize', this.onResize.bind(this)); 62 | } 63 | 64 | onResize(): void { 65 | this.videoWidth = this.youTubePlayer.nativeElement.clientWidth * 0.9; 66 | this.videoHeight = this.videoWidth * 0.6; 67 | this.changeDetectorRef.detectChanges(); 68 | } 69 | 70 | display(lesson: Lesson) { 71 | this.selectedLesson = lesson; 72 | } 73 | 74 | displaySelectedLesson(lesson: Lesson) { 75 | return this.selectedLesson === lesson; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/courses-list/courses-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Course 5 | {{ course.name }} 8 | 9 | 10 | 11 | 12 | Category 13 | 14 | {{ course.category }} 15 | {{ 16 | course.category | category 17 | }} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 32 | 33 | 34 | 35 | 42 | 43 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/courses-list/courses-list.component.scss: -------------------------------------------------------------------------------- 1 | .mat-table { 2 | overflow: auto; 3 | max-height: 600px; 4 | } 5 | 6 | .action-column { 7 | flex: 0 0 125px; 8 | } 9 | 10 | .action-column-header { 11 | margin: 0 auto; 12 | } 13 | 14 | .cdk-header-cell { 15 | font-weight: bold; 16 | } 17 | 18 | a { 19 | color: #536dfe; 20 | cursor: pointer; 21 | } 22 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/courses-list/courses-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { coursesMock } from './../../services/courses.mock'; 5 | import { CoursesListComponent } from './courses-list.component'; 6 | 7 | describe('CoursesListComponent', () => { 8 | let component: CoursesListComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | imports: [CoursesListComponent], 14 | schemas: [NO_ERRORS_SCHEMA] 15 | }).compileComponents(); 16 | 17 | fixture = TestBed.createComponent(CoursesListComponent); 18 | component = fixture.componentInstance; 19 | component.courses = coursesMock; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | 27 | it('should check if displayedColumns have value', () => { 28 | expect(component.displayedColumns).toBeTruthy(); 29 | expect(component.displayedColumns.length).toBe(3); 30 | }); 31 | 32 | it('should check if courses have value', () => { 33 | expect(component.courses).toBeTruthy(); 34 | expect(component.courses.length).toBe(coursesMock.length); 35 | }); 36 | 37 | it('Should emit event when click on details', () => { 38 | spyOn(component.details, 'emit'); 39 | const course = coursesMock[0]; 40 | component.onDetails(course); 41 | expect(component.details.emit).toHaveBeenCalledWith(course); 42 | }); 43 | 44 | it('Should emit event when click on edit', () => { 45 | spyOn(component.edit, 'emit'); 46 | const course = coursesMock[0]; 47 | component.onEdit(course); 48 | expect(component.edit.emit).toHaveBeenCalledWith(course); 49 | }); 50 | 51 | it('Should emit event when click on remove', () => { 52 | spyOn(component.remove, 'emit'); 53 | const course = coursesMock[0]; 54 | component.onRemove(course); 55 | expect(component.remove.emit).toHaveBeenCalledWith(course); 56 | }); 57 | 58 | it('Should emit event when click on add', () => { 59 | spyOn(component.add, 'emit'); 60 | component.onAdd(); 61 | expect(component.add.emit).toHaveBeenCalled(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/components/courses-list/courses-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | import { Course } from '../../model/course'; 4 | import { CategoryPipe } from '../../../shared/pipes/category.pipe'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatIconModule } from '@angular/material/icon'; 7 | import { MatTableModule } from '@angular/material/table'; 8 | 9 | @Component({ 10 | selector: 'app-courses-list', 11 | templateUrl: './courses-list.component.html', 12 | styleUrls: ['./courses-list.component.scss'], 13 | imports: [MatTableModule, MatIconModule, MatButtonModule, CategoryPipe] 14 | }) 15 | export class CoursesListComponent { 16 | @Input() courses: Course[] = []; 17 | @Output() details: EventEmitter = new EventEmitter(false); 18 | @Output() edit: EventEmitter = new EventEmitter(false); 19 | @Output() remove: EventEmitter = new EventEmitter(false); 20 | @Output() add: EventEmitter = new EventEmitter(false); 21 | @Output() view: EventEmitter = new EventEmitter(false); 22 | 23 | readonly displayedColumns = ['name', 'category', 'actions']; 24 | 25 | onDetails(record: Course) { 26 | this.details.emit(record); 27 | } 28 | 29 | onAdd() { 30 | this.add.emit(true); 31 | } 32 | 33 | onEdit(record: Course) { 34 | this.edit.emit(record); 35 | } 36 | 37 | onRemove(record: Course) { 38 | this.remove.emit(record); 39 | } 40 | 41 | onView(record: Course) { 42 | this.view.emit(record); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/course-form/course-form.component.html: -------------------------------------------------------------------------------- 1 | 2 | Course Details 3 | 4 | 5 |
6 | 7 | Name 8 | 9 | {{ name.value.length }} / 100 10 | 11 | {{ getErrorMessage('name') }} 12 | 13 | 14 | 15 | 16 | Category 17 | 18 | 19 | Front-End 20 | Back-End 21 | 22 | {{ 23 | getErrorMessage('category') 24 | }} 25 | 26 | 27 | 28 | Lessons 29 |
30 | 33 |
34 | 35 | 38 | At least one lesson is required. 39 | 40 | 41 | 45 | 46 | 55 | 65 | 70 | 71 |
47 | 48 | Lesson Name 49 | 50 | 51 | {{ getLessonErrorMessage('name', i) }} 52 | 53 | 54 | 56 | 57 | URL 58 | https://youtu.be/ 59 | 60 | 61 | {{ getLessonErrorMessage('youtubeUrl', i) }} 62 | 63 | 64 | 66 | 69 |
72 |
73 |
74 | 75 | 76 | 79 | 82 | 83 |
84 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/course-form/course-form.component.scss: -------------------------------------------------------------------------------- 1 | mat-card { 2 | max-width: 80%; 3 | margin: 2em auto; 4 | text-align: center; 5 | } 6 | 7 | .full-width { 8 | width: 100%; 9 | } 10 | 11 | .min-width { 12 | width: 100%; 13 | min-width: 150px; 14 | } 15 | 16 | .btn-space { 17 | margin-left: 5px; 18 | } 19 | 20 | .actions-center { 21 | justify-content: center; 22 | } 23 | 24 | .form-array-error { 25 | text-align: left; 26 | } 27 | 28 | .lessons-toolbar { 29 | background: #E8EAF6; 30 | height: 50px; 31 | margin-top: 10px; 32 | margin-bottom: 10px; 33 | font-weight: 500; 34 | } 35 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/course-form/course-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { HarnessLoader } from '@angular/cdk/testing'; 2 | import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; 3 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 4 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 5 | import { ReactiveFormsModule, UntypedFormArray } from '@angular/forms'; 6 | import { MatDialogModule } from '@angular/material/dialog'; 7 | import { MatDialogHarness } from '@angular/material/dialog/testing'; 8 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 9 | import { ActivatedRoute } from '@angular/router'; 10 | import { RouterTestingModule } from '@angular/router/testing'; 11 | import { of, throwError } from 'rxjs'; 12 | 13 | import { Course } from '../../model/course'; 14 | import { coursesMock, coursesPageMock } from '../../services/courses.mock'; 15 | import { CoursesService } from '../../services/courses.service'; 16 | import { CourseFormComponent } from './course-form.component'; 17 | 18 | describe('CourseFormComponent', () => { 19 | let component: CourseFormComponent; 20 | let fixture: ComponentFixture; 21 | let courseServiceSpy: jasmine.SpyObj; 22 | let activatedRouteMock: any; 23 | let loader: HarnessLoader; 24 | 25 | beforeEach(async () => { 26 | courseServiceSpy = jasmine.createSpyObj('CoursesService', { 27 | list: of(coursesPageMock), 28 | loadById: undefined, 29 | save: of(coursesMock[0]), 30 | remove: of(coursesMock[0]) 31 | }); 32 | activatedRouteMock = { 33 | snapshot: { 34 | data: { 35 | course: coursesMock[0] 36 | } 37 | } 38 | }; 39 | 40 | await TestBed.configureTestingModule({ 41 | imports: [ 42 | MatDialogModule, 43 | ReactiveFormsModule, 44 | RouterTestingModule.withRoutes([]), 45 | NoopAnimationsModule, 46 | CourseFormComponent 47 | ], 48 | providers: [ 49 | { provide: CoursesService, useValue: courseServiceSpy }, 50 | { provide: ActivatedRoute, useValue: activatedRouteMock } 51 | ], 52 | schemas: [NO_ERRORS_SCHEMA] 53 | }).compileComponents(); 54 | 55 | fixture = TestBed.createComponent(CourseFormComponent); 56 | component = fixture.componentInstance; 57 | fixture.detectChanges(); 58 | loader = TestbedHarnessEnvironment.documentRootLoader(fixture); 59 | }); 60 | 61 | afterEach(() => { 62 | TestBed.resetTestingModule(); 63 | }); 64 | 65 | it('should create', () => { 66 | expect(component).toBeTruthy(); 67 | }); 68 | 69 | it('should have a form with 4 fields', () => { 70 | expect(component.form.contains('_id')).toBeTruthy(); 71 | expect(component.form.contains('name')).toBeTruthy(); 72 | expect(component.form.contains('category')).toBeTruthy(); 73 | expect(component.form.contains('lessons')).toBeTruthy(); 74 | }); 75 | 76 | it('should have a form with a `name` field and 3 validators', () => { 77 | const nameControl = component.form.get('name'); 78 | nameControl?.setValue(''); 79 | expect(nameControl?.valid).toBeFalsy(); 80 | expect(nameControl?.errors?.['required']).toBeTruthy(); 81 | nameControl?.setValue('a'); 82 | expect(nameControl?.errors?.['minlength']).toBeTruthy(); 83 | nameControl?.setValue('a'.repeat(101)); 84 | expect(nameControl?.errors?.['maxlength']).toBeTruthy(); 85 | nameControl?.setValue('a'.repeat(5)); 86 | expect(nameControl?.errors).toBeNull(); 87 | }); 88 | 89 | it('should have a form with a `category` field and one validator', () => { 90 | const categoryControl = component.form.get('category'); 91 | categoryControl?.setValue(''); 92 | expect(categoryControl?.valid).toBeFalsy(); 93 | expect(categoryControl?.errors?.['required']).toBeTruthy(); 94 | categoryControl?.setValue('a'); 95 | expect(categoryControl?.errors).toBeNull(); 96 | }); 97 | 98 | it('should have a form with a `lessons` field and one validator', () => { 99 | component.removeLesson(0); 100 | const lessonsControl = component.form.get('lessons'); 101 | expect(lessonsControl?.valid).toBeFalsy(); 102 | expect(lessonsControl?.errors?.['required']).toBeTruthy(); 103 | component.addLesson(); 104 | expect(lessonsControl?.errors).toBeNull(); 105 | }); 106 | 107 | it('should return the error message then the `name` field is invalid', () => { 108 | const nameControl = component.form.get('name'); 109 | nameControl?.setValue(''); 110 | expect(component.getErrorMessage('name')).toEqual('Field is required.'); 111 | nameControl?.setValue('a'); 112 | expect(component.getErrorMessage('name')).toEqual( 113 | 'Field cannot be less than 5 characters long.' 114 | ); 115 | nameControl?.setValue('a'.repeat(101)); 116 | expect(component.getErrorMessage('name')).toEqual( 117 | 'Field cannot be more than 100 characters long.' 118 | ); 119 | }); 120 | 121 | it('should return the error message then the `category` field is invalid', () => { 122 | const categoryControl = component.form.get('category'); 123 | categoryControl?.setValue(''); 124 | expect(component.getErrorMessage('category')).toEqual('Field is required.'); 125 | }); 126 | 127 | it('should return the error message then the `lessons.name` field is invalid', () => { 128 | const formArray = component.form.get('lessons') as UntypedFormArray; 129 | const lessonNameControl = formArray.controls[0].get('name'); 130 | lessonNameControl?.setValue(''); 131 | expect(component.getLessonErrorMessage('name', 0)).toEqual('Field is required.'); 132 | 133 | lessonNameControl?.setValue('a'); 134 | expect(component.getLessonErrorMessage('name', 0)).toEqual( 135 | 'Field cannot be less than 5 characters long.' 136 | ); 137 | 138 | lessonNameControl?.setValue('a'.repeat(101)); 139 | expect(component.getLessonErrorMessage('name', 0)).toEqual( 140 | 'Field cannot be more than 100 characters long.' 141 | ); 142 | }); 143 | 144 | it('should return the error message then the `lessons.youtubeUrl` field is invalid', () => { 145 | const formArray = component.form.get('lessons') as UntypedFormArray; 146 | const lessonUrlControl = formArray.controls[0].get('youtubeUrl'); 147 | lessonUrlControl?.setValue(''); 148 | expect(component.getLessonErrorMessage('youtubeUrl', 0)).toEqual( 149 | 'Field is required.' 150 | ); 151 | 152 | lessonUrlControl?.setValue('a'); 153 | expect(component.getLessonErrorMessage('youtubeUrl', 0)).toEqual( 154 | 'Field cannot be less than 10 characters long.' 155 | ); 156 | 157 | lessonUrlControl?.setValue('a'.repeat(101)); 158 | expect(component.getLessonErrorMessage('youtubeUrl', 0)).toEqual( 159 | 'Field cannot be more than 11 characters long.' 160 | ); 161 | }); 162 | 163 | it('should call `CoursesService.save` when the form is submitted', () => { 164 | const saveSpy = spyOn(component, 'onSubmit'); 165 | const saveButton = fixture.debugElement.nativeElement.querySelector( 166 | 'button[type="submit"]' 167 | ); 168 | saveButton.dispatchEvent(new Event('click')); 169 | expect(saveSpy).toHaveBeenCalled(); 170 | }); 171 | 172 | it('should back the location when cancel is clicked', () => { 173 | const cancelSpy = spyOn(component, 'onCancel'); 174 | const cancelButton = fixture.debugElement.nativeElement.querySelector( 175 | 'button[type="button"]' 176 | ); 177 | cancelButton.dispatchEvent(new Event('click')); 178 | expect(cancelSpy).toHaveBeenCalled(); 179 | }); 180 | 181 | it('should call `CoursesService.save` when onSubmit is called and form is valid', () => { 182 | component.form.setValue(coursesMock[0]); 183 | component.onSubmit(); 184 | expect(courseServiceSpy.save).toHaveBeenCalled(); 185 | }); 186 | 187 | it('should call formUtils.validateAllFormFields when onSubmit is called and form is invalid', () => { 188 | const validateAllFormFieldsSpy = spyOn( 189 | component.formUtils, 190 | 'validateAllFormFields' 191 | ).and.callThrough(); 192 | activatedRouteMock.snapshot.data.course = { 193 | _id: '', 194 | name: '', 195 | category: '', 196 | lessons: undefined 197 | } as Course; 198 | component.ngOnInit(); 199 | component.onSubmit(); 200 | expect(validateAllFormFieldsSpy).toHaveBeenCalled(); 201 | }); 202 | 203 | it('should call onError and open dialog when onSubmit save fails', async () => { 204 | courseServiceSpy.save.and.returnValue(throwError(() => new Error('test'))); 205 | component.form.setValue(coursesMock[0]); 206 | component.onSubmit(); 207 | const dialogs = await loader.getAllHarnesses(MatDialogHarness); 208 | expect(dialogs.length).toBe(1); 209 | await dialogs[0].close(); // close the dialog 210 | }); 211 | 212 | it('should load empty form when no course is passed', () => { 213 | activatedRouteMock.snapshot.data.course = { 214 | _id: '', 215 | name: '', 216 | category: '', 217 | lessons: undefined 218 | } as Course; 219 | component.ngOnInit(); 220 | expect(component.form.value).toEqual({ 221 | _id: '', 222 | name: '', 223 | category: '', 224 | lessons: [{ _id: '', name: '', youtubeUrl: '' }] 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/course-form/course-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Location, NgFor, NgIf } from '@angular/common'; 2 | import { Component, OnInit } from '@angular/core'; 3 | import { 4 | FormGroup, 5 | NonNullableFormBuilder, 6 | ReactiveFormsModule, 7 | UntypedFormArray, 8 | Validators 9 | } from '@angular/forms'; 10 | import { MatButtonModule } from '@angular/material/button'; 11 | import { MatCardModule } from '@angular/material/card'; 12 | import { MatOptionModule } from '@angular/material/core'; 13 | import { MatDialog, MatDialogModule } from '@angular/material/dialog'; 14 | import { MatFormFieldModule } from '@angular/material/form-field'; 15 | import { MatIconModule } from '@angular/material/icon'; 16 | import { MatInputModule } from '@angular/material/input'; 17 | import { MatSelectModule } from '@angular/material/select'; 18 | import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; 19 | import { MatToolbarModule } from '@angular/material/toolbar'; 20 | import { ActivatedRoute } from '@angular/router'; 21 | 22 | import { Course } from '../../model/course'; 23 | import { Lesson } from '../../model/lesson'; 24 | import { CoursesService } from '../../services/courses.service'; 25 | import { ErrorDialogComponent } from './../../../shared/components/error-dialog/error-dialog.component'; 26 | import { FormUtilsService } from './../../../shared/services/form-utils.service'; 27 | 28 | @Component({ 29 | selector: 'app-course-form', 30 | templateUrl: './course-form.component.html', 31 | styleUrls: ['./course-form.component.scss'], 32 | imports: [ 33 | MatCardModule, 34 | MatToolbarModule, 35 | ReactiveFormsModule, 36 | MatFormFieldModule, 37 | MatInputModule, 38 | NgIf, 39 | MatSelectModule, 40 | MatOptionModule, 41 | MatButtonModule, 42 | MatIconModule, 43 | MatSnackBarModule, 44 | MatDialogModule, 45 | NgFor 46 | ] 47 | }) 48 | export class CourseFormComponent implements OnInit { 49 | form!: FormGroup; 50 | 51 | constructor( 52 | private formBuilder: NonNullableFormBuilder, 53 | private service: CoursesService, 54 | private snackBar: MatSnackBar, 55 | private dialog: MatDialog, 56 | private location: Location, 57 | private route: ActivatedRoute, 58 | public formUtils: FormUtilsService 59 | ) { } 60 | 61 | ngOnInit(): void { 62 | const course: Course = this.route.snapshot.data['course']; 63 | this.form = this.formBuilder.group({ 64 | _id: [course._id], 65 | name: [ 66 | course.name, 67 | [Validators.required, Validators.minLength(5), Validators.maxLength(100)] 68 | ], 69 | category: [course.category, [Validators.required]], 70 | lessons: this.formBuilder.array(this.retrieveLessons(course), Validators.required) 71 | }); 72 | } 73 | 74 | private retrieveLessons(course: Course) { 75 | const lessons = []; 76 | if (course?.lessons) { 77 | course.lessons.forEach(lesson => lessons.push(this.createLesson(lesson))); 78 | } else { 79 | lessons.push(this.createLesson()); 80 | } 81 | return lessons; 82 | } 83 | 84 | private createLesson(lesson: Lesson = { _id: '', name: '', youtubeUrl: '' }) { 85 | return this.formBuilder.group({ 86 | _id: [lesson._id], 87 | name: [ 88 | lesson.name, 89 | [Validators.required, Validators.minLength(5), Validators.maxLength(100)] 90 | ], 91 | youtubeUrl: [ 92 | lesson.youtubeUrl, 93 | [Validators.required, Validators.minLength(10), Validators.maxLength(11)] 94 | ] 95 | }); 96 | } 97 | 98 | getLessonFormArray() { 99 | return (this.form.get('lessons')).controls; 100 | } 101 | 102 | getErrorMessage(fieldName: string): string { 103 | return this.formUtils.getFieldErrorMessage(this.form, fieldName); 104 | } 105 | 106 | getLessonErrorMessage(fieldName: string, index: number) { 107 | return this.formUtils.getFieldFormArrayErrorMessage( 108 | this.form, 109 | 'lessons', 110 | fieldName, 111 | index 112 | ); 113 | } 114 | 115 | addLesson(): void { 116 | const lessons = this.form.get('lessons') as UntypedFormArray; 117 | lessons.push(this.createLesson()); 118 | } 119 | 120 | removeLesson(index: number) { 121 | const lessons = this.form.get('lessons') as UntypedFormArray; 122 | lessons.removeAt(index); 123 | } 124 | 125 | onSubmit() { 126 | if (this.form.valid) { 127 | this.service.save(this.form.value as Course).subscribe({ 128 | next: () => this.onSuccess(), 129 | error: () => this.onError() 130 | }); 131 | } else { 132 | this.formUtils.validateAllFormFields(this.form); 133 | } 134 | } 135 | 136 | onCancel() { 137 | this.location.back(); 138 | } 139 | 140 | private onSuccess() { 141 | this.snackBar.open('Course saved successfully!', '', { duration: 5000 }); 142 | this.onCancel(); 143 | } 144 | 145 | private onError() { 146 | this.dialog.open(ErrorDialogComponent, { 147 | data: 'Error saving course.' 148 | }); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/courses/courses.component.html: -------------------------------------------------------------------------------- 1 | 2 | Available Courses 3 | 4 |
5 | 11 | 12 | 20 |
21 | 22 |
23 | 24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/courses/courses.component.scss: -------------------------------------------------------------------------------- 1 | mat-card { 2 | max-width: 80%; 3 | margin: 2em auto; 4 | text-align: center; 5 | } 6 | 7 | .loading-spinner { 8 | padding: 25px; 9 | background: rgba(0, 0, 0, 0.15); 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/courses/courses.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { HarnessLoader } from '@angular/cdk/testing'; 2 | import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; 3 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 4 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 5 | import { MatDialog, MatDialogModule } from '@angular/material/dialog'; 6 | import { MatDialogHarness } from '@angular/material/dialog/testing'; 7 | import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; 8 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 9 | import { ActivatedRoute, Router } from '@angular/router'; 10 | import { of, throwError } from 'rxjs'; 11 | 12 | import { ConfirmationDialogComponent } from '../../../shared/components/confirmation-dialog/confirmation-dialog.component'; 13 | import { ErrorDialogComponent } from '../../../shared/components/error-dialog/error-dialog.component'; 14 | import { coursesPageMock } from '../../services/courses.mock'; 15 | import { CoursesService } from '../../services/courses.service'; 16 | import { CoursesComponent } from './courses.component'; 17 | 18 | describe('CoursesComponent', () => { 19 | let component: CoursesComponent; 20 | let fixture: ComponentFixture; 21 | let courseServiceSpy: jasmine.SpyObj; 22 | let routerSpy: jasmine.SpyObj; 23 | let activatedRouteSpy: jasmine.SpyObj; 24 | let loader: HarnessLoader; 25 | let snackBarSpy: jasmine.SpyObj; 26 | 27 | beforeEach(async () => { 28 | courseServiceSpy = jasmine.createSpyObj('CoursesService', { 29 | list: of(coursesPageMock), 30 | loadById: undefined, 31 | save: undefined, 32 | remove: of(coursesPageMock.courses[0]) 33 | }); 34 | routerSpy = jasmine.createSpyObj(['navigate']); 35 | activatedRouteSpy = jasmine.createSpyObj('ActivatedRoute', ['']); 36 | snackBarSpy = jasmine.createSpyObj(['open']); 37 | 38 | await TestBed.configureTestingModule({ 39 | imports: [ 40 | MatDialogModule, 41 | MatSnackBarModule, 42 | NoopAnimationsModule, 43 | ErrorDialogComponent, 44 | ConfirmationDialogComponent, 45 | CoursesComponent 46 | ], 47 | providers: [ 48 | { provide: CoursesService, useValue: courseServiceSpy }, 49 | { provide: Router, useValue: routerSpy }, 50 | { provide: ActivatedRoute, useValue: activatedRouteSpy }, 51 | { provide: MatDialog }, 52 | { provide: MatSnackBar, useValue: snackBarSpy } 53 | ], 54 | schemas: [NO_ERRORS_SCHEMA] 55 | }).compileComponents(); 56 | 57 | fixture = TestBed.createComponent(CoursesComponent); 58 | component = fixture.componentInstance; 59 | }); 60 | 61 | it('should create', () => { 62 | expect(component).toBeTruthy(); 63 | }); 64 | 65 | it('should create and call ngOnInit', () => { 66 | courseServiceSpy.list.and.returnValue(of(coursesPageMock)); 67 | // will trigger ngOnInit 68 | fixture.detectChanges(); 69 | expect(component).toBeTruthy(); 70 | component.courses$?.subscribe(result => { 71 | expect(result).toEqual(coursesPageMock); 72 | }); 73 | }); 74 | 75 | it('should display error dialog when courses are not loaded', async () => { 76 | courseServiceSpy.list.and.returnValue(throwError(() => new Error('test'))); 77 | spyOn(component, 'onError'); 78 | fixture.detectChanges(); // ngOnInit 79 | expect(component.onError).toHaveBeenCalled(); 80 | }); 81 | 82 | it('should navigate to new screen when onAdd', () => { 83 | component.onAdd(); // trigger action 84 | const spy = routerSpy.navigate as jasmine.Spy; 85 | expect(spy).toHaveBeenCalledTimes(1); 86 | const navArgs = spy.calls.first().args; 87 | expect(navArgs[0]).toEqual(['new']); 88 | expect(navArgs[1]).toEqual({ relativeTo: activatedRouteSpy }); 89 | }); 90 | 91 | it('should navigate to form screen when onEdit', () => { 92 | const course = { _id: '1', name: '', category: '' }; 93 | component.onEdit(course); // trigger action 94 | const spy = routerSpy.navigate as jasmine.Spy; 95 | expect(spy).toHaveBeenCalledTimes(1); 96 | const navArgs = spy.calls.first().args; 97 | expect(navArgs[0]).toEqual(['edit', course._id]); 98 | expect(navArgs[1]).toEqual({ relativeTo: activatedRouteSpy }); 99 | }); 100 | 101 | it('should open ErrorDialogComponent onError', async () => { 102 | loader = TestbedHarnessEnvironment.documentRootLoader(fixture); 103 | fixture.detectChanges(); 104 | component.onError('Error'); 105 | const dialogs = await loader.getAllHarnesses(MatDialogHarness); 106 | expect(dialogs.length).toBe(1); 107 | dialogs[0].close(); // close so karma can see all results 108 | }); 109 | 110 | it('should open ConfirmationDialogComponent onRemove', async () => { 111 | loader = TestbedHarnessEnvironment.documentRootLoader(fixture); 112 | fixture.detectChanges(); 113 | component.onRemove(coursesPageMock.courses[0]); 114 | const dialogs = await loader.getAllHarnesses(MatDialogHarness); 115 | expect(dialogs.length).toBe(1); 116 | dialogs[0].close(); // close so karma can see all results 117 | }); 118 | 119 | it('should remove course and display success message', async () => { 120 | loader = TestbedHarnessEnvironment.documentRootLoader(fixture); 121 | fixture.detectChanges(); 122 | spyOn(component, 'refresh'); 123 | component.onRemove(coursesPageMock.courses[0]); 124 | const dialogs = await loader.getAllHarnesses(MatDialogHarness); 125 | expect(dialogs.length).toBe(1); 126 | const button = document.getElementById('yesBtn'); 127 | await button?.click(); 128 | expect(courseServiceSpy.remove).toHaveBeenCalledTimes(1); 129 | expect(component.refresh).toHaveBeenCalledTimes(1); 130 | //expect(snackBarSpy.open as jasmine.Spy).toHaveBeenCalledTimes(1); 131 | }); 132 | 133 | it('should not remove course if No button was clicked', async () => { 134 | loader = TestbedHarnessEnvironment.documentRootLoader(fixture); 135 | fixture.detectChanges(); 136 | component.onRemove(coursesPageMock.courses[0]); 137 | const dialogs = await loader.getAllHarnesses(MatDialogHarness); 138 | expect(dialogs.length).toBe(1); 139 | const button = document.getElementById('noBtn'); 140 | await button?.click(); 141 | expect(courseServiceSpy.remove).toHaveBeenCalledTimes(0); 142 | }); 143 | 144 | it('should display error if course could not be removed', async () => { 145 | courseServiceSpy.remove.and.returnValue(throwError(() => new Error('test'))); 146 | loader = TestbedHarnessEnvironment.documentRootLoader(fixture); 147 | spyOn(component, 'onError'); 148 | fixture.detectChanges(); 149 | component.onRemove(coursesPageMock.courses[0]); 150 | const dialogs = await loader.getAllHarnesses(MatDialogHarness); 151 | expect(dialogs.length).toBe(1); 152 | const button = document.getElementById('yesBtn'); 153 | await button?.click(); 154 | expect(courseServiceSpy.remove).toHaveBeenCalledTimes(1); 155 | expect(component.onError).toHaveBeenCalledTimes(1); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/containers/courses/courses.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe, NgIf } from '@angular/common'; 2 | import { Component, OnInit, ViewChild } from '@angular/core'; 3 | import { MatCardModule } from '@angular/material/card'; 4 | import { MatDialog, MatDialogModule } from '@angular/material/dialog'; 5 | import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; 6 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 7 | import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; 8 | import { MatToolbarModule } from '@angular/material/toolbar'; 9 | import { ActivatedRoute, Router } from '@angular/router'; 10 | import { catchError, Observable, of, tap } from 'rxjs'; 11 | 12 | import { ConfirmationDialogComponent } from '../../../shared/components/confirmation-dialog/confirmation-dialog.component'; 13 | import { ErrorDialogComponent } from '../../../shared/components/error-dialog/error-dialog.component'; 14 | import { CoursesListComponent } from '../../components/courses-list/courses-list.component'; 15 | import { Course } from '../../model/course'; 16 | import { CoursePage } from '../../model/course-page'; 17 | import { CoursesService } from '../../services/courses.service'; 18 | 19 | @Component({ 20 | selector: 'app-courses', 21 | templateUrl: './courses.component.html', 22 | styleUrls: ['./courses.component.scss'], 23 | imports: [ 24 | MatCardModule, 25 | MatToolbarModule, 26 | NgIf, 27 | CoursesListComponent, 28 | MatProgressSpinnerModule, 29 | MatSnackBarModule, 30 | MatDialogModule, 31 | MatPaginatorModule, 32 | AsyncPipe 33 | ] 34 | }) 35 | export class CoursesComponent implements OnInit { 36 | courses$: Observable | null = null; 37 | 38 | pageIndex = 0; 39 | pageSize = 10; 40 | 41 | @ViewChild(MatPaginator) paginator!: MatPaginator; 42 | 43 | constructor( 44 | private coursesService: CoursesService, 45 | public dialog: MatDialog, 46 | private router: Router, 47 | private route: ActivatedRoute, 48 | private snackBar: MatSnackBar 49 | ) { } 50 | 51 | ngOnInit() { 52 | this.refresh(); 53 | } 54 | 55 | refresh(pageEvent: PageEvent = { length: 0, pageIndex: 0, pageSize: 10 }) { 56 | this.courses$ = this.coursesService 57 | .list(pageEvent.pageIndex, pageEvent.pageSize) 58 | .pipe( 59 | tap(() => { 60 | this.pageIndex = pageEvent.pageIndex; 61 | this.pageSize = pageEvent.pageSize; 62 | }), 63 | catchError(() => { 64 | this.onError('Error loading courses.'); 65 | return of({ courses: [], totalElements: 0 } as CoursePage); 66 | }) 67 | ); 68 | } 69 | 70 | onError(errorMsg: string) { 71 | this.dialog.open(ErrorDialogComponent, { 72 | data: errorMsg 73 | }); 74 | } 75 | 76 | onAdd() { 77 | this.router.navigate(['new'], { relativeTo: this.route }); 78 | } 79 | 80 | onEdit(course: Course) { 81 | this.router.navigate(['edit', course._id], { relativeTo: this.route }); 82 | } 83 | 84 | onView(course: Course) { 85 | this.router.navigate(['view', course._id], { relativeTo: this.route }); 86 | } 87 | 88 | onRemove(course: Course) { 89 | const dialogRef = this.dialog.open(ConfirmationDialogComponent, { 90 | data: 'Are you sure you would like to remove this course?' 91 | }); 92 | 93 | dialogRef.afterClosed().subscribe((result: boolean) => { 94 | if (result) { 95 | this.coursesService.remove(course._id).subscribe({ 96 | next: () => { 97 | this.refresh(); 98 | this.snackBar.open('Course removed successfully!', 'X', { 99 | duration: 5000, 100 | verticalPosition: 'top', 101 | horizontalPosition: 'center' 102 | }); 103 | }, 104 | error: () => this.onError('Error trying to remove the course.') 105 | }); 106 | } 107 | }); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/courses.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | import { CourseViewComponent } from './components/course-view/course-view.component'; 4 | import { CourseFormComponent } from './containers/course-form/course-form.component'; 5 | import { CoursesComponent } from './containers/courses/courses.component'; 6 | import { CourseResolver } from './resolver/course.resolver'; 7 | 8 | export const COURSES_ROUTES: Routes = [ 9 | { path: '', component: CoursesComponent }, 10 | { path: 'new', component: CourseFormComponent, resolve: { course: CourseResolver } }, 11 | { 12 | path: 'edit/:id', 13 | component: CourseFormComponent, 14 | resolve: { course: CourseResolver } 15 | }, 16 | { 17 | path: 'view/:id', 18 | component: CourseViewComponent, 19 | resolve: { course: CourseResolver } 20 | } 21 | ]; 22 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/model/course-page.ts: -------------------------------------------------------------------------------- 1 | import { Course } from './course'; 2 | 3 | export interface CoursePage { 4 | courses: Course[]; 5 | totalElements: number; 6 | totalPages?: number; 7 | } 8 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/model/course.ts: -------------------------------------------------------------------------------- 1 | import { Lesson } from './lesson'; 2 | 3 | export interface Course { 4 | _id: string; 5 | name: string; 6 | category: string; 7 | lessons?: Lesson[]; 8 | } 9 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/model/lesson.ts: -------------------------------------------------------------------------------- 1 | export interface Lesson { 2 | _id: string; 3 | name: string; 4 | youtubeUrl: string; 5 | } 6 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/resolver/course.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { of } from 'rxjs'; 3 | 4 | import { CoursesService } from '../services/courses.service'; 5 | import { CourseResolver } from './course.resolver'; 6 | import { Course } from '../model/course'; 7 | 8 | describe('CourseResolver', () => { 9 | let resolver: CourseResolver; 10 | let courseServiceSpy: jasmine.SpyObj; 11 | 12 | beforeEach(() => { 13 | courseServiceSpy = jasmine.createSpyObj('CoursesService', ['loadById']); 14 | TestBed.configureTestingModule({ 15 | providers: [{ provide: CoursesService, useValue: courseServiceSpy }] 16 | }); 17 | resolver = TestBed.inject(CourseResolver); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(resolver).toBeTruthy(); 22 | }); 23 | 24 | it('should return course', () => { 25 | const course = { 26 | _id: '1', 27 | name: 'Angular', 28 | category: 'Angular description', 29 | lessons: [] 30 | }; 31 | courseServiceSpy.loadById.and.returnValue(of(course)); 32 | const result = resolver.resolve({ params: { id: 1 } } as any, {} as any); 33 | result.subscribe((res: Course) => expect(res).toEqual(course)); 34 | }); 35 | 36 | it('should return empty course if new', () => { 37 | const course = { _id: '', name: '', category: '', lessons: [] }; 38 | courseServiceSpy.loadById.and.returnValue(of(course)); 39 | const result = resolver.resolve({ params: {} } as any, {} as any); 40 | result.subscribe((res: Course) => expect(res).toEqual(course)); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/resolver/course.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable, of } from 'rxjs'; 4 | 5 | import { Course } from '../model/course'; 6 | import { CoursesService } from '../services/courses.service'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class CourseResolver { 12 | constructor(private service: CoursesService) { } 13 | 14 | resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { 15 | if (route.params && route.params['id']) { 16 | return this.service.loadById(route.params['id']); 17 | } 18 | 19 | return of({ _id: '', name: '', category: '', lessons: [] }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/services/courses.mock.ts: -------------------------------------------------------------------------------- 1 | import { Course } from '../model/course'; 2 | import { CoursePage } from '../model/course-page'; 3 | 4 | export const coursesMock: Course[] = [ 5 | { 6 | _id: '1', 7 | name: 'Angular', 8 | category: 'front-end', 9 | lessons: [ 10 | { 11 | _id: '1', 12 | name: 'Angular 1', 13 | youtubeUrl: '2OHbjep_WjQ' 14 | } 15 | ] 16 | }, 17 | { 18 | _id: '2', 19 | name: 'Java', 20 | category: 'back-end', 21 | lessons: [ 22 | { 23 | _id: '2', 24 | name: 'Java 1', 25 | youtubeUrl: '2OHbyep_WjQ' 26 | } 27 | ] 28 | } 29 | ]; 30 | 31 | export const coursesPageMock: CoursePage = { 32 | courses: coursesMock, 33 | totalElements: 2, 34 | totalPages: 1 35 | }; 36 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/services/courses.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 2 | import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; 3 | import { TestBed } from '@angular/core/testing'; 4 | 5 | import { CoursesService } from './courses.service'; 6 | import { coursesMock, coursesPageMock } from './courses.mock'; 7 | 8 | describe('CoursesService', () => { 9 | let service: CoursesService; 10 | let httpTestingController: HttpTestingController; 11 | const API = '/api/courses'; 12 | const API_PAGE = `${API}?page=0&pageSize=10`; 13 | 14 | beforeEach(() => { 15 | TestBed.configureTestingModule({ 16 | imports: [], 17 | providers: [CoursesService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] 18 | }); 19 | service = TestBed.inject(CoursesService); 20 | httpTestingController = TestBed.inject(HttpTestingController); 21 | }); 22 | 23 | it('should be created', () => { 24 | expect(service).toBeTruthy(); 25 | }); 26 | 27 | it('should list all courses', () => { 28 | service.list().subscribe(coursePage => { 29 | expect(coursePage).toBeTruthy(); 30 | expect(coursePage.courses.length).toBe(coursesMock.length); 31 | }); 32 | 33 | const req = httpTestingController.expectOne(API_PAGE); 34 | expect(req.request.method).toEqual('GET'); 35 | req.flush(coursesPageMock); 36 | }); 37 | 38 | it('should list course by id', () => { 39 | service.loadById('1').subscribe(course => { 40 | expect(course).toBeTruthy(); 41 | expect(course._id).toBe('1'); 42 | }); 43 | 44 | const req = httpTestingController.expectOne(`${API}/1`); 45 | expect(req.request.method).toEqual('GET'); 46 | req.flush(coursesMock[0]); 47 | }); 48 | 49 | it('should list course by id from cache', () => { 50 | // hack to set private properties 51 | service['cache'] = coursesMock; 52 | 53 | service.loadById('1').subscribe(course => { 54 | expect(course).toBeTruthy(); 55 | expect(course._id).toBe('1'); 56 | }); 57 | 58 | httpTestingController.expectNone(`${API}/1`); 59 | }); 60 | 61 | it('should list course by id by checking cache', () => { 62 | // hack to set private properties 63 | service['cache'] = [coursesMock[0]]; 64 | 65 | service.loadById('2').subscribe(course => { 66 | expect(course).toBeTruthy(); 67 | expect(course._id).toBe('2'); 68 | }); 69 | 70 | const req = httpTestingController.expectOne(`${API}/2`); 71 | expect(req.request.method).toEqual('GET'); 72 | req.flush(coursesMock[1]); 73 | }); 74 | 75 | it('should save a new course', () => { 76 | const course = { _id: undefined, name: 'Testes no Angular', category: 'front-end' }; 77 | 78 | service.save(course).subscribe(course => { 79 | expect(course).toBeTruthy(); 80 | expect(course._id).toBeTruthy(); 81 | expect(course.name).toEqual(course.name); 82 | expect(course.category).toEqual(course.category); 83 | }); 84 | 85 | const req = httpTestingController.expectOne(API); 86 | expect(req.request.body['name']).toEqual(course.name); 87 | expect(req.request.method).toEqual('POST'); 88 | req.flush({ ...course, _id: 123 }); 89 | }); 90 | 91 | it('should update an existing course', () => { 92 | const course = { _id: '1', name: 'Testes no Angular', category: 'front-end' }; 93 | 94 | service.save(course).subscribe(course => { 95 | expect(course).toBeTruthy(); 96 | expect(course._id).toEqual(course._id); 97 | expect(course.name).toEqual(course.name); 98 | expect(course.category).toEqual(course.category); 99 | }); 100 | 101 | const req = httpTestingController.expectOne(`${API}/1`); 102 | expect(req.request.body['name']).toEqual(course.name); 103 | expect(req.request.method).toEqual('PUT'); 104 | req.flush({ ...course }); 105 | }); 106 | 107 | it('should give an error if save course fails', () => { 108 | const course = { _id: '111', name: 'Testes no Angular', category: 'front-end' }; 109 | 110 | service.save(course).subscribe( 111 | () => { 112 | fail('the save course operation should have failed'); 113 | }, 114 | (error: HttpErrorResponse) => { 115 | expect(error.status).toBe(500); 116 | } 117 | ); 118 | 119 | const req = httpTestingController.expectOne(`${API}/111`); 120 | expect(req.request.method).toEqual('PUT'); 121 | req.flush('Save course failed', { status: 500, statusText: 'Internal Server Error' }); 122 | }); 123 | 124 | it('should give an error if course does not exist', () => { 125 | service.loadById('111').subscribe( 126 | () => { 127 | fail('course should not exist'); 128 | }, 129 | (error: HttpErrorResponse) => { 130 | expect(error.status).toBe(404); 131 | } 132 | ); 133 | 134 | const req = httpTestingController.expectOne(`${API}/111`); 135 | expect(req.request.method).toEqual('GET'); 136 | req.flush('Course not found', { status: 404, statusText: 'Not found' }); 137 | }); 138 | 139 | it('should remove course by id', () => { 140 | service.remove('1').subscribe(result => { 141 | expect(result).toBeFalsy(); 142 | }); 143 | 144 | const req = httpTestingController.expectOne(`${API}/1`); 145 | expect(req.request.method).toEqual('DELETE'); 146 | req.flush(null); 147 | }); 148 | 149 | afterEach(() => { 150 | httpTestingController.verify(); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /crud-angular/src/app/courses/services/courses.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { first, of, tap } from 'rxjs'; 4 | 5 | import { Course } from '../model/course'; 6 | import { CoursePage } from '../model/course-page'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class CoursesService { 12 | private readonly API = '/api/courses'; 13 | 14 | private cache: Course[] = []; 15 | 16 | constructor(private http: HttpClient) { } 17 | 18 | list(page = 0, pageSize = 10) { 19 | return this.http.get(this.API, { params: { page, pageSize } }).pipe( 20 | first(), 21 | // map(data => data.courses), 22 | tap(data => (this.cache = data.courses)) 23 | ); 24 | } 25 | 26 | loadById(id: string) { 27 | if (this.cache.length > 0) { 28 | const record = this.cache.find(course => `${course._id}` === `${id}`); 29 | return record != null ? of(record) : this.getById(id); 30 | } 31 | return this.getById(id); 32 | } 33 | 34 | private getById(id: string) { 35 | return this.http.get(`${this.API}/${id}`).pipe(first()); 36 | } 37 | 38 | save(record: Partial) { 39 | if (record._id) { 40 | return this.update(record); 41 | } 42 | return this.create(record); 43 | } 44 | 45 | private update(record: Partial) { 46 | return this.http.put(`${this.API}/${record._id}`, record).pipe(first()); 47 | } 48 | 49 | private create(record: Partial) { 50 | return this.http.post(this.API, record).pipe(first()); 51 | } 52 | 53 | remove(id: string) { 54 | return this.http.delete(`${this.API}/${id}`).pipe(first()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/components/confirmation-dialog/confirmation-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | 4 | import { ConfirmationDialogComponent } from './confirmation-dialog.component'; 5 | 6 | describe('ConfirmationDialogComponent', () => { 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | const matDialogRefMock = jasmine.createSpyObj('MatDialogRef', ['close']); 11 | 12 | await TestBed.configureTestingModule({ 13 | imports: [MatDialogModule, ConfirmationDialogComponent], 14 | providers: [ 15 | { provide: MAT_DIALOG_DATA, useValue: 'Some title.' }, 16 | { provide: MatDialogRef, useValue: matDialogRefMock } 17 | ] 18 | }).compileComponents(); 19 | 20 | fixture = TestBed.createComponent(ConfirmationDialogComponent); 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should launch an error dialog with a message and a Yes button', () => { 25 | const errorMessageDom = fixture.nativeElement.querySelector( 26 | '.mat-mdc-dialog-content' 27 | ); 28 | expect(errorMessageDom.textContent).toContain('Some title.'); 29 | 30 | const okBtn = fixture.nativeElement.querySelector('button'); 31 | expect(okBtn.textContent).toContain('Yes'); 32 | }); 33 | 34 | it('should close the dialog when clicking on the Yes button', () => { 35 | const okBtn = fixture.nativeElement.querySelector('button'); 36 | okBtn.click(); 37 | fixture.detectChanges(); 38 | 39 | const dialog = fixture.debugElement.injector.get(MatDialogRef); 40 | expect(dialog.close).toHaveBeenCalledWith(true); 41 | }); 42 | 43 | it('should close the dialog when clicking on the No button', () => { 44 | const noBtn = fixture.nativeElement.querySelectorAll('button')[1]; 45 | noBtn.click(); 46 | fixture.detectChanges(); 47 | 48 | const dialog = fixture.debugElement.injector.get(MatDialogRef); 49 | expect(dialog.close).toHaveBeenCalledWith(false); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/components/confirmation-dialog/confirmation-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, Inject } from '@angular/core'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; 5 | 6 | @Component({ 7 | selector: 'app-confirmation-dialog', 8 | imports: [CommonModule, MatDialogModule, MatButtonModule], 9 | template: ` 10 |
11 |

{{ data }}

12 |
13 |
14 | 17 | 20 |
21 | ` 22 | }) 23 | export class ConfirmationDialogComponent { 24 | constructor( 25 | public dialogRef: MatDialogRef, 26 | @Inject(MAT_DIALOG_DATA) public data: string 27 | ) { } 28 | 29 | onConfirm(result: boolean): void { 30 | this.dialogRef.close(result); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/components/error-dialog/error-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; 3 | 4 | import { ErrorDialogComponent } from './error-dialog.component'; 5 | 6 | describe('ErrorDialogComponent', () => { 7 | let component: ErrorDialogComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [MatDialogModule, ErrorDialogComponent], 13 | providers: [ 14 | { provide: MAT_DIALOG_DATA, useValue: 'Error' }, 15 | { provide: MatDialogRef, useValue: {} } 16 | ] 17 | }).compileComponents(); 18 | 19 | fixture = TestBed.createComponent(ErrorDialogComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should launch an error dialog with a message and a Close button', () => { 29 | const errorMessageDom = fixture.nativeElement.querySelector( 30 | '.mat-mdc-dialog-content' 31 | ); 32 | expect(errorMessageDom.textContent).toContain('Error'); 33 | 34 | const okBtn = fixture.nativeElement.querySelector('button'); 35 | expect(okBtn.textContent).toContain('Close'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/components/error-dialog/error-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, Inject } from '@angular/core'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; 5 | 6 | @Component({ 7 | selector: 'app-error-dialog', 8 | imports: [CommonModule, MatDialogModule, MatButtonModule], 9 | template: ` 10 |

Error!

11 |
{{ data }}
12 |
13 | 14 |
15 | ` 16 | }) 17 | export class ErrorDialogComponent { 18 | constructor(@Inject(MAT_DIALOG_DATA) public data: string) { } 19 | } 20 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/pipes/category.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { CategoryPipe } from './category.pipe'; 2 | 3 | describe('Pipe: Category', () => { 4 | it('create an instance', () => { 5 | const pipe = new CategoryPipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | 9 | it('transforms a category to an icon', () => { 10 | const pipe = new CategoryPipe(); 11 | expect(pipe.transform('front-end')).toBe('code'); 12 | expect(pipe.transform('back-end')).toBe('computer'); 13 | expect(pipe.transform('')).toBe('code'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/pipes/category.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | standalone: true, 5 | name: 'category' 6 | }) 7 | export class CategoryPipe implements PipeTransform { 8 | transform(value: string): string { 9 | switch (value) { 10 | case 'front-end': 11 | return 'code'; 12 | case 'back-end': 13 | return 'computer'; 14 | } 15 | return 'code'; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/services/form-utils.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from '@angular/core/testing'; 2 | import { UntypedFormArray, UntypedFormBuilder, UntypedFormControl } from '@angular/forms'; 3 | 4 | import { FormUtilsService } from './form-utils.service'; 5 | 6 | describe('Service: FormUtils', () => { 7 | let formControl: UntypedFormControl; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | providers: [FormUtilsService] 12 | }); 13 | }); 14 | 15 | it('should create an instance', inject( 16 | [FormUtilsService], 17 | (formUtils: FormUtilsService) => { 18 | expect(formUtils).toBeTruthy(); 19 | } 20 | )); 21 | 22 | it('should validate all form fields', inject( 23 | [FormUtilsService], 24 | (formUtils: FormUtilsService) => { 25 | const fb = new UntypedFormBuilder(); 26 | const formArray = fb.array([fb.group({ name: new UntypedFormControl('') })]); 27 | const formControl = fb.group({ desc: new UntypedFormControl(''), list: formArray }); 28 | 29 | formUtils.validateAllFormFields(formControl); 30 | 31 | expect(formControl.get('desc')?.touched).toBeTruthy(); 32 | 33 | const fa = formControl.get('list') as UntypedFormArray; 34 | expect(fa.touched).toBeTruthy(); 35 | expect(fa.controls[0].get('name')?.touched).toBeTruthy(); 36 | } 37 | )); 38 | 39 | it('should validate getErrorMessageFromField with custom error message', inject( 40 | [FormUtilsService], 41 | (formUtils: FormUtilsService) => { 42 | formControl = new UntypedFormControl(''); 43 | 44 | formControl.setErrors({ required: true }); 45 | expect(formUtils.getErrorMessageFromField(formControl)).toBe('Field is required.'); 46 | 47 | formControl.setErrors({ maxlength: { requiredLength: 10 } }); 48 | expect(formUtils.getErrorMessageFromField(formControl)).toBe( 49 | 'Field cannot be more than 10 characters long.' 50 | ); 51 | 52 | formControl.setErrors({ minlength: { requiredLength: 10 } }); 53 | expect(formUtils.getErrorMessageFromField(formControl)).toBe( 54 | 'Field cannot be less than 10 characters long.' 55 | ); 56 | 57 | formControl.setErrors({ email: true }); 58 | expect(formUtils.getErrorMessageFromField(formControl)).toBe('Error'); 59 | } 60 | )); 61 | 62 | it('should validate getFieldErrorMessage with custom error message', inject( 63 | [FormUtilsService], 64 | (formUtils: FormUtilsService) => { 65 | const fb = new UntypedFormBuilder(); 66 | const formGroup = fb.group({ name: new UntypedFormControl('') }); 67 | const fieldName = 'name'; 68 | const formControl = formGroup.get(fieldName); 69 | 70 | formControl?.setErrors({ required: true }); 71 | expect(formUtils.getFieldErrorMessage(formGroup, fieldName)).toBe( 72 | 'Field is required.' 73 | ); 74 | 75 | formControl?.setErrors({ maxlength: { requiredLength: 10 } }); 76 | expect(formUtils.getFieldErrorMessage(formGroup, fieldName)).toBe( 77 | 'Field cannot be more than 10 characters long.' 78 | ); 79 | 80 | formControl?.setErrors({ minlength: { requiredLength: 10 } }); 81 | expect(formUtils.getFieldErrorMessage(formGroup, fieldName)).toBe( 82 | 'Field cannot be less than 10 characters long.' 83 | ); 84 | 85 | formControl?.setErrors({ email: true }); 86 | expect(formUtils.getFieldErrorMessage(formGroup, fieldName)).toBe('Error'); 87 | } 88 | )); 89 | 90 | it('should validate getFieldFormArrayErrorMessage with custom error message', inject( 91 | [FormUtilsService], 92 | (formUtils: FormUtilsService) => { 93 | const fb = new UntypedFormBuilder(); 94 | const formGroup = fb.group({ name: new UntypedFormControl('') }); 95 | const formArray = fb.array([formGroup]); 96 | const form = fb.group({ list: formArray }); 97 | const fieldName = 'name'; 98 | const arrayName = 'list'; 99 | const formControl = formArray.controls[0].get(fieldName); 100 | 101 | formControl?.setErrors({ required: true }); 102 | expect(formUtils.getFieldFormArrayErrorMessage(form, arrayName, fieldName, 0)).toBe( 103 | 'Field is required.' 104 | ); 105 | 106 | formControl?.setErrors({ maxlength: { requiredLength: 10 } }); 107 | expect(formUtils.getFieldFormArrayErrorMessage(form, arrayName, fieldName, 0)).toBe( 108 | 'Field cannot be more than 10 characters long.' 109 | ); 110 | 111 | formControl?.setErrors({ minlength: { requiredLength: 10 } }); 112 | expect(formUtils.getFieldFormArrayErrorMessage(form, arrayName, fieldName, 0)).toBe( 113 | 'Field cannot be less than 10 characters long.' 114 | ); 115 | 116 | formControl?.setErrors({ email: true }); 117 | expect(formUtils.getFieldFormArrayErrorMessage(form, arrayName, fieldName, 0)).toBe( 118 | 'Error' 119 | ); 120 | } 121 | )); 122 | 123 | it('should return empty string when calling getFieldFormArrayErrorMessage with a valid field', inject( 124 | [FormUtilsService], 125 | (formUtils: FormUtilsService) => { 126 | const fb = new UntypedFormBuilder(); 127 | const formGroup = fb.group({ name: new UntypedFormControl('') }); 128 | const fieldName = 'name'; 129 | const formControl = formGroup.get(fieldName); 130 | 131 | formControl?.setErrors(null); 132 | expect(formUtils.getFieldErrorMessage(formGroup, fieldName)).toBe(''); 133 | } 134 | )); 135 | 136 | it('should validate if formArray is required', inject( 137 | [FormUtilsService], 138 | (formUtils: FormUtilsService) => { 139 | const fb = new UntypedFormBuilder(); 140 | const formGroup = fb.group({ name: new UntypedFormControl('') }); 141 | const formArray = fb.array([formGroup]); 142 | const form = fb.group({ list: formArray }); 143 | const arrayName = 'list'; 144 | 145 | expect(formUtils.isFormArrayRequired(form, arrayName)).toBe(false); 146 | 147 | formArray.setErrors({ required: true }); 148 | expect(formUtils.isFormArrayRequired(form, arrayName)).toBe(false); 149 | 150 | formArray.markAsTouched(); 151 | expect(formUtils.isFormArrayRequired(form, arrayName)).toBe(true); 152 | } 153 | )); 154 | }); 155 | -------------------------------------------------------------------------------- /crud-angular/src/app/shared/services/form-utils.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | FormControl, 4 | FormGroup, 5 | UntypedFormArray, 6 | UntypedFormControl, 7 | UntypedFormGroup 8 | } from '@angular/forms'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class FormUtilsService { 14 | validateAllFormFields(formGroup: UntypedFormGroup | UntypedFormArray) { 15 | Object.keys(formGroup.controls).forEach(field => { 16 | const control = formGroup.get(field); 17 | if (control instanceof UntypedFormControl) { 18 | control.markAsTouched({ onlySelf: true }); 19 | } else if ( 20 | control instanceof UntypedFormGroup || 21 | control instanceof UntypedFormArray 22 | ) { 23 | control.markAsTouched({ onlySelf: true }); 24 | this.validateAllFormFields(control); 25 | } 26 | }); 27 | } 28 | 29 | getFieldErrorMessage(formGroup: FormGroup, fieldName: string): string { 30 | const field = formGroup.get(fieldName) as FormControl; 31 | return this.getErrorMessageFromField(field); 32 | } 33 | 34 | getFieldFormArrayErrorMessage( 35 | formGroup: FormGroup, 36 | formArrayName: string, 37 | fieldName: string, 38 | index: number 39 | ) { 40 | const formArray = formGroup.get(formArrayName) as UntypedFormArray; 41 | return this.getErrorMessageFromField( 42 | formArray.controls[index].get(fieldName) as UntypedFormControl 43 | ); 44 | } 45 | 46 | getErrorMessageFromField(field: UntypedFormControl): string { 47 | if (field?.hasError('required')) { 48 | return 'Field is required.'; 49 | } 50 | 51 | if (field?.hasError('maxlength') && field.errors) { 52 | const requiredLength = field.errors['maxlength']['requiredLength']; 53 | return `Field cannot be more than ${requiredLength} characters long.`; 54 | } 55 | 56 | if (field?.hasError('minlength') && field.errors) { 57 | const requiredLength = field.errors['minlength']['requiredLength']; 58 | return `Field cannot be less than ${requiredLength} characters long.`; 59 | } 60 | 61 | return field['errors'] ? 'Error' : ''; 62 | } 63 | 64 | isFormArrayRequired(formGroup: UntypedFormGroup, fieldName: string) { 65 | const field = formGroup.get(fieldName) as UntypedFormControl; 66 | return !field.valid && field.hasError('required') && field.touched; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /crud-angular/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loiane/crud-angular-spring/0c9bcde26b9f60aef85d54f611594c3297607e29/crud-angular/src/assets/.gitkeep -------------------------------------------------------------------------------- /crud-angular/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loiane/crud-angular-spring/0c9bcde26b9f60aef85d54f611594c3297607e29/crud-angular/src/favicon.ico -------------------------------------------------------------------------------- /crud-angular/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CrudAngular 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /crud-angular/src/main.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 2 | import { importProvidersFrom } from '@angular/core'; 3 | import { MatToolbarModule } from '@angular/material/toolbar'; 4 | import { bootstrapApplication, BrowserModule } from '@angular/platform-browser'; 5 | import { provideAnimations } from '@angular/platform-browser/animations'; 6 | import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router'; 7 | 8 | import { AppComponent } from './app/app.component'; 9 | import { APP_ROUTES } from './app/app.routes'; 10 | 11 | bootstrapApplication(AppComponent, { 12 | providers: [ 13 | importProvidersFrom(BrowserModule, MatToolbarModule), 14 | provideAnimations(), 15 | provideHttpClient(withInterceptorsFromDi()), 16 | provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)) //, withDebugTracing()) 17 | ] 18 | }).catch(err => console.error(err)); 19 | -------------------------------------------------------------------------------- /crud-angular/src/styles.scss: -------------------------------------------------------------------------------- 1 | // Custom Theming for Angular Material 2 | // For more information: https://material.angular.io/guide/theming 3 | @use '@angular/material' as mat; 4 | // Plus imports for other components in your app. 5 | 6 | // Include the common styles for Angular Material. We include this here so that you only 7 | // have to load a single css file for Angular Material in your app. 8 | // Be sure that you only ever include this mixin once! 9 | @include mat.elevation-classes(); 10 | @include mat.app-background(); 11 | 12 | // Define the palettes for your theme using the Material Design palettes available in palette.scss 13 | // (imported above). For each palette, you can optionally specify a default, lighter, and darker 14 | // hue. Available color palettes: https://material.io/design/color/ 15 | $crud-angular-primary: mat.m2-define-palette(mat.$m2-blue-palette); 16 | $crud-angular-accent: mat.m2-define-palette(mat.$m2-indigo-palette, A200, A400, 700); 17 | 18 | // The warn palette is optional (defaults to red). 19 | $crud-angular-warn: mat.m2-define-palette(mat.$m2-red-palette); 20 | 21 | // Create the theme object. A theme consists of configurations for individual 22 | // theming systems such as "color" or "typography". 23 | $crud-angular-theme: mat.m2-define-light-theme( 24 | ( 25 | color: ( 26 | primary: $crud-angular-primary, 27 | accent: $crud-angular-accent, 28 | warn: $crud-angular-warn 29 | ) 30 | ) 31 | ); 32 | 33 | // Include theme styles for core and each component used in your app. 34 | // Alternatively, you can import and @include the theme mixins for each component 35 | // that you are using. 36 | @include mat.all-component-themes($crud-angular-theme); 37 | 38 | /* You can add global styles to this file, and also import other style files */ 39 | 40 | html, 41 | body { 42 | height: 100%; 43 | } 44 | body { 45 | margin: 0; 46 | font-family: Roboto, 'Helvetica Neue', sans-serif; 47 | } 48 | -------------------------------------------------------------------------------- /crud-angular/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /crud-angular/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "bundler", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crud-angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /crud-spring/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/settings.json 34 | -------------------------------------------------------------------------------- /crud-spring/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loiane/crud-angular-spring/0c9bcde26b9f60aef85d54f611594c3297607e29/crud-spring/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /crud-spring/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar 3 | -------------------------------------------------------------------------------- /crud-spring/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "java", 5 | "name": "Spring Boot-CrudSpringApplication", 6 | "request": "launch", 7 | "cwd": "${workspaceFolder}", 8 | "mainClass": "com.loiane.CrudSpringApplication", 9 | "projectName": "crud-spring", 10 | "args": "", 11 | "envFile": "${workspaceFolder}/.env" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /crud-spring/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "test-coverage", 8 | "type": "shell", 9 | "command": "mvn jacoco:prepare-agent test install jacoco:report", 10 | "group": "test" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /crud-spring/api.http: -------------------------------------------------------------------------------- 1 | GET http://127.0.0.1:8080/api/courses 2 | 3 | ### 4 | 5 | GET http://127.0.0.1:8080/api/courses/1 6 | 7 | ### 8 | 9 | POST http://127.0.0.1:8080/api/courses 10 | content-type: application/json 11 | 12 | { 13 | "name": "Course XXXXXXXXXXX", 14 | "category": "Front-end", 15 | "lessons": [ 16 | { 17 | "name": "Lesson XXXXXXXXXXX", 18 | "youtubeUrl": "Fj3Zvf-N4bk" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /crud-spring/mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /usr/local/etc/mavenrc ] ; then 40 | . /usr/local/etc/mavenrc 41 | fi 42 | 43 | if [ -f /etc/mavenrc ] ; then 44 | . /etc/mavenrc 45 | fi 46 | 47 | if [ -f "$HOME/.mavenrc" ] ; then 48 | . "$HOME/.mavenrc" 49 | fi 50 | 51 | fi 52 | 53 | # OS specific support. $var _must_ be set to either true or false. 54 | cygwin=false; 55 | darwin=false; 56 | mingw=false 57 | case "`uname`" in 58 | CYGWIN*) cygwin=true ;; 59 | MINGW*) mingw=true;; 60 | Darwin*) darwin=true 61 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 62 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 63 | if [ -z "$JAVA_HOME" ]; then 64 | if [ -x "/usr/libexec/java_home" ]; then 65 | export JAVA_HOME="`/usr/libexec/java_home`" 66 | else 67 | export JAVA_HOME="/Library/Java/Home" 68 | fi 69 | fi 70 | ;; 71 | esac 72 | 73 | if [ -z "$JAVA_HOME" ] ; then 74 | if [ -r /etc/gentoo-release ] ; then 75 | JAVA_HOME=`java-config --jre-home` 76 | fi 77 | fi 78 | 79 | if [ -z "$M2_HOME" ] ; then 80 | ## resolve links - $0 may be a link to maven's home 81 | PRG="$0" 82 | 83 | # need this for relative symlinks 84 | while [ -h "$PRG" ] ; do 85 | ls=`ls -ld "$PRG"` 86 | link=`expr "$ls" : '.*-> \(.*\)$'` 87 | if expr "$link" : '/.*' > /dev/null; then 88 | PRG="$link" 89 | else 90 | PRG="`dirname "$PRG"`/$link" 91 | fi 92 | done 93 | 94 | saveddir=`pwd` 95 | 96 | M2_HOME=`dirname "$PRG"`/.. 97 | 98 | # make it fully qualified 99 | M2_HOME=`cd "$M2_HOME" && pwd` 100 | 101 | cd "$saveddir" 102 | # echo Using m2 at $M2_HOME 103 | fi 104 | 105 | # For Cygwin, ensure paths are in UNIX format before anything is touched 106 | if $cygwin ; then 107 | [ -n "$M2_HOME" ] && 108 | M2_HOME=`cygpath --unix "$M2_HOME"` 109 | [ -n "$JAVA_HOME" ] && 110 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 111 | [ -n "$CLASSPATH" ] && 112 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 113 | fi 114 | 115 | # For Mingw, ensure paths are in UNIX format before anything is touched 116 | if $mingw ; then 117 | [ -n "$M2_HOME" ] && 118 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 119 | [ -n "$JAVA_HOME" ] && 120 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 121 | fi 122 | 123 | if [ -z "$JAVA_HOME" ]; then 124 | javaExecutable="`which javac`" 125 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 126 | # readlink(1) is not available as standard on Solaris 10. 127 | readLink=`which readlink` 128 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 129 | if $darwin ; then 130 | javaHome="`dirname \"$javaExecutable\"`" 131 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 132 | else 133 | javaExecutable="`readlink -f \"$javaExecutable\"`" 134 | fi 135 | javaHome="`dirname \"$javaExecutable\"`" 136 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 137 | JAVA_HOME="$javaHome" 138 | export JAVA_HOME 139 | fi 140 | fi 141 | fi 142 | 143 | if [ -z "$JAVACMD" ] ; then 144 | if [ -n "$JAVA_HOME" ] ; then 145 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 146 | # IBM's JDK on AIX uses strange locations for the executables 147 | JAVACMD="$JAVA_HOME/jre/sh/java" 148 | else 149 | JAVACMD="$JAVA_HOME/bin/java" 150 | fi 151 | else 152 | JAVACMD="`\\unset -f command; \\command -v java`" 153 | fi 154 | fi 155 | 156 | if [ ! -x "$JAVACMD" ] ; then 157 | echo "Error: JAVA_HOME is not defined correctly." >&2 158 | echo " We cannot execute $JAVACMD" >&2 159 | exit 1 160 | fi 161 | 162 | if [ -z "$JAVA_HOME" ] ; then 163 | echo "Warning: JAVA_HOME environment variable is not set." 164 | fi 165 | 166 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 167 | 168 | # traverses directory structure from process work directory to filesystem root 169 | # first directory with .mvn subdirectory is considered project base directory 170 | find_maven_basedir() { 171 | 172 | if [ -z "$1" ] 173 | then 174 | echo "Path not specified to find_maven_basedir" 175 | return 1 176 | fi 177 | 178 | basedir="$1" 179 | wdir="$1" 180 | while [ "$wdir" != '/' ] ; do 181 | if [ -d "$wdir"/.mvn ] ; then 182 | basedir=$wdir 183 | break 184 | fi 185 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 186 | if [ -d "${wdir}" ]; then 187 | wdir=`cd "$wdir/.."; pwd` 188 | fi 189 | # end of workaround 190 | done 191 | echo "${basedir}" 192 | } 193 | 194 | # concatenates all lines of a file 195 | concat_lines() { 196 | if [ -f "$1" ]; then 197 | echo "$(tr -s '\n' ' ' < "$1")" 198 | fi 199 | } 200 | 201 | BASE_DIR=`find_maven_basedir "$(pwd)"` 202 | if [ -z "$BASE_DIR" ]; then 203 | exit 1; 204 | fi 205 | 206 | ########################################################################################## 207 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 208 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 209 | ########################################################################################## 210 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Found .mvn/wrapper/maven-wrapper.jar" 213 | fi 214 | else 215 | if [ "$MVNW_VERBOSE" = true ]; then 216 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 217 | fi 218 | if [ -n "$MVNW_REPOURL" ]; then 219 | jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 220 | else 221 | jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 222 | fi 223 | while IFS="=" read key value; do 224 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 225 | esac 226 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 227 | if [ "$MVNW_VERBOSE" = true ]; then 228 | echo "Downloading from: $jarUrl" 229 | fi 230 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 231 | if $cygwin; then 232 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 233 | fi 234 | 235 | if command -v wget > /dev/null; then 236 | if [ "$MVNW_VERBOSE" = true ]; then 237 | echo "Found wget ... using wget" 238 | fi 239 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 240 | wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 241 | else 242 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 243 | fi 244 | elif command -v curl > /dev/null; then 245 | if [ "$MVNW_VERBOSE" = true ]; then 246 | echo "Found curl ... using curl" 247 | fi 248 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 249 | curl -o "$wrapperJarPath" "$jarUrl" -f 250 | else 251 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 252 | fi 253 | 254 | else 255 | if [ "$MVNW_VERBOSE" = true ]; then 256 | echo "Falling back to using Java to download" 257 | fi 258 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 259 | # For Cygwin, switch paths to Windows format before running javac 260 | if $cygwin; then 261 | javaClass=`cygpath --path --windows "$javaClass"` 262 | fi 263 | if [ -e "$javaClass" ]; then 264 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 265 | if [ "$MVNW_VERBOSE" = true ]; then 266 | echo " - Compiling MavenWrapperDownloader.java ..." 267 | fi 268 | # Compiling the Java class 269 | ("$JAVA_HOME/bin/javac" "$javaClass") 270 | fi 271 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 272 | # Running the downloader 273 | if [ "$MVNW_VERBOSE" = true ]; then 274 | echo " - Running MavenWrapperDownloader.java ..." 275 | fi 276 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 277 | fi 278 | fi 279 | fi 280 | fi 281 | ########################################################################################## 282 | # End of extension 283 | ########################################################################################## 284 | 285 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 286 | if [ "$MVNW_VERBOSE" = true ]; then 287 | echo $MAVEN_PROJECTBASEDIR 288 | fi 289 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 290 | 291 | # For Cygwin, switch paths to Windows format before running java 292 | if $cygwin; then 293 | [ -n "$M2_HOME" ] && 294 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 295 | [ -n "$JAVA_HOME" ] && 296 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 297 | [ -n "$CLASSPATH" ] && 298 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 299 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 300 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 301 | fi 302 | 303 | # Provide a "standardized" way to retrieve the CLI args that will 304 | # work with both Windows and non-Windows executions. 305 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 306 | export MAVEN_CMD_LINE_ARGS 307 | 308 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 309 | 310 | exec "$JAVACMD" \ 311 | $MAVEN_OPTS \ 312 | $MAVEN_DEBUG_OPTS \ 313 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 314 | "-Dmaven.home=${M2_HOME}" \ 315 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 316 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 317 | -------------------------------------------------------------------------------- /crud-spring/mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 50 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 124 | 125 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% ^ 162 | %JVM_CONFIG_MAVEN_PROPS% ^ 163 | %MAVEN_OPTS% ^ 164 | %MAVEN_DEBUG_OPTS% ^ 165 | -classpath %WRAPPER_JAR% ^ 166 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 167 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 168 | if ERRORLEVEL 1 goto error 169 | goto end 170 | 171 | :error 172 | set ERROR_CODE=1 173 | 174 | :end 175 | @endlocal & set ERROR_CODE=%ERROR_CODE% 176 | 177 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 178 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 179 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 180 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 181 | :skipRcPost 182 | 183 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 184 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 185 | 186 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 187 | 188 | cmd /C exit /B %ERROR_CODE% 189 | -------------------------------------------------------------------------------- /crud-spring/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 3.4.4 10 | 11 | 12 | com.loiane 13 | crud-spring 14 | 0.0.1-SNAPSHOT 15 | crud-spring 16 | REST API 17 | 18 | 21 19 | 20 | 21 | 22 | 23 | org.springframework.boot 24 | spring-boot-starter-actuator 25 | 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-data-jpa 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-validation 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-web 38 | 39 | 40 | 41 | org.springdoc 42 | springdoc-openapi-starter-webmvc-ui 43 | 2.8.8 44 | 45 | 46 | 47 | 48 | org.springframework.boot 49 | spring-boot-devtools 50 | runtime 51 | true 52 | 53 | 54 | com.h2database 55 | h2 56 | runtime 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-test 61 | test 62 | 63 | 64 | 65 | com.mysql 66 | mysql-connector-j 67 | runtime 68 | 69 | 70 | 71 | 72 | 73 | 74 | org.jacoco 75 | jacoco-maven-plugin 76 | 0.8.13 77 | 78 | 79 | 80 | prepare-agent 81 | 82 | 83 | 84 | report 85 | prepare-package 86 | 87 | report 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | spring-milestones 97 | Spring Milestones 98 | https://repo.spring.io/milestone 99 | 100 | false 101 | 102 | 103 | 104 | 105 | 106 | spring-milestones 107 | Spring Milestones 108 | https://repo.spring.io/milestone 109 | 110 | false 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/CrudSpringApplication.java: -------------------------------------------------------------------------------- 1 | package com.loiane; 2 | 3 | import org.springframework.boot.CommandLineRunner; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Profile; 8 | 9 | import com.loiane.course.Course; 10 | import com.loiane.course.CourseRepository; 11 | import com.loiane.course.Lesson; 12 | import com.loiane.course.enums.Category; 13 | import com.loiane.course.enums.Status; 14 | 15 | @SpringBootApplication 16 | public class CrudSpringApplication { 17 | 18 | public static void main(String[] args) { 19 | SpringApplication.run(CrudSpringApplication.class, args); 20 | } 21 | 22 | @Bean 23 | @Profile("test") 24 | CommandLineRunner initDatabase(CourseRepository courseRepository) { 25 | return args -> extracted(courseRepository); 26 | } 27 | 28 | private void extracted(CourseRepository courseRepository) { 29 | courseRepository.deleteAll(); 30 | for (int i = 1; i < 5; i++) { 31 | Course c = new Course(); 32 | c.setName("Course " + i); 33 | c.setCategory(Category.FRONT_END); 34 | c.setStatus(Status.ACTIVE); 35 | 36 | for (int j = 1; j < 10; j++) { 37 | Lesson lesson = new Lesson(); 38 | lesson.setName("Lesson " + j); 39 | lesson.setYoutubeUrl("Fj3Zvf-N4bk"); 40 | c.addLesson(lesson); 41 | } 42 | 43 | courseRepository.save(c); 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/Course.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import java.util.HashSet; 4 | import java.util.Set; 5 | 6 | import org.hibernate.annotations.SQLDelete; 7 | import org.hibernate.annotations.SQLRestriction; 8 | import org.hibernate.validator.constraints.Length; 9 | 10 | import com.loiane.course.enums.Category; 11 | import com.loiane.course.enums.Status; 12 | import com.loiane.course.enums.converters.CategoryConverter; 13 | import com.loiane.course.enums.converters.StatusConverter; 14 | 15 | import jakarta.persistence.CascadeType; 16 | import jakarta.persistence.Column; 17 | import jakarta.persistence.Convert; 18 | import jakarta.persistence.Entity; 19 | import jakarta.persistence.GeneratedValue; 20 | import jakarta.persistence.GenerationType; 21 | import jakarta.persistence.Id; 22 | import jakarta.persistence.OneToMany; 23 | import jakarta.persistence.OrderBy; 24 | import jakarta.validation.Valid; 25 | import jakarta.validation.constraints.NotBlank; 26 | import jakarta.validation.constraints.NotEmpty; 27 | import jakarta.validation.constraints.NotNull; 28 | 29 | @SQLDelete(sql = "UPDATE Course SET status = 'Inactive' WHERE id=?") 30 | @SQLRestriction("status <> 'Inactive'") 31 | @Entity 32 | public class Course { 33 | 34 | @Id 35 | @GeneratedValue(strategy = GenerationType.IDENTITY) 36 | private Long id; 37 | 38 | @NotBlank 39 | @NotNull 40 | @Length(min = 5, max = 200) 41 | @Column(length = 200, nullable = false) 42 | private String name; 43 | 44 | @NotNull 45 | @Column(length = 10, nullable = false) 46 | @Convert(converter = CategoryConverter.class) 47 | private Category category; 48 | 49 | @NotNull 50 | @Column(length = 8, nullable = false) 51 | @Convert(converter = StatusConverter.class) 52 | private Status status = Status.ACTIVE; 53 | 54 | @NotNull 55 | @NotEmpty 56 | @Valid 57 | @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) 58 | @OrderBy("id") 59 | private Set lessons = new HashSet<>(); 60 | 61 | public Long getId() { 62 | return id; 63 | } 64 | 65 | public void setId(Long id) { 66 | this.id = id; 67 | } 68 | 69 | public String getName() { 70 | return name; 71 | } 72 | 73 | public void setName(String name) { 74 | this.name = name; 75 | } 76 | 77 | public Category getCategory() { 78 | return category; 79 | } 80 | 81 | public void setCategory(Category category) { 82 | this.category = category; 83 | } 84 | 85 | public Status getStatus() { 86 | return status; 87 | } 88 | 89 | public void setStatus(Status status) { 90 | this.status = status; 91 | } 92 | 93 | public Set getLessons() { 94 | return lessons; 95 | } 96 | 97 | public void setLessons(Set lessons) { 98 | if (lessons == null) { 99 | throw new IllegalArgumentException("Lessons cannot be null."); 100 | } 101 | this.lessons.clear(); 102 | this.lessons.addAll(lessons); 103 | } 104 | 105 | public void addLesson(Lesson lesson) { 106 | if (lesson == null) { 107 | throw new IllegalArgumentException("Lesson cannot be null."); 108 | } 109 | lesson.setCourse(this); 110 | this.lessons.add(lesson); 111 | } 112 | 113 | public void removeLesson(Lesson lesson) { 114 | if (lesson == null) { 115 | throw new IllegalArgumentException("Lesson cannot be null."); 116 | } 117 | lesson.setCourse(null); 118 | this.lessons.remove(lesson); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/CourseController.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.validation.annotation.Validated; 7 | import org.springframework.web.bind.annotation.DeleteMapping; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | import org.springframework.web.bind.annotation.PutMapping; 12 | import org.springframework.web.bind.annotation.RequestBody; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RequestParam; 15 | import org.springframework.web.bind.annotation.ResponseStatus; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import com.loiane.course.dto.CourseDTO; 19 | import com.loiane.course.dto.CoursePageDTO; 20 | import com.loiane.course.dto.CourseRequestDTO; 21 | 22 | import jakarta.validation.Valid; 23 | import jakarta.validation.constraints.NotBlank; 24 | import jakarta.validation.constraints.NotNull; 25 | import jakarta.validation.constraints.Positive; 26 | 27 | /** 28 | * Represents the REST API for the Course resource. 29 | */ 30 | @Validated 31 | @RestController 32 | @RequestMapping("api/courses") 33 | public class CourseController { 34 | 35 | private final CourseService courseService; 36 | 37 | public CourseController(CourseService courseService) { 38 | this.courseService = courseService; 39 | } 40 | 41 | @GetMapping 42 | public CoursePageDTO findAll(@RequestParam(defaultValue = "0") int page, 43 | @RequestParam(defaultValue = "10") int pageSize) { 44 | return courseService.findAll(page, pageSize); 45 | } 46 | 47 | @GetMapping("/searchByName") 48 | public List findByName(@RequestParam @NotNull @NotBlank String name) { 49 | return courseService.findByName(name); 50 | } 51 | 52 | @GetMapping("/{id}") 53 | public CourseDTO findById(@PathVariable @Positive @NotNull Long id) { 54 | return courseService.findById(id); 55 | } 56 | 57 | @PostMapping 58 | @ResponseStatus(code = HttpStatus.CREATED) 59 | public CourseDTO create(@RequestBody @Valid CourseRequestDTO course) { 60 | return courseService.create(course); 61 | } 62 | 63 | @PutMapping(value = "/{id}") 64 | public CourseDTO update(@PathVariable @Positive @NotNull Long id, 65 | @RequestBody @Valid CourseRequestDTO course) { 66 | return courseService.update(id, course); 67 | } 68 | 69 | @DeleteMapping("/{id}") 70 | @ResponseStatus(code = HttpStatus.NO_CONTENT) 71 | public void delete(@PathVariable @Positive @NotNull Long id) { 72 | courseService.delete(id); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/CourseRepository.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.data.jpa.repository.JpaRepository; 8 | 9 | import com.loiane.course.enums.Status; 10 | 11 | public interface CourseRepository extends JpaRepository { 12 | 13 | Page findByStatus(Pageable pageable, Status status); 14 | 15 | List findByName(String name); 16 | } 17 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/CourseService.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.PageRequest; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.validation.annotation.Validated; 9 | 10 | import com.loiane.course.dto.CourseDTO; 11 | import com.loiane.course.dto.CoursePageDTO; 12 | import com.loiane.course.dto.CourseRequestDTO; 13 | import com.loiane.course.dto.mapper.CourseMapper; 14 | import com.loiane.course.enums.Status; 15 | import com.loiane.exception.BusinessException; 16 | import com.loiane.exception.RecordNotFoundException; 17 | 18 | import jakarta.validation.Valid; 19 | import jakarta.validation.constraints.Max; 20 | import jakarta.validation.constraints.NotBlank; 21 | import jakarta.validation.constraints.NotNull; 22 | import jakarta.validation.constraints.Positive; 23 | import jakarta.validation.constraints.PositiveOrZero; 24 | 25 | @Service 26 | @Validated 27 | @SuppressWarnings("null") 28 | public class CourseService { 29 | 30 | private final CourseRepository courseRepository; 31 | private final CourseMapper courseMapper; 32 | 33 | public CourseService(CourseRepository courseRepository, CourseMapper courseMapper) { 34 | this.courseRepository = courseRepository; 35 | this.courseMapper = courseMapper; 36 | } 37 | 38 | public CoursePageDTO findAll(@PositiveOrZero int page, @Positive @Max(1000) int pageSize) { 39 | Page coursePage = courseRepository.findAll(PageRequest.of(page, pageSize)); 40 | List list = coursePage.getContent().stream() 41 | .map(courseMapper::toDTO) 42 | .toList(); 43 | return new CoursePageDTO(list, coursePage.getTotalElements(), coursePage.getTotalPages()); 44 | } 45 | 46 | public List findByName(@NotNull @NotBlank String name) { 47 | return courseRepository.findByName(name).stream().map(courseMapper::toDTO).toList(); 48 | } 49 | 50 | public CourseDTO findById(@Positive @NotNull Long id) { 51 | return courseRepository.findById(id).map(courseMapper::toDTO) 52 | .orElseThrow(() -> new RecordNotFoundException(id)); 53 | } 54 | 55 | public CourseDTO create(@Valid CourseRequestDTO courseRequestDTO) { 56 | courseRepository.findByName(courseRequestDTO.name()).stream() 57 | .filter(c -> c.getStatus().equals(Status.ACTIVE)) 58 | .findAny().ifPresent(c -> { 59 | throw new BusinessException("A course with name " + courseRequestDTO.name() + " already exists."); 60 | }); 61 | Course course = courseMapper.toModel(courseRequestDTO); 62 | course.setStatus(Status.ACTIVE); 63 | return courseMapper.toDTO(courseRepository.save(course)); 64 | } 65 | 66 | public CourseDTO update(@Positive @NotNull Long id, @Valid CourseRequestDTO courseRequestDTO) { 67 | return courseRepository.findById(id).map(actual -> { 68 | actual.setName(courseRequestDTO.name()); 69 | actual.setCategory(courseMapper.convertCategoryValue(courseRequestDTO.category())); 70 | mergeLessonsForUpdate(actual, courseRequestDTO); 71 | return courseMapper.toDTO(courseRepository.save(actual)); 72 | }) 73 | .orElseThrow(() -> new RecordNotFoundException(id)); 74 | } 75 | 76 | private void mergeLessonsForUpdate(Course updatedCourse, CourseRequestDTO courseRequestDTO) { 77 | 78 | // find the lessons that were removed 79 | List lessonsToRemove = updatedCourse.getLessons().stream() 80 | .filter(lesson -> courseRequestDTO.lessons().stream() 81 | .anyMatch(lessonDto -> lessonDto._id() != 0 && lessonDto._id() == lesson.getId())) 82 | .toList(); 83 | lessonsToRemove.forEach(updatedCourse::removeLesson); 84 | 85 | courseRequestDTO.lessons().forEach(lessonDto -> { 86 | // new lesson, add it 87 | if (lessonDto._id() == 0) { 88 | updatedCourse.addLesson(courseMapper.convertLessonDTOToLesson(lessonDto)); 89 | } else { 90 | // existing lesson, find it and update 91 | updatedCourse.getLessons().stream() 92 | .filter(lesson -> lesson.getId() == lessonDto._id()) 93 | .findAny() 94 | .ifPresent(lesson -> { 95 | lesson.setName(lessonDto.name()); 96 | lesson.setYoutubeUrl(lessonDto.youtubeUrl()); 97 | }); 98 | } 99 | }); 100 | } 101 | 102 | public void delete(@Positive @NotNull Long id) { 103 | courseRepository.delete(courseRepository.findById(id) 104 | .orElseThrow(() -> new RecordNotFoundException(id))); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/Lesson.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import org.hibernate.validator.constraints.Length; 4 | 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.FetchType; 10 | import jakarta.persistence.GeneratedValue; 11 | import jakarta.persistence.GenerationType; 12 | import jakarta.persistence.Id; 13 | import jakarta.persistence.JoinColumn; 14 | import jakarta.persistence.ManyToOne; 15 | import jakarta.persistence.OrderBy; 16 | import jakarta.validation.constraints.NotBlank; 17 | import jakarta.validation.constraints.NotNull; 18 | 19 | @Entity 20 | public class Lesson { 21 | 22 | @Id 23 | @GeneratedValue(strategy = GenerationType.IDENTITY) 24 | private int id; 25 | 26 | @NotBlank 27 | @NotNull 28 | @Length(min = 5, max = 100) 29 | @Column(length = 100, nullable = false) 30 | private String name; 31 | 32 | @NotBlank 33 | @NotNull 34 | @Length(min = 10, max = 11) 35 | @Column(length = 11, nullable = false) 36 | private String youtubeUrl; 37 | 38 | @ManyToOne(fetch = FetchType.LAZY, optional = false) 39 | @JoinColumn(name = "course_id", nullable = false) 40 | @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) 41 | private Course course; 42 | 43 | public int getId() { 44 | return id; 45 | } 46 | 47 | public void setId(int id) { 48 | this.id = id; 49 | } 50 | 51 | public String getName() { 52 | return name; 53 | } 54 | 55 | public void setName(String name) { 56 | this.name = name; 57 | } 58 | 59 | public String getYoutubeUrl() { 60 | return youtubeUrl; 61 | } 62 | 63 | public void setYoutubeUrl(String youtubeUrl) { 64 | this.youtubeUrl = youtubeUrl; 65 | } 66 | 67 | public Course getCourse() { 68 | return course; 69 | } 70 | 71 | public void setCourse(Course course) { 72 | this.course = course; 73 | } 74 | 75 | @Override 76 | public boolean equals(Object obj) { 77 | if (this == obj) { 78 | return true; 79 | } 80 | if (obj == null || getClass() != obj.getClass()) { 81 | return false; 82 | } 83 | Lesson lesson = (Lesson) obj; 84 | return id == lesson.id && name.equals(lesson.name) && youtubeUrl.equals(lesson.youtubeUrl); 85 | } 86 | 87 | @Override 88 | public String toString() { 89 | StringBuilder builder = new StringBuilder(); 90 | builder.append("Lesson [id=").append(id).append(", name=").append(name).append(", youtubeUrl=") 91 | .append(youtubeUrl).append(", course=").append(course).append("]"); 92 | return builder.toString(); 93 | } 94 | 95 | @Override 96 | public int hashCode() { 97 | return 31 * id + name.hashCode() + youtubeUrl.hashCode(); 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/dto/CourseDTO.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.dto; 2 | 3 | import java.util.List; 4 | 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | 7 | /** 8 | * Used as response object that represents a Course 9 | */ 10 | public record CourseDTO( 11 | @JsonProperty("_id") Long id, 12 | String name, String category, List lessons) { 13 | } 14 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/dto/CoursePageDTO.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.dto; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * Used as response object that represents a Page with a list of Courses. 7 | */ 8 | public record CoursePageDTO(List courses, long totalElements, int totalPages) { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/dto/CourseRequestDTO.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.dto; 2 | 3 | import java.util.List; 4 | 5 | import org.hibernate.validator.constraints.Length; 6 | 7 | import com.loiane.course.enums.Category; 8 | import com.loiane.shared.validation.ValueOfEnum; 9 | 10 | import jakarta.validation.Valid; 11 | import jakarta.validation.constraints.NotBlank; 12 | import jakarta.validation.constraints.NotEmpty; 13 | import jakarta.validation.constraints.NotNull; 14 | 15 | /** 16 | * Used as request object that represents a Course. 17 | */ 18 | public record CourseRequestDTO( 19 | @NotBlank @NotNull @Length(min = 5, max = 200) String name, 20 | @NotBlank @NotNull @ValueOfEnum(enumClass = Category.class) String category, 21 | @NotNull @NotEmpty @Valid List lessons) { 22 | } 23 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/dto/LessonDTO.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.dto; 2 | 3 | import org.hibernate.validator.constraints.Length; 4 | 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.NotNull; 7 | 8 | /** 9 | * Used as response and request object that represents a Lesson. 10 | */ 11 | public record LessonDTO( 12 | int _id, 13 | @NotBlank @NotNull @Length(min = 5, max = 100) String name, 14 | @NotBlank @NotNull @Length(min = 10, max = 11) String youtubeUrl) { 15 | } 16 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/dto/mapper/CourseMapper.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.dto.mapper; 2 | 3 | import java.util.List; 4 | import java.util.Set; 5 | import java.util.stream.Collectors; 6 | 7 | import org.springframework.stereotype.Component; 8 | 9 | import com.loiane.course.Course; 10 | import com.loiane.course.Lesson; 11 | import com.loiane.course.dto.CourseDTO; 12 | import com.loiane.course.dto.CourseRequestDTO; 13 | import com.loiane.course.dto.LessonDTO; 14 | import com.loiane.course.enums.Category; 15 | 16 | /** 17 | * Class to map the Course entity to the CourseRequestDTO and vice-versa. 18 | * ModelMapper currently does not support record types. 19 | */ 20 | @Component 21 | public class CourseMapper { 22 | 23 | public Course toModel(CourseRequestDTO courseRequestDTO) { 24 | 25 | Course course = new Course(); 26 | course.setName(courseRequestDTO.name()); 27 | course.setCategory(convertCategoryValue(courseRequestDTO.category())); 28 | 29 | Set lessons = courseRequestDTO.lessons().stream() 30 | .map(lessonDTO -> { 31 | Lesson lesson = new Lesson(); 32 | if (lessonDTO._id() > 0) { 33 | lesson.setId(lessonDTO._id()); 34 | } 35 | lesson.setName(lessonDTO.name()); 36 | lesson.setYoutubeUrl(lessonDTO.youtubeUrl()); 37 | lesson.setCourse(course); 38 | return lesson; 39 | }).collect(Collectors.toSet()); 40 | course.setLessons(lessons); 41 | 42 | return course; 43 | } 44 | 45 | public CourseDTO toDTO(Course course) { 46 | if (course == null) { 47 | return null; 48 | } 49 | List lessonDTOList = course.getLessons() 50 | .stream() 51 | .map(lesson -> new LessonDTO(lesson.getId(), lesson.getName(), lesson.getYoutubeUrl())) 52 | .toList(); 53 | return new CourseDTO(course.getId(), course.getName(), course.getCategory().getValue(), 54 | lessonDTOList); 55 | } 56 | 57 | public Category convertCategoryValue(String value) { 58 | if (value == null) { 59 | return null; 60 | } 61 | return switch (value) { 62 | case "Front-end" -> Category.FRONT_END; 63 | case "Back-end" -> Category.BACK_END; 64 | default -> throw new IllegalArgumentException("Invalid Category."); 65 | }; 66 | } 67 | 68 | public Lesson convertLessonDTOToLesson(LessonDTO lessonDTO) { 69 | Lesson lesson = new Lesson(); 70 | lesson.setId(lessonDTO._id()); 71 | lesson.setName(lessonDTO.name()); 72 | lesson.setYoutubeUrl(lessonDTO.youtubeUrl()); 73 | return lesson; 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/enums/Category.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.enums; 2 | 3 | public enum Category { 4 | FRONT_END("Front-end"), BACK_END("Back-end"); 5 | 6 | private final String value; 7 | 8 | Category(String value) { 9 | this.value = value; 10 | } 11 | 12 | public String getValue() { 13 | return value; 14 | } 15 | 16 | @Override 17 | public String toString() { 18 | return value; // required for @ValueOfEnum 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/enums/Status.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.enums; 2 | 3 | public enum Status { 4 | ACTIVE("Active"), INACTIVE("Inactive"); 5 | 6 | private final String value; 7 | 8 | Status(String value) { 9 | this.value = value; 10 | } 11 | 12 | public String getValue() { 13 | return value; 14 | } 15 | 16 | @Override 17 | public String toString() { 18 | return value; // required for @ValueOfEnum 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/enums/converters/CategoryConverter.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.enums.converters; 2 | 3 | import java.util.stream.Stream; 4 | 5 | import com.loiane.course.enums.Category; 6 | 7 | import jakarta.persistence.AttributeConverter; 8 | import jakarta.persistence.Converter; 9 | 10 | @Converter(autoApply = true) 11 | public class CategoryConverter implements AttributeConverter { 12 | 13 | @Override 14 | public String convertToDatabaseColumn(Category status) { 15 | if (status == null) { 16 | return null; 17 | } 18 | return status.getValue(); 19 | } 20 | 21 | @Override 22 | public Category convertToEntityAttribute(String value) { 23 | if (value == null) { 24 | return null; 25 | } 26 | return Stream.of(Category.values()) 27 | .filter(c -> c.getValue().equals(value)) 28 | .findFirst() 29 | .orElseThrow(IllegalArgumentException::new); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/course/enums/converters/StatusConverter.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course.enums.converters; 2 | 3 | import java.util.stream.Stream; 4 | 5 | import com.loiane.course.enums.Status; 6 | 7 | import jakarta.persistence.AttributeConverter; 8 | import jakarta.persistence.Converter; 9 | 10 | @Converter(autoApply = true) 11 | public class StatusConverter implements AttributeConverter { 12 | 13 | @Override 14 | public String convertToDatabaseColumn(Status status) { 15 | if (status == null) { 16 | return null; 17 | } 18 | return status.getValue(); 19 | } 20 | 21 | @Override 22 | public Status convertToEntityAttribute(String value) { 23 | if (value == null) { 24 | return null; 25 | } 26 | return Stream.of(Status.values()) 27 | .filter(c -> c.getValue().equals(value)) 28 | .findFirst() 29 | .orElseThrow(IllegalArgumentException::new); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/exception/BusinessException.java: -------------------------------------------------------------------------------- 1 | package com.loiane.exception; 2 | 3 | public class BusinessException extends RuntimeException { 4 | 5 | public BusinessException(String message) { 6 | super(message); 7 | } 8 | } -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/exception/RecordNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.loiane.exception; 2 | 3 | public class RecordNotFoundException extends RuntimeException { 4 | 5 | public RecordNotFoundException( Long id) { 6 | super("Could not find record " + id); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/shared/controller/ApplicationControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package com.loiane.shared.controller; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.validation.BindingResult; 7 | import org.springframework.validation.FieldError; 8 | import org.springframework.web.bind.MethodArgumentNotValidException; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | import org.springframework.web.bind.annotation.ResponseStatus; 11 | import org.springframework.web.bind.annotation.RestControllerAdvice; 12 | 13 | import com.loiane.exception.RecordNotFoundException; 14 | 15 | import jakarta.validation.ConstraintViolationException; 16 | 17 | /** 18 | * Controller advice that handles exceptions thrown by the controllers. 19 | */ 20 | @RestControllerAdvice 21 | public class ApplicationControllerAdvice { 22 | 23 | @ExceptionHandler(RecordNotFoundException.class) 24 | @ResponseStatus(HttpStatus.NOT_FOUND) 25 | public String handleNotFoundException(RecordNotFoundException e) { 26 | return e.getMessage(); 27 | } 28 | 29 | @ExceptionHandler(MethodArgumentNotValidException.class) 30 | @ResponseStatus(HttpStatus.BAD_REQUEST) 31 | public FieldError[] validationError(MethodArgumentNotValidException ex) { 32 | BindingResult result = ex.getBindingResult(); 33 | final List fieldErrors = result.getFieldErrors(); 34 | return fieldErrors.toArray(new FieldError[0]); 35 | } 36 | 37 | @ExceptionHandler(ConstraintViolationException.class) 38 | @ResponseStatus(HttpStatus.BAD_REQUEST) 39 | public String handleConstraintViolationException(ConstraintViolationException e) { 40 | return "not valid due to validation error: " + e.getMessage(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/shared/validation/ValueOfEnum.java: -------------------------------------------------------------------------------- 1 | package com.loiane.shared.validation; 2 | 3 | import static java.lang.annotation.ElementType.ANNOTATION_TYPE; 4 | import static java.lang.annotation.ElementType.CONSTRUCTOR; 5 | import static java.lang.annotation.ElementType.FIELD; 6 | import static java.lang.annotation.ElementType.METHOD; 7 | import static java.lang.annotation.ElementType.PARAMETER; 8 | import static java.lang.annotation.ElementType.TYPE_USE; 9 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 10 | 11 | import java.lang.annotation.Documented; 12 | import java.lang.annotation.Retention; 13 | import java.lang.annotation.Target; 14 | 15 | import jakarta.validation.Constraint; 16 | import jakarta.validation.Payload; 17 | 18 | @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) 19 | @Retention(RUNTIME) 20 | @Documented 21 | @Constraint(validatedBy = ValueOfEnumValidator.class) 22 | public @interface ValueOfEnum { 23 | Class> enumClass(); 24 | 25 | String message() default "must be any of enum {enumClass}"; 26 | 27 | Class[] groups() default {}; 28 | 29 | Class[] payload() default {}; 30 | } 31 | -------------------------------------------------------------------------------- /crud-spring/src/main/java/com/loiane/shared/validation/ValueOfEnumValidator.java: -------------------------------------------------------------------------------- 1 | package com.loiane.shared.validation; 2 | 3 | import java.util.List; 4 | import java.util.stream.Collectors; 5 | import java.util.stream.Stream; 6 | 7 | import jakarta.validation.ConstraintValidator; 8 | import jakarta.validation.ConstraintValidatorContext; 9 | 10 | public class ValueOfEnumValidator implements ConstraintValidator { 11 | 12 | private List acceptedValues; 13 | 14 | @Override 15 | public void initialize(ValueOfEnum annotation) { 16 | acceptedValues = Stream.of(annotation.enumClass().getEnumConstants()) 17 | .map(Enum::toString) 18 | .collect(Collectors.toList()); 19 | } 20 | 21 | @Override 22 | public boolean isValid(CharSequence value, ConstraintValidatorContext context) { 23 | if (value == null) { 24 | return true; 25 | } 26 | return acceptedValues.contains(value.toString()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crud-spring/src/main/resources/application-dev.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=dev 2 | 3 | spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/courses 4 | spring.datasource.username=root 5 | spring.datasource.password=rootroot 6 | spring.datasource.driver-class-name=com.mysql.jdbc.Driver 7 | spring.jpa.show-sql=false 8 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect 9 | spring.jpa.hibernate.ddl-auto=create-drop 10 | spring.jpa.defer-datasource-initialization=true 11 | spring.sql.init.mode=always -------------------------------------------------------------------------------- /crud-spring/src/main/resources/application-prod.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=prod 2 | 3 | spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/courses 4 | spring.datasource.username=root 5 | spring.datasource.password=rootroot 6 | spring.datasource.driver-class-name=com.mysql.jdbc.Driver 7 | spring.jpa.show-sql=false 8 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect 9 | spring.jpa.hibernate.ddl-auto=none 10 | spring.jpa.defer-datasource-initialization=false 11 | spring.sql.init.mode=never 12 | -------------------------------------------------------------------------------- /crud-spring/src/main/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=test 2 | spring.datasource.url=jdbc:h2:mem:testdb 3 | spring.datasource.driverClassName=org.h2.Driver 4 | spring.datasource.username=sa 5 | spring.datasource.password=password 6 | spring.jpa.database-platform=org.hibernate.dialect.H2Dialect 7 | spring.jpa.show-sql=true 8 | #http://localhost:8080/h2-console/ 9 | 10 | spring.jpa.defer-datasource-initialization=false 11 | spring.sql.init.mode=never -------------------------------------------------------------------------------- /crud-spring/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=test 2 | 3 | server.error.include-stacktrace=never 4 | 5 | # DATA WEB (SpringDataWebProperties) 6 | spring.data.web.pageable.default-page-size=20 7 | spring.data.web.pageable.max-page-size=2000 8 | 9 | springdoc.swagger-ui.path=/swagger-ui.html 10 | springdoc.show-actuator=true -------------------------------------------------------------------------------- /crud-spring/src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | /*CREATE DATABASE `courses` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; 2 | 3 | /*CREATE TABLE `courses`.`course` ( 4 | `id` bigint NOT NULL AUTO_INCREMENT, 5 | `status` varchar(8) NOT NULL, 6 | `category` varchar(10) NOT NULL, 7 | `name` varchar(200) NOT NULL, 8 | PRIMARY KEY (`id`) 9 | ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 10 | 11 | CREATE TABLE `lesson` ( 12 | `id` int NOT NULL AUTO_INCREMENT, 13 | `course_id` bigint NOT NULL, 14 | `youtube_url` varchar(11) NOT NULL, 15 | `name` varchar(100) NOT NULL, 16 | PRIMARY KEY (`id`), 17 | KEY `FKjs3c7skmg8bvdddok5lc7s807` (`course_id`), 18 | CONSTRAINT `FKjs3c7skmg8bvdddok5lc7s807` FOREIGN KEY (`course_id`) REFERENCES `course` (`id`) 19 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci 20 | */ 21 | INSERT INTO `courses`.`course` (`id`,`status`,`category`,`name`) VALUES (1,'Active','Back-end','Angular + Spring'); 22 | 23 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'qJnjz8FIs6Q','01: Introdução'); 24 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'PqVuSKCk_OU','02: Overview do Projeto e Instalando o Angular Material'); 25 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'N4uIR7jgFGA','03: Criando uma Toolbar na Página Principal'); 26 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'5QHAtRyiPQ4','04: Criando o Módulo de Cursos e Usando Roteamento com Lazy Loading'); 27 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'VnJdZ_od0wY','05: Customizando o Tema do Angular Material'); 28 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'LvYXiOh3vZ4','06: Criando Material Table para Listar Cursos'); 29 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'jjv5YZhPjfc','07: CSS do Material Table e Criando um Módulo App Material'); 30 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'76fUSr1nSDM','08: Criando um Service no Angular'); 31 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'LUUn1BWIUA8','09: Chamada HTTP Get no Angular e RXJS'); 32 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'vOz_o7oYv9I','10: Lista de Cursos: Spinner (Carregando)'); 33 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'gi0ZJ8-r6IM','11: Lista de Cursos: Tratamento de Erros e MatDialog'); 34 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'uNFIh3jvp34','12: Lista de Cursos: Pipe para mostrar ícone'); 35 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'Ge7Em4byou8','13: Ambiente Java + Maven para Spring'); 36 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'-UpIRFONkjs','14: Hello World com Spring'); 37 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'w2xOL_yW8Tc','15: Listar Cursos (API HTTP GET)'); 38 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'ATjHgBh8dWg','16: Banco de Dados H2 e Conectando o Angular na API Spring'); 39 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'OBU_K7jq0nM','17: Update da Versão Angular (ng update) e Spring'); 40 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'9_02JxDn_AM','18: Componente de Formulário e Roteamento para criar cursos'); 41 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'R3yy3RX4FyM','19: Formulário para Criar Cursos'); 42 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'xLhNxqEQnLo','20: Formulário: Salvando os dados com HTTP POST + tratamento de erros'); 43 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'R9thmwiG2ns','21: Formulário: API Spring: Criar Curso (HTTP POST)'); 44 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'oEawp1Ey3TI','22: Update para o Angular v14'); 45 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'9q4tNVeUAao','23: Angular Typed Forms'); 46 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'tOIp89BBHgw','24: Refatoração Angular Material Table'); 47 | INSERT INTO `courses`.`lesson` (`course_id`,`youtube_url`,`name`) VALUES (1,'3rVmS6psL_U','25: Componentes Inteligentes x Componentes de Apresentação'); 48 | -------------------------------------------------------------------------------- /crud-spring/src/test/java/com/loiane/CrudSpringApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.loiane; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | import org.springframework.test.context.ActiveProfiles; 6 | 7 | @ActiveProfiles("test") 8 | @SpringBootTest 9 | class CrudSpringApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /crud-spring/src/test/java/com/loiane/config/ValidationAdvice.java: -------------------------------------------------------------------------------- 1 | package com.loiane.config; 2 | 3 | import java.lang.reflect.Method; 4 | import java.util.Set; 5 | 6 | import jakarta.validation.ConstraintViolation; 7 | import jakarta.validation.ConstraintViolationException; 8 | import jakarta.validation.executable.ExecutableValidator; 9 | 10 | import org.springframework.aop.MethodBeforeAdvice; 11 | import org.springframework.lang.Nullable; 12 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; 13 | 14 | public class ValidationAdvice implements MethodBeforeAdvice { 15 | 16 | static private final ExecutableValidator executableValidator; 17 | 18 | static { 19 | LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean(); 20 | factory.afterPropertiesSet(); 21 | executableValidator = factory.getValidator().forExecutables(); 22 | factory.close(); 23 | } 24 | 25 | @SuppressWarnings("null") 26 | @Override 27 | public void before(Method method, Object[] args, @Nullable Object target) { 28 | Set> violations = executableValidator.validateParameters(target, method, args); 29 | if (!violations.isEmpty()) { 30 | throw new ConstraintViolationException(violations); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crud-spring/src/test/java/com/loiane/course/CourseControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import static org.hamcrest.Matchers.hasSize; 4 | import static org.hamcrest.Matchers.is; 5 | import static org.junit.jupiter.api.Assertions.assertThrows; 6 | import static org.mockito.ArgumentMatchers.any; 7 | import static org.mockito.ArgumentMatchers.anyInt; 8 | import static org.mockito.ArgumentMatchers.anyLong; 9 | import static org.mockito.ArgumentMatchers.anyString; 10 | import static org.mockito.Mockito.doNothing; 11 | import static org.mockito.Mockito.doThrow; 12 | import static org.mockito.Mockito.when; 13 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 14 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 15 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 16 | 17 | import java.util.List; 18 | 19 | import org.junit.jupiter.api.BeforeEach; 20 | import org.junit.jupiter.api.DisplayName; 21 | import org.junit.jupiter.api.Test; 22 | import org.springframework.aop.framework.ProxyFactory; 23 | import org.springframework.beans.factory.annotation.Autowired; 24 | import org.springframework.http.MediaType; 25 | import org.springframework.test.context.ActiveProfiles; 26 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 27 | import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 28 | import org.springframework.test.web.servlet.MockMvc; 29 | import org.springframework.test.web.servlet.ResultActions; 30 | import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; 31 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; 32 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 33 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 34 | 35 | import com.fasterxml.jackson.databind.ObjectMapper; 36 | import com.loiane.config.ValidationAdvice; 37 | import com.loiane.course.dto.CourseDTO; 38 | import com.loiane.course.dto.CoursePageDTO; 39 | import com.loiane.course.dto.CourseRequestDTO; 40 | import com.loiane.exception.RecordNotFoundException; 41 | 42 | import jakarta.servlet.ServletException; 43 | 44 | @SuppressWarnings("null") 45 | @ActiveProfiles("test") 46 | @SpringJUnitConfig(classes = { CourseController.class }) 47 | class CourseControllerTest { 48 | 49 | private static final String API = "/api/courses"; 50 | private static final String API_ID = "/api/courses/{id}"; 51 | 52 | @Autowired 53 | private CourseController courseController; 54 | 55 | @MockitoBean 56 | private CourseService courseService; 57 | 58 | @BeforeEach 59 | void setUp() { 60 | ProxyFactory factory = new ProxyFactory(new CourseController(courseService)); 61 | factory.addAdvice(new ValidationAdvice()); 62 | courseController = (CourseController) factory.getProxy(); 63 | } 64 | 65 | /** 66 | * Method under test: {@link CourseController#findAll(int, int)} 67 | */ 68 | @Test 69 | @DisplayName("Should return a list of courses in JSON format") 70 | void testFindAll() throws Exception { 71 | CourseDTO course = TestData.createValidCourseDTO(); 72 | List courses = List.of(course); 73 | CoursePageDTO pageDTO = new CoursePageDTO(courses, 1L, 1); 74 | when(this.courseService.findAll(anyInt(), anyInt())).thenReturn(pageDTO); 75 | MockMvcBuilders.standaloneSetup(this.courseController) 76 | .build() 77 | .perform(MockMvcRequestBuilders.get(API)) 78 | .andExpect(status().isOk()) 79 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 80 | .andExpect(jsonPath("courses", hasSize(courses.size()))) 81 | .andExpect(jsonPath("totalElements", is(1))) 82 | .andExpect(jsonPath("courses[0]._id", is(course.id()), Long.class)) 83 | .andExpect(jsonPath("courses[0].name", is(course.name()))) 84 | .andExpect(jsonPath("courses[0].category", is(course.category()))); 85 | } 86 | 87 | /** 88 | * Method under test: {@link CourseController#findById(Long)} 89 | */ 90 | @Test 91 | @DisplayName("Should return a course by id") 92 | void testFindById() throws Exception { 93 | CourseDTO course = TestData.createValidCourseDTO(); 94 | when(this.courseService.findById(anyLong())).thenReturn(course); 95 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(API_ID, course.id()); 96 | MockMvcBuilders.standaloneSetup(this.courseController) 97 | .build() 98 | .perform(requestBuilder) 99 | .andExpect(status().isOk()) 100 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 101 | .andExpect(jsonPath("_id", is(course.id()), Long.class)) 102 | .andExpect(jsonPath("name", is(course.name()))) 103 | .andExpect(jsonPath("category", is(course.category()))); 104 | } 105 | 106 | /** 107 | * Method under test: {@link CourseController#findById(Long)} 108 | */ 109 | @Test 110 | @DisplayName("Should return a 404 status code when course is not found") 111 | void testFindByIdNotFound() { 112 | when(this.courseService.findById(anyLong())).thenThrow(new RecordNotFoundException(1L)); 113 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(API_ID, 1); 114 | assertThrows(ServletException.class, () -> { 115 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 116 | .build() 117 | .perform(requestBuilder); 118 | actualPerformResult.andExpect(status().isNotFound()); 119 | }); 120 | } 121 | 122 | /** 123 | * Method under test: {@link CourseController#findById(Long)} 124 | */ 125 | @Test 126 | @DisplayName("Should return bad request status code when id is not a positive number") 127 | void testFindByIdNegative() { 128 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(API_ID, -1); 129 | assertThrows(ServletException.class, () -> { 130 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 131 | .build() 132 | .perform(requestBuilder); 133 | actualPerformResult.andExpect(status().isBadRequest()); 134 | }); 135 | } 136 | 137 | /** 138 | * Method under test: {@link CourseController#findByName(String)} 139 | */ 140 | @Test 141 | @DisplayName("Should return a course by name") 142 | void testFindByName() throws Exception { 143 | CourseDTO course = TestData.createValidCourseDTO(); 144 | List courses = List.of(course); 145 | when(this.courseService.findByName(anyString())).thenReturn(courses); 146 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(API + "/searchByName") 147 | .param("name", course.name()); 148 | MockMvcBuilders.standaloneSetup(this.courseController) 149 | .build() 150 | .perform(requestBuilder) 151 | .andExpect(status().isOk()) 152 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 153 | .andExpect(jsonPath("$", hasSize(courses.size()))) 154 | .andExpect(jsonPath("$[0]._id", is(course.id()), Long.class)) 155 | .andExpect(jsonPath("$[0].name", is(course.name()))) 156 | .andExpect(jsonPath("$[0].category", is(course.category()))); 157 | } 158 | 159 | /** 160 | * Method under test: {@link CourseController#create(CourseRequestDTO)} 161 | */ 162 | @Test 163 | @DisplayName("Should create a course when valid") 164 | void testCreate() throws Exception { 165 | CourseRequestDTO courseDTO = TestData.createValidCourseRequest(); 166 | CourseDTO course = TestData.createValidCourseDTO(); 167 | when(this.courseService.create(courseDTO)).thenReturn(course); 168 | 169 | String content = (new ObjectMapper()).writeValueAsString(course); 170 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post(API) 171 | .contentType(MediaType.APPLICATION_JSON) 172 | .content(content); 173 | MockMvcBuilders.standaloneSetup(this.courseController) 174 | .build() 175 | .perform(requestBuilder) 176 | .andExpect(MockMvcResultMatchers.status().isCreated()) 177 | .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) 178 | .andExpect(jsonPath("_id", is(course.id()), Long.class)) 179 | .andExpect(jsonPath("name", is(course.name()))) 180 | .andExpect(jsonPath("category", is(course.category()))); 181 | } 182 | 183 | /** 184 | * Method under test: {@link CourseController#create(CourseRequestDTO)} 185 | */ 186 | @Test 187 | @DisplayName("Should return bad request when creating an invalid course") 188 | void testCreateInvalid() throws Exception { 189 | final List courses = TestData.createInvalidCourses(); 190 | for (Course course : courses) { 191 | String content = (new ObjectMapper()).writeValueAsString(course); 192 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post(API) 193 | .contentType(MediaType.APPLICATION_JSON) 194 | .content(content); 195 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 196 | .build() 197 | .perform(requestBuilder); 198 | actualPerformResult.andExpect(status().isBadRequest()); 199 | } 200 | } 201 | 202 | /** 203 | * Method under test: {@link CourseController#update(Long, CourseRequestDTO)} 204 | */ 205 | @Test 206 | @DisplayName("Should update a course when valid") 207 | void testUpdate() throws Exception { 208 | CourseDTO course = TestData.createValidCourseDTO(); 209 | when(this.courseService.update(anyLong(), any())).thenReturn(course); 210 | 211 | String content = (new ObjectMapper()).writeValueAsString(course); 212 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.put(API_ID, 1L) 213 | .contentType(MediaType.APPLICATION_JSON) 214 | .content(content); 215 | MockMvcBuilders.standaloneSetup(this.courseController) 216 | .build() 217 | .perform(requestBuilder) 218 | .andExpect(MockMvcResultMatchers.status().isOk()) 219 | .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) 220 | .andExpect(jsonPath("_id", is(course.id()), Long.class)) 221 | .andExpect(jsonPath("name", is(course.name()))) 222 | .andExpect(jsonPath("category", is(course.category()))); 223 | } 224 | 225 | /** 226 | * Method under test: {@link CourseController#update(Long, CourseRequestDTO)} 227 | */ 228 | @Test 229 | @DisplayName("Should throw an exception when updating an invalid course ID") 230 | void testUpdateNotFound() throws Exception { 231 | Course course = TestData.createValidCourse(); 232 | when(this.courseService.update(anyLong(), any())).thenThrow(new RecordNotFoundException(1L)); 233 | 234 | String content = (new ObjectMapper()).writeValueAsString(course); 235 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.put(API_ID, 1L) 236 | .contentType(MediaType.APPLICATION_JSON) 237 | .content(content); 238 | MockMvc mockMvc = MockMvcBuilders.standaloneSetup(this.courseController).build(); 239 | assertThrows(AssertionError.class, () -> { 240 | ResultActions actualPerformResult = mockMvc.perform(requestBuilder); 241 | actualPerformResult.andExpect(status().isNotFound()); 242 | }); 243 | } 244 | 245 | /** 246 | * Method under test: {@link CourseController#update(Long, CourseRequestDTO)} 247 | */ 248 | @Test 249 | @DisplayName("Should throw exception when id is not valid - update") 250 | void testUpdateInvalid() throws Exception { 251 | 252 | // invalid id and valid course 253 | final Course validCourse = TestData.createValidCourse(); 254 | final String content = (new ObjectMapper()).writeValueAsString(validCourse); 255 | assertThrows(AssertionError.class, () -> { 256 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.put(API_ID, -1) 257 | .contentType(MediaType.APPLICATION_JSON) 258 | .content(content); 259 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 260 | .build() 261 | .perform(requestBuilder); 262 | actualPerformResult.andExpect(status().isMethodNotAllowed()); 263 | }); 264 | 265 | // valid id and invalid course 266 | final List courses = TestData.createInvalidCourses(); 267 | for (Course course : courses) { 268 | final String contentUpdate = (new ObjectMapper()).writeValueAsString(course); 269 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.put(API_ID, 1L) 270 | .contentType(MediaType.APPLICATION_JSON) 271 | .content(contentUpdate); 272 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 273 | .build() 274 | .perform(requestBuilder); 275 | actualPerformResult.andExpect(status().isBadRequest()); 276 | } 277 | 278 | // invalid id and invalid course 279 | for (Course course : courses) { 280 | final String contentUpdate = (new ObjectMapper()).writeValueAsString(course); 281 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.put(API_ID, -1L) 282 | .contentType(MediaType.APPLICATION_JSON) 283 | .content(contentUpdate); 284 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 285 | .build() 286 | .perform(requestBuilder); 287 | actualPerformResult.andExpect(status().isBadRequest()); 288 | } 289 | } 290 | 291 | /** 292 | * Method under test: {@link CourseController#delete(Long)} 293 | */ 294 | @Test 295 | @DisplayName("Should delete a course") 296 | void testDelete() throws Exception { 297 | doNothing().when(this.courseService).delete(anyLong()); 298 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.delete(API_ID, 1); 299 | MockMvcBuilders.standaloneSetup(this.courseController) 300 | .build() 301 | .perform(requestBuilder) 302 | .andExpect(MockMvcResultMatchers.status().isNoContent()); 303 | } 304 | 305 | /** 306 | * Method under test: {@link CourseController#delete(Long)} 307 | */ 308 | @Test 309 | @DisplayName("Should return empty when course not found - delete") 310 | void testDeleteNotFound() { 311 | doThrow(new RecordNotFoundException(1L)).doNothing().when(this.courseService).delete(anyLong()); 312 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.delete(API_ID, 1); 313 | assertThrows(ServletException.class, () -> { 314 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 315 | .build() 316 | .perform(requestBuilder); 317 | actualPerformResult.andExpect(status().isNotFound()); 318 | }); 319 | } 320 | 321 | /** 322 | * Method under test: {@link CourseController#delete(Long)} 323 | */ 324 | @Test 325 | @DisplayName("Should throw exception when id is not valid - delete") 326 | void testDeleteInvalid() { 327 | MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.delete(API_ID, -1); 328 | assertThrows(ServletException.class, () -> { 329 | ResultActions actualPerformResult = MockMvcBuilders.standaloneSetup(this.courseController) 330 | .build() 331 | .perform(requestBuilder); 332 | actualPerformResult.andExpect(status().isBadRequest()); 333 | }); 334 | } 335 | 336 | } 337 | -------------------------------------------------------------------------------- /crud-spring/src/test/java/com/loiane/course/CourseRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.util.List; 6 | import java.util.Set; 7 | 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 12 | import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; 13 | import org.springframework.data.domain.Page; 14 | import org.springframework.data.domain.PageRequest; 15 | import org.springframework.data.domain.Pageable; 16 | import org.springframework.test.context.ActiveProfiles; 17 | 18 | import com.loiane.course.enums.Category; 19 | import com.loiane.course.enums.Status; 20 | 21 | /** 22 | * This is a sample class to test the CourseRepository. 23 | * In practice, only additional methods to the interface should be tested. 24 | */ 25 | @ActiveProfiles("test") 26 | @DataJpaTest 27 | @SuppressWarnings("null") 28 | class CourseRepositoryTest { 29 | 30 | @Autowired 31 | private TestEntityManager entityManager; 32 | 33 | @Autowired 34 | CourseRepository courseRepository; 35 | 36 | /** 37 | * Method under test: {@link CourseRepository#findByStatus(Pageable, Status)} 38 | */ 39 | @Test 40 | @DisplayName("Should find all courses in the database by Status with pagination") 41 | void testFindAllByStatus() { 42 | Course course = createValidCourse(); 43 | entityManager.persist(course); 44 | Page coursePage = courseRepository.findByStatus(PageRequest.of(0, 5), Status.ACTIVE); 45 | 46 | assertThat(coursePage).isNotNull(); 47 | assertThat(coursePage.getContent()).isNotEmpty(); 48 | assertThat(coursePage.getContent().get(0).getLessons()).isNotEmpty(); 49 | coursePage.getContent().forEach(c -> { 50 | assertThat(c.getStatus()).isEqualTo(Status.ACTIVE); 51 | assertThat(c.getLessons()).isNotEmpty(); 52 | }); 53 | } 54 | 55 | @Test 56 | @DisplayName("Should save a course when record is valid") 57 | void testSave() { 58 | Course course = createValidCourse(); 59 | final Course courseSaved = courseRepository.save(course); 60 | 61 | final Course actual = entityManager.find(Course.class, courseSaved.getId()); 62 | 63 | assertThat(courseSaved.getId()).isPositive(); 64 | assertThat(courseSaved.getStatus()).isEqualTo(Status.ACTIVE); 65 | assertThat(actual).isEqualTo(courseSaved); 66 | } 67 | 68 | /** 69 | * Method under test: {@link CourseRepository#findByName(String)} 70 | */ 71 | @Test 72 | @DisplayName("Should find a course by name") 73 | void testFindByName() { 74 | Course course = createValidCourse(); 75 | entityManager.persist(course); 76 | 77 | List courseFound = courseRepository.findByName(course.getName()); 78 | 79 | assertThat(courseFound).isNotEmpty(); 80 | assertThat(courseFound.get(0).getStatus()).isEqualTo(Status.ACTIVE); 81 | assertThat(courseFound.get(0).getLessons()).isNotEmpty(); 82 | } 83 | 84 | private Course createValidCourse() { 85 | Course course = new Course(); 86 | course.setName("Spring"); 87 | course.setCategory(Category.BACK_END); 88 | 89 | Lesson lesson = new Lesson(); 90 | lesson.setName("Lesson 1"); 91 | lesson.setYoutubeUrl("abcdefgh123"); 92 | lesson.setCourse(course); 93 | course.setLessons(Set.of(lesson)); 94 | 95 | return course; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /crud-spring/src/test/java/com/loiane/course/CourseServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertThrows; 6 | import static org.mockito.ArgumentMatchers.any; 7 | import static org.mockito.ArgumentMatchers.anyLong; 8 | import static org.mockito.ArgumentMatchers.anyString; 9 | import static org.mockito.BDDMockito.then; 10 | import static org.mockito.Mockito.doNothing; 11 | import static org.mockito.Mockito.doThrow; 12 | import static org.mockito.Mockito.times; 13 | import static org.mockito.Mockito.verify; 14 | import static org.mockito.Mockito.when; 15 | 16 | import java.util.ArrayList; 17 | import java.util.List; 18 | import java.util.Optional; 19 | 20 | import org.junit.jupiter.api.BeforeEach; 21 | import org.junit.jupiter.api.DisplayName; 22 | import org.junit.jupiter.api.Test; 23 | import org.springframework.aop.framework.ProxyFactory; 24 | import org.springframework.beans.factory.annotation.Autowired; 25 | import org.springframework.data.domain.Page; 26 | import org.springframework.data.domain.PageImpl; 27 | import org.springframework.data.domain.PageRequest; 28 | import org.springframework.test.context.ActiveProfiles; 29 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 30 | import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 31 | import com.loiane.config.ValidationAdvice; 32 | import com.loiane.course.dto.CourseDTO; 33 | import com.loiane.course.dto.CoursePageDTO; 34 | import com.loiane.course.dto.CourseRequestDTO; 35 | import com.loiane.course.dto.mapper.CourseMapper; 36 | import com.loiane.exception.BusinessException; 37 | import com.loiane.exception.RecordNotFoundException; 38 | 39 | import jakarta.validation.ConstraintViolationException; 40 | 41 | @SuppressWarnings("null") 42 | @ActiveProfiles("test") 43 | @SpringJUnitConfig(classes = { CourseService.class, CourseMapper.class }) 44 | class CourseServiceTest { 45 | 46 | @MockitoBean 47 | private CourseRepository courseRepository; 48 | 49 | @Autowired 50 | private CourseMapper courseMapper; 51 | 52 | @Autowired 53 | private CourseService courseService; 54 | 55 | @BeforeEach 56 | void setUp() { 57 | ProxyFactory factory = new ProxyFactory(new CourseService(courseRepository, courseMapper)); 58 | factory.addAdvice(new ValidationAdvice()); 59 | courseService = (CourseService) factory.getProxy(); 60 | } 61 | 62 | /** 63 | * Method under test: {@link CourseService#findAll(int, int)} 64 | */ 65 | @Test 66 | @DisplayName("Should return a list of courses with pagination") 67 | void testFindAllPageable() { 68 | List courseList = List.of(TestData.createValidCourse()); 69 | Page coursePage = new PageImpl<>(courseList); 70 | when(this.courseRepository.findAll(any(PageRequest.class))).thenReturn(coursePage); 71 | List dtoList = new ArrayList<>(courseList.size()); 72 | for (Course course : courseList) { 73 | dtoList.add(courseMapper.toDTO(course)); 74 | } 75 | 76 | CoursePageDTO coursePageDTO = this.courseService.findAll(0, 5); 77 | assertEquals(dtoList, coursePageDTO.courses()); 78 | assertThat(coursePageDTO.courses()).isNotEmpty(); 79 | assertEquals(1, coursePageDTO.totalElements()); 80 | assertThat(coursePageDTO.courses().get(0).lessons()).isNotEmpty(); 81 | verify(this.courseRepository).findAll(any(PageRequest.class)); 82 | } 83 | 84 | /** 85 | * Method under test: {@link CourseService#findById(Long)} 86 | * Happy path 87 | */ 88 | @Test 89 | @DisplayName("Should return a course by id") 90 | void testFindById() { 91 | Course course = TestData.createValidCourse(); 92 | Optional ofResult = Optional.of(course); 93 | when(this.courseRepository.findById(anyLong())).thenReturn(ofResult); 94 | CourseDTO actualFindByIdResult = this.courseService.findById(1L); 95 | assertEquals(courseMapper.toDTO(ofResult.get()), actualFindByIdResult); 96 | verify(this.courseRepository).findById(anyLong()); 97 | } 98 | 99 | /** 100 | * Method under test: {@link CourseService#findById(Long)} 101 | */ 102 | @Test 103 | @DisplayName("Should throw NotFound exception when course not found") 104 | void testFindByIdNotFound() { 105 | when(this.courseRepository.findById(anyLong())).thenReturn(Optional.empty()); 106 | assertThrows(RecordNotFoundException.class, () -> this.courseService.findById(123L)); 107 | verify(this.courseRepository).findById(anyLong()); 108 | } 109 | 110 | /** 111 | * Method under test: {@link CourseService#findById(Long)} 112 | */ 113 | @Test 114 | @DisplayName("Should throw exception when id is not valid - findById") 115 | void testFindByIdInvalid() { 116 | assertThrows(ConstraintViolationException.class, () -> this.courseService.findById(-1L)); 117 | assertThrows(ConstraintViolationException.class, () -> this.courseService.findById(null)); 118 | } 119 | 120 | /** 121 | * Method under test: {@link CourseService#findByName(String)} 122 | */ 123 | @Test 124 | @DisplayName("Should return a course by name") 125 | void testFindByName() { 126 | Course course = TestData.createValidCourse(); 127 | when(this.courseRepository.findByName(anyString())).thenReturn(List.of(course)); 128 | List listByName = this.courseService.findByName("Spring"); 129 | assertThat(listByName).isNotEmpty(); 130 | assertEquals(courseMapper.toDTO(course), listByName.get(0)); 131 | verify(this.courseRepository).findByName(anyString()); 132 | } 133 | 134 | /** 135 | * Method under test: {@link CourseService#create(CourseRequestDTO)} 136 | */ 137 | @Test 138 | @DisplayName("Should create a course when valid") 139 | void testCreate() { 140 | CourseRequestDTO courseDTO = TestData.createValidCourseRequest(); 141 | Course course = TestData.createValidCourse(); 142 | when(this.courseRepository.save(any())).thenReturn(course); 143 | 144 | assertEquals(courseMapper.toDTO(course), this.courseService.create(courseDTO)); 145 | verify(this.courseRepository).save(any()); 146 | } 147 | 148 | /** 149 | * Method under test: {@link CourseService#create(CourseRequestDTO)} 150 | */ 151 | @Test 152 | @DisplayName("Should throw an exception when creating an invalid course") 153 | void testCreateInvalid() { 154 | final List courses = TestData.createInvalidCoursesDTO(); 155 | for (CourseRequestDTO course : courses) { 156 | assertThrows(ConstraintViolationException.class, () -> this.courseService.create(course)); 157 | } 158 | then(courseRepository).shouldHaveNoInteractions(); 159 | } 160 | 161 | /** 162 | * Method under test: {@link CourseService#create(CourseRequestDTO)} 163 | */ 164 | @Test 165 | @DisplayName("Should throw an exception when creating a duplicate course") 166 | void testCreateSameName() { 167 | CourseRequestDTO courseRequestDTO = TestData.createValidCourseRequest(); 168 | when(this.courseRepository.findByName(any())) 169 | .thenReturn(List.of(TestData.createValidCourse())); 170 | 171 | assertThrows(BusinessException.class, () -> this.courseService.create(courseRequestDTO)); 172 | verify(this.courseRepository).findByName(any()); 173 | verify(this.courseRepository, times(0)).save(any()); 174 | } 175 | 176 | /** 177 | * Method under test: {@link CourseService#update(Long, CourseRequestDTO)} 178 | */ 179 | @Test 180 | @DisplayName("Should update a course when valid") 181 | void testUpdate() { 182 | Course course = TestData.createValidCourse(); 183 | Optional ofResult = Optional.of(course); 184 | 185 | Course course1 = TestData.createValidCourse(); 186 | when(this.courseRepository.save(any())).thenReturn(course1); 187 | when(this.courseRepository.findById(anyLong())).thenReturn(ofResult); 188 | 189 | CourseRequestDTO course2 = TestData.createValidCourseRequest(); 190 | assertEquals(courseMapper.toDTO(course1), this.courseService.update(1L, course2)); 191 | verify(this.courseRepository).save(any()); 192 | verify(this.courseRepository).findById(anyLong()); 193 | } 194 | 195 | /** 196 | * Method under test: {@link CourseService#update(Long, CourseRequestDTO)} 197 | */ 198 | @Test 199 | @DisplayName("Should throw an exception when updating an invalid course ID") 200 | void testUpdateNotFound() { 201 | Course course = TestData.createValidCourse(); 202 | Optional ofResult = Optional.of(course); 203 | when(this.courseRepository.save(any())).thenThrow(new RecordNotFoundException(123L)); 204 | when(this.courseRepository.findById(anyLong())).thenReturn(ofResult); 205 | 206 | CourseRequestDTO course1 = TestData.createValidCourseRequest(); 207 | assertThrows(RecordNotFoundException.class, () -> this.courseService.update(123L, course1)); 208 | verify(this.courseRepository).save(any()); 209 | verify(this.courseRepository).findById(anyLong()); 210 | } 211 | 212 | /** 213 | * Method under test: {@link CourseService#update(Long, CourseRequestDTO)} 214 | */ 215 | @Test 216 | @DisplayName("Should throw exception when id is not valid - update") 217 | void testUpdateInvalid() { 218 | 219 | CourseRequestDTO validCourse = TestData.createValidCourseRequest(); 220 | 221 | // invalid id and valid course 222 | assertThrows(ConstraintViolationException.class, () -> this.courseService.update(-1L, validCourse)); 223 | assertThrows(ConstraintViolationException.class, () -> this.courseService.update(null, validCourse)); 224 | 225 | // valid id and invalid course 226 | final List courses = TestData.createInvalidCoursesDTO(); 227 | for (CourseRequestDTO course : courses) { 228 | assertThrows(ConstraintViolationException.class, () -> this.courseService.update(1L, course)); 229 | } 230 | 231 | // invalid id and invalid course 232 | for (CourseRequestDTO course : courses) { 233 | assertThrows(ConstraintViolationException.class, () -> this.courseService.update(-1L, course)); 234 | assertThrows(ConstraintViolationException.class, () -> this.courseService.update(null, course)); 235 | } 236 | 237 | then(courseRepository).shouldHaveNoInteractions(); 238 | } 239 | 240 | /** 241 | * Method under test: {@link CourseService#delete(Long)} 242 | */ 243 | @Test 244 | @DisplayName("Should soft delete a course") 245 | void testDelete() { 246 | Course course = TestData.createValidCourse(); 247 | Optional ofResult = Optional.of(course); 248 | doNothing().when(this.courseRepository).delete(any()); 249 | when(this.courseRepository.findById(anyLong())).thenReturn(ofResult); 250 | this.courseService.delete(1L); 251 | verify(this.courseRepository).findById(anyLong()); 252 | verify(this.courseRepository).delete(any()); 253 | } 254 | 255 | /** 256 | * Method under test: {@link CourseService#delete(Long)} 257 | */ 258 | @Test 259 | @DisplayName("Should return empty when course not found - delete") 260 | void testDeleteNotFound() { 261 | Course course = TestData.createValidCourse(); 262 | Optional ofResult = Optional.of(course); 263 | doThrow(new RecordNotFoundException(1L)).when(this.courseRepository).delete(any()); 264 | when(this.courseRepository.findById(anyLong())).thenReturn(ofResult); 265 | assertThrows(RecordNotFoundException.class, () -> this.courseService.delete(1L)); 266 | verify(this.courseRepository).findById(anyLong()); 267 | verify(this.courseRepository).delete(any()); 268 | } 269 | 270 | /** 271 | * Method under test: {@link CourseService#delete(Long)} 272 | */ 273 | @Test 274 | @DisplayName("Should throw exception when id is not valid - delete") 275 | void testDeleteInvalid() { 276 | assertThrows(ConstraintViolationException.class, () -> this.courseService.delete(-1L)); 277 | assertThrows(ConstraintViolationException.class, () -> this.courseService.delete(null)); 278 | } 279 | 280 | } 281 | -------------------------------------------------------------------------------- /crud-spring/src/test/java/com/loiane/course/TestData.java: -------------------------------------------------------------------------------- 1 | package com.loiane.course; 2 | 3 | import java.util.List; 4 | import java.util.Set; 5 | 6 | import com.loiane.course.dto.CourseDTO; 7 | import com.loiane.course.dto.CourseRequestDTO; 8 | import com.loiane.course.dto.LessonDTO; 9 | import com.loiane.course.enums.Category; 10 | import com.loiane.course.enums.Status; 11 | 12 | public class TestData { 13 | 14 | private static final String COURSE_NAME = "Spring"; 15 | private static final String INVALID_COURSE_NAME = "Spr"; 16 | private static final String LOREN_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc et quam nec diam tristique mollis eget quis urna. Sed dapibus lectus in arcu rutrum, non luctus sem finibus. Cras nisl neque, pellentesque et tortor id, dapibus auctor turpis."; 17 | 18 | private static final String LESSON_NAME = "Spring Intro"; 19 | private static final String LESSON_YOUTUBE = "abcdefgh123"; 20 | 21 | private TestData() { 22 | } 23 | 24 | public static Course createValidCourse() { 25 | Course course = new Course(); 26 | course.setId(1L); 27 | course.setName(COURSE_NAME); 28 | course.setCategory(Category.BACK_END); 29 | course.setStatus(Status.ACTIVE); 30 | 31 | Lesson lesson = new Lesson(); 32 | lesson.setName(LESSON_NAME); 33 | lesson.setYoutubeUrl(LESSON_YOUTUBE); 34 | lesson.setCourse(course); 35 | course.setLessons(Set.of(lesson)); 36 | return course; 37 | } 38 | 39 | public static CourseDTO createValidCourseDTO() { 40 | return new CourseDTO(1L, COURSE_NAME, Category.BACK_END.getValue(), createLessonsDTO()); 41 | } 42 | 43 | public static CourseRequestDTO createValidCourseRequest() { 44 | return new CourseRequestDTO(COURSE_NAME, Category.BACK_END.getValue(), createLessonsDTO()); 45 | } 46 | 47 | private static List createLessonsDTO() { 48 | return List.of(new LessonDTO(1, LESSON_NAME, LESSON_YOUTUBE)); 49 | } 50 | 51 | public static List createInvalidCourses() { 52 | final String validName = COURSE_NAME; 53 | final String empty = ""; 54 | 55 | return List.of( 56 | buildCourse(null, null), 57 | buildCourse(null, Category.BACK_END), 58 | buildCourse(empty, Category.BACK_END), 59 | buildCourse(INVALID_COURSE_NAME, Category.BACK_END), 60 | buildCourse(LOREN_IPSUM, Category.BACK_END), 61 | buildCourse(validName, null), 62 | buildCourse(validName, Category.BACK_END)); 63 | } 64 | 65 | private static Course buildCourse(String name, Category category) { 66 | Course course = new Course(); 67 | course.setName(name); 68 | course.setCategory(category); 69 | return course; 70 | } 71 | 72 | public static List createInvalidCoursesDTO() { 73 | final String validName = COURSE_NAME; 74 | final String validCategory = Category.BACK_END.getValue(); 75 | final String empty = ""; 76 | 77 | return List.of( 78 | new CourseRequestDTO(null, null, createLessonsDTO()), 79 | new CourseRequestDTO(validCategory, null, createLessonsDTO()), 80 | new CourseRequestDTO(validCategory, empty, createLessonsDTO()), 81 | new CourseRequestDTO(validCategory, INVALID_COURSE_NAME, createLessonsDTO()), 82 | new CourseRequestDTO(validCategory, LOREN_IPSUM, createLessonsDTO()), 83 | new CourseRequestDTO(null, validName, createLessonsDTO()), 84 | new CourseRequestDTO(empty, validName, createLessonsDTO()), 85 | new CourseRequestDTO(LOREN_IPSUM, validName, createLessonsDTO()), 86 | new CourseRequestDTO(validCategory, validName, createLessonsDTO()), 87 | new CourseRequestDTO(validCategory, validName, List.of())); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /crud-spring/test-coverage.md: -------------------------------------------------------------------------------- 1 | # How to get test coverage in VSCode 2 | 3 | Install extension: [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) 4 | 5 | Run the following command to generate the coverage report: 6 | 7 | ``` 8 | mvn jacoco:prepare-agent test install jacoco:report 9 | ``` 10 | 11 | On the status bar, click on `Watch`. 12 | 13 | Open the class and see the test coverage details. -------------------------------------------------------------------------------- /docs/form.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loiane/crud-angular-spring/0c9bcde26b9f60aef85d54f611594c3297607e29/docs/form.jpeg -------------------------------------------------------------------------------- /docs/main.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loiane/crud-angular-spring/0c9bcde26b9f60aef85d54f611594c3297607e29/docs/main.jpeg -------------------------------------------------------------------------------- /docs/view.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loiane/crud-angular-spring/0c9bcde26b9f60aef85d54f611594c3297607e29/docs/view.jpeg --------------------------------------------------------------------------------