├── .github
└── workflows
│ ├── deploy.yml
│ └── pr.yml
├── .gitignore
├── client
├── .env
├── .eslintrc.json
├── .gitignore
├── .npmrc
├── .prettierrc
├── README.md
├── next.config.js
├── package.json
├── pages
│ ├── _app.js
│ ├── api
│ │ └── hello.js
│ ├── cars
│ │ └── index.js
│ ├── customers
│ │ ├── add.js
│ │ └── index.js
│ ├── dashboard
│ │ └── index.js
│ ├── index.js
│ └── rentals
│ │ └── index.js
├── public
│ ├── favicon.ico
│ └── vercel.svg
├── src
│ ├── component
│ │ ├── common
│ │ │ └── infinite-scroll.js
│ │ ├── customer
│ │ │ └── customer-form.js
│ │ ├── sidebar
│ │ │ └── sidebar.js
│ │ └── table
│ │ │ └── load-more.js
│ ├── redux
│ │ └── reducer
│ │ │ └── auth.js
│ └── service
│ │ ├── api
│ │ ├── api.service.js
│ │ ├── auth
│ │ │ └── auth.service.js
│ │ └── customer
│ │ │ └── customer.service.js
│ │ └── storage
│ │ └── storage.service.js
├── styles
│ ├── Home.module.css
│ └── globals.css
└── yarn.lock
└── server
├── .dockerignore
├── .env
├── .env.test
├── .gitignore
├── .php-cs-fixer.dist.php
├── Dockerfile
├── Makefile
├── README.md
├── bin
├── console
└── phpunit
├── composer.json
├── composer.lock
├── config
├── bundles.php
├── orm
│ └── mapping
│ │ ├── employee
│ │ └── Employee.orm.xml
│ │ └── rental
│ │ ├── Car.orm.xml
│ │ └── Rental.orm.xml
├── packages
│ ├── cache.yaml
│ ├── doctrine.yaml
│ ├── doctrine_migrations.yaml
│ ├── framework.yaml
│ ├── hautelook_alice.yaml
│ ├── lexik_jwt_authentication.yaml
│ ├── monolog.yaml
│ ├── nelmio_alice.yaml
│ ├── nelmio_api_doc.yaml
│ ├── nelmio_cors.yaml
│ ├── routing.yaml
│ ├── security.yaml
│ ├── sensio_framework_extra.yaml
│ ├── test
│ │ ├── dama_doctrine_test_bundle.yaml
│ │ ├── doctrine.yaml
│ │ └── doctrine_migrations.yaml
│ └── twig.yaml
├── preload.php
├── routes.yaml
├── routes
│ ├── annotations.yaml
│ ├── framework.yaml
│ └── nelmio_api_doc.yaml
├── services.yaml
└── services
│ ├── employee.yaml
│ └── rental.yaml
├── docker-compose.yml.dist
├── docker
├── database
│ ├── Dockerfile
│ └── databases.sql
└── php-apache
│ ├── Dockerfile
│ ├── default.conf
│ └── xdebug.ini
├── fixtures
└── Customer.yaml
├── migrations
├── dev
│ └── Version20220518191517.php
└── test
│ └── Version20220518191518.php
├── phpunit.xml.dist
├── public
└── index.php
├── src
├── Customer
│ ├── Adapter
│ │ ├── Database
│ │ │ └── ORM
│ │ │ │ └── Doctrine
│ │ │ │ ├── Mapping
│ │ │ │ └── Customer.orm.xml
│ │ │ │ └── Repository
│ │ │ │ └── DoctrineCustomerRepository.php
│ │ └── Framework
│ │ │ ├── Config
│ │ │ └── Services
│ │ │ │ └── customer.yaml
│ │ │ ├── Http
│ │ │ ├── API
│ │ │ │ ├── Filter
│ │ │ │ │ └── CustomerFilter.php
│ │ │ │ └── Response
│ │ │ │ │ └── PaginatedResponse.php
│ │ │ ├── ArgumentResolver
│ │ │ │ └── RequestArgumentResolver.php
│ │ │ ├── Controller
│ │ │ │ ├── Customer
│ │ │ │ │ ├── CreateCustomerController.php
│ │ │ │ │ ├── DeleteCustomerController.php
│ │ │ │ │ ├── GetCustomerByIdController.php
│ │ │ │ │ ├── SearchCustomerController.php
│ │ │ │ │ └── UpdateCustomerController.php
│ │ │ │ └── HealthCheckController.php
│ │ │ ├── DTO
│ │ │ │ ├── CreateCustomerRequestDTO.php
│ │ │ │ ├── DeleteCustomerRequestDTO.php
│ │ │ │ ├── GetCustomerByIdRequestDTO.php
│ │ │ │ ├── GetCustomersRequest.php
│ │ │ │ ├── RequestDTO.php
│ │ │ │ └── UpdateCustomerRequestDTO.php
│ │ │ └── RequestTransformer
│ │ │ │ └── RequestTransformer.php
│ │ │ └── Listener
│ │ │ └── JsonTransformerExceptionListener.php
│ ├── Application
│ │ └── UseCase
│ │ │ └── Customer
│ │ │ ├── CreateCustomer
│ │ │ ├── CreateCustomer.php
│ │ │ └── DTO
│ │ │ │ ├── CreateCustomerInputDTO.php
│ │ │ │ └── CreateCustomerOutputDTO.php
│ │ │ ├── DeleteCustomer
│ │ │ ├── DTO
│ │ │ │ └── DeleteCustomerInputDTO.php
│ │ │ └── DeleteCustomer.php
│ │ │ ├── GetCustomerById
│ │ │ ├── DTO
│ │ │ │ ├── GetCustomerByIdInputDTO.php
│ │ │ │ └── GetCustomerByIdOutputDTO.php
│ │ │ └── GetCustomerById.php
│ │ │ ├── Search
│ │ │ ├── DTO
│ │ │ │ └── SearchCustomersOutput.php
│ │ │ └── SearchCustomers.php
│ │ │ └── UpdateCustomer
│ │ │ ├── DTO
│ │ │ ├── UpdateCustomerInputDTO.php
│ │ │ └── UpdateCustomerOutputDTO.php
│ │ │ └── UpdateCustomer.php
│ ├── Domain
│ │ ├── Exception
│ │ │ ├── CustomerAlreadyExistsException.php
│ │ │ ├── InvalidArgumentException.php
│ │ │ └── ResourceNotFoundException.php
│ │ ├── Model
│ │ │ └── Customer.php
│ │ ├── Repository
│ │ │ └── CustomerRepository.php
│ │ ├── Validation
│ │ │ └── Traits
│ │ │ │ ├── AssertLengthRangeTrait.php
│ │ │ │ ├── AssertMinimumAgeTrait.php
│ │ │ │ └── AssertNotNullTrait.php
│ │ └── ValueObject
│ │ │ └── Uuid.php
│ └── Service
│ │ └── .gitignore
├── Employee
│ ├── Command
│ │ ├── CreateEmployeeCommand.php
│ │ └── RemoveEmployeeCommand.php
│ ├── Controller
│ │ ├── CreateCustomerController.php
│ │ ├── DeleteCustomerController.php
│ │ ├── GetCustomersController.php
│ │ └── HealthCheckController.php
│ ├── Entity
│ │ └── Employee.php
│ ├── Exception
│ │ ├── DatabaseException.php
│ │ ├── EmployeeAlreadyExistsException.php
│ │ └── ResourceNotFoundException.php
│ ├── Http
│ │ ├── HttpClient.php
│ │ └── HttpClientInterface.php
│ ├── Repository
│ │ ├── DoctrineEmployeeRepository.php
│ │ └── EmployeeRepository.php
│ └── Service
│ │ ├── CreateCustomerService.php
│ │ ├── CreateEmployeeService.php
│ │ ├── DeleteCustomerService.php
│ │ ├── GetEmployeeCustomers.php
│ │ ├── RemoveEmployeeService.php
│ │ └── Security
│ │ ├── Listener
│ │ └── JWTCreatedListener.php
│ │ ├── PasswordHasherInterface.php
│ │ ├── SymfonyPasswordHasher.php
│ │ └── Voter
│ │ └── EmployeeVoter.php
├── Kernel.php
└── Rental
│ ├── Controller
│ └── HealthCheckController.php
│ ├── Entity
│ ├── Car.php
│ └── Rental.php
│ ├── Repository
│ ├── DoctrineRentRepository.php
│ └── RentRepository.php
│ └── Service
│ └── .gitignore
├── symfony.lock
├── templates
└── base.html.twig
└── tests
├── Functional
└── Customer
│ └── Controller
│ ├── Customer
│ ├── CreateCustomerControllerTest.php
│ ├── DeleteCustomerControllerTest.php
│ └── UpdateCustomerControllerTest.php
│ ├── CustomerControllerTestBase.php
│ └── HealthCheckControllerTest.php
├── Unit
├── Customer
│ └── Application
│ │ └── UseCase
│ │ └── Customer
│ │ ├── CreateCustomer
│ │ ├── CreateCustomerTest.php
│ │ └── DTO
│ │ │ └── CreateCustomerInputDTOTest.php
│ │ ├── DeleteCustomer
│ │ └── DeleteCustomerTest.php
│ │ ├── GetCustomerById
│ │ ├── DTO
│ │ │ └── GetCustomerByIdInputDTOTest.php
│ │ └── GetCustomerByIdTest.php
│ │ └── UpdateCustomer
│ │ ├── DTO
│ │ └── UpdateCustomerInputDTOTest.php
│ │ └── UpdateCustomerTest.php
└── Employee
│ └── Service
│ ├── CreateEmployeeServiceTest.php
│ └── RemoveEmployeeServiceTest.php
└── bootstrap.php
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | jobs:
8 | build:
9 | name: Deploy on production
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Deploy commands
13 | uses: appleboy/ssh-action@master
14 | with:
15 | host: ${{ secrets.HOST }}
16 | username: ${{ secrets.USERNAME }}
17 | key: ${{ secrets.KEY }}
18 | port: ${{ secrets.PORT }}
19 | script: |
20 | ./deploy.sh
21 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | on:
2 | pull_request:
3 | branches:
4 | - main
5 |
6 | jobs:
7 | app_checks:
8 | name: app checks
9 | runs-on: ubuntu-latest
10 | defaults:
11 | run:
12 | working-directory: server
13 | steps:
14 | - name: Checkout code
15 | uses: actions/checkout@v2
16 | - name: Pull docker-compose
17 | run: docker-compose -f docker-compose.yml.dist pull
18 | - uses: satackey/action-docker-layer-caching@v0.0.11
19 | continue-on-error: true
20 | - name: Prepare containers
21 | run: make start && make composer-install
22 | - name: Run coding style check
23 | run: make code-style-check
24 | - name: buffering time for db container
25 | uses: jakejarvis/wait-action@master
26 | with:
27 | time: '10s'
28 | - name: Run tests
29 | run: make migrations-test && make generate-ssh-keys && make tests
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 |
--------------------------------------------------------------------------------
/client/.env:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_BASE_URL=http://localhost:1000
2 | NEXT_PUBLIC_API_PATH=/api
3 |
--------------------------------------------------------------------------------
/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
--------------------------------------------------------------------------------
/client/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
2 |
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
6 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
16 |
17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
18 |
19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/client/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | }
6 |
7 | module.exports = nextConfig
8 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "lint:fix": "next lint --fix",
11 | "format": "prettier --check --ignore-path .gitignore .",
12 | "format:fix": "prettier --write --ignore-path .gitignore ."
13 | },
14 | "dependencies": {
15 | "@chakra-ui/icons": "^2.0.12",
16 | "@chakra-ui/react": "^2.3.1",
17 | "@emotion/react": "^11.10.4",
18 | "@emotion/styled": "^11.10.4",
19 | "@hookform/resolvers": "^2.9.6",
20 | "axios": "^1.1.2",
21 | "framer-motion": "^7.2.1",
22 | "jwt-decode": "^3.1.2",
23 | "lodash": "^4.17.21",
24 | "next": "12.2.3",
25 | "next-redux-wrapper": "^8.0.0",
26 | "react": "18.2.0",
27 | "react-bootstrap": "^2.4.0",
28 | "react-dom": "18.2.0",
29 | "react-hook-form": "^7.33.1",
30 | "react-icons": "^4.4.0",
31 | "react-redux": "^8.0.4",
32 | "redux": "^4.2.0",
33 | "yup": "^0.32.11"
34 | },
35 | "devDependencies": {
36 | "eslint": "^8.20.0",
37 | "eslint-config-next": "^12.2.3",
38 | "eslint-config-prettier": "^8.5.0",
39 | "prettier": "^2.7.1"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/client/pages/_app.js:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from '@chakra-ui/react'
2 | import axios from 'axios'
3 | import { combineReducers, legacy_createStore as createStore } from 'redux'
4 | import AuthReducer, {
5 | fromLocalStorage,
6 | logout,
7 | } from '../src/redux/reducer/auth'
8 | import { loadState, saveState } from '../src/service/storage/storage.service'
9 |
10 | import throttle from 'lodash/throttle'
11 | import { createWrapper } from 'next-redux-wrapper'
12 | import { Provider, useSelector } from 'react-redux'
13 |
14 | const rootReducer = combineReducers({
15 | auth: AuthReducer,
16 | })
17 |
18 | const store = createStore(rootReducer)
19 | const makeStore = () => store
20 | const wrapper = createWrapper(makeStore)
21 |
22 | loadState()
23 | .then((state) => {
24 | undefined !== state && store.dispatch(fromLocalStorage(state.auth))
25 | })
26 | .catch((err) => console.log(err))
27 |
28 | store.subscribe(
29 | throttle(() => {
30 | saveState({
31 | auth: store.getState().auth,
32 | })
33 | }, 1000),
34 | )
35 |
36 | function MyApp({ Component, pageProps }) {
37 | const token = useSelector((state) => state.auth.token)
38 |
39 | axios.defaults.baseURL = process.env.NEXT_PUBLIC_API_BASE_URL
40 | axios.defaults.headers.Authorization = `Bearer ${token}`
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | )
49 | }
50 |
51 | export default wrapper.withRedux(MyApp)
52 |
--------------------------------------------------------------------------------
/client/pages/api/hello.js:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | export default function handler(req, res) {
4 | res.status(200).json({ name: 'John Doe' })
5 | }
6 |
--------------------------------------------------------------------------------
/client/pages/cars/index.js:
--------------------------------------------------------------------------------
1 | import { Heading } from '@chakra-ui/react'
2 | import SidebarWithHeader from '../../src/component/sidebar/sidebar'
3 |
4 | export default function Cars() {
5 | return (
6 |
7 | Cars list!
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/client/pages/customers/add.js:
--------------------------------------------------------------------------------
1 | import { Box, Heading, useToast } from '@chakra-ui/react'
2 | import { useRouter } from 'next/router'
3 |
4 | import * as yup from 'yup'
5 | import { yupResolver } from '@hookform/resolvers/yup'
6 | import { useForm } from 'react-hook-form'
7 | import { useSelector } from 'react-redux'
8 | import SidebarWithHeader from '../../src/component/sidebar/sidebar'
9 | import CustomerForm from '../../src/component/customer/customer-form'
10 | import { createCustomer } from '../../src/service/api/customer/customer.service'
11 |
12 | export default function Add() {
13 | const router = useRouter()
14 | const toast = useToast()
15 | const employeeId = useSelector((state) => state.auth.id)
16 |
17 | const validationSchema = yup.object().shape({
18 | name: yup
19 | .string()
20 | .required('Debes introducir el nombre del cliente para poder guardarlo'),
21 | email: yup.string().required('Email obligatorio').email('Email no válido'),
22 | age: yup
23 | .number()
24 | .typeError()
25 | .required('Edad obligatoria')
26 | .min(18, 'La edad mínima debe ser 18 años o superior')
27 | .max(150, 'La edad no puede ser superior a 150 años'),
28 | address: yup.string().required('Debes introducir una dirección'),
29 | })
30 |
31 | const {
32 | register,
33 | handleSubmit,
34 | formState: { errors },
35 | } = useForm({
36 | resolver: yupResolver(validationSchema),
37 | })
38 |
39 | const onSubmitForm = async (data) => {
40 | try {
41 | await createCustomer(employeeId, {
42 | name: data.name,
43 | email: data.email,
44 | age: data.age,
45 | address: data.address,
46 | })
47 | await router.push('/customers')
48 | } catch (e) {
49 | if (409 === e.response.status) {
50 | toast({
51 | title: 'No se ha podido crear al cliente.',
52 | description: `Ya existe un cliente en la base de datos con email ${data.email}`,
53 | status: 'error',
54 | duration: 5000,
55 | isClosable: true,
56 | })
57 | }
58 | }
59 | }
60 |
61 | return (
62 |
63 | Nuevo cliente
64 |
65 | router.back()}
70 | />
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/client/pages/customers/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Flex,
4 | FormControl,
5 | Heading,
6 | Icon,
7 | IconButton,
8 | Input,
9 | Spacer,
10 | } from '@chakra-ui/react'
11 | import SidebarWithHeader from '../../src/component/sidebar/sidebar'
12 | import { useSelector } from 'react-redux'
13 | import {
14 | deleteCustomer,
15 | searchCustomers,
16 | } from '../../src/service/api/customer/customer.service'
17 | import { useCallback, useEffect, useState } from 'react'
18 | import {
19 | Table,
20 | Thead,
21 | Tbody,
22 | Tr,
23 | Th,
24 | Td,
25 | TableContainer,
26 | } from '@chakra-ui/react'
27 | import InfiniteScroll from '../../src/component/common/infinite-scroll'
28 | import { FiChevronDown, FiChevronUp, FiPlus, FiTrash } from 'react-icons/fi'
29 | import { useRouter } from 'next/router'
30 |
31 | export default function Customers() {
32 | const id = useSelector((state) => state.auth.id)
33 | const router = useRouter()
34 | const [customers, setCustomers] = useState([])
35 | const [meta, setMeta] = useState({
36 | page: 1,
37 | limit: 30,
38 | hasNext: false,
39 | total: 0,
40 | })
41 | const [filters, setFilters] = useState({
42 | name: '',
43 | })
44 | const [sorting, setSorting] = useState({
45 | sort: 'name',
46 | order: 'asc',
47 | })
48 |
49 | const buildFilters = useCallback(
50 | (page, limit) => {
51 | let result = `?page=${page}&limit=${limit}&sort=${sorting.sort}&order=${sorting.order}`
52 |
53 | if ('' !== filters.name) {
54 | result += `&name=${filters.name}`
55 | }
56 |
57 | return result
58 | },
59 | [filters, sorting],
60 | )
61 |
62 | const search = useCallback(
63 | async (page = 1, limit = 30, loadMore = false) => {
64 | try {
65 | const response = await searchCustomers(id, buildFilters(page, limit))
66 | setCustomers(
67 | loadMore
68 | ? customers.concat(response.data.items)
69 | : response.data.items,
70 | )
71 | setMeta(response.data.meta)
72 | } catch (e) {
73 | console.log(e)
74 | }
75 | },
76 | [id, customers, buildFilters],
77 | )
78 |
79 | useEffect(() => {
80 | search()
81 | }, [filters, sorting]) // eslint-disable-line
82 |
83 | const remove = async (customerId) => {
84 | try {
85 | await deleteCustomer(id, customerId)
86 | setCustomers(customers.filter((customer) => customerId !== customer.id))
87 | setMeta((prevState) => {
88 | return {
89 | ...prevState,
90 | total: meta.total - 1,
91 | }
92 | })
93 | } catch (e) {
94 | console.log(e)
95 | }
96 | }
97 |
98 | return (
99 |
100 | Customers list
101 |
102 |
103 |
104 | {
110 | if ('' === e.target.value) {
111 | setFilters((prevState) => {
112 | return {
113 | ...prevState,
114 | name: e.target.value,
115 | }
116 | })
117 | }
118 | }}
119 | onKeyDown={(e) => {
120 | if (e.code === 'Enter') {
121 | setFilters((prevState) => {
122 | return {
123 | ...prevState,
124 | name: e.target.value,
125 | }
126 | })
127 | }
128 | }}
129 | />
130 |
131 |
132 |
133 |
134 | }
139 | onClick={() => router.push('/customers/add')}
140 | />
141 |
142 |
143 |
144 |
145 |
146 |
147 | ID |
148 |
149 | Name
150 | {
156 | setSorting((prevState) => {
157 | return {
158 | ...prevState,
159 | sort: 'name',
160 | order: 'asc' === prevState.order ? 'desc' : 'asc',
161 | }
162 | })
163 | }}
164 | />
165 | |
166 | Address |
167 | Actions |
168 |
169 |
170 |
171 | {customers.map((customer) => (
172 |
173 | {customer.id} |
174 | {customer.name} |
175 | {customer.address} |
176 |
177 | remove(customer.id)}
181 | />
182 | |
183 |
184 | ))}
185 |
186 |
187 |
188 |
189 |
190 | )
191 | }
192 |
--------------------------------------------------------------------------------
/client/pages/dashboard/index.js:
--------------------------------------------------------------------------------
1 | import { Heading } from '@chakra-ui/react'
2 | import SidebarWithHeader from '../../src/component/sidebar/sidebar'
3 |
4 | export default function Dashboard() {
5 | return (
6 |
7 | Welcome to Codenip Car Rental System!
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/client/pages/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup'
2 | import { yupResolver } from '@hookform/resolvers/yup'
3 | import { useForm, Controller } from 'react-hook-form'
4 | import {
5 | Flex,
6 | Heading,
7 | Input,
8 | Button,
9 | InputGroup,
10 | Stack,
11 | Box,
12 | Avatar,
13 | FormControl,
14 | InputRightElement,
15 | Text,
16 | useToast,
17 | useColorMode,
18 | } from '@chakra-ui/react'
19 | import { useEffect, useState } from 'react'
20 | import { decodeToken, login } from '../src/service/api/auth/auth.service'
21 | import { useRouter } from 'next/router'
22 | import { useDispatch, useSelector } from 'react-redux'
23 | import { saveUser } from '../src/redux/reducer/auth'
24 |
25 | export default function Home() {
26 | const { colorMode, toggleColorMode } = useColorMode()
27 | const router = useRouter()
28 | const dispatch = useDispatch()
29 | const token = useSelector((state) => state.auth.token)
30 | const toast = useToast()
31 |
32 | const validationSchema = yup.object().shape({
33 | email: yup.string().email('Invalid email').required('Email is mandatory'),
34 | password: yup
35 | .string()
36 | .required('Password is mandatory')
37 | .min(6, 'Password must be at least 6 characters'),
38 | })
39 |
40 | const {
41 | control,
42 | handleSubmit,
43 | formState: { errors },
44 | } = useForm({
45 | resolver: yupResolver(validationSchema),
46 | })
47 |
48 | const [showPassword, setShowPassword] = useState(false)
49 |
50 | const handleShowClick = () => setShowPassword(!showPassword)
51 |
52 | const onSubmitForm = async (data) => {
53 | try {
54 | const response = await login(data.email.trim(), data.password.trim())
55 | const token = response.data.token
56 | const payload = decodeToken(token)
57 |
58 | dispatch(saveUser(token, payload))
59 |
60 | await router.push('/dashboard')
61 | } catch (e) {
62 | toast({
63 | title: 'Invalid credential.',
64 | description: 'Invalid email or password. Please try again!',
65 | status: 'error',
66 | duration: 5000,
67 | isClosable: true,
68 | })
69 | }
70 | }
71 |
72 | useEffect(() => {
73 | async function toDashboard() {
74 | await router.push('/dashboard')
75 | }
76 |
77 | if (undefined !== token) {
78 | toDashboard()
79 | }
80 | })
81 |
82 | useEffect(() => {
83 | if ('dark' === colorMode) {
84 | toggleColorMode()
85 | }
86 | })
87 |
88 | return (
89 |
97 |
103 |
104 | Codenip Car Rental
105 |
106 |
171 |
172 |
173 |
174 | )
175 | }
176 |
--------------------------------------------------------------------------------
/client/pages/rentals/index.js:
--------------------------------------------------------------------------------
1 | import { Heading } from '@chakra-ui/react'
2 | import SidebarWithHeader from '../../src/component/sidebar/sidebar'
3 |
4 | export default function Rentals() {
5 | return (
6 |
7 | Rentals list!
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenip-tech/modular-monolith-example/62c7d29bbaa125de733d9f91a7c65a238de5e280/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/component/common/infinite-scroll.js:
--------------------------------------------------------------------------------
1 | import { Center, Text } from '@chakra-ui/react'
2 | import LoadMore from '../table/load-more'
3 |
4 | export default function InfiniteScroll({ meta, collection, search }) {
5 | return meta.hasNext ? (
6 | <>
7 |
8 | search(meta.page + 1, meta.limit, true)} />
9 |
10 |
11 | Showing {collection.length} of {meta.total} results
12 |
13 | >
14 | ) : (
15 |
16 | Showing {collection.length} of {meta.total} results
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/component/customer/customer-form.js:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Flex,
4 | FormControl,
5 | Input,
6 | InputGroup,
7 | NumberDecrementStepper,
8 | NumberIncrementStepper,
9 | NumberInput,
10 | NumberInputField,
11 | NumberInputStepper,
12 | Stack,
13 | Text,
14 | Textarea,
15 | } from '@chakra-ui/react'
16 |
17 | export default function CustomerForm({ onSubmit, register, errors, goBack }) {
18 | return (
19 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/client/src/component/sidebar/sidebar.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import {
3 | IconButton,
4 | Avatar,
5 | Box,
6 | CloseButton,
7 | Flex,
8 | HStack,
9 | VStack,
10 | Icon,
11 | useColorModeValue,
12 | Drawer,
13 | DrawerContent,
14 | Text,
15 | useDisclosure,
16 | Menu,
17 | MenuButton,
18 | MenuDivider,
19 | MenuItem,
20 | MenuList,
21 | useColorMode,
22 | Button,
23 | } from '@chakra-ui/react'
24 | import Link from 'next/link'
25 | import { FiMenu, FiBell, FiChevronDown } from 'react-icons/fi'
26 | import { BsFillCalendar2WeekFill, BsPersonFill, BsTruck } from 'react-icons/bs'
27 | import { MoonIcon, SunIcon } from '@chakra-ui/icons'
28 |
29 | import { FaFileContract } from 'react-icons/fa'
30 | import { useRouter } from 'next/router'
31 | import { useDispatch, useSelector } from 'react-redux'
32 | import { logout } from '../../redux/reducer/auth'
33 |
34 | const LinkItems = [
35 | { name: 'Dashboard', icon: BsFillCalendar2WeekFill, path: '/dashboard' },
36 | { name: 'Customers', icon: BsPersonFill, path: '/customers' },
37 | { name: 'Cars', icon: BsTruck, path: '/cars' },
38 | { name: 'Rentals', icon: FaFileContract, path: '/rentals' },
39 | ]
40 |
41 | export default function SidebarWithHeader({ children }) {
42 | const { colorMode, toggleColorMode } = useColorMode()
43 | const { isOpen, onOpen, onClose } = useDisclosure()
44 | const router = useRouter()
45 | const dispatch = useDispatch()
46 | const token = useSelector((state) => state.auth.token)
47 | const name = useSelector((state) => state.auth.name)
48 | const [username, setUsername] = useState('')
49 |
50 | const handleLogout = async () => {
51 | dispatch(logout())
52 | await router.push('/')
53 | }
54 |
55 | useEffect(() => {
56 | async function toLogin() {
57 | await router.push('/')
58 | }
59 |
60 | if (undefined === token) {
61 | toLogin()
62 | }
63 | })
64 |
65 | useEffect(() => setUsername(name), [username]) // eslint-disable-line
66 |
67 | return (
68 |
69 | onClose}
71 | display={{ base: 'none', md: 'block' }}
72 | />
73 |
82 |
83 |
84 |
85 |
86 | {/* mobilenav */}
87 |
94 |
95 | {children}
96 |
97 |
98 | )
99 | }
100 |
101 | const SidebarContent = ({ onClose, ...rest }) => {
102 | return (
103 |
113 |
114 |
115 | Logo
116 |
117 |
118 |
119 | {LinkItems.map((link) => (
120 |
121 | {link.name}
122 |
123 | ))}
124 |
125 | )
126 | }
127 |
128 | const NavItem = ({ icon, children, path, ...rest }) => {
129 | return (
130 |
135 |
148 | {icon && (
149 |
157 | )}
158 | {children}
159 |
160 |
161 | )
162 | }
163 |
164 | const MobileNav = ({
165 | onOpen,
166 | name,
167 | handleLogout,
168 | colorMode,
169 | toggleColorMode,
170 | ...rest
171 | }) => {
172 | return (
173 |
184 | }
190 | />
191 |
192 |
198 | Logo
199 |
200 |
201 |
202 |
203 |
206 |
236 |
237 |
238 |
239 | )
240 | }
241 |
--------------------------------------------------------------------------------
/client/src/component/table/load-more.js:
--------------------------------------------------------------------------------
1 | import { Button } from '@chakra-ui/react'
2 |
3 | export default function LoadMore({ loadMore }) {
4 | return (
5 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/redux/reducer/auth.js:
--------------------------------------------------------------------------------
1 | const SAVE_USER = 'codenip/saveUser'
2 | const FROM_LOCAL_STORAGE = 'codenip/fromLocalStorage'
3 | const LOGOUT = 'codenip/logout'
4 |
5 | const initialState = {}
6 |
7 | const AuthReducer = (state = initialState, action) => {
8 | switch (action.type) {
9 | case SAVE_USER:
10 | return {
11 | ...state,
12 | id: action.payload.id,
13 | email: action.payload.username,
14 | token: action.token,
15 | name: action.payload.name,
16 | }
17 | case FROM_LOCAL_STORAGE:
18 | return {
19 | ...state,
20 | id: action.values.id,
21 | email: action.values.email,
22 | token: action.values.token,
23 | name: action.values.name,
24 | }
25 | case LOGOUT:
26 | return {}
27 | default:
28 | return state
29 | }
30 | }
31 |
32 | export default AuthReducer
33 |
34 | export const saveUser = (token, payload) => ({
35 | type: SAVE_USER,
36 | token: token,
37 | payload: payload,
38 | })
39 |
40 | export const fromLocalStorage = (values) => ({
41 | type: FROM_LOCAL_STORAGE,
42 | values: values,
43 | })
44 |
45 | export const logout = () => ({
46 | type: LOGOUT,
47 | })
48 |
--------------------------------------------------------------------------------
/client/src/service/api/api.service.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | const API_PATH = process.env.NEXT_PUBLIC_API_PATH
4 |
5 | export const get = async (path, config = {}) => {
6 | return axios.get(`${API_PATH}/${path}`, config)
7 | }
8 |
9 | export const post = async (path, payload) => {
10 | return axios.post(`${API_PATH}/${path}`, payload)
11 | }
12 |
13 | export const put = async (path, payload) => {
14 | return axios.put(`${API_PATH}/${path}`, payload)
15 | }
16 |
17 | export const remove = async (path) => {
18 | return axios.delete(`${API_PATH}/${path}`)
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/service/api/auth/auth.service.js:
--------------------------------------------------------------------------------
1 | import decode from 'jwt-decode'
2 | import { post } from '../api.service'
3 |
4 | const routes = {
5 | login: 'login_check',
6 | }
7 |
8 | export const login = async (username, password) => {
9 | return await post(routes.login, { username, password })
10 | }
11 |
12 | export const decodeToken = (token) => {
13 | try {
14 | return decode(token)
15 | } catch (e) {
16 | console.log(e)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/service/api/customer/customer.service.js:
--------------------------------------------------------------------------------
1 | import { get, post, remove } from '../api.service'
2 |
3 | const routes = {
4 | base: 'employees',
5 | }
6 |
7 | export const searchCustomers = async (id, filters) => {
8 | return await get(`${routes.base}/${id}/customers${filters}`)
9 | }
10 |
11 | export const createCustomer = async (id, payload) => {
12 | return await post(`${routes.base}/${id}/customers`, payload)
13 | }
14 |
15 | export const deleteCustomer = async (employeeId, customerId) => {
16 | return await remove(`${routes.base}/${employeeId}/customers/${customerId}`)
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/service/storage/storage.service.js:
--------------------------------------------------------------------------------
1 | export const loadState = async () => {
2 | try {
3 | const serializedState = localStorage.getItem('state')
4 | if (null === serializedState) {
5 | return undefined
6 | }
7 | return JSON.parse(serializedState)
8 | } catch (e) {
9 | return undefined
10 | }
11 | }
12 |
13 | export const saveState = (state) => {
14 | try {
15 | const serializedState = JSON.stringify(state)
16 | localStorage.setItem('state', serializedState)
17 | } catch (e) {
18 | console.log(e)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/client/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0 2rem;
3 | }
4 |
5 | .main {
6 | min-height: 100vh;
7 | padding: 4rem 0;
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .footer {
16 | display: flex;
17 | flex: 1;
18 | padding: 2rem 0;
19 | border-top: 1px solid #eaeaea;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .footer a {
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | flex-grow: 1;
29 | }
30 |
31 | .title a {
32 | color: #0070f3;
33 | text-decoration: none;
34 | }
35 |
36 | .title a:hover,
37 | .title a:focus,
38 | .title a:active {
39 | text-decoration: underline;
40 | }
41 |
42 | .title {
43 | margin: 0;
44 | line-height: 1.15;
45 | font-size: 4rem;
46 | }
47 |
48 | .title,
49 | .description {
50 | text-align: center;
51 | }
52 |
53 | .description {
54 | margin: 4rem 0;
55 | line-height: 1.5;
56 | font-size: 1.5rem;
57 | }
58 |
59 | .code {
60 | background: #fafafa;
61 | border-radius: 5px;
62 | padding: 0.75rem;
63 | font-size: 1.1rem;
64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
65 | Bitstream Vera Sans Mono, Courier New, monospace;
66 | }
67 |
68 | .grid {
69 | display: flex;
70 | align-items: center;
71 | justify-content: center;
72 | flex-wrap: wrap;
73 | max-width: 800px;
74 | }
75 |
76 | .card {
77 | margin: 1rem;
78 | padding: 1.5rem;
79 | text-align: left;
80 | color: inherit;
81 | text-decoration: none;
82 | border: 1px solid #eaeaea;
83 | border-radius: 10px;
84 | transition: color 0.15s ease, border-color 0.15s ease;
85 | max-width: 300px;
86 | }
87 |
88 | .card:hover,
89 | .card:focus,
90 | .card:active {
91 | color: #0070f3;
92 | border-color: #0070f3;
93 | }
94 |
95 | .card h2 {
96 | margin: 0 0 1rem 0;
97 | font-size: 1.5rem;
98 | }
99 |
100 | .card p {
101 | margin: 0;
102 | font-size: 1.25rem;
103 | line-height: 1.5;
104 | }
105 |
106 | .logo {
107 | height: 1em;
108 | margin-left: 0.5rem;
109 | }
110 |
111 | @media (max-width: 600px) {
112 | .grid {
113 | width: 100%;
114 | flex-direction: column;
115 | }
116 | }
117 |
118 | @media (prefers-color-scheme: dark) {
119 | .card,
120 | .footer {
121 | border-color: #222;
122 | }
123 | .code {
124 | background: #111;
125 | }
126 | .logo img {
127 | filter: invert(1);
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/client/styles/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 | }
8 |
9 | a {
10 | color: inherit;
11 | text-decoration: none;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
18 | @media (prefers-color-scheme: dark) {
19 | html {
20 | color-scheme: dark;
21 | }
22 | body {
23 | color: white;
24 | background: black;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | vendor
2 | var
3 |
--------------------------------------------------------------------------------
/server/.env:
--------------------------------------------------------------------------------
1 | ###> symfony/framework-bundle ###
2 | APP_ENV=dev
3 | APP_SECRET=49e4af7269b614c645a9a6a2f479733d
4 | ###< symfony/framework-bundle ###
5 |
6 | ###> doctrine/doctrine-bundle ###
7 | # DEV & PROD
8 | DATABASE_URL_CUSTOMER="mysql://root:root@modular-monolith-example-mysql:3306/customer_db?serverVersion=mariadb-10.9.2&charset=utf8mb4"
9 | DATABASE_URL_EMPLOYEE="mysql://root:root@modular-monolith-example-mysql:3306/employee_db?serverVersion=mariadb-10.9.2&charset=utf8mb4"
10 | DATABASE_URL_RENTAL="mysql://root:root@modular-monolith-example-mysql:3306/rental_db?serverVersion=mariadb-10.9.2&charset=utf8mb4"
11 | # TESTING
12 | #DATABASE_URL_CUSTOMER_TEST="mysql://root:root@modular-monolith-example-mysql:3306/customer_db_test?serverVersion=8.0&charset=utf8mb4"
13 | #DATABASE_URL_EMPLOYEE_TEST="mysql://root:root@modular-monolith-example-mysql:3306/employee_db_test?serverVersion=8.0&charset=utf8mb4"
14 | #DATABASE_URL_RENT_TEST="mysql://root:root@modular-monolith-example-mysql:3306/rent_db_test?serverVersion=8.0&charset=utf8mb4"
15 | ###< doctrine/doctrine-bundle ###
16 |
17 | ###> lexik/jwt-authentication-bundle ###
18 | JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
19 | JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
20 | JWT_PASSPHRASE=efa1812f4ffee7a6f2520f16931f7e8b
21 | # seconds (15 days)
22 | JWT_TTL=1296000
23 | ###< lexik/jwt-authentication-bundle ###
24 |
25 | ###> nelmio/cors-bundle ###
26 | CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
27 | ###< nelmio/cors-bundle ###
28 |
29 | ###> API ###
30 | BASE_URL=http://modular-monolith-example-app:80
31 | ###< API ###
32 |
--------------------------------------------------------------------------------
/server/.env.test:
--------------------------------------------------------------------------------
1 | # define your env variables for the test env here
2 | KERNEL_CLASS='App\Kernel'
3 | APP_SECRET='$ecretf0rt3st'
4 | SYMFONY_DEPRECATIONS_HELPER=999999
5 | PANTHER_APP_ENV=panther
6 | PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
7 |
8 | ###> doctrine/doctrine-bundle ###
9 | DATABASE_URL_CUSTOMER="mysql://root:root@modular-monolith-example-mysql:3306/customer_db_test?serverVersion=mariadb-10.9.2&charset=utf8mb4"
10 | DATABASE_URL_EMPLOYEE="mysql://root:root@modular-monolith-example-mysql:3306/employee_db_test?serverVersion=mariadb-10.9.2&charset=utf8mb4"
11 | DATABASE_URL_RENT="mysql://root:root@modular-monolith-example-mysql:3306/rental_db_test?serverVersion=mariadb-10.9.2&charset=utf8mb4"
12 | ###< doctrine/doctrine-bundle ###
13 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 | docker-compose.yml
4 |
5 | ###> symfony/framework-bundle ###
6 | /.env.local
7 | /.env.local.php
8 | /.env.*.local
9 | /config/secrets/prod/prod.decrypt.private.php
10 | /public/bundles/
11 | /var/
12 | /vendor/
13 | ###< symfony/framework-bundle ###
14 | ###> friendsofphp/php-cs-fixer ###
15 | /.php-cs-fixer.php
16 | /.php-cs-fixer.cache
17 | ###< friendsofphp/php-cs-fixer ###
18 |
19 | ###> symfony/phpunit-bridge ###
20 | .phpunit.result.cache
21 | /phpunit.xml
22 | ###< symfony/phpunit-bridge ###
23 |
24 | ###> phpunit/phpunit ###
25 | /phpunit.xml
26 | .phpunit.result.cache
27 | ###< phpunit/phpunit ###
28 |
29 | ###> lexik/jwt-authentication-bundle ###
30 | /config/jwt/*.pem
31 | ###< lexik/jwt-authentication-bundle ###
32 |
--------------------------------------------------------------------------------
/server/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in(__DIR__)
5 | ->exclude('var')
6 | ;
7 |
8 | return (new PhpCsFixer\Config())
9 | ->setRules([
10 | '@Symfony' => true,
11 | ])
12 | ->setFinder($finder)
13 | ;
14 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM php:8.1.1-apache
2 |
3 | # Install dependencies
4 | RUN apt update \
5 | # common libraries and extensions
6 | && apt install -y git acl openssl openssh-client zip \
7 | && apt install -y libpng-dev zlib1g-dev libzip-dev libxml2-dev libicu-dev \
8 | && docker-php-ext-install intl pdo zip \
9 | # for MySQL
10 | && docker-php-ext-install pdo_mysql \
11 | # APCu
12 | && pecl install apcu \
13 | # enable Docker extensions
14 | && docker-php-ext-enable --ini-name 05-opcache.ini opcache apcu
15 |
16 | # Install and run composer
17 | COPY --from=composer:2.3.5 /usr/bin/composer /usr/bin/composer
18 |
19 | COPY ./composer.* /var/www/html/
20 |
21 | RUN composer install --prefer-dist --no-scripts --no-interaction --no-dev
22 |
23 | # Copy project content and execute necessary commands
24 | COPY . /var/www/html/
25 | RUN /var/www/html/bin/console a:i
26 | RUN /var/www/html/bin/console lexik:jwt:generate-keypair --skip-if-exists
27 |
28 | ## Create var folder and assign to Apache user
29 | RUN mkdir -p /var/www/html/var
30 | RUN chown -R www-data:www-data /var/www/html/var
31 |
32 | # Update Apache config
33 | COPY ./docker/php-apache/default.conf /etc/apache2/sites-available/default.conf
34 | RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf \
35 | && a2enmod rewrite \
36 | && a2dissite 000-default \
37 | && a2ensite default \
38 | && service apache2 restart
39 |
40 | # Setup PHP
41 | RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
42 |
43 | EXPOSE 80
44 |
--------------------------------------------------------------------------------
/server/Makefile:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | UID = $(shell id -u)
4 | DOCKER_BE = modular-monolith-example-app
5 |
6 | help: ## Show this help message
7 | @echo 'usage: make [target]'
8 | @echo
9 | @echo 'targets:'
10 | @egrep '^(.+)\:\ ##\ (.+)' ${MAKEFILE_LIST} | column -t -c 2 -s ':#'
11 |
12 | start: ## Start the containers
13 | docker network create modular-monolith-example-network || true
14 | cp -n docker-compose.yml.dist docker-compose.yml || true
15 | U_ID=${UID} docker-compose up -d
16 |
17 | stop: ## Stop the containers
18 | U_ID=${UID} docker-compose stop
19 |
20 | restart: ## Restart the containers
21 | $(MAKE) stop && $(MAKE) start
22 |
23 | build: ## Rebuilds all the containers
24 | docker network create modular-monolith-example-network || true
25 | cp -n docker-compose.yml.dist docker-compose.yml || true
26 | U_ID=${UID} docker-compose build
27 |
28 | prepare: ## Runs backend commands
29 | $(MAKE) composer-install
30 | $(MAKE) migrations
31 | $(MAKE) migrations-test
32 |
33 | run: ## starts the Symfony development server in detached mode
34 | U_ID=${UID} docker exec -it --user ${UID} ${DOCKER_BE} symfony serve -d
35 |
36 | logs: ## Show Symfony logs in real time
37 | U_ID=${UID} docker exec -it --user ${UID} ${DOCKER_BE} symfony server:log
38 |
39 | # Backend commands
40 | composer-install: ## Installs composer dependencies
41 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} composer install --no-interaction
42 |
43 | .PHONY: migrations migrations-test
44 | migrations: ## Run migrations for dev/prod environments
45 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} bin/console doctrine:migration:migrate -n
46 |
47 | migrations-test: ## Run migrations for test environments
48 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} bin/console doctrine:migration:migrate -n --env=test
49 |
50 | code-style:
51 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} vendor/bin/php-cs-fixer fix src --rules=@Symfony
52 |
53 | code-style-check:
54 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} vendor/bin/php-cs-fixer fix src --rules=@Symfony --dry-run
55 |
56 | generate-ssh-keys: ## Generates SSH keys for JWT authentication
57 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} bin/console lexik:jwt:generate-keypair
58 | # End backend commands
59 |
60 | ssh-be: ## bash into the be container
61 | U_ID=${UID} docker exec -it --user ${UID} ${DOCKER_BE} bash
62 |
63 | .PHONY: tests
64 | tests:
65 | U_ID=${UID} docker exec --user ${UID} ${DOCKER_BE} vendor/bin/simple-phpunit -c phpunit.xml.dist
66 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | # Modular Monolith Example
2 |
3 | ## Content
4 | - PHP container running version 8.1.1
5 | - MySQL container running version 8.0.26
6 |
7 | ## Instructions
8 | - `make build` to build the containers
9 | - `make start` to start the containers
10 | - `make stop` to stop the containers
11 | - `make restart` to restart the containers
12 | - `make prepare` to install dependencies with composer (once the project has been created)
13 | - `make run` to start a web server listening on port 1000 (8000 in the container)
14 | - `make logs` to see application logs
15 | - `make ssh-be` to SSH into the application container
--------------------------------------------------------------------------------
/server/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | =8.1",
8 | "ext-ctype": "*",
9 | "ext-iconv": "*",
10 | "doctrine/doctrine-bundle": "^2.6",
11 | "doctrine/doctrine-migrations-bundle": "^3.2",
12 | "doctrine/orm": "^2.12",
13 | "guzzlehttp/guzzle": "^7.5",
14 | "lexik/jwt-authentication-bundle": "^2.16",
15 | "nelmio/api-doc-bundle": "^4.9",
16 | "nelmio/cors-bundle": "^2.2",
17 | "sensio/framework-extra-bundle": "^6.2",
18 | "symfony/asset": "5.4.*",
19 | "symfony/console": "5.4.*",
20 | "symfony/dotenv": "5.4.*",
21 | "symfony/expression-language": "5.4.*",
22 | "symfony/flex": "^1.17|^2",
23 | "symfony/framework-bundle": "5.4.*",
24 | "symfony/monolog-bundle": "^3.8",
25 | "symfony/proxy-manager-bridge": "5.4.*",
26 | "symfony/runtime": "5.4.*",
27 | "symfony/security-bundle": "5.4.*",
28 | "symfony/twig-bundle": "5.4.*",
29 | "symfony/uid": "5.4.*",
30 | "symfony/yaml": "5.4.*",
31 | "twig/extra-bundle": "^2.12|^3.0",
32 | "twig/twig": "^2.12|^3.0"
33 | },
34 | "config": {
35 | "allow-plugins": {
36 | "composer/package-versions-deprecated": true,
37 | "symfony/flex": true,
38 | "symfony/runtime": true
39 | },
40 | "optimize-autoloader": true,
41 | "preferred-install": {
42 | "*": "dist"
43 | },
44 | "sort-packages": true
45 | },
46 | "autoload": {
47 | "psr-4": {
48 | "App\\": "src/",
49 | "Employee\\": "src/Employee",
50 | "Customer\\": "src/Customer",
51 | "Rental\\": "src/Rental"
52 | }
53 | },
54 | "autoload-dev": {
55 | "psr-4": {
56 | "App\\Tests\\": "tests/"
57 | }
58 | },
59 | "replace": {
60 | "symfony/polyfill-ctype": "*",
61 | "symfony/polyfill-iconv": "*",
62 | "symfony/polyfill-php72": "*"
63 | },
64 | "scripts": {
65 | "auto-scripts": {
66 | "cache:clear": "symfony-cmd",
67 | "assets:install %PUBLIC_DIR%": "symfony-cmd"
68 | },
69 | "post-install-cmd": [
70 | "@auto-scripts"
71 | ],
72 | "post-update-cmd": [
73 | "@auto-scripts"
74 | ]
75 | },
76 | "conflict": {
77 | "symfony/symfony": "*"
78 | },
79 | "extra": {
80 | "symfony": {
81 | "allow-contrib": false,
82 | "require": "5.4.*"
83 | }
84 | },
85 | "require-dev": {
86 | "dama/doctrine-test-bundle": "^7.1",
87 | "friendsofphp/php-cs-fixer": "^3.8",
88 | "hautelook/alice-bundle": "^2.11",
89 | "phpunit/phpunit": "^9.5",
90 | "symfony/browser-kit": "5.4.*",
91 | "symfony/css-selector": "5.4.*",
92 | "symfony/phpunit-bridge": "^6.1"
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/server/config/bundles.php:
--------------------------------------------------------------------------------
1 | ['all' => true],
5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
7 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
8 | DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
9 | Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
10 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
11 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
12 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
13 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
14 | Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
15 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
16 | Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['dev' => true, 'test' => true],
17 | Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['dev' => true, 'test' => true],
18 | Hautelook\AliceBundle\HautelookAliceBundle::class => ['dev' => true, 'test' => true],
19 | ];
20 |
--------------------------------------------------------------------------------
/server/config/orm/mapping/employee/Employee.orm.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/server/config/orm/mapping/rental/Car.orm.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/server/config/orm/mapping/rental/Rental.orm.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/server/config/packages/cache.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | cache:
3 | # Unique name of your app: used to compute stable namespaces for cache keys.
4 | #prefix_seed: your_vendor_name/app_name
5 |
6 | # The "app" cache stores to the filesystem by default.
7 | # The data in this cache should persist between deploys.
8 | # Other options include:
9 |
10 | # Redis
11 | #app: cache.adapter.redis
12 | #default_redis_provider: redis://localhost
13 |
14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
15 | #app: cache.adapter.apcu
16 |
17 | # Namespaced pools use the above "app" backend by default
18 | #pools:
19 | #my.dedicated.cache: null
20 |
--------------------------------------------------------------------------------
/server/config/packages/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | dbal:
3 | default_connection: customer_connection
4 | connections:
5 | customer_connection:
6 | url: '%env(resolve:DATABASE_URL_CUSTOMER)%'
7 | employee_connection:
8 | url: '%env(resolve:DATABASE_URL_EMPLOYEE)%'
9 | rental_connection:
10 | url: '%env(resolve:DATABASE_URL_RENTAL)%'
11 | orm:
12 | default_entity_manager: employee_em
13 | entity_managers:
14 | customer_em:
15 | connection: customer_connection
16 | mappings:
17 | Customer:
18 | is_bundle: false
19 | type: xml
20 | dir: '%kernel.project_dir%/src/Customer/Adapter/Database/ORM/Doctrine/Mapping'
21 | prefix: 'Customer\Domain\Model'
22 | alias: Customer\Domain\Model
23 | employee_em:
24 | connection: employee_connection
25 | mappings:
26 | Employee:
27 | is_bundle: false
28 | type: xml
29 | dir: '%kernel.project_dir%/config/orm/mapping/employee'
30 | prefix: 'Employee\Entity'
31 | alias: Employee
32 | rental_em:
33 | connection: rental_connection
34 | mappings:
35 | rental:
36 | is_bundle: false
37 | type: xml
38 | dir: '%kernel.project_dir%/config/orm/mapping/rental'
39 | prefix: 'Rental\Entity'
40 | alias: Rental
41 |
42 | when@test:
43 | doctrine:
44 | dbal:
45 | # "TEST_TOKEN" is typically set by ParaTest
46 | dbname_suffix: '_test%env(default::TEST_TOKEN)%'
47 |
48 | #when@prod:
49 | # doctrine:
50 | # dbal:
51 | # default_connection: customer_connection
52 | # connections:
53 | # customer_connection:
54 | # url: '%env(resolve:DATABASE_URL_CUSTOMER)%'
55 | # employee_connection:
56 | # url: '%env(resolve:DATABASE_URL_EMPLOYEE)%'
57 | # rental_connection:
58 | # url: '%env(resolve:DATABASE_URL_RENTAL)%'
59 | # orm:
60 | # auto_generate_proxy_classes: false
61 | # query_cache_driver:
62 | # type: pool
63 | # pool: doctrine.system_cache_pool
64 | # result_cache_driver:
65 | # type: pool
66 | # pool: doctrine.result_cache_pool
67 | # default_entity_manager: employee_em
68 | # entity_managers:
69 | # customer_em:
70 | # connection: customer_connection
71 | # mappings:
72 | # Customer:
73 | # is_bundle: false
74 | # type: xml
75 | # dir: '%kernel.project_dir%/src/Customer/Adapter/Database/ORM/Doctrine/Mapping'
76 | # prefix: 'Customer\Domain\Model'
77 | # alias: Customer\Domain\Model
78 | # employee_em:
79 | # connection: employee_connection
80 | # mappings:
81 | # Employee:
82 | # is_bundle: false
83 | # type: xml
84 | # dir: '%kernel.project_dir%/config/orm/mapping/employee'
85 | # prefix: 'Employee\Entity'
86 | # alias: Employee
87 | # rental_em:
88 | # connection: rental_connection
89 | # mappings:
90 | # rental:
91 | # is_bundle: false
92 | # type: xml
93 | # dir: '%kernel.project_dir%/config/orm/mapping/rental'
94 | # prefix: 'Rental\Entity'
95 | # alias: Rental
96 |
97 | framework:
98 | cache:
99 | pools:
100 | doctrine.result_cache_pool:
101 | adapter: cache.app
102 | doctrine.system_cache_pool:
103 | adapter: cache.system
104 |
--------------------------------------------------------------------------------
/server/config/packages/doctrine_migrations.yaml:
--------------------------------------------------------------------------------
1 | doctrine_migrations:
2 | migrations_paths:
3 | # namespace is arbitrary but should be different from App\Migrations
4 | # as migrations classes should NOT be autoloaded
5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations/dev'
6 | enable_profiler: '%kernel.debug%'
7 |
--------------------------------------------------------------------------------
/server/config/packages/framework.yaml:
--------------------------------------------------------------------------------
1 | # see https://symfony.com/doc/current/reference/configuration/framework.html
2 | framework:
3 | secret: '%env(APP_SECRET)%'
4 | #csrf_protection: true
5 | http_method_override: false
6 |
7 | # Enables session support. Note that the session will ONLY be started if you read or write from it.
8 | # Remove or comment this section to explicitly disable session support.
9 | session:
10 | handler_id: null
11 | cookie_secure: auto
12 | cookie_samesite: lax
13 | storage_factory_id: session.storage.factory.native
14 |
15 | #esi: true
16 | #fragments: true
17 | php_errors:
18 | log: true
19 |
20 | when@test:
21 | framework:
22 | test: true
23 | session:
24 | storage_factory_id: session.storage.factory.mock_file
25 |
--------------------------------------------------------------------------------
/server/config/packages/hautelook_alice.yaml:
--------------------------------------------------------------------------------
1 | when@dev: &dev
2 | hautelook_alice:
3 | fixtures_path: fixtures
4 |
5 | when@test: *dev
6 |
--------------------------------------------------------------------------------
/server/config/packages/lexik_jwt_authentication.yaml:
--------------------------------------------------------------------------------
1 | lexik_jwt_authentication:
2 | secret_key: '%env(resolve:JWT_SECRET_KEY)%'
3 | public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
4 | pass_phrase: '%env(JWT_PASSPHRASE)%'
5 | token_ttl: '%env(JWT_TTL)%'
6 |
--------------------------------------------------------------------------------
/server/config/packages/monolog.yaml:
--------------------------------------------------------------------------------
1 | monolog:
2 | channels:
3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
4 |
5 | when@dev:
6 | monolog:
7 | handlers:
8 | main:
9 | type: stream
10 | path: "%kernel.logs_dir%/%kernel.environment%.log"
11 | level: debug
12 | channels: ["!event"]
13 | # uncomment to get logging in your browser
14 | # you may have to allow bigger header sizes in your Web server configuration
15 | #firephp:
16 | # type: firephp
17 | # level: info
18 | #chromephp:
19 | # type: chromephp
20 | # level: info
21 | console:
22 | type: console
23 | process_psr_3_messages: false
24 | channels: ["!event", "!doctrine", "!console"]
25 |
26 | when@test:
27 | monolog:
28 | handlers:
29 | main:
30 | type: fingers_crossed
31 | action_level: error
32 | handler: nested
33 | excluded_http_codes: [404, 405]
34 | channels: ["!event"]
35 | nested:
36 | type: stream
37 | path: "%kernel.logs_dir%/%kernel.environment%.log"
38 | level: debug
39 |
40 | when@prod:
41 | monolog:
42 | handlers:
43 | main:
44 | type: fingers_crossed
45 | action_level: error
46 | handler: nested
47 | excluded_http_codes: [404, 405]
48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks
49 | nested:
50 | type: stream
51 | path: php://stderr
52 | level: debug
53 | formatter: monolog.formatter.json
54 | console:
55 | type: console
56 | process_psr_3_messages: false
57 | channels: ["!event", "!doctrine"]
58 | deprecation:
59 | type: stream
60 | channels: [deprecation]
61 | path: php://stderr
62 |
--------------------------------------------------------------------------------
/server/config/packages/nelmio_alice.yaml:
--------------------------------------------------------------------------------
1 | when@dev: &dev
2 | nelmio_alice:
3 | functions_blacklist:
4 | - 'current'
5 | - 'shuffle'
6 | - 'date'
7 | - 'time'
8 | - 'file'
9 | - 'md5'
10 | - 'sha1'
11 |
12 | when@test: *dev
13 |
--------------------------------------------------------------------------------
/server/config/packages/nelmio_api_doc.yaml:
--------------------------------------------------------------------------------
1 | nelmio_api_doc:
2 | documentation:
3 | info:
4 | title: Codenip Car Rental
5 | version: 1.0.0
6 | components:
7 | securitySchemes:
8 | Bearer:
9 | type: http
10 | scheme: bearer
11 | bearerFormat: JWT
12 | paths:
13 | ### CUSTOMER PATHS ###
14 | /api/customers/health-check:
15 | get:
16 | tags:
17 | - Customers
18 | responses:
19 | 200:
20 | description: Module Customer running
21 | content:
22 | application/json:
23 | schema:
24 | type: object
25 | properties:
26 | message: { type: string, example: Module Customer up and running! }
27 | /api/customers:
28 | post:
29 | tags:
30 | - Customers
31 | requestBody:
32 | content:
33 | application/json:
34 | schema:
35 | type: object
36 | properties:
37 | name: { type: string, example: Peter }
38 | email: { type: string, example: peter@api.com }
39 | address: { type: string, example: Fake street 123 }
40 | age: { type: number, example: 30 }
41 | employeeId: { type: string, example: 2d995fa5-7da7-4f49-94ad-b6bed527dca9 }
42 | responses:
43 | 201:
44 | description: Customer created
45 | content:
46 | application/json:
47 | schema:
48 | type: object
49 | properties:
50 | customerId: { type: string, example: 2d995fa5-7da7-4f49-94ad-b6bed527dca9 }
51 | /api/customers/{id}:
52 | get:
53 | tags:
54 | - Customers
55 | responses:
56 | 200:
57 | description: Customer data
58 | content:
59 | application/json:
60 | schema:
61 | type: object
62 | properties:
63 | id: { type: string, example: 2d995fa5-7da7-4f49-94ad-b6bed527dca9 }
64 | name: { type: string, example: Peter }
65 | email: { type: string, example: peter@api.com }
66 | address: { type: string, example: Fake street 123 }
67 | age: { type: number, example: 30 }
68 | employeeId: { type: string, example: 2d995fa5-7da7-4f49-94ad-b6bed527dca9 }
69 | patch:
70 | tags:
71 | - Customers
72 | requestBody:
73 | content:
74 | application/json:
75 | schema:
76 | type: object
77 | properties:
78 | name: { type: string, example: Peter }
79 | email: { type: string, example: peter@api.com }
80 | address: { type: string, example: Fake street 123 }
81 | age: { type: number, example: 30 }
82 | responses:
83 | 200:
84 | description: Customer data
85 | content:
86 | application/json:
87 | schema:
88 | type: object
89 | properties:
90 | id: { type: string, example: 2d995fa5-7da7-4f49-94ad-b6bed527dca9 }
91 | name: { type: string, example: Peter }
92 | email: { type: string, example: peter@api.com }
93 | address: { type: string, example: Fake street 123 }
94 | age: { type: number, example: 30 }
95 | employeeId: { type: string, example: 2d995fa5-7da7-4f49-94ad-b6bed527dca9 }
96 | delete:
97 | tags:
98 | - Customers
99 | responses:
100 | 204:
101 | description: Customer deleted
102 |
103 | ### EMPLOYEE PATHS ###
104 | /api/employees/health-check:
105 | get:
106 | tags:
107 | - Employee
108 | responses:
109 | 200:
110 | description: Module Employee running
111 | content:
112 | application/json:
113 | schema:
114 | type: object
115 | properties:
116 | message: { type: string, example: Module Employee up and running! }
117 | /api/employees/{id}/customers:
118 | get:
119 | tags:
120 | - Employee
121 | parameters:
122 | - in: query
123 | name: page
124 | schema:
125 | type: number
126 | required: false
127 | - in: query
128 | name: limit
129 | schema:
130 | type: number
131 | required: false
132 | responses:
133 | 200:
134 | description: Collection of customers per employee id
135 | content:
136 | application/json:
137 | schema:
138 | type: object
139 | properties:
140 | items:
141 | type: array
142 | items:
143 | type: object
144 | properties:
145 | id: { type: string }
146 | name: { type: string }
147 | address: { type: string }
148 | meta:
149 | type: object
150 | properties:
151 | total: { type: number, example: 2 }
152 | page: { type: number, example: 1 }
153 | limit: { type: number, example: 10 }
154 | hasNext: { type: boolean, example: true }
155 |
156 | ### RENTAL PATHS ###
157 | /api/rentals/health-check:
158 | get:
159 | tags:
160 | - Rental
161 | responses:
162 | 200:
163 | description: Module Rental running
164 | content:
165 | application/json:
166 | schema:
167 | type: object
168 | properties:
169 | message: { type: string, example: Module Rental up and running! }
170 |
171 | areas: # to filter documented areas
172 | path_patterns:
173 | - ^/api(?!/doc|/doc.json$) # Accepts routes under /api except /api/doc
174 |
--------------------------------------------------------------------------------
/server/config/packages/nelmio_cors.yaml:
--------------------------------------------------------------------------------
1 | nelmio_cors:
2 | defaults:
3 | origin_regex: true
4 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
5 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
6 | allow_headers: ['Content-Type', 'Authorization']
7 | expose_headers: ['Link']
8 | max_age: 3600
9 | paths:
10 | '^/': null
11 |
--------------------------------------------------------------------------------
/server/config/packages/routing.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | router:
3 | utf8: true
4 |
5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
7 | #default_uri: http://localhost
8 |
9 | when@prod:
10 | framework:
11 | router:
12 | strict_requirements: null
13 |
--------------------------------------------------------------------------------
/server/config/packages/security.yaml:
--------------------------------------------------------------------------------
1 | security:
2 | enable_authenticator_manager: true
3 | password_hashers:
4 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
5 | providers:
6 | app_user_provider:
7 | entity:
8 | class: Employee\Entity\Employee
9 | property: email
10 | firewalls:
11 | dev:
12 | pattern: ^/(_(profiler|wdt)|css|images|js)/
13 | security: false
14 | # docs:
15 | # pattern: ^/api/doc$
16 | # methods: [ GET ]
17 | # security: false
18 | login:
19 | pattern: ^/api/login
20 | stateless: true
21 | json_login:
22 | check_path: /api/login_check
23 | success_handler: lexik_jwt_authentication.handler.authentication_success
24 | failure_handler: lexik_jwt_authentication.handler.authentication_failure
25 |
26 | api:
27 | pattern: ^/api
28 | stateless: true
29 | jwt: ~
30 |
31 | access_control:
32 | - { path: ^/api/customers, allow_if: "false == request.headers.has('X-Forwarded-For')" }
33 | - { path: ^/api/rental, allow_if: "false == request.headers.has('X-Forwarded-For')" }
34 | - { path: ^/api/employees/health-check, roles: PUBLIC_ACCESS }
35 | - { path: ^/api/doc, roles: PUBLIC_ACCESS }
36 | - { path: ^/api/login, roles: PUBLIC_ACCESS }
37 | - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
38 |
39 | when@test:
40 | security:
41 | password_hashers:
42 | # By default, password hashers are resource intensive and take time. This is
43 | # important to generate secure password hashes. In tests however, secure hashes
44 | # are not important, waste resources and increase test times. The following
45 | # reduces the work factor to the lowest possible values.
46 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
47 | algorithm: auto
48 | cost: 4 # Lowest possible value for bcrypt
49 | time_cost: 3 # Lowest possible value for argon
50 | memory_cost: 10 # Lowest possible value for argon
51 |
--------------------------------------------------------------------------------
/server/config/packages/sensio_framework_extra.yaml:
--------------------------------------------------------------------------------
1 | sensio_framework_extra:
2 | router:
3 | annotations: false
4 |
--------------------------------------------------------------------------------
/server/config/packages/test/dama_doctrine_test_bundle.yaml:
--------------------------------------------------------------------------------
1 | dama_doctrine_test:
2 | enable_static_connection: true
3 | enable_static_meta_data_cache: true
4 | enable_static_query_cache: true
5 |
--------------------------------------------------------------------------------
/server/config/packages/test/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | dbal:
3 | default_connection: customer_connection
4 | connections:
5 | customer_connection:
6 | url: '%env(resolve:DATABASE_URL_CUSTOMER)%'
7 | employee_connection:
8 | url: '%env(resolve:DATABASE_URL_EMPLOYEE)%'
9 | rent_connection:
10 | url: '%env(resolve:DATABASE_URL_RENT)%'
--------------------------------------------------------------------------------
/server/config/packages/test/doctrine_migrations.yaml:
--------------------------------------------------------------------------------
1 | doctrine_migrations:
2 | migrations_paths:
3 | # namespace is arbitrary but should be different from App\Migrations
4 | # as migrations classes should NOT be autoloaded
5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations/test'
6 | enable_profiler: '%kernel.debug%'
7 |
--------------------------------------------------------------------------------
/server/config/packages/twig.yaml:
--------------------------------------------------------------------------------
1 | twig:
2 | default_path: '%kernel.project_dir%/templates'
3 |
4 | when@test:
5 | twig:
6 | strict_variables: true
7 |
--------------------------------------------------------------------------------
/server/config/preload.php:
--------------------------------------------------------------------------------
1 | XDEBUG 3 ###
13 | # Use your client IP here
14 | # Linux: run "ip a | grep docker0"
15 | # Windows (with WSL2) and Mac: host.docker.internal
16 | environment:
17 | XDEBUG_CLIENT_HOST: host.docker.internal
18 | XDEBUG_CLIENT_PORT: 9003
19 | PHP_IDE_CONFIG: serverName=modular-monolith-example-server
20 | ports:
21 | - '1000:80'
22 | networks:
23 | - modular-monolith-example-network
24 | depends_on:
25 | - modular-monolith-example-mysql
26 |
27 | modular-monolith-example-mysql:
28 | container_name: modular-monolith-example-mysql
29 | build:
30 | context: ./docker/database
31 | ports:
32 | - '3336:3306'
33 | environment:
34 | MYSQL_DATABASE: customer_db
35 | MYSQL_ROOT_PASSWORD: root
36 | volumes:
37 | - modular-monolith-example-mysql-data:/var/lib/mysql
38 | networks:
39 | - modular-monolith-example-network
40 | command: [ 'mysqld', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci' ]
41 |
42 | networks:
43 | modular-monolith-example-network:
44 |
45 | volumes:
46 | modular-monolith-example-mysql-data:
47 |
--------------------------------------------------------------------------------
/server/docker/database/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mariadb:10.9.2
2 |
3 | COPY ./databases.sql /docker-entrypoint-initdb.d/databases.sql
--------------------------------------------------------------------------------
/server/docker/database/databases.sql:
--------------------------------------------------------------------------------
1 | CREATE DATABASE IF NOT EXISTS customer_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
2 | CREATE DATABASE IF NOT EXISTS customer_db_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
3 | CREATE DATABASE IF NOT EXISTS employee_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
4 | CREATE DATABASE IF NOT EXISTS employee_db_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
5 | CREATE DATABASE IF NOT EXISTS rental_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
6 | CREATE DATABASE IF NOT EXISTS rental_db_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
--------------------------------------------------------------------------------
/server/docker/php-apache/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM php:8.1.1-apache
2 |
3 | ARG UID
4 |
5 | # Create user with same permissions as host and some useful stuff
6 | RUN adduser -u ${UID} --disabled-password --gecos "" appuser
7 | RUN mkdir /home/appuser/.ssh
8 | RUN chown -R appuser:appuser /home/appuser/
9 | RUN echo "StrictHostKeyChecking no" >> /home/appuser/.ssh/config
10 | RUN echo "alias sf=/var/www/html/bin/console" >> /home/appuser/.bashrc
11 |
12 | # Install packages and PHP extensions
13 | RUN apt update \
14 | # common libraries and extensions
15 | && apt install -y git acl openssl openssh-client wget zip \
16 | && apt install -y libpng-dev zlib1g-dev libzip-dev libxml2-dev libicu-dev \
17 | && docker-php-ext-install intl pdo gd zip \
18 | # for MySQL
19 | && docker-php-ext-install pdo_mysql \
20 | # XDEBUG and APCu
21 | && pecl install xdebug apcu \
22 | # enable Docker extensions
23 | && docker-php-ext-enable --ini-name 05-opcache.ini opcache xdebug apcu
24 |
25 | # Install and update composer
26 | RUN curl https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer
27 | RUN composer self-update
28 |
29 | ## Install Symfony binary
30 | RUN curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | bash
31 | RUN apt install symfony-cli
32 |
33 | RUN mkdir -p /var/www/html
34 |
35 | # Config XDEBUG
36 | COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini
37 |
38 | # Update Apache config
39 | COPY ./default.conf /etc/apache2/sites-available/default.conf
40 | RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf \
41 | && a2enmod rewrite \
42 | && a2dissite 000-default \
43 | && a2ensite default \
44 | && service apache2 restart
45 |
46 | # Modify upload file size
47 | RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
48 |
49 | WORKDIR /var/www/html
50 |
--------------------------------------------------------------------------------
/server/docker/php-apache/default.conf:
--------------------------------------------------------------------------------
1 |
2 | ServerName localhost
3 |
4 | DocumentRoot /var/www/html/public
5 | DirectoryIndex /index.php
6 |
7 |
8 | AllowOverride None
9 | Order Allow,Deny
10 | Allow from All
11 |
12 | FallbackResource /index.php
13 | DirectoryIndex index.php
14 |
15 |
16 | Options -MultiViews
17 |
18 |
19 |
20 | RewriteEngine On
21 | RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
22 | RewriteRule .* - [E=BASE:%1]
23 | RewriteCond %{HTTP:Authorization} .+
24 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
25 | RewriteCond %{ENV:REDIRECT_STATUS} =""
26 | RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
27 | RewriteCond %{REQUEST_FILENAME} !-f
28 | RewriteRule ^ %{ENV:BASE}/index.php [L]
29 |
30 |
31 |
32 |
33 | RedirectMatch 307 ^/$ /index.php/
34 |
35 |
36 |
37 |
38 |
39 | DirectoryIndex disabled
40 | FallbackResource disabled
41 |
42 | ErrorLog /var/log/apache2/codenip.log
43 | CustomLog /var/log/apache2/codenip.log combined
44 |
45 |
--------------------------------------------------------------------------------
/server/docker/php-apache/xdebug.ini:
--------------------------------------------------------------------------------
1 | xdebug.mode=debug
2 | xdebug.start_with_request=yes
3 | xdebug.client_host=${XDEBUG_CLIENT_HOST}
4 | xdebug.client_port=${XDEBUG_CLIENT_PORT}
5 | xdebug.log_level=0
--------------------------------------------------------------------------------
/server/fixtures/Customer.yaml:
--------------------------------------------------------------------------------
1 | Customer\Domain\Model\Customer:
2 | Customer-{0..99}:
3 | __construct:
4 | id (unique):
5 | name:
6 | email (unique):
7 | address (unique):
8 | age:
9 | employeeId: 'b095a15c-c605-4afd-99ae-e1b62b10ee5a'
10 |
--------------------------------------------------------------------------------
/server/migrations/dev/Version20220518191517.php:
--------------------------------------------------------------------------------
1 | addSql(
20 | <<addSql(
71 | <<addSql(
20 | <<addSql(
69 | <<
2 |
3 |
4 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | tests
24 |
25 |
26 |
27 |
28 |
29 | src
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
47 |
48 |
--------------------------------------------------------------------------------
/server/public/index.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Database/ORM/Doctrine/Repository/DoctrineCustomerRepository.php:
--------------------------------------------------------------------------------
1 | repository = new ServiceEntityRepository($managerRegistry, Customer::class);
26 | $this->manager = $managerRegistry->getManager('customer_em');
27 | }
28 |
29 | public function findOneByIdOrFail(string $id): Customer
30 | {
31 | if (null === $customer = $this->repository->find($id)) {
32 | throw ResourceNotFoundException::createFromClassAndId(Customer::class, $id);
33 | }
34 |
35 | return $customer;
36 | }
37 |
38 | public function findOneByEmail(string $email): ?Customer
39 | {
40 | return $this->repository->findOneBy(['email' => $email]);
41 | }
42 |
43 | public function search(CustomerFilter $filter): PaginatedResponse
44 | {
45 | $page = $filter->page;
46 | $limit = $filter->limit;
47 | $employeeId = $filter->employeeId;
48 | $sort = $filter->sort;
49 | $order = $filter->order;
50 | $name = $filter->name;
51 |
52 | $qb = $this->repository->createQueryBuilder('c');
53 | $qb->orderBy(\sprintf('c.%s', $sort), $order);
54 | $qb
55 | ->andWhere('c.employeeId = :employeeId')
56 | ->setParameter(':employeeId', $employeeId);
57 |
58 | if (null !== $name) {
59 | $qb
60 | ->andWhere('c.name LIKE :name')
61 | ->setParameter(':name', $name.'%');
62 | }
63 |
64 | $paginator = new Paginator($qb->getQuery());
65 | $paginator->getQuery()
66 | ->setFirstResult($limit * ($page - 1))
67 | ->setMaxResults($limit);
68 |
69 | return PaginatedResponse::create($paginator->getIterator()->getArrayCopy(), $paginator->count(), $page, $limit);
70 | }
71 |
72 | public function save(Customer $customer): void
73 | {
74 | $this->manager->persist($customer);
75 | $this->manager->flush();
76 | }
77 |
78 | public function remove(Customer $customer): void
79 | {
80 | $this->manager->remove($customer);
81 | $this->manager->flush();
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Config/Services/customer.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | _defaults:
3 | autowire: true
4 | autoconfigure: true
5 |
6 | Customer\:
7 | resource: '../../../../../Customer/'
8 | exclude:
9 | - '../../../../../Customer/Domain/Model/'
10 |
11 | Customer\Adapter\Framework\Http\Controller\:
12 | resource: '../../../../../Customer/Adapter/Framework/Http/Controller/'
13 | tags: [ 'controller.service_arguments' ]
14 |
15 | Customer\Adapter\Framework\Listener\JsonTransformerExceptionListener:
16 | tags:
17 | - { name: kernel.event_listener, event: kernel.exception, method: onKernelException, priority: 100 }
18 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/API/Filter/CustomerFilter.php:
--------------------------------------------------------------------------------
1 | page = $page;
27 | } else {
28 | $this->page = self::PAGE;
29 | }
30 |
31 | if (0 !== $limit) {
32 | $this->limit = $limit;
33 | } else {
34 | $this->limit = self::LIMIT;
35 | }
36 |
37 | $this->validateSort($this->sort);
38 | $this->validateOrder($this->order);
39 | }
40 |
41 | private function validateSort(string $sort): void
42 | {
43 | if (!\in_array($sort, self::ALLOWED_SORT_PARAMS, true)) {
44 | throw new \InvalidArgumentException(\sprintf('Invalid sort param [%s]', $sort));
45 | }
46 | }
47 |
48 | private function validateOrder(string $order): void
49 | {
50 | if (!\in_array($order, self::ALLOWED_ORDER_PARAMS, true)) {
51 | throw new \InvalidArgumentException(\sprintf('Invalid order param [%s]', $order));
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/API/Response/PaginatedResponse.php:
--------------------------------------------------------------------------------
1 | $total,
25 | 'page' => $page,
26 | 'limit' => $limit,
27 | 'hasNext' => $page < $lastPage,
28 | ];
29 |
30 | return new PaginatedResponse($items, $meta);
31 | }
32 |
33 | public function getItems(): array
34 | {
35 | return $this->items;
36 | }
37 |
38 | public function getMeta(): array
39 | {
40 | return $this->meta;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/ArgumentResolver/RequestArgumentResolver.php:
--------------------------------------------------------------------------------
1 | getType()) {
23 | return false;
24 | }
25 |
26 | return (new \ReflectionClass($argument->getType()))->implementsInterface(RequestDTO::class);
27 | }
28 |
29 | public function resolve(Request $request, ArgumentMetadata $argument): \Generator
30 | {
31 | $this->requestTransformer->transform($request);
32 |
33 | $class = $argument->getType();
34 |
35 | yield new $class($request);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/Controller/Customer/CreateCustomerController.php:
--------------------------------------------------------------------------------
1 | service->handle(CreateCustomerInputDTO::create($request->name, $request->email, $request->address, $request->age, $request->employeeId));
26 | } catch (CustomerAlreadyExistsException $e) {
27 | return $this->json(['error' => $e->getMessage()], Response::HTTP_CONFLICT);
28 | }
29 |
30 | return $this->json(['customerId' => $responseDTO->id], Response::HTTP_CREATED);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/Controller/Customer/DeleteCustomerController.php:
--------------------------------------------------------------------------------
1 | useCase->handle(DeleteCustomerInputDTO::create($request->id));
25 |
26 | return $this->json([], Response::HTTP_NO_CONTENT);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/Controller/Customer/GetCustomerByIdController.php:
--------------------------------------------------------------------------------
1 | useCase->handle(GetCustomerByIdInputDTO::create($request->id));
25 |
26 | return $this->json($responseDTO);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/Controller/Customer/SearchCustomerController.php:
--------------------------------------------------------------------------------
1 | page,
26 | $request->limit,
27 | $request->employeeId,
28 | $request->sort,
29 | $request->order,
30 | $request->name
31 | );
32 |
33 | $output = $this->useCase->execute($filter);
34 |
35 | return $this->json($output->customers);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/Controller/Customer/UpdateCustomerController.php:
--------------------------------------------------------------------------------
1 | id, $request->name, $request->email, $request->address, $request->age, $request->keys);
24 |
25 | $responseDTO = $this->useCase->handle($inputDTO);
26 |
27 | return $this->json($responseDTO->customerData);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/Controller/HealthCheckController.php:
--------------------------------------------------------------------------------
1 | json(['message' => 'Module Customer up and running!']);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/DTO/CreateCustomerRequestDTO.php:
--------------------------------------------------------------------------------
1 | name = $request->request->get('name');
20 | $this->email = $request->request->get('email');
21 | $this->address = $request->request->get('address');
22 | $this->age = $request->request->get('age');
23 | $this->employeeId = $request->request->get('employeeId');
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/DTO/DeleteCustomerRequestDTO.php:
--------------------------------------------------------------------------------
1 | id = $request->attributes->get('id');
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/DTO/GetCustomerByIdRequestDTO.php:
--------------------------------------------------------------------------------
1 | id = $request->attributes->get('id');
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/DTO/GetCustomersRequest.php:
--------------------------------------------------------------------------------
1 | page = $request->query->getInt('page');
21 | $this->limit = $request->query->getInt('limit');
22 | $this->employeeId = $request->query->get('employeeId');
23 | $this->sort = $request->query->get('sort');
24 | $this->order = $request->query->get('order');
25 | $this->name = $request->query->get('name');
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/DTO/RequestDTO.php:
--------------------------------------------------------------------------------
1 | id = $request->attributes->get('id');
21 | $this->name = $request->request->get('name');
22 | $this->email = $request->request->get('email');
23 | $this->address = $request->request->get('address');
24 | $this->age = $request->request->get('age');
25 | $this->keys = \array_keys($request->request->all());
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Http/RequestTransformer/RequestTransformer.php:
--------------------------------------------------------------------------------
1 | headers->get('Content-Type')) {
24 | throw InvalidArgumentException::createFromMessage(\sprintf('[%s] is the only Content-Type allowed', self::ALLOWED_CONTENT_TYPE));
25 | }
26 |
27 | if (\in_array($request->getMethod(), self::METHODS_TO_DECODE, true)) {
28 | try {
29 | $request->request = new ParameterBag((array) \json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR));
30 | } catch (\JsonException) {
31 | throw InvalidArgumentException::createFromMessage('Invalid JSON payload');
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/server/src/Customer/Adapter/Framework/Listener/JsonTransformerExceptionListener.php:
--------------------------------------------------------------------------------
1 | getThrowable();
20 |
21 | $data = [
22 | 'class' => \get_class($e),
23 | 'code' => Response::HTTP_INTERNAL_SERVER_ERROR,
24 | 'message' => $e->getMessage(),
25 | ];
26 |
27 | if ($e instanceof ResourceNotFoundException) {
28 | $data['code'] = Response::HTTP_NOT_FOUND;
29 | }
30 |
31 | if ($e instanceof InvalidArgumentException) {
32 | $data['code'] = Response::HTTP_BAD_REQUEST;
33 | }
34 |
35 | if ($e instanceof AccessDeniedException) {
36 | $data['code'] = Response::HTTP_FORBIDDEN;
37 | }
38 |
39 | if ($e instanceof CustomerAlreadyExistsException) {
40 | $data['code'] = Response::HTTP_CONFLICT;
41 | }
42 |
43 | $response = new JsonResponse($data, $data['code']);
44 |
45 | $event->setResponse($response);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/CreateCustomer/CreateCustomer.php:
--------------------------------------------------------------------------------
1 | repository->findOneByEmail($dto->email)) {
23 | throw CustomerAlreadyExistsException::fromEmail($dto->email);
24 | }
25 |
26 | $customer = Customer::create(Uuid::random()->value(), $dto->name, $dto->email, $dto->address, $dto->age, $dto->employeeId);
27 |
28 | $this->repository->save($customer);
29 |
30 | return new CreateCustomerOutputDTO($customer->id());
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/CreateCustomer/DTO/CreateCustomerInputDTO.php:
--------------------------------------------------------------------------------
1 | assertNotNull(self::ARGS, [$this->age, $this->employeeId]);
32 |
33 | if (!\is_null($this->name)) {
34 | $this->assertValueRangeLength($this->name, Customer::NAME_MIN_LENGTH, Customer::NAME_MAX_LENGTH);
35 | }
36 |
37 | $this->assertMinimumAge($this->age, Customer::MIN_AGE);
38 | }
39 |
40 | public static function create(?string $name, ?string $email, ?string $address, ?int $age, ?string $employeeId): self
41 | {
42 | return new static($name, $email, $address, $age, $employeeId);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/CreateCustomer/DTO/CreateCustomerOutputDTO.php:
--------------------------------------------------------------------------------
1 | customerRepository->findOneByIdOrFail($dto->id);
20 |
21 | $this->customerRepository->remove($customer);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/GetCustomerById/DTO/GetCustomerByIdInputDTO.php:
--------------------------------------------------------------------------------
1 | assertNotNull(self::ARGS, [$this->id]);
19 | }
20 |
21 | public static function create(?string $id): self
22 | {
23 | return new static($id);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/GetCustomerById/DTO/GetCustomerByIdOutputDTO.php:
--------------------------------------------------------------------------------
1 | id(),
25 | $customer->name(),
26 | $customer->email(),
27 | $customer->address(),
28 | $customer->age(),
29 | $customer->employeeId(),
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/GetCustomerById/GetCustomerById.php:
--------------------------------------------------------------------------------
1 | customerRepository->findOneByIdOrFail($dto->id));
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/Search/DTO/SearchCustomersOutput.php:
--------------------------------------------------------------------------------
1 | $customer->id(),
22 | 'name' => $customer->name(),
23 | 'address' => $customer->address(),
24 | ];
25 | }, $paginatedResponse->getItems());
26 |
27 | $response['items'] = $items;
28 | $response['meta'] = $paginatedResponse->getMeta();
29 |
30 | return new SearchCustomersOutput($response);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/Search/SearchCustomers.php:
--------------------------------------------------------------------------------
1 | customerRepository->search($filter);
21 |
22 | return SearchCustomersOutput::createFromPaginatedResponse($paginatedResponse);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/UpdateCustomer/DTO/UpdateCustomerInputDTO.php:
--------------------------------------------------------------------------------
1 | assertNotNull(self::ARGS, [$this->id]);
29 |
30 | if (!\is_null($this->name)) {
31 | $this->assertValueRangeLength($this->name, Customer::NAME_MIN_LENGTH, Customer::NAME_MAX_LENGTH);
32 | }
33 |
34 | if (!\is_null($this->age)) {
35 | $this->assertMinimumAge($this->age, Customer::MIN_AGE);
36 | }
37 | }
38 |
39 | public static function create(?string $id, ?string $name, ?string $email, ?string $address, ?int $age, array $paramsToUpdate): self
40 | {
41 | return new static($id, $name, $email, $address, $age, $paramsToUpdate);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/UpdateCustomer/DTO/UpdateCustomerOutputDTO.php:
--------------------------------------------------------------------------------
1 | $customer->id(),
19 | 'name' => $customer->name(),
20 | 'email' => $customer->email(),
21 | 'address' => $customer->address(),
22 | 'age' => $customer->age(),
23 | 'employeeId' => $customer->employeeId(),
24 | ]);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/Customer/Application/UseCase/Customer/UpdateCustomer/UpdateCustomer.php:
--------------------------------------------------------------------------------
1 | customerRepository->findOneByIdOrFail($dto->id);
23 |
24 | foreach ($dto->paramsToUpdate as $param) {
25 | $customer->{\sprintf('%s%s', self::SETTER_PREFIX, \ucfirst($param))}($dto->{$param});
26 | }
27 |
28 | $this->customerRepository->save($customer);
29 |
30 | return UpdateCustomerOutputDTO::createFromModel($customer);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/server/src/Customer/Domain/Exception/CustomerAlreadyExistsException.php:
--------------------------------------------------------------------------------
1 | id;
31 | }
32 |
33 | public function name(): ?string
34 | {
35 | return $this->name;
36 | }
37 |
38 | public function setName(?string $name): void
39 | {
40 | $this->name = $name;
41 | }
42 |
43 | public function email(): ?string
44 | {
45 | return $this->email;
46 | }
47 |
48 | public function setEmail(?string $email): void
49 | {
50 | $this->email = $email;
51 | }
52 |
53 | public function address(): ?string
54 | {
55 | return $this->address;
56 | }
57 |
58 | public function setAddress(?string $address): void
59 | {
60 | $this->address = $address;
61 | }
62 |
63 | public function age(): int
64 | {
65 | return $this->age;
66 | }
67 |
68 | public function setAge(int $age): void
69 | {
70 | $this->age = $age;
71 | }
72 |
73 | public function employeeId(): string
74 | {
75 | return $this->employeeId;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/server/src/Customer/Domain/Repository/CustomerRepository.php:
--------------------------------------------------------------------------------
1 | $max) {
14 | throw InvalidArgumentException::createFromMinAndMaxLength($min, $max);
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/Customer/Domain/Validation/Traits/AssertMinimumAgeTrait.php:
--------------------------------------------------------------------------------
1 | $age) {
14 | throw InvalidArgumentException::createFromMessage(\sprintf('Age has to be at least %d', $minimumAge));
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/Customer/Domain/Validation/Traits/AssertNotNullTrait.php:
--------------------------------------------------------------------------------
1 | $value) {
17 | if (\is_null($value)) {
18 | $emptyValues[] = $key;
19 | }
20 | }
21 |
22 | if (!empty($emptyValues)) {
23 | throw InvalidArgumentException::createFromArray($emptyValues);
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/Customer/Domain/ValueObject/Uuid.php:
--------------------------------------------------------------------------------
1 | ensureIsValidUuid($value);
12 | }
13 |
14 | public static function random(): self
15 | {
16 | return new static(\Symfony\Component\Uid\Uuid::v4()->toRfc4122());
17 | }
18 |
19 | public function value(): string
20 | {
21 | return $this->value;
22 | }
23 |
24 | public function equals(Uuid $other): bool
25 | {
26 | return $this->value() === $other->value();
27 | }
28 |
29 | public function __toString(): string
30 | {
31 | return $this->value();
32 | }
33 |
34 | private function ensureIsValidUuid(string $id): void
35 | {
36 | if (!\Symfony\Component\Uid\Uuid::isValid($id)) {
37 | throw new \InvalidArgumentException(sprintf('<%s> does not allow the value <%s>.', static::class, $id));
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/server/src/Customer/Service/.gitignore:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codenip-tech/modular-monolith-example/62c7d29bbaa125de733d9f91a7c65a238de5e280/server/src/Customer/Service/.gitignore
--------------------------------------------------------------------------------
/server/src/Employee/Command/CreateEmployeeCommand.php:
--------------------------------------------------------------------------------
1 | setName('app:employee:create')
23 | ->setDescription('Create new employee in the system')
24 | ->addArgument('name', InputArgument::REQUIRED, 'Employee name')
25 | ->addArgument('email', InputArgument::REQUIRED, 'Employee email')
26 | ->addArgument('password', InputArgument::REQUIRED, 'Employee password');
27 | }
28 |
29 | public function execute(InputInterface $input, OutputInterface $output): int
30 | {
31 | $name = $input->getArgument('name');
32 | $email = $input->getArgument('email');
33 | $password = $input->getArgument('password');
34 |
35 | $this->createEmployeeService->create($name, $email, $password);
36 |
37 | $output->writeln(sprintf('Employee [%s] has been created', $name));
38 |
39 | return Command::SUCCESS;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/server/src/Employee/Command/RemoveEmployeeCommand.php:
--------------------------------------------------------------------------------
1 | setName('app:employee:remove')
23 | ->setDescription('Remove an employee from the system')
24 | ->addArgument('email', InputArgument::REQUIRED, 'The employee email');
25 | }
26 |
27 | public function execute(InputInterface $input, OutputInterface $output): int
28 | {
29 | $email = $input->getArgument('email');
30 |
31 | $this->removeEmployeeService->remove($email);
32 |
33 | $output->writeln(sprintf('Employee with email [%s] has been deleted', $email));
34 |
35 | return Command::SUCCESS;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/Employee/Controller/CreateCustomerController.php:
--------------------------------------------------------------------------------
1 | attributes->get('id');
26 |
27 | $this->denyAccessUnlessGranted(EmployeeVoter::CREATE_CUSTOMER, $employeeId);
28 |
29 | $payload = \json_decode($request->getContent(), true);
30 |
31 | $name = $payload['name'];
32 | $email = $payload['email'];
33 | $address = $payload['address'];
34 | $age = (int) $payload['age'];
35 |
36 | try {
37 | $customerId = $this->createCustomerService->create($name, $email, $address, $age, $employeeId);
38 |
39 | return $this->json(['customerId' => $customerId], Response::HTTP_CREATED);
40 | } catch (\Exception $e) {
41 | if ($e instanceof ClientException && 409 === $e->getResponse()->getStatusCode()) {
42 | return $this->json(['error' => $e->getMessage()], Response::HTTP_CONFLICT);
43 | }
44 |
45 | return $this->json(['error' => 'Internal server error'], Response::HTTP_INTERNAL_SERVER_ERROR);
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/server/src/Employee/Controller/DeleteCustomerController.php:
--------------------------------------------------------------------------------
1 | attributes->get('id');
25 |
26 | $this->denyAccessUnlessGranted(EmployeeVoter::DELETE_CUSTOMER, $employeeId);
27 |
28 | $customerId = $request->attributes->get('customerId');
29 |
30 | $this->deleteCustomerService->delete($customerId);
31 |
32 | return $this->json([], Response::HTTP_NO_CONTENT);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/src/Employee/Controller/GetCustomersController.php:
--------------------------------------------------------------------------------
1 | attributes->get('id');
25 | $page = $request->query->getInt('page');
26 | $limit = $request->query->getInt('limit');
27 | $name = $request->query->get('name');
28 | $sort = $request->query->get('sort');
29 | $order = $request->query->get('order');
30 |
31 | $this->denyAccessUnlessGranted(EmployeeVoter::GET_EMPLOYEE_CUSTOMERS, $employeeId);
32 |
33 | $customers = $this->service->execute($employeeId, $page, $limit, $sort, $order, $name);
34 |
35 | return $this->json($customers);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/Employee/Controller/HealthCheckController.php:
--------------------------------------------------------------------------------
1 | json(['message' => 'Module Employee up and running!']);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/Employee/Entity/Employee.php:
--------------------------------------------------------------------------------
1 | id;
24 | }
25 |
26 | public function getName(): string
27 | {
28 | return $this->name;
29 | }
30 |
31 | public function setName(string $name): void
32 | {
33 | $this->name = $name;
34 | }
35 |
36 | public function getEmail(): string
37 | {
38 | return $this->email;
39 | }
40 |
41 | public function setEmail(string $email): void
42 | {
43 | $this->email = $email;
44 | }
45 |
46 | public function getRoles(): array
47 | {
48 | return [];
49 | }
50 |
51 | public function getPassword(): string
52 | {
53 | return $this->password;
54 | }
55 |
56 | public function setPassword(string $password): void
57 | {
58 | $this->password = $password;
59 | }
60 |
61 | public function getSalt()
62 | {
63 | // TODO: Implement getSalt() method.
64 | }
65 |
66 | public function eraseCredentials()
67 | {
68 | // TODO: Implement eraseCredentials() method.
69 | }
70 |
71 | public function getUsername(): string
72 | {
73 | return $this->email;
74 | }
75 |
76 | public function __call(string $name, array $arguments)
77 | {
78 | // TODO: Implement @method string getUserIdentifier()
79 | }
80 |
81 | public function equals(Employee $employee): bool
82 | {
83 | return $this->id === $employee->id;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/server/src/Employee/Exception/DatabaseException.php:
--------------------------------------------------------------------------------
1 | already exists', $email));
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/Employee/Exception/ResourceNotFoundException.php:
--------------------------------------------------------------------------------
1 | client = new Client([
17 | 'base_uri' => $baseUrl,
18 | 'headers' => [
19 | 'Content-Type' => 'application/json',
20 | ],
21 | ]);
22 | }
23 |
24 | public function get(string $uri, array $options = []): ResponseInterface
25 | {
26 | return $this->client->get($uri, $options);
27 | }
28 |
29 | public function post(string $uri, array $options = []): ResponseInterface
30 | {
31 | return $this->client->post($uri, $options);
32 | }
33 |
34 | public function delete(string $uri, array $options = []): ResponseInterface
35 | {
36 | return $this->client->delete($uri, $options);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/server/src/Employee/Http/HttpClientInterface.php:
--------------------------------------------------------------------------------
1 | repository = new ServiceEntityRepository($managerRegistry, Employee::class);
22 | $this->manager = $managerRegistry->getManager('employee_em');
23 | }
24 |
25 | public function save(Employee $employee): void
26 | {
27 | try {
28 | $this->manager->persist($employee);
29 | $this->manager->flush();
30 | } catch (\Exception $e) {
31 | throw DatabaseException::createFromMessage($e->getMessage());
32 | }
33 | }
34 |
35 | public function remove(Employee $employee): void
36 | {
37 | try {
38 | $this->manager->remove($employee);
39 | $this->manager->flush();
40 | } catch (\Exception $e) {
41 | throw DatabaseException::createFromMessage($e->getMessage());
42 | }
43 | }
44 |
45 | public function findOneByEmail(string $email): ?Employee
46 | {
47 | return $this->repository->findOneBy(['email' => $email]);
48 | }
49 |
50 | public function findOneByEmailOrFail(string $email): Employee
51 | {
52 | if (null === $employee = $this->repository->findOneBy(['email' => $email])) {
53 | throw ResourceNotFoundException::createFromResourceAndProperty(Employee::class, $email);
54 | }
55 |
56 | return $employee;
57 | }
58 |
59 | public function findOneByIdOrFail(string $id): Employee
60 | {
61 | if (null === $employee = $this->repository->find($id)) {
62 | throw ResourceNotFoundException::createFromResourceAndId(Employee::class, $id);
63 | }
64 |
65 | return $employee;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/server/src/Employee/Repository/EmployeeRepository.php:
--------------------------------------------------------------------------------
1 | $name,
22 | 'email' => $email,
23 | 'address' => $address,
24 | 'age' => $age,
25 | 'employeeId' => $employeeId,
26 | ];
27 |
28 | $response = $this->httpClient->post(
29 | self::CREATE_CUSTOMERS_ENDPOINT,
30 | ['json' => $payload]
31 | );
32 |
33 | $responseData = \json_decode($response->getBody()->getContents(), true, 512, \JSON_THROW_ON_ERROR);
34 |
35 | return $responseData['customerId'];
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/CreateEmployeeService.php:
--------------------------------------------------------------------------------
1 | employeeRepository->findOneByEmailOrFail($email);
26 | //
27 | // throw EmployeeAlreadyExistsException::createFromEmail($email);
28 | // } catch (ResourceNotFoundException) {
29 | // $employee = new Employee(Uuid::v4()->toRfc4122(), $name, $email);
30 | // $password = $this->passwordHasher->hashPasswordForUser($employee, $password);
31 | // $employee->setPassword($password);
32 | //
33 | // $this->employeeRepository->save($employee);
34 | //
35 | // return [
36 | // 'id' => $employee->getId(),
37 | // 'name' => $employee->getName(),
38 | // 'email' => $employee->getEmail(),
39 | // ];
40 | // }
41 |
42 | /*
43 | * CASE_1: Case for repository method without exception
44 | */
45 | if (null !== $this->employeeRepository->findOneByEmail($email)) {
46 | throw EmployeeAlreadyExistsException::createFromEmail($email);
47 | }
48 |
49 | $employee = new Employee(Uuid::v4()->toRfc4122(), $name, $email);
50 | $password = $this->passwordHasher->hashPasswordForUser($employee, $password);
51 | $employee->setPassword($password);
52 |
53 | $this->employeeRepository->save($employee);
54 |
55 | return [
56 | 'id' => $employee->getId(),
57 | 'name' => $employee->getName(),
58 | 'email' => $employee->getEmail(),
59 | ];
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/DeleteCustomerService.php:
--------------------------------------------------------------------------------
1 | httpClient->delete(\sprintf(self::DELETE_CUSTOMER_ENDPOINT, $customerId));
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/GetEmployeeCustomers.php:
--------------------------------------------------------------------------------
1 | httpClient->get(
27 | \sprintf(
28 | $filter,
29 | self::SEARCH_CUSTOMERS_ENDPOINT,
30 | $employeeId,
31 | $page,
32 | $limit,
33 | $sort,
34 | $order,
35 | $name
36 | )
37 | );
38 |
39 | return \json_decode($response->getBody()->getContents(), true, 512, \JSON_THROW_ON_ERROR);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/RemoveEmployeeService.php:
--------------------------------------------------------------------------------
1 | employeeRepository->findOneByEmail($email)) {
19 | throw ResourceNotFoundException::createFromResourceAndProperty(Employee::class, $email);
20 | }
21 |
22 | $this->employeeRepository->remove($employee);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/Security/Listener/JWTCreatedListener.php:
--------------------------------------------------------------------------------
1 | getUser();
16 |
17 | $payload = $event->getData();
18 | $payload['id'] = $user->getId();
19 | $payload['name'] = $user->getName();
20 |
21 | $event->setData($payload);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/Security/PasswordHasherInterface.php:
--------------------------------------------------------------------------------
1 | passwordHasher->hashPassword($user, $password);
18 | }
19 |
20 | public function isPasswordValid(PasswordAuthenticatedUserInterface $user, string $plainPassword): bool
21 | {
22 | return $this->isPasswordValid($user, $plainPassword);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/Employee/Service/Security/Voter/EmployeeVoter.php:
--------------------------------------------------------------------------------
1 | allowedAttributes(), true) && \is_string($subject);
20 | }
21 |
22 | /**
23 | * @param string $subject
24 | */
25 | protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
26 | {
27 | /** @var Employee $tokenUser */
28 | $tokenUser = $token->getUser();
29 |
30 | if (\in_array($attribute, $this->allowedAttributes(), true)) {
31 | return $tokenUser->getId() === $subject;
32 | }
33 |
34 | return false;
35 | }
36 |
37 | private function allowedAttributes(): array
38 | {
39 | return [
40 | self::GET_EMPLOYEE_CUSTOMERS,
41 | self::CREATE_CUSTOMER,
42 | self::DELETE_CUSTOMER,
43 | ];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/server/src/Kernel.php:
--------------------------------------------------------------------------------
1 | json(['message' => 'Module Rental up and running!']);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/Rental/Entity/Car.php:
--------------------------------------------------------------------------------
1 | id;
20 | }
21 |
22 | public function getBrand(): string
23 | {
24 | return $this->brand;
25 | }
26 |
27 | public function getModel(): string
28 | {
29 | return $this->model;
30 | }
31 |
32 | public function getColor(): string
33 | {
34 | return $this->color;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/server/src/Rental/Entity/Rental.php:
--------------------------------------------------------------------------------
1 | id;
22 | }
23 |
24 | public function getEmployeeId(): string
25 | {
26 | return $this->employeeId;
27 | }
28 |
29 | public function getCustomerId(): string
30 | {
31 | return $this->customerId;
32 | }
33 |
34 | public function getCar(): Car
35 | {
36 | return $this->car;
37 | }
38 |
39 | public function getStartDate(): \DateTime
40 | {
41 | return $this->startDate;
42 | }
43 |
44 | public function getEndDate(): \DateTime
45 | {
46 | return $this->endDate;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/server/src/Rental/Repository/DoctrineRentRepository.php:
--------------------------------------------------------------------------------
1 | repository = new ServiceEntityRepository($managerRegistry, Rental::class);
20 | $this->manager = $managerRegistry->getManager();
21 | }
22 |
23 | public function save(Rental $rental): void
24 | {
25 | $this->manager->persist($rental);
26 | $this->manager->flush();
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/Rental/Repository/RentRepository.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% block title %}Welcome!{% endblock %}
6 |
7 | {# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #}
8 | {% block stylesheets %}
9 | {{ encore_entry_link_tags('app') }}
10 | {% endblock %}
11 |
12 | {% block javascripts %}
13 | {{ encore_entry_script_tags('app') }}
14 | {% endblock %}
15 |
16 |
17 | {% block body %}{% endblock %}
18 |
19 |
20 |
--------------------------------------------------------------------------------
/server/tests/Functional/Customer/Controller/Customer/CreateCustomerControllerTest.php:
--------------------------------------------------------------------------------
1 | 'Peter',
19 | 'email' => 'peter@api.com',
20 | 'address' => 'Fake street 123',
21 | 'age' => 30,
22 | 'employeeId' => 'd368263a-ab71-4587-960d-cfe9725c373f',
23 | ];
24 |
25 | self::$admin->request(Request::METHOD_POST, self::ENDPOINT, [], [], [], \json_encode($payload));
26 |
27 | $response = self::$admin->getResponse();
28 | $responseData = $this->getResponseData($response);
29 |
30 | self::assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
31 | self::assertArrayHasKey('customerId', $responseData);
32 | self::assertEquals(36, \strlen($responseData['customerId']));
33 |
34 | $generatedCustomerId = $responseData['customerId'];
35 |
36 | self::$admin->request(Request::METHOD_GET, \sprintf('/api/customers/%s', $generatedCustomerId));
37 |
38 | $response = self::$admin->getResponse();
39 | $responseData = $this->getResponseData($response);
40 |
41 | self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
42 |
43 | self::assertArrayHasKey('id', $responseData);
44 | self::assertArrayHasKey('name', $responseData);
45 | self::assertArrayHasKey('email', $responseData);
46 | self::assertArrayHasKey('address', $responseData);
47 | self::assertArrayHasKey('age', $responseData);
48 | self::assertArrayHasKey('employeeId', $responseData);
49 |
50 | self::assertEquals($generatedCustomerId, $responseData['id']);
51 | self::assertEquals($payload['name'], $responseData['name']);
52 | self::assertEquals($payload['email'], $responseData['email']);
53 | self::assertEquals($payload['address'], $responseData['address']);
54 | self::assertEquals($payload['age'], $responseData['age']);
55 | self::assertEquals($payload['employeeId'], $responseData['employeeId']);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/server/tests/Functional/Customer/Controller/Customer/DeleteCustomerControllerTest.php:
--------------------------------------------------------------------------------
1 | createCustomer();
18 |
19 | self::$admin->request(Request::METHOD_DELETE, \sprintf(self::ENDPOINT, $customerId));
20 |
21 | $response = self::$admin->getResponse();
22 |
23 | self::assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode());
24 | }
25 |
26 | public function testDeleteNonExistingCustomer(): void
27 | {
28 | self::$admin->request(Request::METHOD_DELETE, \sprintf(self::ENDPOINT, self::NON_EXISTING_CUSTOMER_ID));
29 |
30 | $response = self::$admin->getResponse();
31 |
32 | self::assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode());
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/tests/Functional/Customer/Controller/Customer/UpdateCustomerControllerTest.php:
--------------------------------------------------------------------------------
1 | createCustomer();
23 | // update a customer
24 | self::$admin->request(Request::METHOD_PATCH, \sprintf(self::ENDPOINT, $customerId), [], [], [], \json_encode($payload));
25 | // checks
26 | $response = self::$admin->getResponse();
27 | $responseData = $this->getResponseData($response);
28 |
29 | self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
30 |
31 | $keys = \array_keys($payload);
32 |
33 | foreach ($keys as $key) {
34 | self::assertEquals($payload[$key], $responseData[$key]);
35 | }
36 | }
37 |
38 | public function testUpdateCustomerWithWrongValue(): void
39 | {
40 | $payload = ['name' => 'A'];
41 |
42 | $customerId = $this->createCustomer();
43 |
44 | self::$admin->request(Request::METHOD_PATCH, \sprintf(self::ENDPOINT, $customerId), [], [], [], \json_encode($payload));
45 |
46 | $response = self::$admin->getResponse();
47 |
48 | self::assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
49 | }
50 |
51 | public function testUpdateNonExistingCustomer(): void
52 | {
53 | $payload = [
54 | 'name' => 'Brian',
55 | ];
56 |
57 | self::$admin->request(Request::METHOD_PATCH, \sprintf(self::ENDPOINT, self::NON_EXISTING_CUSTOMER_ID), [], [], [], \json_encode($payload));
58 |
59 | $response = self::$admin->getResponse();
60 | $responseData = $this->getResponseData($response);
61 |
62 | self::assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode());
63 | self::assertEquals(ResourceNotFoundException::class, $responseData['class']);
64 | }
65 |
66 | public function updateCustomerDataProvider(): iterable
67 | {
68 | yield 'Update name payload' => [
69 | [
70 | 'name' => 'Brian',
71 | ],
72 | ];
73 |
74 | yield 'Update address payload' => [
75 | [
76 | 'address' => 'New address 111',
77 | ],
78 | ];
79 |
80 | yield 'Update name and address payload' => [
81 | [
82 | 'name' => 'Peter',
83 | 'address' => 'New address 222',
84 | ],
85 | ];
86 |
87 | yield 'Update age payload' => [
88 | [
89 | 'age' => 33,
90 | ],
91 | ];
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/server/tests/Functional/Customer/Controller/CustomerControllerTestBase.php:
--------------------------------------------------------------------------------
1 | toRfc4122(), 'admin', 'admin@api.com');
31 | $password = static::getContainer()->get(PasswordHasherInterface::class)->hashPasswordForUser($admin, 'Password1!');
32 | $admin->setPassword($password);
33 |
34 | static::getContainer()->get(EmployeeRepository::class)->save($admin);
35 |
36 | $jwt = static::getContainer()->get(JWTTokenManagerInterface::class)->create($admin);
37 |
38 | self::$admin->setServerParameters([
39 | 'CONTENT_TYPE' => 'application/json',
40 | 'HTTP_Authorization' => \sprintf('Bearer %s', $jwt)
41 | ]);
42 | }
43 |
44 | protected function getResponseData(Response $response): array
45 | {
46 | try {
47 | return \json_decode($response->getContent(), true);
48 | } catch (\Exception $e) {
49 | throw $e;
50 | }
51 | }
52 |
53 | protected function createCustomer(): string
54 | {
55 | $payload = [
56 | 'name' => 'Peter',
57 | 'email' => 'peter@api.com',
58 | 'address' => 'Fake street 123',
59 | 'age' => 30,
60 | 'employeeId' => 'd368263a-ab71-4587-960d-cfe9725c373f',
61 | ];
62 |
63 | self::$admin->request(Request::METHOD_POST, self::CREATE_CUSTOMER_ENDPOINT, [], [], [], \json_encode($payload));
64 |
65 | $response = self::$admin->getResponse();
66 |
67 | if (Response::HTTP_CREATED !== $response->getStatusCode()) {
68 | throw new \RuntimeException('Error creating customer');
69 | }
70 |
71 | $responseData = $this->getResponseData($response);
72 |
73 | return $responseData['customerId'];
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/server/tests/Functional/Customer/Controller/HealthCheckControllerTest.php:
--------------------------------------------------------------------------------
1 | request(Request::METHOD_GET, self::ENDPOINT);
17 |
18 | $response = self::$admin->getResponse();
19 | $responseData = $this->getResponseData($response);
20 |
21 | self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
22 | self::assertEquals('Module Customer up and running!', $responseData['message']);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/CreateCustomer/CreateCustomerTest.php:
--------------------------------------------------------------------------------
1 | 'Peter',
19 | 'email' => 'peter@api.com',
20 | 'address' => 'Fake street 123',
21 | 'age' => 30,
22 | 'employeeId' => 'ba0f3716-528e-41d8-83ca-be2a48efa7ac',
23 | ];
24 |
25 | private CustomerRepository|MockObject $customerRepository;
26 | private CreateCustomer $useCase;
27 |
28 | public function setUp(): void
29 | {
30 | $this->customerRepository = $this->createMock(CustomerRepository::class);
31 | $this->useCase = new CreateCustomer($this->customerRepository);
32 | }
33 |
34 | public function testCreateCustomer(): void
35 | {
36 | $dto = CreateCustomerInputDTO::create(
37 | self::VALUES['name'],
38 | self::VALUES['email'],
39 | self::VALUES['address'],
40 | self::VALUES['age'],
41 | self::VALUES['employeeId'],
42 | );
43 |
44 | $this->customerRepository
45 | ->expects($this->once())
46 | ->method('save')
47 | ->with(
48 | $this->callback(function (Customer $customer): bool {
49 | return $customer->name() === self::VALUES['name']
50 | && $customer->email() === self::VALUES['email']
51 | && $customer->address() === self::VALUES['address']
52 | && $customer->age() === self::VALUES['age']
53 | && $customer->employeeId() === self::VALUES['employeeId'];
54 | })
55 | );
56 |
57 | $responseDTO = $this->useCase->handle($dto);
58 |
59 | self::assertInstanceOf(CreateCustomerOutputDTO::class, $responseDTO);
60 | }
61 | }
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/CreateCustomer/DTO/CreateCustomerInputDTOTest.php:
--------------------------------------------------------------------------------
1 | 'Peter',
15 | 'email' => 'peter@api.com',
16 | 'address' => 'Fake street 123',
17 | 'age' => 30,
18 | 'employeeId' => 'ba0f3716-528e-41d8-83ca-be2a48efa7ac',
19 | ];
20 |
21 | public function testCreate(): void
22 | {
23 | $dto = CreateCustomerInputDTO::create(
24 | self::VALUES['name'],
25 | self::VALUES['email'],
26 | self::VALUES['address'],
27 | self::VALUES['age'],
28 | self::VALUES['employeeId'],
29 | );
30 |
31 | self::assertInstanceOf(CreateCustomerInputDTO::class, $dto);
32 |
33 | self::assertEquals(self::VALUES['name'], $dto->name);
34 | self::assertEquals(self::VALUES['address'], $dto->address);
35 | self::assertEquals(self::VALUES['age'], $dto->age);
36 | self::assertEquals(self::VALUES['employeeId'], $dto->employeeId);
37 | }
38 |
39 | public function testCreateWithNullValues(): void
40 | {
41 | self::expectException(InvalidArgumentException::class);
42 | self::expectExceptionMessage('Invalid arguments [age]');
43 |
44 | CreateCustomerInputDTO::create(
45 | null,
46 | self::VALUES['email'],
47 | self::VALUES['address'],
48 | null,
49 | self::VALUES['employeeId'],
50 | );
51 | }
52 |
53 | public function testNameLengthIsLessThan2(): void
54 | {
55 | self::expectException(InvalidArgumentException::class);
56 | self::expectExceptionMessage('Value must be min [2] and max [10] characters');
57 |
58 | CreateCustomerInputDTO::create(
59 | 'A',
60 | self::VALUES['email'],
61 | self::VALUES['address'],
62 | self::VALUES['age'],
63 | self::VALUES['employeeId'],
64 | );
65 | }
66 |
67 | public function testNameLengthIsGreaterThan10(): void
68 | {
69 | self::expectException(InvalidArgumentException::class);
70 | self::expectExceptionMessage('Value must be min [2] and max [10] characters');
71 |
72 | CreateCustomerInputDTO::create(
73 | 'asdfghrtyuiasdwerasdasd',
74 | self::VALUES['email'],
75 | self::VALUES['address'],
76 | self::VALUES['age'],
77 | self::VALUES['employeeId'],
78 | );
79 | }
80 |
81 | public function testAgeHasToBeAtLeast18(): void
82 | {
83 | self::expectException(InvalidArgumentException::class);
84 | self::expectExceptionMessage('Age has to be at least 18');
85 |
86 | CreateCustomerInputDTO::create(
87 | self::VALUES['name'],
88 | self::VALUES['email'],
89 | self::VALUES['address'],
90 | 17,
91 | self::VALUES['employeeId'],
92 | );
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/DeleteCustomer/DeleteCustomerTest.php:
--------------------------------------------------------------------------------
1 | customerRepository = $this->createMock(CustomerRepository::class);
23 |
24 | $this->useCase = new DeleteCustomer($this->customerRepository);
25 | }
26 |
27 | public function testDeleteCustomer(): void
28 | {
29 | $customerId = '37fb348b-891a-4b1c-a4e4-a4a68a3c6bae';
30 |
31 | $customer = Customer::create(
32 | $customerId,
33 | 'Juan',
34 | 'peter@api.com',
35 | 'Fake street 123',
36 | 30,
37 | '37fb348b-891a-4b1c-a4e4-a4a68a3c6111',
38 | );
39 |
40 | $inputDTO = DeleteCustomerInputDTO::create($customerId);
41 |
42 | $this->customerRepository
43 | ->expects($this->once())
44 | ->method('findOneByIdOrFail')
45 | ->with($customerId)
46 | ->willReturn($customer);
47 |
48 | $this->customerRepository
49 | ->expects($this->once())
50 | ->method('remove')
51 | ->with($customer);
52 |
53 | $this->useCase->handle($inputDTO);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/GetCustomerById/DTO/GetCustomerByIdInputDTOTest.php:
--------------------------------------------------------------------------------
1 | id);
21 | }
22 |
23 | public function testCreateGetCustomerByIdInputDTOWithNullValue(): void
24 | {
25 | self::expectException(InvalidArgumentException::class);
26 | self::expectExceptionMessage('Invalid arguments [id]');
27 |
28 | GetCustomerByIdInputDTO::create(null);
29 | }
30 | }
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/GetCustomerById/GetCustomerByIdTest.php:
--------------------------------------------------------------------------------
1 | '9b5c0b1f-09bf-4fed-acc9-fcaafc933a19',
20 | 'name' => 'Peter',
21 | 'email' => 'peter@api.com',
22 | 'address' => 'Fake street 123',
23 | 'age' => 30,
24 | 'employeeId' => '9b5c0b1f-09bf-4fed-acc9-fcaafc933000',
25 | ];
26 |
27 | private CustomerRepository|MockObject $customerRepository;
28 |
29 | private GetCustomerById $useCase;
30 |
31 | public function setUp(): void
32 | {
33 | $this->customerRepository = $this->createMock(CustomerRepository::class);
34 |
35 | $this->useCase = new GetCustomerById($this->customerRepository);
36 | }
37 |
38 | public function testGetCustomerById(): void
39 | {
40 | $inputDto = GetCustomerByIdInputDTO::create(self::CUSTOMER_DATA['id']);
41 |
42 | $customer = Customer::create(
43 | self::CUSTOMER_DATA['id'],
44 | self::CUSTOMER_DATA['name'],
45 | self::CUSTOMER_DATA['email'],
46 | self::CUSTOMER_DATA['address'],
47 | self::CUSTOMER_DATA['age'],
48 | self::CUSTOMER_DATA['employeeId'],
49 | );
50 |
51 | $this->customerRepository
52 | ->expects($this->once())
53 | ->method('findOneByIdOrFail')
54 | ->with($inputDto->id)
55 | ->willReturn($customer);
56 |
57 | $responseDTO = $this->useCase->handle($inputDto);
58 |
59 | self::assertInstanceOf(GetCustomerByIdOutputDTO::class, $responseDTO);
60 |
61 | self::assertEquals(self::CUSTOMER_DATA['id'], $responseDTO->id);
62 | self::assertEquals(self::CUSTOMER_DATA['name'], $responseDTO->name);
63 | self::assertEquals(self::CUSTOMER_DATA['email'], $responseDTO->email);
64 | self::assertEquals(self::CUSTOMER_DATA['address'], $responseDTO->address);
65 | self::assertEquals(self::CUSTOMER_DATA['age'], $responseDTO->age);
66 | self::assertEquals(self::CUSTOMER_DATA['employeeId'], $responseDTO->employeeId);
67 | }
68 |
69 | public function testGetCustomerByIdException(): void
70 | {
71 | $inputDto = GetCustomerByIdInputDTO::create(self::CUSTOMER_DATA['id']);
72 |
73 | $this->customerRepository
74 | ->expects($this->once())
75 | ->method('findOneByIdOrFail')
76 | ->with($inputDto->id)
77 | ->willThrowException(ResourceNotFoundException::createFromClassAndId(Customer::class, $inputDto->id));
78 |
79 | self::expectException(ResourceNotFoundException::class);
80 |
81 | $this->useCase->handle($inputDto);
82 | }
83 | }
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/UpdateCustomer/DTO/UpdateCustomerInputDTOTest.php:
--------------------------------------------------------------------------------
1 | '37fb348b-891a-4b1c-a4e4-a4a68a3c6bae',
15 | 'name' => 'Brian',
16 | 'email' => 'peter@api.com',
17 | 'address' => 'Test address 123',
18 | 'age' => 20,
19 | 'keys' => [],
20 | ];
21 |
22 | public function testCreateDTO(): void
23 | {
24 | $dto = UpdateCustomerInputDTO::create(
25 | self::DATA['id'],
26 | self::DATA['name'],
27 | self::DATA['email'],
28 | self::DATA['address'],
29 | self::DATA['age'],
30 | self::DATA['keys']
31 | );
32 |
33 | self::assertInstanceOf(UpdateCustomerInputDTO::class, $dto);
34 | }
35 |
36 | public function testCreateWithNullId(): void
37 | {
38 | self::expectException(InvalidArgumentException::class);
39 |
40 | UpdateCustomerInputDTO::create(
41 | null,
42 | self::DATA['name'],
43 | self::DATA['email'],
44 | self::DATA['address'],
45 | self::DATA['age'],
46 | self::DATA['keys']
47 | );
48 | }
49 |
50 | public function testCreateWithInvalidAge(): void
51 | {
52 | self::expectException(InvalidArgumentException::class);
53 |
54 | UpdateCustomerInputDTO::create(
55 | self::DATA['id'],
56 | self::DATA['name'],
57 | self::DATA['email'],
58 | self::DATA['address'],
59 | 10,
60 | self::DATA['keys']
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/server/tests/Unit/Customer/Application/UseCase/Customer/UpdateCustomer/UpdateCustomerTest.php:
--------------------------------------------------------------------------------
1 | '37fb348b-891a-4b1c-a4e4-a4a68a3c6bae',
20 | 'name' => 'Brian',
21 | 'email' => 'brian@api.com',
22 | 'address' => 'Test address 123',
23 | 'age' => 20,
24 | ];
25 |
26 | private const DATA_TO_UPDATE = [
27 | 'id' => '37fb348b-891a-4b1c-a4e4-a4a68a3c6bae',
28 | 'name' => 'Peter',
29 | 'email' => 'peter@api.com',
30 | 'address' => 'Address 111',
31 | 'age' => 40,
32 | 'keys' => [
33 | 'name',
34 | 'address',
35 | 'age',
36 | ],
37 | ];
38 |
39 | private const EMPLOYEE_ID = '37fb348b-891a-4b1c-a4e4-a4a68a3c6111';
40 |
41 | private CustomerRepository|MockObject $customerRepository;
42 |
43 | private UpdateCustomerInputDTO $inputDTO;
44 |
45 | private UpdateCustomer $useCase;
46 |
47 | public function setUp(): void
48 | {
49 | $this->customerRepository = $this->createMock(CustomerRepository::class);
50 |
51 | $this->inputDTO = UpdateCustomerInputDTO::create(
52 | self::DATA_TO_UPDATE['id'],
53 | self::DATA_TO_UPDATE['name'],
54 | self::DATA_TO_UPDATE['email'],
55 | self::DATA_TO_UPDATE['address'],
56 | self::DATA_TO_UPDATE['age'],
57 | self::DATA_TO_UPDATE['keys']
58 | );
59 |
60 | $this->useCase = new UpdateCustomer($this->customerRepository);
61 | }
62 |
63 | public function testUpdateCustomer(): void
64 | {
65 | $customer = Customer::create(
66 | self::DATA['id'],
67 | self::DATA['name'],
68 | self::DATA['email'],
69 | self::DATA['address'],
70 | self::DATA['age'],
71 | self::EMPLOYEE_ID
72 | );
73 |
74 | $this->customerRepository
75 | ->expects($this->once())
76 | ->method('findOneByIdOrFail')
77 | ->with($this->inputDTO->id)
78 | ->willReturn($customer);
79 |
80 | $this->customerRepository
81 | ->expects($this->once())
82 | ->method('save')
83 | ->with(
84 | $this->callback(function (Customer $customer): bool {
85 | return $customer->name() === $this->inputDTO->name
86 | && $customer->address() === $this->inputDTO->address
87 | && $customer->age() === $this->inputDTO->age;
88 | })
89 | );
90 |
91 | $responseDTO = $this->useCase->handle($this->inputDTO);
92 |
93 | self::assertInstanceOf(UpdateCustomerOutputDTO::class, $responseDTO);
94 |
95 | self::assertEquals($this->inputDTO->name, $responseDTO->customerData['name']);
96 | self::assertEquals($this->inputDTO->address, $responseDTO->customerData['address']);
97 | self::assertEquals($this->inputDTO->age, $responseDTO->customerData['age']);
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/server/tests/Unit/Employee/Service/CreateEmployeeServiceTest.php:
--------------------------------------------------------------------------------
1 | employeeRepository = $this->createMock(EmployeeRepository::class);
25 | $this->passwordHasher = $this->createMock(PasswordHasherInterface::class);
26 | $this->service = new CreateEmployeeService($this->employeeRepository, $this->passwordHasher);
27 | }
28 |
29 | public function testCreateEmployee(): void
30 | {
31 | $name = 'Peter';
32 | $email = 'peter@api.com';
33 | $password = 'Password1!';
34 |
35 | $this->passwordHasher
36 | ->expects($this->once())
37 | ->method('hashPasswordForUser')
38 | ->with(
39 | $this->callback(function (Employee $employee) use ($name, $email): bool {
40 | return $employee->getName() === $name
41 | && $employee->getEmail() === $email;
42 | }),
43 | $this->callback(function (string $plainPassword) use ($password): bool {
44 | return $plainPassword === $password;
45 | })
46 | )
47 | ->willReturn('super-encrypted-password');
48 |
49 | $this->employeeRepository
50 | ->expects($this->once())
51 | ->method('save')
52 | ->with($this->callback(fn(Employee $employee) => $employee->getName() === $name && $employee->getEmail() === $email));
53 |
54 | $output = $this->service->create($name, $email, $password);
55 |
56 | self::assertArrayHasKey('id', $output);
57 | self::assertArrayHasKey('name', $output);
58 | self::assertArrayHasKey('email', $output);
59 | self::assertEquals($name, $output['name']);
60 | self::assertEquals($email, $output['email']);
61 | }
62 |
63 | /**
64 | * CASE_0: Case for repository method with exception
65 | */
66 | // public function testCreateEmployeeWithExistingEmail(): void
67 | // {
68 | // $name = 'Peter';
69 | // $email = 'peter@api.com';
70 | // $password = 'Password1!';
71 | //
72 | // $this->employeeRepository
73 | // ->expects($this->once())
74 | // ->method('findOneByEmailOrFail')
75 | // ->with($email)
76 | // ->willThrowException(ResourceNotFoundException::createFromResourceAndProperty(Employee::class, $email));
77 | //
78 | // $this->passwordHasher
79 | // ->expects($this->once())
80 | // ->method('hashPasswordForUser')
81 | // ->with(
82 | // $this->callback(function (Employee $employee) use ($name, $email): bool {
83 | // return $employee->getName() === $name
84 | // && $employee->getEmail() === $email;
85 | // }),
86 | // $this->callback(function (string $plainPassword) use ($password): bool {
87 | // return $plainPassword === $password;
88 | // })
89 | // )
90 | // ->willReturn('super-encrypted-password');
91 | //
92 | // $this->employeeRepository
93 | // ->expects($this->once())
94 | // ->method('save')
95 | // ->with($this->callback(fn(Employee $employee) => $employee->getName() === $name && $employee->getEmail() === $email));
96 | //
97 | // $this->service->create($name, $email, $password);
98 | // }
99 |
100 | /**
101 | * CASE_1: Case for repository method without exception
102 | */
103 | public function testCreateEmployeeWithExistingEmail(): void
104 | {
105 | $name = 'Peter';
106 | $email = 'peter@api.com';
107 | $password = 'Password1!';
108 |
109 | $employee = new Employee(Uuid::random()->value(), $name, $email);
110 |
111 | $this->employeeRepository
112 | ->expects($this->once())
113 | ->method('findOneByEmail')
114 | ->with($email)
115 | ->willReturn($employee);
116 |
117 | self::expectException(EmployeeAlreadyExistsException::class);
118 |
119 | $this->service->create($name, $email, $password);
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/server/tests/Unit/Employee/Service/RemoveEmployeeServiceTest.php:
--------------------------------------------------------------------------------
1 | employeeRepository = $this->createMock(EmployeeRepository::class);
22 | $this->service = new RemoveEmployeeService($this->employeeRepository);
23 | }
24 |
25 | public function testRemoveEmployee(): void
26 | {
27 | $email = 'peter@api.com';
28 |
29 | $employee = new Employee(Uuid::random()->value(), 'Peter', $email);
30 |
31 | $this->employeeRepository
32 | ->expects($this->once())
33 | ->method('findOneByEmail')
34 | ->with($email)
35 | ->willReturn($employee);
36 |
37 | $this->employeeRepository
38 | ->expects($this->once())
39 | ->method('remove')
40 | ->with($this->callback(fn(Employee $employee) => $employee->getEmail() === $email));
41 |
42 | $this->service->remove($email);
43 | }
44 |
45 | public function testRemoveNonExistingEmployee(): void
46 | {
47 | $email = 'peter@api.com';
48 |
49 | $this->employeeRepository
50 | ->expects($this->once())
51 | ->method('findOneByEmail')
52 | ->with($email)
53 | ->willReturn(null);
54 |
55 | self::expectException(ResourceNotFoundException::class);
56 |
57 | $this->service->remove($email);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/server/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | bootEnv(dirname(__DIR__).'/.env');
11 | }
12 |
--------------------------------------------------------------------------------