├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── Makefile ├── README.md ├── cucumber.js ├── docker-compose.yml ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── Contexts │ ├── Backoffice │ │ └── Courses │ │ │ ├── application │ │ │ ├── BackofficeCoursesResponse.ts │ │ │ ├── Create │ │ │ │ ├── BackofficeCourseCreator.ts │ │ │ │ └── CreateBackofficeCourseOnCourseCreated.ts │ │ │ ├── SearchAll │ │ │ │ ├── CoursesFinder.ts │ │ │ │ ├── SearchAllCoursesQuery.ts │ │ │ │ └── SearchAllCoursesQueryHandler.ts │ │ │ └── SearchByCriteria │ │ │ │ ├── CoursesByCriteriaSearcher.ts │ │ │ │ ├── SearchCoursesByCriteriaQuery.ts │ │ │ │ └── SearchCoursesByCriteriaQueryHandler.ts │ │ │ ├── domain │ │ │ ├── BackofficeCourse.ts │ │ │ ├── BackofficeCourseDuration.ts │ │ │ ├── BackofficeCourseId.ts │ │ │ ├── BackofficeCourseName.ts │ │ │ └── BackofficeCourseRepository.ts │ │ │ └── infrastructure │ │ │ ├── RabbitMQ │ │ │ ├── RabbitMQConfigFactory.ts │ │ │ └── RabbitMQEventBusFactory.ts │ │ │ ├── config │ │ │ ├── default.json │ │ │ ├── dev.json │ │ │ ├── end2end.json │ │ │ ├── index.ts │ │ │ ├── production.json │ │ │ ├── staging.json │ │ │ └── test.json │ │ │ └── persistence │ │ │ ├── BackofficeElasticConfigFactory.ts │ │ │ ├── ElasticBackofficeCourseRepository.ts │ │ │ ├── MongoBackofficeCourseRepository.ts │ │ │ └── MongoCriteriaConverter.ts │ ├── Mooc │ │ ├── Courses │ │ │ ├── application │ │ │ │ ├── CourseCreator.ts │ │ │ │ ├── Create │ │ │ │ │ ├── CourseCreator.ts │ │ │ │ │ └── CreateCourseCommandHandler.ts │ │ │ │ ├── CreateCourseCommandHandler.ts │ │ │ │ └── SearchAll │ │ │ │ │ ├── CoursesFinder.ts │ │ │ │ │ ├── CoursesResponse.ts │ │ │ │ │ ├── SearchAllCoursesQuery.ts │ │ │ │ │ └── SearchAllCoursesQueryHandler.ts │ │ │ ├── domain │ │ │ │ ├── Course.ts │ │ │ │ ├── CourseCreatedDomainEvent.ts │ │ │ │ ├── CourseDuration.ts │ │ │ │ ├── CourseName.ts │ │ │ │ ├── CourseNameLengthExceeded.ts │ │ │ │ ├── CourseRepository.ts │ │ │ │ └── CreateCourseCommand.ts │ │ │ └── infrastructure │ │ │ │ └── persistence │ │ │ │ ├── MongoCourseRepository.ts │ │ │ │ ├── TypeOrmCourseRepository.ts │ │ │ │ └── typeorm │ │ │ │ └── CourseEntity.ts │ │ ├── CoursesCounter │ │ │ ├── application │ │ │ │ ├── Find │ │ │ │ │ ├── CoursesCounterFinder.ts │ │ │ │ │ ├── FindCoursesCounterQuery.ts │ │ │ │ │ ├── FindCoursesCounterQueryHandler.ts │ │ │ │ │ └── FindCoursesCounterResponse.ts │ │ │ │ └── Increment │ │ │ │ │ ├── CoursesCounterIncrementer.ts │ │ │ │ │ └── IncrementCoursesCounterOnCourseCreated.ts │ │ │ ├── domain │ │ │ │ ├── CoursesCounter.ts │ │ │ │ ├── CoursesCounterId.ts │ │ │ │ ├── CoursesCounterIncrementedDomainEvent.ts │ │ │ │ ├── CoursesCounterNotExist.ts │ │ │ │ ├── CoursesCounterRepository.ts │ │ │ │ └── CoursesCounterTotal.ts │ │ │ └── infrastructure │ │ │ │ ├── InMemoryCoursesCounterRepository.ts │ │ │ │ └── persistence │ │ │ │ └── mongo │ │ │ │ └── MongoCoursesCounterRepository.ts │ │ └── Shared │ │ │ ├── domain │ │ │ └── Courses │ │ │ │ └── CourseId.ts │ │ │ └── infrastructure │ │ │ ├── RabbitMQ │ │ │ ├── RabbitMQConfigFactory.ts │ │ │ └── RabbitMQEventBusFactory.ts │ │ │ ├── config │ │ │ ├── default.json │ │ │ ├── dev.json │ │ │ ├── end2end.json │ │ │ ├── index.ts │ │ │ ├── production.json │ │ │ ├── staging.json │ │ │ └── test.json │ │ │ └── persistence │ │ │ ├── mongo │ │ │ └── MongoConfigFactory.ts │ │ │ └── postgre │ │ │ └── TypeOrmConfigFactory.ts │ └── Shared │ │ ├── domain │ │ ├── AggregateRoot.ts │ │ ├── Command.ts │ │ ├── CommandBus.ts │ │ ├── CommandHandler.ts │ │ ├── CommandNotRegisteredError.ts │ │ ├── DomainEvent.ts │ │ ├── DomainEventSubscriber.ts │ │ ├── EventBus.ts │ │ ├── Logger.ts │ │ ├── NewableClass.ts │ │ ├── Nullable.ts │ │ ├── Query.ts │ │ ├── QueryBus.ts │ │ ├── QueryHandler.ts │ │ ├── QueryNotRegisteredError.ts │ │ ├── Response.ts │ │ ├── criteria │ │ │ ├── Criteria.ts │ │ │ ├── Filter.ts │ │ │ ├── FilterField.ts │ │ │ ├── FilterOperator.ts │ │ │ ├── FilterValue.ts │ │ │ ├── Filters.ts │ │ │ ├── Order.ts │ │ │ ├── OrderBy.ts │ │ │ └── OrderType.ts │ │ └── value-object │ │ │ ├── EnumValueObject.ts │ │ │ ├── IntValueObject.ts │ │ │ ├── InvalidArgumentError.ts │ │ │ ├── StringValueObject.ts │ │ │ ├── Uuid.ts │ │ │ └── ValueObject.ts │ │ └── infrastructure │ │ ├── CommandBus │ │ ├── CommandHandlers.ts │ │ └── InMemoryCommandBus.ts │ │ ├── EventBus │ │ ├── DomainEventDeserializer.ts │ │ ├── DomainEventFailoverPublisher │ │ │ └── DomainEventFailoverPublisher.ts │ │ ├── DomainEventJsonSerializer.ts │ │ ├── DomainEventSubscribers.ts │ │ ├── InMemory │ │ │ └── InMemoryAsyncEventBus.ts │ │ └── RabbitMq │ │ │ ├── ConnectionSettings.ts │ │ │ ├── ExchangeSetting.ts │ │ │ ├── RabbitMQConfigurer.ts │ │ │ ├── RabbitMQConsumer.ts │ │ │ ├── RabbitMQConsumerFactory.ts │ │ │ ├── RabbitMQExchangeNameFormatter.ts │ │ │ ├── RabbitMQqueueFormatter.ts │ │ │ ├── RabbitMqConnection.ts │ │ │ └── RabbitMqEventBus.ts │ │ ├── QueryBus │ │ ├── InMemoryQueryBus.ts │ │ └── QueryHandlers.ts │ │ ├── WinstonLogger.ts │ │ └── persistence │ │ ├── elasticsearch │ │ ├── ElasticClientFactory.ts │ │ ├── ElasticConfig.ts │ │ ├── ElasticCriteriaConverter.ts │ │ └── ElasticRepository.ts │ │ ├── mongo │ │ ├── MongoClientFactory.ts │ │ ├── MongoConfig.ts │ │ └── MongoRepository.ts │ │ └── typeorm │ │ ├── TypeOrmClientFactory.ts │ │ ├── TypeOrmConfig.ts │ │ ├── TypeOrmRepository.ts │ │ └── ValueObjectTransformer.ts └── apps │ ├── backoffice │ ├── backend │ │ ├── BackofficeBackendApp.ts │ │ ├── command │ │ │ ├── ConfigureRabbitMQCommand.ts │ │ │ └── runConfigureRabbitMQCommand.ts │ │ ├── controllers │ │ │ ├── Controller.ts │ │ │ ├── CoursesGetController.ts │ │ │ ├── CoursesPostController.ts │ │ │ └── StatusGetController.ts │ │ ├── dependency-injection │ │ │ ├── Courses │ │ │ │ └── application.yaml │ │ │ ├── Shared │ │ │ │ └── application.yaml │ │ │ ├── application.yaml │ │ │ ├── application_dev.yaml │ │ │ ├── application_production.yaml │ │ │ ├── application_staging.yaml │ │ │ ├── application_test.yaml │ │ │ ├── apps │ │ │ │ └── application.yaml │ │ │ └── index.ts │ │ ├── routes │ │ │ ├── courses.route.ts │ │ │ ├── index.ts │ │ │ └── status.route.ts │ │ ├── server.ts │ │ └── start.ts │ └── frontend │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ │ ├── src │ │ ├── App.css │ │ ├── App.test.tsx │ │ ├── App.tsx │ │ ├── components │ │ │ ├── course-listing │ │ │ │ ├── CourseListing.tsx │ │ │ │ ├── ListingTitle.tsx │ │ │ │ └── filter │ │ │ │ │ ├── AddFilterButton.tsx │ │ │ │ │ ├── Filter.tsx │ │ │ │ │ ├── FilterButton.tsx │ │ │ │ │ └── FilterManager.tsx │ │ │ ├── footer │ │ │ │ └── Footer.tsx │ │ │ ├── form │ │ │ │ ├── Form.tsx │ │ │ │ ├── FormInput.tsx │ │ │ │ ├── FormSubmit.tsx │ │ │ │ └── FormTitle.tsx │ │ │ ├── header │ │ │ │ ├── Header.tsx │ │ │ │ ├── Navigation.tsx │ │ │ │ └── logo.svg │ │ │ ├── new-course-form │ │ │ │ └── NewCourseForm.tsx │ │ │ ├── page-container │ │ │ │ ├── PageAlert.tsx │ │ │ │ ├── PageContainer.tsx │ │ │ │ ├── PageContent.tsx │ │ │ │ ├── PageSeparator.tsx │ │ │ │ └── PageTitle.tsx │ │ │ └── table │ │ │ │ ├── Table.tsx │ │ │ │ ├── TableBody.tsx │ │ │ │ ├── TableCell.tsx │ │ │ │ ├── TableHead.tsx │ │ │ │ ├── TableHeader.tsx │ │ │ │ ├── TableRow.tsx │ │ │ │ └── TableTitle.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── pages │ │ │ ├── Courses.tsx │ │ │ └── Home.tsx │ │ ├── react-app-env.d.ts │ │ ├── reportWebVitals.ts │ │ ├── services │ │ │ └── courses.ts │ │ └── setupTests.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── mooc │ └── backend │ ├── MoocBackendApp.ts │ ├── command │ ├── ConfigureRabbitMQCommand.ts │ └── runConfigureRabbitMQCommand.ts │ ├── controllers │ ├── Controller.ts │ ├── CoursePutController.ts │ ├── CoursesCounterGetController.ts │ └── StatusGetController.ts │ ├── dependency-injection │ ├── Courses │ │ └── application.yaml │ ├── CoursesCounter │ │ └── application.yaml │ ├── Shared │ │ └── application.yaml │ ├── application.yaml │ ├── application_dev.yaml │ ├── application_production.yaml │ ├── application_test.yaml │ ├── apps │ │ └── application.yaml │ └── index.ts │ ├── routes │ ├── courses-counter.route.ts │ ├── courses.route.ts │ ├── index.ts │ └── status.route.ts │ ├── server.ts │ └── start.ts ├── tests ├── Contexts │ ├── Backoffice │ │ └── Courses │ │ │ ├── __mocks__ │ │ │ └── BackofficeCourseRepositoryMock.ts │ │ │ ├── application │ │ │ ├── Create │ │ │ │ └── BackofficeCourseCreator.test.ts │ │ │ ├── SearchAll │ │ │ │ └── SearchAllCoursesQueryHandler.test.ts │ │ │ └── SearchByCriteria │ │ │ │ └── SearchCoursesByCriteriaQueryHandler.test.ts │ │ │ ├── domain │ │ │ ├── BackofficeCourseCriteriaMother.ts │ │ │ ├── BackofficeCourseDurationMother.ts │ │ │ ├── BackofficeCourseIdMother.ts │ │ │ ├── BackofficeCourseMother.ts │ │ │ ├── BackofficeCourseNameMother.ts │ │ │ ├── SearchAllCoursesResponseMother.ts │ │ │ └── SearchCoursesByCriteriaResponseMother.ts │ │ │ └── infrastructure │ │ │ └── BackofficeCourseRepository.test.ts │ ├── Mooc │ │ ├── Courses │ │ │ ├── __mocks__ │ │ │ │ └── CourseRepositoryMock.ts │ │ │ ├── application │ │ │ │ ├── Create │ │ │ │ │ ├── CreateCourseCommandHandler.test.ts │ │ │ │ │ └── CreateCourseCommandMother.ts │ │ │ │ ├── CreateCourseCommandHandler.test.ts │ │ │ │ ├── CreateCourseCommandMother.ts │ │ │ │ └── SearchAll │ │ │ │ │ ├── SearchAllCoursesQueryHandler.test.ts │ │ │ │ │ └── SearchAllCoursesResponseMother.ts │ │ │ ├── domain │ │ │ │ ├── CourseCreatedDomainEventMother.ts │ │ │ │ ├── CourseDurationMother.ts │ │ │ │ ├── CourseMother.ts │ │ │ │ └── CourseNameMother.ts │ │ │ └── infrastructure │ │ │ │ └── persistence │ │ │ │ └── CourseRepository.test.ts │ │ ├── CoursesCounter │ │ │ ├── __mocks__ │ │ │ │ └── CoursesCounterRepositoryMock.ts │ │ │ ├── application │ │ │ │ ├── Find │ │ │ │ │ └── FindCoursesCounterQueryHandler.test.ts │ │ │ │ └── Increment │ │ │ │ │ └── CoursesCounterIncrementer.test.ts │ │ │ ├── domain │ │ │ │ ├── CoursesCounterIncrementedDomainEventMother.ts │ │ │ │ ├── CoursesCounterMother.ts │ │ │ │ └── CoursesCounterTotalMother.ts │ │ │ └── infrastructure │ │ │ │ └── CoursesCounterRepository.test.ts │ │ └── Shared │ │ │ └── domain │ │ │ ├── Courses │ │ │ └── CourseIdMother.ts │ │ │ └── EventBusMock.ts │ └── Shared │ │ ├── domain │ │ ├── IntegerMother.ts │ │ ├── MotherCreator.ts │ │ ├── Repeater.ts │ │ ├── UuidMother.ts │ │ └── WordMother.ts │ │ └── infrastructure │ │ ├── CommandBus │ │ ├── InMemoryCommandBus.test.ts │ │ └── __mocks__ │ │ │ ├── CommandHandlerDummy.ts │ │ │ ├── DummyCommand.ts │ │ │ └── UnhandledCommand.ts │ │ ├── EventBus │ │ ├── DomainEventFailoverPublisher.test.ts │ │ ├── RabbitMQEventBus.test.ts │ │ ├── __mocks__ │ │ │ ├── DomainEventDummy.ts │ │ │ ├── DomainEventFailoverPublisherDouble.ts │ │ │ ├── DomainEventSubscriberDummy.ts │ │ │ └── RabbitMQConnectionDouble.ts │ │ └── __mother__ │ │ │ ├── DomainEventDeserializerMother.ts │ │ │ ├── DomainEventFailoverPublisherMother.ts │ │ │ ├── RabbitMQConnectionConfigurationMother.ts │ │ │ ├── RabbitMQConnectionMother.ts │ │ │ └── RabbitMQMongoClientMother.ts │ │ ├── MongoClientFactory.test.ts │ │ ├── QueryBus │ │ └── InMemoryQueryBus.test.ts │ │ ├── TypeOrmClientFactory.test.ts │ │ ├── arranger │ │ └── EnvironmentArranger.ts │ │ ├── mongo │ │ └── MongoEnvironmentArranger.ts │ │ └── typeorm │ │ └── TypeOrmEnvironmentArranger.ts └── apps │ ├── backoffice │ └── backend │ │ └── features │ │ ├── courses │ │ └── get-courses.feature │ │ ├── status.feature │ │ └── step_definitions │ │ ├── controller.steps.ts │ │ ├── eventBus.steps.ts │ │ ├── hooks.steps.ts │ │ └── repository.steps.ts │ ├── mooc │ └── backend │ │ └── features │ │ ├── courses │ │ └── create-course.feature │ │ ├── courses_counter │ │ └── get-courses-counter.feature │ │ ├── status.feature │ │ └── step_definitions │ │ ├── controller.steps.ts │ │ ├── eventBus.steps.ts │ │ └── hooks.steps.ts │ └── tsconfig.json ├── tsconfig.json └── tsconfig.prod.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | .git 3 | docker-compose.yml 4 | Dockerfile 5 | node_modules 6 | dist 7 | data 8 | logs 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x, 15.x] 12 | mongodb-version: [4.2] 13 | elasticsearch-version: ['7.9.3'] 14 | rabbitmq-version: ['3.8.2-management-alpine'] 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Launch MongoDB 23 | uses: wbari/start-mongoDB@v0.2 24 | with: 25 | mongoDBVersion: ${{ matrix.mongodb-version }} 26 | - name: Launch RabbitMQ 27 | uses: nijel/rabbitmq-action@v1.0.0 28 | with: 29 | rabbitmq version: ${{ matrix.rabbitmq-version }} 30 | - name: Configure sysctl limits 31 | run: | 32 | sudo swapoff -a 33 | sudo sysctl -w vm.swappiness=1 34 | sudo sysctl -w fs.file-max=262144 35 | sudo sysctl -w vm.max_map_count=262144 36 | - name: Launch elasticsearch 37 | uses: getong/elasticsearch-action@v1.2 38 | with: 39 | elasticsearch version: ${{ matrix.elasticsearch-version }} 40 | host port: 9200 41 | container port: 9200 42 | host node port: 9300 43 | node port: 9300 44 | discovery type: 'single-node' 45 | - name: npm install 46 | run: | 47 | npm install 48 | - name: npm run build 49 | run: | 50 | npm run build --if-present 51 | npm run lint 52 | - name: npm test 53 | run: | 54 | npm test 55 | - name: Cypress run 56 | uses: cypress-io/github-action@v1 57 | env: 58 | NODE_ENV: end2end 59 | ACTIONS_ALLOW_UNSECURE_COMMANDS: true 60 | with: 61 | start: npm run cypress:ci:start 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .tmp 4 | logs/ 5 | data 6 | test-results.xml 7 | /src/Contexts/Mooc/Courses/infrastructure/persistence/courses.*.repo 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.16.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/apps/backoffice/frontend -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "quoteProps": "as-needed", 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.16.3-slim 2 | 3 | WORKDIR /code 4 | 5 | COPY package.json package-lock.json ./ 6 | RUN npm install 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY = default deps build test start-mooc-backend clean start-database start-backoffice-frontend 2 | 3 | # Shell to use for running scripts 4 | SHELL := $(shell which bash) 5 | IMAGE_NAME := codelytv/typescript-ddd-skeleton 6 | SERVICE_NAME := app 7 | MOOC_APP_NAME := mooc 8 | BACKOFFICE_APP_NAME := backoffice 9 | 10 | # Test if the dependencies we need to run this Makefile are installed 11 | DOCKER := $(shell command -v docker) 12 | DOCKER_COMPOSE := $(shell command -v docker-compose) 13 | deps: 14 | ifndef DOCKER 15 | @echo "Docker is not available. Please install docker" 16 | @exit 1 17 | endif 18 | ifndef DOCKER_COMPOSE 19 | @echo "docker-compose is not available. Please install docker-compose" 20 | @exit 1 21 | endif 22 | 23 | default: build 24 | 25 | # Build image 26 | build: 27 | docker build -t $(IMAGE_NAME):dev . 28 | 29 | # Clean containers 30 | clean: 31 | docker-compose down --rmi local --volumes --remove-orphans 32 | 33 | # Start databases containers in background 34 | start_database: 35 | docker-compose up -d mongo elasticsearch rabbitmq -------------------------------------------------------------------------------- /cucumber.js: -------------------------------------------------------------------------------- 1 | const common = [ 2 | '--require-module ts-node/register' // Load TypeScript module 3 | ]; 4 | 5 | const mooc_backend = [ 6 | ...common, 7 | 'tests/apps/mooc/backend/features/**/*.feature', 8 | '--require tests/apps/mooc/backend/features/step_definitions/*.steps.ts' 9 | ].join(' '); 10 | 11 | const backoffice_backend = [ 12 | ...common, 13 | 'tests/apps/backoffice/backend/features/**/*.feature', 14 | '--require tests/apps/backoffice/backend/features/step_definitions/*.steps.ts' 15 | ].join(' '); 16 | 17 | module.exports = { 18 | mooc_backend, 19 | backoffice_backend 20 | }; 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mongo: 5 | image: mongo:5.0.0 6 | environment: 7 | - MONGO_URL=mongodb://mongo:27017/dev 8 | volumes: 9 | - ./data/mongo:/data/db:delegated 10 | ports: 11 | - 27017:27017 12 | postgres: 13 | image: postgres 14 | environment: 15 | - POSTGRES_PASSWORD=codely 16 | - POSTGRES_USER=codely 17 | - POSTGRES_DB=mooc-backend-dev 18 | ports: 19 | - '5432:5432' 20 | restart: always 21 | rabbitmq: 22 | image: 'rabbitmq:3.8-management' 23 | ports: 24 | - 5672:5672 25 | - 15672:15672 26 | elasticsearch: 27 | image: docker.elastic.co/elasticsearch/elasticsearch:7.9.3 28 | container_name: codely-elasticsearch 29 | environment: 30 | - node.name=codely-elasticsearch 31 | - discovery.type=single-node #Elasticsearch forms a single-node cluster 32 | - bootstrap.memory_lock=true # might cause the JVM or shell session to exit if it tries to allocate more memory than is available! 33 | - 'ES_JAVA_OPTS=-Xms2048m -Xmx2048m' 34 | ulimits: 35 | memlock: 36 | soft: -1 # The memlock soft and hard values configures the range of memory that ElasticSearch will use. Setting this to –1 means unlimited. 37 | hard: -1 38 | volumes: 39 | - esdata:/usr/share/elasticsearch/data 40 | ports: 41 | - '9200:9200' 42 | kibana: 43 | image: docker.elastic.co/kibana/kibana:7.8.1 44 | container_name: codely-kibana 45 | environment: 46 | ELASTICSEARCH_URL: http://codely-elasticsearch:9200 47 | ELASTICSEARCH_HOSTS: http://codely-elasticsearch:9200 48 | ports: 49 | - 5601:5601 50 | 51 | volumes: 52 | node_modules: 53 | esdata: 54 | driver: local -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | cacheDirectory: '.tmp/jestCache' 5 | }; 6 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/BackofficeCoursesResponse.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourse } from '../domain/BackofficeCourse'; 2 | 3 | interface BackofficeCourseResponse { 4 | id: string; 5 | name: string; 6 | duration: string; 7 | } 8 | 9 | export class BackofficeCoursesResponse { 10 | public readonly courses: Array; 11 | 12 | constructor(courses: Array) { 13 | this.courses = courses.map(course => course.toPrimitives()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/Create/BackofficeCourseCreator.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourse } from '../../domain/BackofficeCourse'; 2 | import { BackofficeCourseDuration } from '../../domain/BackofficeCourseDuration'; 3 | import { BackofficeCourseId } from '../../domain/BackofficeCourseId'; 4 | import { BackofficeCourseName } from '../../domain/BackofficeCourseName'; 5 | import { BackofficeCourseRepository } from '../../domain/BackofficeCourseRepository'; 6 | 7 | export class BackofficeCourseCreator { 8 | constructor(private backofficeCourseRepository: BackofficeCourseRepository) {} 9 | 10 | async run(id: string, duration: string, name: string) { 11 | const course = new BackofficeCourse( 12 | new BackofficeCourseId(id), 13 | new BackofficeCourseName(name), 14 | new BackofficeCourseDuration(duration) 15 | ); 16 | 17 | return this.backofficeCourseRepository.save(course); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/Create/CreateBackofficeCourseOnCourseCreated.ts: -------------------------------------------------------------------------------- 1 | import { CourseCreatedDomainEvent } from '../../../../Mooc/Courses/domain/CourseCreatedDomainEvent'; 2 | import { DomainEventClass } from '../../../../Shared/domain/DomainEvent'; 3 | import { DomainEventSubscriber } from '../../../../Shared/domain/DomainEventSubscriber'; 4 | import { BackofficeCourseCreator } from './BackofficeCourseCreator'; 5 | 6 | export class CreateBackofficeCourseOnCourseCreated implements DomainEventSubscriber { 7 | constructor(private creator: BackofficeCourseCreator) {} 8 | 9 | subscribedTo(): DomainEventClass[] { 10 | return [CourseCreatedDomainEvent]; 11 | } 12 | 13 | async on(domainEvent: CourseCreatedDomainEvent): Promise { 14 | const { aggregateId, duration, name } = domainEvent; 15 | 16 | return this.creator.run(aggregateId, duration, name); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/SearchAll/CoursesFinder.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourseRepository } from '../../domain/BackofficeCourseRepository'; 2 | 3 | export class CoursesFinder { 4 | constructor(private coursesRepository: BackofficeCourseRepository) {} 5 | 6 | async run() { 7 | const courses = await this.coursesRepository.searchAll(); 8 | 9 | return courses; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/SearchAll/SearchAllCoursesQuery.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../Shared/domain/Query'; 2 | 3 | export class SearchAllCoursesQuery implements Query {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/SearchAll/SearchAllCoursesQueryHandler.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../Shared/domain/Query'; 2 | import { QueryHandler } from '../../../../Shared/domain/QueryHandler'; 3 | import { BackofficeCoursesResponse } from '../BackofficeCoursesResponse'; 4 | import { CoursesFinder } from './CoursesFinder'; 5 | import { SearchAllCoursesQuery } from './SearchAllCoursesQuery'; 6 | 7 | export class SearchAllCoursesQueryHandler implements QueryHandler { 8 | constructor(private readonly coursesFinder: CoursesFinder) {} 9 | 10 | subscribedTo(): Query { 11 | return SearchAllCoursesQuery; 12 | } 13 | 14 | async handle(_query: SearchAllCoursesQuery): Promise { 15 | return new BackofficeCoursesResponse(await this.coursesFinder.run()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/SearchByCriteria/CoursesByCriteriaSearcher.ts: -------------------------------------------------------------------------------- 1 | import { Criteria } from '../../../../Shared/domain/criteria/Criteria'; 2 | import { Filters } from '../../../../Shared/domain/criteria/Filters'; 3 | import { Order } from '../../../../Shared/domain/criteria/Order'; 4 | import { BackofficeCourseRepository } from '../../domain/BackofficeCourseRepository'; 5 | import { BackofficeCoursesResponse } from '../BackofficeCoursesResponse'; 6 | 7 | export class CoursesByCriteriaSearcher { 8 | constructor(private repository: BackofficeCourseRepository) {} 9 | 10 | async run(filters: Filters, order: Order, limit?: number, offset?: number): Promise { 11 | const criteria = new Criteria(filters, order, limit, offset); 12 | 13 | const courses = await this.repository.matching(criteria); 14 | 15 | return new BackofficeCoursesResponse(courses); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/SearchByCriteria/SearchCoursesByCriteriaQuery.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../Shared/domain/Query'; 2 | 3 | export class SearchCoursesByCriteriaQuery implements Query { 4 | readonly filters: Array>; 5 | readonly orderBy?: string; 6 | readonly orderType?: string; 7 | readonly limit?: number; 8 | readonly offset?: number; 9 | 10 | constructor( 11 | filters: Array>, 12 | orderBy?: string, 13 | orderType?: string, 14 | limit?: number, 15 | offset?: number 16 | ) { 17 | this.filters = filters; 18 | this.orderBy = orderBy; 19 | this.orderType = orderType; 20 | this.limit = limit; 21 | this.offset = offset; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/application/SearchByCriteria/SearchCoursesByCriteriaQueryHandler.ts: -------------------------------------------------------------------------------- 1 | import { Filters } from '../../../../Shared/domain/criteria/Filters'; 2 | import { Order } from '../../../../Shared/domain/criteria/Order'; 3 | import { Query } from '../../../../Shared/domain/Query'; 4 | import { QueryHandler } from '../../../../Shared/domain/QueryHandler'; 5 | import { BackofficeCoursesResponse } from '../BackofficeCoursesResponse'; 6 | import { CoursesByCriteriaSearcher } from './CoursesByCriteriaSearcher'; 7 | import { SearchCoursesByCriteriaQuery } from './SearchCoursesByCriteriaQuery'; 8 | 9 | export class SearchCoursesByCriteriaQueryHandler 10 | implements QueryHandler 11 | { 12 | constructor(private searcher: CoursesByCriteriaSearcher) {} 13 | 14 | subscribedTo(): Query { 15 | return SearchCoursesByCriteriaQuery; 16 | } 17 | 18 | handle(query: SearchCoursesByCriteriaQuery): Promise { 19 | const filters = Filters.fromValues(query.filters); 20 | const order = Order.fromValues(query.orderBy, query.orderType); 21 | 22 | return this.searcher.run(filters, order, query.limit, query.offset); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/domain/BackofficeCourse.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '../../../Shared/domain/AggregateRoot'; 2 | import { BackofficeCourseDuration } from './BackofficeCourseDuration'; 3 | import { BackofficeCourseId } from './BackofficeCourseId'; 4 | import { BackofficeCourseName } from './BackofficeCourseName'; 5 | 6 | export class BackofficeCourse extends AggregateRoot { 7 | readonly id: BackofficeCourseId; 8 | readonly name: BackofficeCourseName; 9 | readonly duration: BackofficeCourseDuration; 10 | 11 | constructor(id: BackofficeCourseId, name: BackofficeCourseName, duration: BackofficeCourseDuration) { 12 | super(); 13 | this.id = id; 14 | this.name = name; 15 | this.duration = duration; 16 | } 17 | 18 | static create( 19 | id: BackofficeCourseId, 20 | name: BackofficeCourseName, 21 | duration: BackofficeCourseDuration 22 | ): BackofficeCourse { 23 | const course = new BackofficeCourse(id, name, duration); 24 | 25 | return course; 26 | } 27 | 28 | static fromPrimitives(plainData: { id: string; name: string; duration: string }): BackofficeCourse { 29 | return new BackofficeCourse( 30 | new BackofficeCourseId(plainData.id), 31 | new BackofficeCourseName(plainData.name), 32 | new BackofficeCourseDuration(plainData.duration) 33 | ); 34 | } 35 | 36 | toPrimitives() { 37 | return { 38 | id: this.id.value, 39 | name: this.name.value, 40 | duration: this.duration.value 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/domain/BackofficeCourseDuration.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../../../Shared/domain/value-object/StringValueObject'; 2 | 3 | export class BackofficeCourseDuration extends StringValueObject {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/domain/BackofficeCourseId.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '../../../Shared/domain/value-object/Uuid'; 2 | 3 | export class BackofficeCourseId extends Uuid {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/domain/BackofficeCourseName.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../../../Shared/domain/value-object/StringValueObject'; 2 | 3 | export class BackofficeCourseName extends StringValueObject {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/domain/BackofficeCourseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Criteria } from '../../../Shared/domain/criteria/Criteria'; 2 | import { BackofficeCourse } from './BackofficeCourse'; 3 | 4 | export interface BackofficeCourseRepository { 5 | save(course: BackofficeCourse): Promise; 6 | searchAll(): Promise>; 7 | matching(criteria: Criteria): Promise>; 8 | } 9 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/RabbitMQ/RabbitMQConfigFactory.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionSettings } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/ConnectionSettings'; 2 | import { ExchangeSetting } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/ExchangeSetting'; 3 | import config from '../config'; 4 | 5 | export type RabbitMQConfig = { 6 | connectionSettings: ConnectionSettings; 7 | exchangeSettings: ExchangeSetting; 8 | maxRetries: number; 9 | retryTtl: number; 10 | }; 11 | 12 | export class RabbitMQConfigFactory { 13 | static createConfig(): RabbitMQConfig { 14 | return config.get('rabbitmq'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/RabbitMQ/RabbitMQEventBusFactory.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventFailoverPublisher } from '../../../../Shared/infrastructure/EventBus/DomainEventFailoverPublisher/DomainEventFailoverPublisher'; 2 | import { RabbitMqConnection } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 3 | import { RabbitMQEventBus } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/RabbitMQEventBus'; 4 | import { RabbitMQqueueFormatter } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/RabbitMQqueueFormatter'; 5 | import { RabbitMQConfig } from './RabbitMQConfigFactory'; 6 | 7 | export class RabbitMQEventBusFactory { 8 | static create( 9 | failoverPublisher: DomainEventFailoverPublisher, 10 | connection: RabbitMqConnection, 11 | queueNameFormatter: RabbitMQqueueFormatter, 12 | config: RabbitMQConfig 13 | ): RabbitMQEventBus { 14 | return new RabbitMQEventBus({ 15 | failoverPublisher, 16 | connection, 17 | exchange: config.exchangeSettings.name, 18 | queueNameFormatter: queueNameFormatter, 19 | maxRetries: config.maxRetries 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/config/default.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/config/dev.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/config/end2end.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "elastic": { 3 | "url": "http://elasticsearch:9200" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/config/staging.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongo": { "url": "mongodb://localhost:27017/mooc-backend-test" } 3 | } 4 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/persistence/BackofficeElasticConfigFactory.ts: -------------------------------------------------------------------------------- 1 | import ElasticConfig from '../../../../Shared/infrastructure/persistence/elasticsearch/ElasticConfig'; 2 | import config from '../config'; 3 | 4 | export class BackofficeElasticConfigFactory { 5 | static createConfig(): ElasticConfig { 6 | return { 7 | url: config.get('elastic.url'), 8 | indexName: config.get('elastic.indexName'), 9 | indexConfig: config.get('elastic.config') 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/persistence/ElasticBackofficeCourseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Criteria } from '../../../../Shared/domain/criteria/Criteria'; 2 | import { ElasticRepository } from '../../../../Shared/infrastructure/persistence/elasticsearch/ElasticRepository'; 3 | import { BackofficeCourse } from '../../domain/BackofficeCourse'; 4 | import { BackofficeCourseRepository } from '../../domain/BackofficeCourseRepository'; 5 | 6 | export class ElasticBackofficeCourseRepository 7 | extends ElasticRepository 8 | implements BackofficeCourseRepository 9 | { 10 | async searchAll(): Promise { 11 | return this.searchAllInElastic(BackofficeCourse.fromPrimitives); 12 | } 13 | 14 | async save(course: BackofficeCourse): Promise { 15 | return this.persist(course.id.value, course); 16 | } 17 | 18 | async matching(criteria: Criteria): Promise { 19 | return this.searchByCriteria(criteria, BackofficeCourse.fromPrimitives); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Contexts/Backoffice/Courses/infrastructure/persistence/MongoBackofficeCourseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Criteria } from '../../../../Shared/domain/criteria/Criteria'; 2 | import { MongoRepository } from '../../../../Shared/infrastructure/persistence/mongo/MongoRepository'; 3 | import { BackofficeCourse } from '../../domain/BackofficeCourse'; 4 | import { BackofficeCourseRepository } from '../../domain/BackofficeCourseRepository'; 5 | 6 | interface CourseDocument { 7 | _id: string; 8 | name: string; 9 | duration: string; 10 | } 11 | 12 | export class MongoBackofficeCourseRepository 13 | extends MongoRepository 14 | implements BackofficeCourseRepository 15 | { 16 | public save(course: BackofficeCourse): Promise { 17 | return this.persist(course.id.value, course); 18 | } 19 | 20 | protected collectionName(): string { 21 | return 'backoffice_courses'; 22 | } 23 | 24 | public async searchAll(): Promise { 25 | const collection = await this.collection(); 26 | const documents = await collection.find({}, {}).toArray(); 27 | 28 | return documents.map(document => 29 | BackofficeCourse.fromPrimitives({ name: document.name, duration: document.duration, id: document._id }) 30 | ); 31 | } 32 | 33 | public async matching(criteria: Criteria): Promise { 34 | const documents = await this.searchByCriteria(criteria); 35 | 36 | return documents.map(document => 37 | BackofficeCourse.fromPrimitives({ name: document.name, duration: document.duration, id: document._id }) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/CourseCreator.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from '../../../Shared/domain/EventBus'; 2 | import { CourseId } from '../../Shared/domain/Courses/CourseId'; 3 | import { Course } from '../domain/Course'; 4 | import { CourseDuration } from '../domain/CourseDuration'; 5 | import { CourseName } from '../domain/CourseName'; 6 | import { CourseRepository } from '../domain/CourseRepository'; 7 | 8 | export class CourseCreator { 9 | constructor(private repository: CourseRepository, private eventBus: EventBus) {} 10 | 11 | async run(params: { id: CourseId; name: CourseName; duration: CourseDuration }): Promise { 12 | const course = Course.create(params.id, params.name, params.duration); 13 | await this.repository.save(course); 14 | await this.eventBus.publish(course.pullDomainEvents()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/Create/CourseCreator.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from '../../../../Shared/domain/EventBus'; 2 | import { CourseId } from '../../../Shared/domain/Courses/CourseId'; 3 | import { Course } from '../../domain/Course'; 4 | import { CourseDuration } from '../../domain/CourseDuration'; 5 | import { CourseName } from '../../domain/CourseName'; 6 | import { CourseRepository } from '../../domain/CourseRepository'; 7 | 8 | export class CourseCreator { 9 | constructor(private repository: CourseRepository, private eventBus: EventBus) {} 10 | 11 | async run(params: { id: CourseId; name: CourseName; duration: CourseDuration }): Promise { 12 | const course = Course.create(params.id, params.name, params.duration); 13 | await this.repository.save(course); 14 | await this.eventBus.publish(course.pullDomainEvents()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/Create/CreateCourseCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '../../../../Shared/domain/CommandHandler'; 2 | import { CourseCreator } from './CourseCreator'; 3 | import { Command } from '../../../../Shared/domain/Command'; 4 | import { CourseId } from '../../../Shared/domain/Courses/CourseId'; 5 | import { CourseName } from '../../domain/CourseName'; 6 | import { CourseDuration } from '../../domain/CourseDuration'; 7 | import { CreateCourseCommand } from '../../domain/CreateCourseCommand'; 8 | 9 | export class CreateCourseCommandHandler implements CommandHandler { 10 | constructor(private courseCreator: CourseCreator) {} 11 | 12 | subscribedTo(): Command { 13 | return CreateCourseCommand; 14 | } 15 | 16 | async handle(command: CreateCourseCommand): Promise { 17 | const id = new CourseId(command.id); 18 | const name = new CourseName(command.name); 19 | const duration = new CourseDuration(command.duration); 20 | await this.courseCreator.run({ id, name, duration }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/CreateCourseCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '../../../Shared/domain/CommandHandler'; 2 | import { CourseCreator } from './CourseCreator'; 3 | import { Command } from '../../../Shared/domain/Command'; 4 | import { CourseId } from '../../Shared/domain/Courses/CourseId'; 5 | import { CourseName } from '../domain/CourseName'; 6 | import { CourseDuration } from '../domain/CourseDuration'; 7 | import { CreateCourseCommand } from '../domain/CreateCourseCommand'; 8 | 9 | export class CreateCourseCommandHandler implements CommandHandler { 10 | constructor(private courseCreator: CourseCreator) {} 11 | 12 | subscribedTo(): Command { 13 | return CreateCourseCommand; 14 | } 15 | 16 | async handle(command: CreateCourseCommand): Promise { 17 | const id = new CourseId(command.id); 18 | const name = new CourseName(command.name); 19 | const duration = new CourseDuration(command.duration); 20 | await this.courseCreator.run({ id, name, duration }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/SearchAll/CoursesFinder.ts: -------------------------------------------------------------------------------- 1 | import { CourseRepository } from '../../domain/CourseRepository'; 2 | import { CoursesResponse } from './CoursesResponse'; 3 | 4 | export class CoursesFinder { 5 | constructor(private coursesRepository: CourseRepository) {} 6 | 7 | async run() { 8 | const courses = await this.coursesRepository.searchAll(); 9 | 10 | return new CoursesResponse(courses); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/SearchAll/CoursesResponse.ts: -------------------------------------------------------------------------------- 1 | import { Course } from '../../domain/Course'; 2 | 3 | interface CourseResponse { 4 | id: string; 5 | name: string; 6 | duration: string; 7 | } 8 | 9 | export class CoursesResponse { 10 | public readonly courses: Array; 11 | 12 | constructor(courses: Array) { 13 | this.courses = courses.map(course => course.toPrimitives()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/SearchAll/SearchAllCoursesQuery.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../Shared/domain/Query'; 2 | 3 | export class SearchAllCoursesQuery implements Query {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/application/SearchAll/SearchAllCoursesQueryHandler.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../Shared/domain/Query'; 2 | import { QueryHandler } from '../../../../Shared/domain/QueryHandler'; 3 | import { CoursesResponse } from './CoursesResponse'; 4 | import { CoursesFinder } from './CoursesFinder'; 5 | import { SearchAllCoursesQuery } from './SearchAllCoursesQuery'; 6 | 7 | export class SearchAllCoursesQueryHandler implements QueryHandler { 8 | constructor(private coursesFinder: CoursesFinder) {} 9 | 10 | subscribedTo(): Query { 11 | return SearchAllCoursesQuery; 12 | } 13 | 14 | async handle(_query: SearchAllCoursesQuery): Promise { 15 | return this.coursesFinder.run(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/Course.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '../../../Shared/domain/AggregateRoot'; 2 | import { CourseId } from '../../Shared/domain/Courses/CourseId'; 3 | import { CourseCreatedDomainEvent } from './CourseCreatedDomainEvent'; 4 | import { CourseDuration } from './CourseDuration'; 5 | import { CourseName } from './CourseName'; 6 | 7 | export class Course extends AggregateRoot { 8 | readonly id: CourseId; 9 | readonly name: CourseName; 10 | readonly duration: CourseDuration; 11 | 12 | constructor(id: CourseId, name: CourseName, duration: CourseDuration) { 13 | super(); 14 | this.id = id; 15 | this.name = name; 16 | this.duration = duration; 17 | } 18 | 19 | static create(id: CourseId, name: CourseName, duration: CourseDuration): Course { 20 | const course = new Course(id, name, duration); 21 | 22 | course.record( 23 | new CourseCreatedDomainEvent({ 24 | aggregateId: course.id.value, 25 | duration: course.duration.value, 26 | name: course.name.value 27 | }) 28 | ); 29 | 30 | return course; 31 | } 32 | static fromPrimitives(plainData: { id: string; name: string; duration: string }): Course { 33 | return new Course( 34 | new CourseId(plainData.id), 35 | new CourseName(plainData.name), 36 | new CourseDuration(plainData.duration) 37 | ); 38 | } 39 | 40 | toPrimitives(): any { 41 | return { 42 | id: this.id.value, 43 | name: this.name.value, 44 | duration: this.duration.value 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/CourseCreatedDomainEvent.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../Shared/domain/DomainEvent'; 2 | 3 | type CreateCourseDomainEventAttributes = { 4 | readonly duration: string; 5 | readonly name: string; 6 | }; 7 | 8 | export class CourseCreatedDomainEvent extends DomainEvent { 9 | static readonly EVENT_NAME = 'course.created'; 10 | 11 | readonly duration: string; 12 | readonly name: string; 13 | 14 | constructor({ 15 | aggregateId, 16 | name, 17 | duration, 18 | eventId, 19 | occurredOn 20 | }: { 21 | aggregateId: string; 22 | eventId?: string; 23 | duration: string; 24 | name: string; 25 | occurredOn?: Date; 26 | }) { 27 | super({ eventName: CourseCreatedDomainEvent.EVENT_NAME, aggregateId, eventId, occurredOn }); 28 | this.duration = duration; 29 | this.name = name; 30 | } 31 | 32 | toPrimitives(): CreateCourseDomainEventAttributes { 33 | const { name, duration } = this; 34 | return { 35 | name, 36 | duration 37 | }; 38 | } 39 | 40 | static fromPrimitives(params: { 41 | aggregateId: string; 42 | attributes: CreateCourseDomainEventAttributes; 43 | eventId: string; 44 | occurredOn: Date; 45 | }): DomainEvent { 46 | const { aggregateId, attributes, occurredOn, eventId } = params; 47 | return new CourseCreatedDomainEvent({ 48 | aggregateId, 49 | duration: attributes.duration, 50 | name: attributes.name, 51 | eventId, 52 | occurredOn 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/CourseDuration.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../../../Shared/domain/value-object/StringValueObject'; 2 | 3 | export class CourseDuration extends StringValueObject {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/CourseName.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../../../Shared/domain/value-object/StringValueObject'; 2 | import { CourseNameLengthExceeded } from './CourseNameLengthExceeded'; 3 | 4 | export class CourseName extends StringValueObject { 5 | constructor(value: string) { 6 | super(value); 7 | this.ensureLengthIsLessThan30Characters(value); 8 | } 9 | 10 | private ensureLengthIsLessThan30Characters(value: string): void { 11 | if (value.length > 30) { 12 | throw new CourseNameLengthExceeded(`The Course Name <${value}> has more than 30 characters`); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/CourseNameLengthExceeded.ts: -------------------------------------------------------------------------------- 1 | import { InvalidArgumentError } from '../../../Shared/domain/value-object/InvalidArgumentError'; 2 | 3 | export class CourseNameLengthExceeded extends InvalidArgumentError {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/CourseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Course } from './Course'; 2 | 3 | export interface CourseRepository { 4 | save(course: Course): Promise; 5 | searchAll(): Promise>; 6 | } 7 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/domain/CreateCourseCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../../../Shared/domain/Command'; 2 | 3 | type Params = { 4 | id: string; 5 | name: string; 6 | duration: string; 7 | }; 8 | 9 | export class CreateCourseCommand extends Command { 10 | id: string; 11 | name: string; 12 | duration: string; 13 | 14 | constructor({ id, name, duration }: Params) { 15 | super(); 16 | this.id = id; 17 | this.name = name; 18 | this.duration = duration; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/infrastructure/persistence/MongoCourseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '../../../../Shared/domain/Nullable'; 2 | import { MongoRepository } from '../../../../Shared/infrastructure/persistence/mongo/MongoRepository'; 3 | import { CourseId } from '../../../Shared/domain/Courses/CourseId'; 4 | import { Course } from '../../domain/Course'; 5 | import { CourseRepository } from '../../domain/CourseRepository'; 6 | 7 | interface CourseDocument { 8 | _id: string; 9 | name: string; 10 | duration: string; 11 | } 12 | 13 | export class MongoCourseRepository extends MongoRepository implements CourseRepository { 14 | public save(course: Course): Promise { 15 | return this.persist(course.id.value, course); 16 | } 17 | 18 | public async search(id: CourseId): Promise> { 19 | const collection = await this.collection(); 20 | const document = await collection.findOne({ _id: id.value }); 21 | 22 | return document ? Course.fromPrimitives({ name: document.name, duration: document.duration, id: id.value }) : null; 23 | } 24 | 25 | protected collectionName(): string { 26 | return 'courses'; 27 | } 28 | 29 | public async searchAll(): Promise { 30 | const collection = await this.collection(); 31 | const documents = await collection.find({}, {}).toArray(); 32 | 33 | return documents.map(document => 34 | Course.fromPrimitives({ name: document.name, duration: document.duration, id: document._id }) 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/infrastructure/persistence/TypeOrmCourseRepository.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema } from 'typeorm'; 2 | import { Nullable } from '../../../../Shared/domain/Nullable'; 3 | import { TypeOrmRepository } from '../../../../Shared/infrastructure/persistence/typeorm/TypeOrmRepository'; 4 | import { CourseId } from '../../../Shared/domain/Courses/CourseId'; 5 | import { Course } from '../../domain/Course'; 6 | import { CourseRepository } from '../../domain/CourseRepository'; 7 | import { CourseEntity } from './typeorm/CourseEntity'; 8 | 9 | export class TypeOrmCourseRepository extends TypeOrmRepository implements CourseRepository { 10 | public save(course: Course): Promise { 11 | return this.persist(course); 12 | } 13 | 14 | public async search(id: CourseId): Promise> { 15 | const repository = await this.repository(); 16 | 17 | const course = await repository.findOne({ id }); 18 | 19 | return course; 20 | } 21 | 22 | protected entitySchema(): EntitySchema { 23 | return CourseEntity; 24 | } 25 | 26 | public async searchAll(): Promise { 27 | const repository = await this.repository(); 28 | 29 | const courses = await repository.find(); 30 | 31 | return courses; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Courses/infrastructure/persistence/typeorm/CourseEntity.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema } from 'typeorm'; 2 | import { ValueObjectTransformer } from '../../../../../Shared/infrastructure/persistence/typeorm/ValueObjectTransformer'; 3 | import { CourseId } from '../../../../Shared/domain/Courses/CourseId'; 4 | import { Course } from '../../../domain/Course'; 5 | import { CourseDuration } from '../../../domain/CourseDuration'; 6 | import { CourseName } from '../../../domain/CourseName'; 7 | 8 | export const CourseEntity = new EntitySchema({ 9 | name: 'Course', 10 | tableName: 'courses', 11 | target: Course, 12 | columns: { 13 | id: { 14 | type: String, 15 | primary: true, 16 | transformer: ValueObjectTransformer(CourseId) 17 | }, 18 | name: { 19 | type: String, 20 | transformer: ValueObjectTransformer(CourseName) 21 | }, 22 | duration: { 23 | type: String, 24 | transformer: ValueObjectTransformer(CourseDuration) 25 | } 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/application/Find/CoursesCounterFinder.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounterNotExist } from '../../domain/CoursesCounterNotExist'; 2 | import { CoursesCounterRepository } from '../../domain/CoursesCounterRepository'; 3 | 4 | export class CoursesCounterFinder { 5 | constructor(private repository: CoursesCounterRepository) {} 6 | 7 | async run() { 8 | const counter = await this.repository.search(); 9 | if (!counter) { 10 | throw new CoursesCounterNotExist(); 11 | } 12 | 13 | return counter.total.value; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQuery.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../Shared/domain/Query'; 2 | 3 | export class FindCoursesCounterQuery implements Query {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQueryHandler.ts: -------------------------------------------------------------------------------- 1 | import { QueryHandler } from '../../../../Shared/domain/QueryHandler'; 2 | import { FindCoursesCounterQuery } from './FindCoursesCounterQuery'; 3 | import { FindCoursesCounterResponse } from './FindCoursesCounterResponse'; 4 | import { Query } from '../../../../Shared/domain/Query'; 5 | import { CoursesCounterFinder } from './CoursesCounterFinder'; 6 | 7 | export class FindCoursesCounterQueryHandler 8 | implements QueryHandler 9 | { 10 | constructor(private finder: CoursesCounterFinder) {} 11 | 12 | subscribedTo(): Query { 13 | return FindCoursesCounterQuery; 14 | } 15 | 16 | async handle(_query: FindCoursesCounterQuery): Promise { 17 | const counter = await this.finder.run(); 18 | return new FindCoursesCounterResponse(counter); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterResponse.ts: -------------------------------------------------------------------------------- 1 | export class FindCoursesCounterResponse { 2 | readonly total: number; 3 | 4 | constructor(total: number) { 5 | this.total = total; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/application/Increment/CoursesCounterIncrementer.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from '../../../../Shared/domain/EventBus'; 2 | import { CourseId } from '../../../Shared/domain/Courses/CourseId'; 3 | import { CoursesCounterRepository } from '../../domain/CoursesCounterRepository'; 4 | import { CoursesCounter } from '../../domain/CoursesCounter'; 5 | import { CoursesCounterId } from '../../domain/CoursesCounterId'; 6 | 7 | export class CoursesCounterIncrementer { 8 | constructor(private repository: CoursesCounterRepository, private bus: EventBus) {} 9 | 10 | async run(courseId: CourseId) { 11 | const counter = (await this.repository.search()) || this.initializeCounter(); 12 | 13 | if (!counter.hasIncremented(courseId)) { 14 | counter.increment(courseId); 15 | 16 | await this.repository.save(counter); 17 | await this.bus.publish(counter.pullDomainEvents()); 18 | } 19 | } 20 | 21 | private initializeCounter(): CoursesCounter { 22 | return CoursesCounter.initialize(CoursesCounterId.random()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/application/Increment/IncrementCoursesCounterOnCourseCreated.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventClass } from '../../../../Shared/domain/DomainEvent'; 2 | import { DomainEventSubscriber } from '../../../../Shared/domain/DomainEventSubscriber'; 3 | import { CourseCreatedDomainEvent } from '../../../Courses/domain/CourseCreatedDomainEvent'; 4 | import { CourseId } from '../../../Shared/domain/Courses/CourseId'; 5 | import { CoursesCounterIncrementer } from './CoursesCounterIncrementer'; 6 | 7 | export class IncrementCoursesCounterOnCourseCreated implements DomainEventSubscriber { 8 | constructor(private incrementer: CoursesCounterIncrementer) {} 9 | 10 | subscribedTo(): DomainEventClass[] { 11 | return [CourseCreatedDomainEvent]; 12 | } 13 | 14 | async on(domainEvent: CourseCreatedDomainEvent) { 15 | await this.incrementer.run(new CourseId(domainEvent.aggregateId)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterId.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '../../../Shared/domain/value-object/Uuid'; 2 | 3 | export class CoursesCounterId extends Uuid {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterIncrementedDomainEvent.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../Shared/domain/DomainEvent'; 2 | 3 | type CoursesCounterIncrementedAttributes = { total: number }; 4 | 5 | export class CoursesCounterIncrementedDomainEvent extends DomainEvent { 6 | static readonly EVENT_NAME = 'courses_counter.incremented'; 7 | readonly total: number; 8 | 9 | constructor(data: { aggregateId: string; total: number; eventId?: string; occurredOn?: Date }) { 10 | const { aggregateId, eventId, occurredOn } = data; 11 | super({ eventName: CoursesCounterIncrementedDomainEvent.EVENT_NAME, aggregateId, eventId, occurredOn }); 12 | this.total = data.total; 13 | } 14 | 15 | toPrimitives() { 16 | return { 17 | total: this.total 18 | }; 19 | } 20 | 21 | static fromPrimitives(params: { 22 | aggregateId: string; 23 | attributes: CoursesCounterIncrementedAttributes; 24 | eventId: string; 25 | occurredOn: Date; 26 | }) { 27 | const { aggregateId, attributes, eventId, occurredOn } = params; 28 | return new CoursesCounterIncrementedDomainEvent({ 29 | aggregateId, 30 | total: attributes.total, 31 | eventId, 32 | occurredOn 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterNotExist.ts: -------------------------------------------------------------------------------- 1 | export class CoursesCounterNotExist extends Error { 2 | constructor() { 3 | super('The courses counter does not exists'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterRepository.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounter } from './CoursesCounter'; 2 | import { Nullable } from '../../../Shared/domain/Nullable'; 3 | 4 | export interface CoursesCounterRepository { 5 | search(): Promise>; 6 | save(counter: CoursesCounter): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterTotal.ts: -------------------------------------------------------------------------------- 1 | import { NumberValueObject } from '../../../Shared/domain/value-object/IntValueObject'; 2 | 3 | export class CoursesCounterTotal extends NumberValueObject { 4 | increment(): CoursesCounterTotal { 5 | return new CoursesCounterTotal(this.value + 1); 6 | } 7 | 8 | static initialize(): CoursesCounterTotal { 9 | return new CoursesCounterTotal(0); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/infrastructure/InMemoryCoursesCounterRepository.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounterRepository } from '../domain/CoursesCounterRepository'; 2 | import { CoursesCounter } from '../domain/CoursesCounter'; 3 | import { CoursesCounterId } from '../domain/CoursesCounterId'; 4 | import { CoursesCounterTotal } from '../domain/CoursesCounterTotal'; 5 | 6 | export class InMemoryCoursesCounterRepository implements CoursesCounterRepository { 7 | private counter: CoursesCounter; 8 | constructor() { 9 | this.counter = new CoursesCounter(CoursesCounterId.random(), new CoursesCounterTotal(0), []); 10 | } 11 | 12 | async search(): Promise { 13 | return this.counter; 14 | } 15 | 16 | async save(counter: CoursesCounter): Promise { 17 | this.counter = counter; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/CoursesCounter/infrastructure/persistence/mongo/MongoCoursesCounterRepository.ts: -------------------------------------------------------------------------------- 1 | import { MongoRepository } from '../../../../../Shared/infrastructure/persistence/mongo/MongoRepository'; 2 | import { Nullable } from '../../../../../Shared/domain/Nullable'; 3 | import { CoursesCounter } from '../../../domain/CoursesCounter'; 4 | import { CoursesCounterRepository } from '../../../domain/CoursesCounterRepository'; 5 | 6 | interface CoursesCounterDocument { 7 | _id: string; 8 | total: number; 9 | existingCourses: string[]; 10 | } 11 | 12 | export class MongoCoursesCounterRepository extends MongoRepository implements CoursesCounterRepository { 13 | protected collectionName(): string { 14 | return 'coursesCounter'; 15 | } 16 | 17 | public save(counter: CoursesCounter): Promise { 18 | return this.persist(counter.id.value, counter); 19 | } 20 | 21 | public async search(): Promise> { 22 | const collection = await this.collection(); 23 | 24 | const document = await collection.findOne({}); 25 | return document ? CoursesCounter.fromPrimitives({ ...document, id: document._id }) : null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/domain/Courses/CourseId.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '../../../../Shared/domain/value-object/Uuid'; 2 | 3 | export class CourseId extends Uuid {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/RabbitMQ/RabbitMQConfigFactory.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionSettings } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/ConnectionSettings'; 2 | import { ExchangeSetting } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/ExchangeSetting'; 3 | import config from '../config'; 4 | 5 | export type RabbitMQConfig = { 6 | connectionSettings: ConnectionSettings; 7 | exchangeSettings: ExchangeSetting; 8 | maxRetries: number; 9 | retryTtl: number; 10 | }; 11 | export class RabbitMQConfigFactory { 12 | static createConfig(): RabbitMQConfig { 13 | return config.get('rabbitmq'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/RabbitMQ/RabbitMQEventBusFactory.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventFailoverPublisher } from '../../../../Shared/infrastructure/EventBus/DomainEventFailoverPublisher/DomainEventFailoverPublisher'; 2 | import { RabbitMqConnection } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 3 | import { RabbitMQEventBus } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/RabbitMQEventBus'; 4 | import { RabbitMQqueueFormatter } from '../../../../Shared/infrastructure/EventBus/RabbitMQ/RabbitMQqueueFormatter'; 5 | import { RabbitMQConfig } from './RabbitMQConfigFactory'; 6 | 7 | export class RabbitMQEventBusFactory { 8 | static create( 9 | failoverPublisher: DomainEventFailoverPublisher, 10 | connection: RabbitMqConnection, 11 | queueNameFormatter: RabbitMQqueueFormatter, 12 | config: RabbitMQConfig 13 | ): RabbitMQEventBus { 14 | return new RabbitMQEventBus({ 15 | failoverPublisher, 16 | connection, 17 | exchange: config.exchangeSettings.name, 18 | queueNameFormatter: queueNameFormatter, 19 | maxRetries: config.maxRetries 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/config/default.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/config/dev.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/config/end2end.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/config/production.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/config/staging.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongo": { "url": "mongodb://localhost:27017/mooc-backend-test" }, 3 | "typeorm": {"database": "mooc-backend-dev"} 4 | } 5 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/persistence/mongo/MongoConfigFactory.ts: -------------------------------------------------------------------------------- 1 | import config from '../../config'; 2 | import MongoConfig from '../../../../../Shared/infrastructure/persistence/mongo/MongoConfig'; 3 | 4 | export class MongoConfigFactory { 5 | static createConfig(): MongoConfig { 6 | return { 7 | url: config.get('mongo.url') 8 | }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Contexts/Mooc/Shared/infrastructure/persistence/postgre/TypeOrmConfigFactory.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmConfig } from '../../../../../Shared/infrastructure/persistence/typeorm/TypeOrmConfig'; 2 | import config from '../../config'; 3 | 4 | export class TypeOrmConfigFactory { 5 | static createConfig(): TypeOrmConfig { 6 | return { 7 | host: config.get('typeorm.host'), 8 | port: config.get('typeorm.port'), 9 | username: config.get('typeorm.username'), 10 | password: config.get('typeorm.password'), 11 | database: config.get('typeorm.database') 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/AggregateRoot.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from './DomainEvent'; 2 | 3 | export abstract class AggregateRoot { 4 | private domainEvents: Array; 5 | 6 | constructor() { 7 | this.domainEvents = []; 8 | } 9 | 10 | pullDomainEvents(): Array { 11 | const domainEvents = this.domainEvents.slice(); 12 | this.domainEvents = []; 13 | 14 | return domainEvents; 15 | } 16 | 17 | record(event: DomainEvent): void { 18 | this.domainEvents.push(event); 19 | } 20 | 21 | abstract toPrimitives(): any; 22 | } 23 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/Command.ts: -------------------------------------------------------------------------------- 1 | export abstract class Command {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/CommandBus.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './Command'; 2 | 3 | export interface CommandBus { 4 | dispatch(command: Command): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/CommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './Command'; 2 | 3 | export interface CommandHandler { 4 | subscribedTo(): Command; 5 | handle(command: T): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/CommandNotRegisteredError.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './Command'; 2 | 3 | export class CommandNotRegisteredError extends Error { 4 | constructor(command: Command) { 5 | super(`The command <${command.constructor.name}> hasn't a command handler associated`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/DomainEvent.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from './value-object/Uuid'; 2 | 3 | export abstract class DomainEvent { 4 | static EVENT_NAME: string; 5 | static fromPrimitives: (params: { 6 | aggregateId: string; 7 | eventId: string; 8 | occurredOn: Date; 9 | attributes: DomainEventAttributes; 10 | }) => DomainEvent; 11 | 12 | readonly aggregateId: string; 13 | readonly eventId: string; 14 | readonly occurredOn: Date; 15 | readonly eventName: string; 16 | 17 | constructor(params: { eventName: string; aggregateId: string; eventId?: string; occurredOn?: Date }) { 18 | const { aggregateId, eventName, eventId, occurredOn } = params; 19 | this.aggregateId = aggregateId; 20 | this.eventId = eventId || Uuid.random().value; 21 | this.occurredOn = occurredOn || new Date(); 22 | this.eventName = eventName; 23 | } 24 | 25 | abstract toPrimitives(): DomainEventAttributes; 26 | } 27 | 28 | export type DomainEventClass = { 29 | EVENT_NAME: string; 30 | fromPrimitives(params: { 31 | aggregateId: string; 32 | eventId: string; 33 | occurredOn: Date; 34 | attributes: DomainEventAttributes; 35 | }): DomainEvent; 36 | }; 37 | 38 | type DomainEventAttributes = any; 39 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/DomainEventSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent, DomainEventClass } from './DomainEvent'; 2 | 3 | export interface DomainEventSubscriber { 4 | subscribedTo(): Array; 5 | on(domainEvent: T): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/EventBus.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventSubscribers } from '../infrastructure/EventBus/DomainEventSubscribers'; 2 | import { DomainEvent } from './DomainEvent'; 3 | 4 | export interface EventBus { 5 | publish(events: Array): Promise; 6 | addSubscribers(subscribers: DomainEventSubscribers): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/Logger.ts: -------------------------------------------------------------------------------- 1 | export default interface Logger { 2 | debug(message: string): void; 3 | error(message: string | Error): void; 4 | info(message: string): void; 5 | } 6 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/NewableClass.ts: -------------------------------------------------------------------------------- 1 | export interface NewableClass extends Function { 2 | new (...args: any[]): T; 3 | } 4 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/Nullable.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = T | null | undefined; 2 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/Query.ts: -------------------------------------------------------------------------------- 1 | export abstract class Query {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/QueryBus.ts: -------------------------------------------------------------------------------- 1 | import { Query } from './Query'; 2 | import { Response } from './Response'; 3 | 4 | export interface QueryBus { 5 | ask(query: Query): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/QueryHandler.ts: -------------------------------------------------------------------------------- 1 | import { Query } from './Query'; 2 | import { Response } from './Response'; 3 | 4 | export interface QueryHandler { 5 | subscribedTo(): Query; 6 | handle(query: Q): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/QueryNotRegisteredError.ts: -------------------------------------------------------------------------------- 1 | import { Query } from './Query'; 2 | 3 | export class QueryNotRegisteredError extends Error { 4 | constructor(query: Query) { 5 | super(`The query <${query.constructor.name}> hasn't a query handler associated`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/Response.ts: -------------------------------------------------------------------------------- 1 | export interface Response {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/Criteria.ts: -------------------------------------------------------------------------------- 1 | import { Filters } from './Filters'; 2 | import { Order } from './Order'; 3 | 4 | export class Criteria { 5 | readonly filters: Filters; 6 | readonly order: Order; 7 | readonly limit?: number; 8 | readonly offset?: number; 9 | 10 | constructor(filters: Filters, order: Order, limit?: number, offset?: number) { 11 | this.filters = filters; 12 | this.order = order; 13 | this.limit = limit; 14 | this.offset = offset; 15 | } 16 | 17 | public hasFilters(): boolean { 18 | return this.filters.filters.length > 0; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/Filter.ts: -------------------------------------------------------------------------------- 1 | import { InvalidArgumentError } from '../value-object/InvalidArgumentError'; 2 | import { FilterField } from './FilterField'; 3 | import { FilterOperator } from './FilterOperator'; 4 | import { FilterValue } from './FilterValue'; 5 | 6 | export class Filter { 7 | readonly field: FilterField; 8 | readonly operator: FilterOperator; 9 | readonly value: FilterValue; 10 | 11 | constructor(field: FilterField, operator: FilterOperator, value: FilterValue) { 12 | this.field = field; 13 | this.operator = operator; 14 | this.value = value; 15 | } 16 | 17 | static fromValues(values: Map): Filter { 18 | const field = values.get('field'); 19 | const operator = values.get('operator'); 20 | const value = values.get('value'); 21 | 22 | if (!field || !operator || !value) { 23 | throw new InvalidArgumentError(`The filter is invalid`); 24 | } 25 | 26 | return new Filter(new FilterField(field), FilterOperator.fromValue(operator), new FilterValue(value)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/FilterField.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../value-object/StringValueObject'; 2 | 3 | export class FilterField extends StringValueObject { 4 | constructor(value: string) { 5 | super(value); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/FilterOperator.ts: -------------------------------------------------------------------------------- 1 | import { EnumValueObject } from '../value-object/EnumValueObject'; 2 | import { InvalidArgumentError } from '../value-object/InvalidArgumentError'; 3 | 4 | export enum Operator { 5 | EQUAL = '=', 6 | NOT_EQUAL = '!=', 7 | GT = '>', 8 | LT = '<', 9 | CONTAINS = 'CONTAINS', 10 | NOT_CONTAINS = 'NOT_CONTAINS' 11 | } 12 | 13 | export class FilterOperator extends EnumValueObject { 14 | constructor(value: Operator) { 15 | super(value, Object.values(Operator)); 16 | } 17 | 18 | static fromValue(value: string): FilterOperator { 19 | for (const operatorValue of Object.values(Operator)) { 20 | if (value === operatorValue.toString()) { 21 | return new FilterOperator(operatorValue); 22 | } 23 | } 24 | 25 | throw new InvalidArgumentError(`The filter operator ${value} is invalid`); 26 | } 27 | 28 | public isPositive(): boolean { 29 | return this.value !== Operator.NOT_EQUAL && this.value !== Operator.NOT_CONTAINS; 30 | } 31 | 32 | protected throwErrorForInvalidValue(value: Operator): void { 33 | throw new InvalidArgumentError(`The filter operator ${value} is invalid`); 34 | } 35 | 36 | static equal() { 37 | return this.fromValue(Operator.EQUAL); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/FilterValue.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../value-object/StringValueObject'; 2 | 3 | export class FilterValue extends StringValueObject { 4 | constructor(value: string) { 5 | super(value); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/Filters.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from './Filter'; 2 | 3 | export class Filters { 4 | readonly filters: Filter[]; 5 | 6 | constructor(filters: Filter[]) { 7 | this.filters = filters; 8 | } 9 | 10 | static fromValues(filters: Array>): Filters { 11 | return new Filters(filters.map(Filter.fromValues)); 12 | } 13 | 14 | static none(): Filters { 15 | return new Filters([]); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/Order.ts: -------------------------------------------------------------------------------- 1 | import { OrderBy } from './OrderBy'; 2 | import { OrderType, OrderTypes } from './OrderType'; 3 | 4 | export class Order { 5 | readonly orderBy: OrderBy; 6 | readonly orderType: OrderType; 7 | 8 | constructor(orderBy: OrderBy, orderType: OrderType) { 9 | this.orderBy = orderBy; 10 | this.orderType = orderType; 11 | } 12 | 13 | static fromValues(orderBy?: string, orderType?: string): Order { 14 | if (!orderBy) { 15 | return Order.none(); 16 | } 17 | 18 | return new Order(new OrderBy(orderBy), OrderType.fromValue(orderType || OrderTypes.ASC)); 19 | } 20 | 21 | static none(): Order { 22 | return new Order(new OrderBy(''), new OrderType(OrderTypes.NONE)); 23 | } 24 | 25 | static desc(orderBy: string): Order { 26 | return new Order(new OrderBy(orderBy), new OrderType(OrderTypes.DESC)); 27 | } 28 | 29 | static asc(orderBy: string): Order { 30 | return new Order(new OrderBy(orderBy), new OrderType(OrderTypes.ASC)); 31 | } 32 | 33 | public hasOrder() { 34 | return !this.orderType.isNone(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/OrderBy.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '../value-object/StringValueObject'; 2 | 3 | export class OrderBy extends StringValueObject { 4 | constructor(value: string) { 5 | super(value); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/criteria/OrderType.ts: -------------------------------------------------------------------------------- 1 | import { EnumValueObject } from '../value-object/EnumValueObject'; 2 | import { InvalidArgumentError } from '../value-object/InvalidArgumentError'; 3 | 4 | export enum OrderTypes { 5 | ASC = 'asc', 6 | DESC = 'desc', 7 | NONE = 'none' 8 | } 9 | 10 | export class OrderType extends EnumValueObject { 11 | constructor(value: OrderTypes) { 12 | super(value, Object.values(OrderTypes)); 13 | } 14 | 15 | static fromValue(value: string): OrderType { 16 | for (const orderTypeValue of Object.values(OrderTypes)) { 17 | if (value === orderTypeValue.toString()) { 18 | return new OrderType(orderTypeValue); 19 | } 20 | } 21 | 22 | throw new InvalidArgumentError(`The order type ${value} is invalid`); 23 | } 24 | 25 | public isNone(): boolean { 26 | return this.value === OrderTypes.NONE; 27 | } 28 | 29 | public isAsc(): boolean { 30 | return this.value === OrderTypes.ASC; 31 | } 32 | 33 | protected throwErrorForInvalidValue(value: OrderTypes): void { 34 | throw new InvalidArgumentError(`The order type ${value} is invalid`); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/value-object/EnumValueObject.ts: -------------------------------------------------------------------------------- 1 | export abstract class EnumValueObject { 2 | readonly value: T; 3 | 4 | constructor(value: T, public readonly validValues: T[]) { 5 | this.value = value; 6 | this.checkValueIsValid(value); 7 | } 8 | 9 | public checkValueIsValid(value: T): void { 10 | if (!this.validValues.includes(value)) { 11 | this.throwErrorForInvalidValue(value); 12 | } 13 | } 14 | 15 | protected abstract throwErrorForInvalidValue(value: T): void; 16 | } 17 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/value-object/IntValueObject.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from './ValueObject'; 2 | 3 | export abstract class NumberValueObject extends ValueObject { 4 | isBiggerThan(other: NumberValueObject): boolean { 5 | return this.value > other.value; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/value-object/InvalidArgumentError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidArgumentError extends Error {} 2 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/value-object/StringValueObject.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from './ValueObject'; 2 | 3 | export abstract class StringValueObject extends ValueObject {} 4 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/value-object/Uuid.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | import validate from 'uuid-validate'; 3 | import { InvalidArgumentError } from './InvalidArgumentError'; 4 | import { ValueObject } from './ValueObject'; 5 | 6 | export class Uuid extends ValueObject { 7 | constructor(value: string) { 8 | super(value); 9 | this.ensureIsValidUuid(value); 10 | } 11 | 12 | static random(): Uuid { 13 | return new Uuid(uuid()); 14 | } 15 | 16 | private ensureIsValidUuid(id: string): void { 17 | if (!validate(id)) { 18 | throw new InvalidArgumentError(`<${this.constructor.name}> does not allow the value <${id}>`); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Contexts/Shared/domain/value-object/ValueObject.ts: -------------------------------------------------------------------------------- 1 | import { InvalidArgumentError } from './InvalidArgumentError'; 2 | 3 | export type Primitives = String | string | number | Boolean | boolean | Date; 4 | 5 | export abstract class ValueObject { 6 | readonly value: T; 7 | 8 | constructor(value: T) { 9 | this.value = value; 10 | this.ensureValueIsDefined(value); 11 | } 12 | 13 | private ensureValueIsDefined(value: T): void { 14 | if (value === null || value === undefined) { 15 | throw new InvalidArgumentError('Value must be defined'); 16 | } 17 | } 18 | 19 | equals(other: ValueObject): boolean { 20 | return other.constructor.name === this.constructor.name && other.value === this.value; 21 | } 22 | 23 | toString(): string { 24 | return this.value.toString(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/CommandBus/CommandHandlers.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../../domain/Command'; 2 | import { CommandHandler } from '../../domain/CommandHandler'; 3 | import { CommandNotRegisteredError } from '../../domain/CommandNotRegisteredError'; 4 | 5 | export class CommandHandlers extends Map> { 6 | constructor(commandHandlers: Array>) { 7 | super(); 8 | 9 | commandHandlers.forEach(commandHandler => { 10 | this.set(commandHandler.subscribedTo(), commandHandler); 11 | }); 12 | } 13 | 14 | public get(command: Command): CommandHandler { 15 | const commandHandler = super.get(command.constructor); 16 | 17 | if (!commandHandler) { 18 | throw new CommandNotRegisteredError(command); 19 | } 20 | 21 | return commandHandler; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/CommandBus/InMemoryCommandBus.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../../domain/Command'; 2 | import { CommandBus } from './../../domain/CommandBus'; 3 | import { CommandHandlers } from './CommandHandlers'; 4 | 5 | export class InMemoryCommandBus implements CommandBus { 6 | constructor(private commandHandlers: CommandHandlers) {} 7 | 8 | async dispatch(command: Command): Promise { 9 | const handler = this.commandHandlers.get(command); 10 | 11 | await handler.handle(command); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/DomainEventDeserializer.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventClass } from '../../domain/DomainEvent'; 2 | import { DomainEventSubscribers } from './DomainEventSubscribers'; 3 | 4 | type DomainEventJSON = { 5 | type: string; 6 | aggregateId: string; 7 | attributes: string; 8 | id: string; 9 | occurred_on: string; 10 | }; 11 | 12 | export class DomainEventDeserializer extends Map { 13 | static configure(subscribers: DomainEventSubscribers) { 14 | const mapping = new DomainEventDeserializer(); 15 | subscribers.items.forEach(subscriber => { 16 | subscriber.subscribedTo().forEach(mapping.registerEvent.bind(mapping)); 17 | }); 18 | 19 | return mapping; 20 | } 21 | 22 | private registerEvent(domainEvent: DomainEventClass) { 23 | const eventName = domainEvent.EVENT_NAME; 24 | this.set(eventName, domainEvent); 25 | } 26 | 27 | deserialize(event: string) { 28 | const eventData = JSON.parse(event).data as DomainEventJSON; 29 | const { type, aggregateId, attributes, id, occurred_on } = eventData; 30 | const eventClass = super.get(type); 31 | 32 | if (!eventClass) { 33 | throw Error(`DomainEvent mapping not found for event ${type}`); 34 | } 35 | 36 | return eventClass.fromPrimitives({ 37 | aggregateId, 38 | attributes, 39 | occurredOn: new Date(occurred_on), 40 | eventId: id 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/DomainEventFailoverPublisher/DomainEventFailoverPublisher.ts: -------------------------------------------------------------------------------- 1 | import { Collection, MongoClient } from 'mongodb'; 2 | import { DomainEvent } from '../../../domain/DomainEvent'; 3 | import { DomainEventDeserializer } from '../DomainEventDeserializer'; 4 | import { DomainEventJsonSerializer } from '../DomainEventJsonSerializer'; 5 | 6 | export class DomainEventFailoverPublisher { 7 | static collectionName = 'DomainEvents'; 8 | 9 | constructor(private client: Promise, private deserializer?: DomainEventDeserializer) {} 10 | 11 | protected async collection(): Promise { 12 | return (await this.client).db().collection(DomainEventFailoverPublisher.collectionName); 13 | } 14 | 15 | setDeserializer(deserializer: DomainEventDeserializer) { 16 | this.deserializer = deserializer; 17 | } 18 | 19 | async publish(event: DomainEvent): Promise { 20 | const collection = await this.collection(); 21 | 22 | const eventSerialized = DomainEventJsonSerializer.serialize(event); 23 | const options = { upsert: true }; 24 | const update = { $set: { eventId: event.eventId, event: eventSerialized } }; 25 | 26 | await collection.updateOne({ eventId: event.eventId }, update, options); 27 | } 28 | 29 | async consume(): Promise> { 30 | const collection = await this.collection(); 31 | const documents = await collection.find().limit(200).toArray(); 32 | if (!this.deserializer) { 33 | throw new Error('Deserializer has not been set yet'); 34 | } 35 | 36 | const events = documents.map(document => this.deserializer!.deserialize(document.event)); 37 | 38 | return events.filter(Boolean) as Array; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/DomainEventJsonSerializer.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../domain/DomainEvent'; 2 | 3 | export class DomainEventJsonSerializer { 4 | static serialize(event: DomainEvent): string { 5 | return JSON.stringify({ 6 | data: { 7 | id: event.eventId, 8 | type: event.eventName, 9 | occurred_on: event.occurredOn.toISOString(), 10 | aggregateId: event.aggregateId, 11 | attributes: event.toPrimitives() 12 | } 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers.ts: -------------------------------------------------------------------------------- 1 | import { ContainerBuilder, Definition } from 'node-dependency-injection'; 2 | import { DomainEvent } from '../../domain/DomainEvent'; 3 | import { DomainEventSubscriber } from '../../domain/DomainEventSubscriber'; 4 | 5 | export class DomainEventSubscribers { 6 | constructor(public items: Array>) {} 7 | 8 | static from(container: ContainerBuilder): DomainEventSubscribers { 9 | const subscriberDefinitions = container.findTaggedServiceIds('domainEventSubscriber') as Map; 10 | const subscribers: Array> = []; 11 | 12 | subscriberDefinitions.forEach((value: Definition, key: String) => { 13 | const domainEventSubscriber = container.get>(key.toString()); 14 | subscribers.push(domainEventSubscriber); 15 | }); 16 | 17 | return new DomainEventSubscribers(subscribers); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/InMemory/InMemoryAsyncEventBus.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { DomainEvent } from '../../../domain/DomainEvent'; 3 | import { EventBus } from '../../../domain/EventBus'; 4 | import { DomainEventSubscribers } from '../DomainEventSubscribers'; 5 | 6 | export class InMemoryAsyncEventBus extends EventEmitter implements EventBus { 7 | async publish(events: DomainEvent[]): Promise { 8 | events.map(event => this.emit(event.eventName, event)); 9 | } 10 | 11 | addSubscribers(subscribers: DomainEventSubscribers) { 12 | subscribers.items.forEach(subscriber => { 13 | subscriber.subscribedTo().forEach(event => { 14 | this.on(event.EVENT_NAME, subscriber.on.bind(subscriber)); 15 | }); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/RabbitMq/ConnectionSettings.ts: -------------------------------------------------------------------------------- 1 | export type ConnectionSettings = { 2 | username: string; 3 | password: string; 4 | vhost: string; 5 | connection: { 6 | secure: boolean; 7 | hostname: string; 8 | port: number; 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/RabbitMq/ExchangeSetting.ts: -------------------------------------------------------------------------------- 1 | export type ExchangeSetting = { 2 | name: string; 3 | type?: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/RabbitMq/RabbitMQConsumerFactory.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../domain/DomainEvent'; 2 | import { DomainEventSubscriber } from '../../../domain/DomainEventSubscriber'; 3 | import { DomainEventDeserializer } from '../DomainEventDeserializer'; 4 | import { RabbitMqConnection } from './RabbitMqConnection'; 5 | import { RabbitMQConsumer } from './RabbitMQConsumer'; 6 | 7 | export class RabbitMQConsumerFactory { 8 | constructor( 9 | private deserializer: DomainEventDeserializer, 10 | private connection: RabbitMqConnection, 11 | private maxRetries: Number 12 | ) {} 13 | 14 | build(subscriber: DomainEventSubscriber, exchange: string, queueName: string) { 15 | return new RabbitMQConsumer({ 16 | subscriber, 17 | deserializer: this.deserializer, 18 | connection: this.connection, 19 | queueName, 20 | exchange, 21 | maxRetries: this.maxRetries 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/RabbitMq/RabbitMQExchangeNameFormatter.ts: -------------------------------------------------------------------------------- 1 | export class RabbitMQExchangeNameFormatter { 2 | public static retry(exchangeName: string): string { 3 | return `retry-${exchangeName}`; 4 | } 5 | 6 | public static deadLetter(exchangeName: string): string { 7 | return `dead_letter-${exchangeName}`; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/EventBus/RabbitMq/RabbitMQqueueFormatter.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../domain/DomainEvent'; 2 | import { DomainEventSubscriber } from '../../../domain/DomainEventSubscriber'; 3 | 4 | export class RabbitMQqueueFormatter { 5 | constructor(private moduleName: string) {} 6 | 7 | format(subscriber: DomainEventSubscriber) { 8 | const value = subscriber.constructor.name; 9 | const name = value 10 | .split(/(?=[A-Z])/) 11 | .join('_') 12 | .toLowerCase(); 13 | return `${this.moduleName}.${name}`; 14 | } 15 | 16 | formatRetry(subscriber: DomainEventSubscriber) { 17 | const name = this.format(subscriber); 18 | return `retry.${name}`; 19 | } 20 | 21 | formatDeadLetter(subscriber: DomainEventSubscriber) { 22 | const name = this.format(subscriber); 23 | return `dead_letter.${name}`; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/QueryBus/InMemoryQueryBus.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../domain/Query'; 2 | import { Response } from '../../domain/Response'; 3 | import { QueryBus } from './../../domain/QueryBus'; 4 | import { QueryHandlers } from './QueryHandlers'; 5 | 6 | export class InMemoryQueryBus implements QueryBus { 7 | constructor(private queryHandlersInformation: QueryHandlers) {} 8 | 9 | async ask(query: Query): Promise { 10 | const handler = this.queryHandlersInformation.get(query); 11 | 12 | return (await handler.handle(query)) as Promise; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/QueryBus/QueryHandlers.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../domain/Query'; 2 | import { QueryHandler } from '../../domain/QueryHandler'; 3 | import { Response } from '../../domain/Response'; 4 | import { QueryNotRegisteredError } from '../../domain/QueryNotRegisteredError'; 5 | 6 | export class QueryHandlers extends Map> { 7 | constructor(queryHandlers: Array>) { 8 | super(); 9 | queryHandlers.forEach(queryHandler => { 10 | this.set(queryHandler.subscribedTo(), queryHandler); 11 | }); 12 | } 13 | 14 | public get(query: Query): QueryHandler { 15 | const queryHandler = super.get(query.constructor); 16 | 17 | if (!queryHandler) { 18 | throw new QueryNotRegisteredError(query); 19 | } 20 | 21 | return queryHandler; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/WinstonLogger.ts: -------------------------------------------------------------------------------- 1 | import winston, { Logger as WinstonLoggerType } from 'winston'; 2 | import Logger from '../domain/Logger'; 3 | 4 | enum Levels { 5 | DEBUG = 'debug', 6 | ERROR = 'error', 7 | INFO = 'info' 8 | } 9 | 10 | class WinstonLogger implements Logger { 11 | private logger: WinstonLoggerType; 12 | 13 | constructor() { 14 | this.logger = winston.createLogger({ 15 | format: winston.format.combine( 16 | winston.format.prettyPrint(), 17 | winston.format.errors({ stack: true }), 18 | winston.format.splat(), 19 | winston.format.colorize(), 20 | winston.format.simple() 21 | ), 22 | transports: [ 23 | new winston.transports.Console(), 24 | new winston.transports.File({ filename: `logs/${Levels.DEBUG}.log`, level: Levels.DEBUG }), 25 | new winston.transports.File({ filename: `logs/${Levels.ERROR}.log`, level: Levels.ERROR }), 26 | new winston.transports.File({ filename: `logs/${Levels.INFO}.log`, level: Levels.INFO }) 27 | ] 28 | }); 29 | } 30 | 31 | debug(message: string) { 32 | this.logger.debug(message); 33 | } 34 | 35 | error(message: string | Error) { 36 | this.logger.error(message); 37 | } 38 | 39 | info(message: string) { 40 | this.logger.info(message); 41 | } 42 | } 43 | export default WinstonLogger; 44 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/elasticsearch/ElasticClientFactory.ts: -------------------------------------------------------------------------------- 1 | import { Client as ElasticClient } from '@elastic/elasticsearch'; 2 | import { Nullable } from '../../../domain/Nullable'; 3 | import ElasticConfig from './ElasticConfig'; 4 | 5 | export class ElasticClientFactory { 6 | private static clients: { [key: string]: ElasticClient } = {}; 7 | 8 | static async createClient(contextName: string, config: ElasticConfig): Promise { 9 | let client = ElasticClientFactory.getClient(contextName); 10 | 11 | if (!client) { 12 | client = await ElasticClientFactory.createAndConnectClient(config); 13 | 14 | await ElasticClientFactory.createIndexWithSettingsIfNotExists(client, config); 15 | 16 | ElasticClientFactory.registerClient(client, contextName); 17 | } 18 | 19 | return client; 20 | } 21 | 22 | private static getClient(contextName: string): Nullable { 23 | return ElasticClientFactory.clients[contextName]; 24 | } 25 | 26 | private static async createAndConnectClient(config: ElasticConfig): Promise { 27 | const client = new ElasticClient({ node: config.url }); 28 | 29 | return client; 30 | } 31 | 32 | private static registerClient(client: ElasticClient, contextName: string): void { 33 | ElasticClientFactory.clients[contextName] = client; 34 | } 35 | 36 | private static async createIndexWithSettingsIfNotExists(client: ElasticClient, config: ElasticConfig): Promise { 37 | const { body: exist } = await client.indices.exists({ index: config.indexName }); 38 | 39 | if (!exist) { 40 | await client.indices.create({ 41 | index: config.indexName, 42 | body: config.indexConfig 43 | }); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/elasticsearch/ElasticConfig.ts: -------------------------------------------------------------------------------- 1 | type ElasticConfig = { url: string; indexName: string; indexConfig: any }; 2 | 3 | export default ElasticConfig; 4 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/mongo/MongoClientFactory.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb'; 2 | import MongoConfig from './MongoConfig'; 3 | 4 | export class MongoClientFactory { 5 | private static clients: { [key: string]: MongoClient } = {}; 6 | 7 | static async createClient(contextName: string, config: MongoConfig): Promise { 8 | let client = MongoClientFactory.getClient(contextName); 9 | 10 | if (!client) { 11 | client = await MongoClientFactory.createAndConnectClient(config); 12 | 13 | MongoClientFactory.registerClient(client, contextName); 14 | } 15 | 16 | return client; 17 | } 18 | 19 | private static getClient(contextName: string): MongoClient | null { 20 | return MongoClientFactory.clients[contextName]; 21 | } 22 | 23 | private static async createAndConnectClient(config: MongoConfig): Promise { 24 | const client = new MongoClient(config.url, { ignoreUndefined: true }); 25 | 26 | await client.connect(); 27 | 28 | return client; 29 | } 30 | 31 | private static registerClient(client: MongoClient, contextName: string): void { 32 | MongoClientFactory.clients[contextName] = client; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/mongo/MongoConfig.ts: -------------------------------------------------------------------------------- 1 | interface MongoConfig { 2 | url: string; 3 | } 4 | 5 | export default MongoConfig; 6 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/mongo/MongoRepository.ts: -------------------------------------------------------------------------------- 1 | import { Collection, MongoClient } from 'mongodb'; 2 | import { MongoCriteriaConverter } from '../../../../Backoffice/Courses/infrastructure/persistence/MongoCriteriaConverter'; 3 | import { AggregateRoot } from '../../../domain/AggregateRoot'; 4 | import { Criteria } from '../../../domain/criteria/Criteria'; 5 | 6 | export abstract class MongoRepository { 7 | private criteriaConverter: MongoCriteriaConverter; 8 | 9 | constructor(private _client: Promise) { 10 | this.criteriaConverter = new MongoCriteriaConverter(); 11 | } 12 | 13 | protected abstract collectionName(): string; 14 | 15 | protected client(): Promise { 16 | return this._client; 17 | } 18 | 19 | protected async collection(): Promise { 20 | return (await this._client).db().collection(this.collectionName()); 21 | } 22 | 23 | protected async persist(id: string, aggregateRoot: T): Promise { 24 | const collection = await this.collection(); 25 | 26 | const document = { ...aggregateRoot.toPrimitives(), _id: id, id: undefined }; 27 | 28 | await collection.updateOne({ _id: id }, { $set: document }, { upsert: true }); 29 | } 30 | 31 | protected async searchByCriteria(criteria: Criteria): Promise { 32 | const query = this.criteriaConverter.convert(criteria); 33 | 34 | const collection = await this.collection(); 35 | 36 | return await collection.find(query.filter, {}).sort(query.sort).skip(query.skip).limit(query.limit).toArray(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/typeorm/TypeOrmClientFactory.ts: -------------------------------------------------------------------------------- 1 | import { Connection, createConnection, getConnection } from 'typeorm'; 2 | import { TypeOrmConfig } from './TypeOrmConfig'; 3 | 4 | export class TypeOrmClientFactory { 5 | static async createClient(contextName: string, config: TypeOrmConfig): Promise { 6 | try { 7 | const connection = await createConnection({ 8 | name: contextName, 9 | type: 'postgres', 10 | host: config.host, 11 | port: config.port, 12 | username: config.username, 13 | password: config.password, 14 | database: config.database, 15 | entities: [__dirname + '/../../../../**/**/infrastructure/persistence/typeorm/*{.js,.ts}'], 16 | synchronize: true, 17 | logging: true 18 | }); 19 | 20 | return connection; 21 | } catch (error) { 22 | return getConnection(contextName); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/typeorm/TypeOrmConfig.ts: -------------------------------------------------------------------------------- 1 | export interface TypeOrmConfig { 2 | host: string; 3 | port: number; 4 | username: string; 5 | password: string; 6 | database: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/typeorm/TypeOrmRepository.ts: -------------------------------------------------------------------------------- 1 | import { Connection, EntitySchema, Repository } from 'typeorm'; 2 | import { AggregateRoot } from '../../../domain/AggregateRoot'; 3 | 4 | export abstract class TypeOrmRepository { 5 | constructor(private _client: Promise) {} 6 | 7 | protected abstract entitySchema(): EntitySchema; 8 | 9 | protected client(): Promise { 10 | return this._client; 11 | } 12 | 13 | protected async repository(): Promise> { 14 | return (await this._client).getRepository(this.entitySchema()); 15 | } 16 | 17 | protected async persist(aggregateRoot: T): Promise { 18 | const repository = await this.repository(); 19 | await repository.save(aggregateRoot as any); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Contexts/Shared/infrastructure/persistence/typeorm/ValueObjectTransformer.ts: -------------------------------------------------------------------------------- 1 | import { NewableClass } from '../../../domain/NewableClass'; 2 | import { Primitives, ValueObject } from '../../../domain/value-object/ValueObject'; 3 | 4 | export const ValueObjectTransformer = (ValueObject: NewableClass>) => { 5 | return { 6 | to: (value: ValueObject): T => value.value, 7 | from: (value: T): ValueObject => new ValueObject(value) 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/BackofficeBackendApp.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from '../../../Contexts/Shared/domain/EventBus'; 2 | import container from './dependency-injection'; 3 | import { DomainEventSubscribers } from '../../../Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 4 | import { Server } from './server'; 5 | import { RabbitMqConnection } from '../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 6 | 7 | export class BackofficeBackendApp { 8 | server?: Server; 9 | 10 | async start() { 11 | const port = process.env.PORT || '3000'; 12 | this.server = new Server(port); 13 | 14 | await this.configureEventBus(); 15 | 16 | return this.server.listen(); 17 | } 18 | 19 | get httpServer() { 20 | return this.server?.getHTTPServer(); 21 | } 22 | 23 | async stop() { 24 | const rabbitMQConnection = container.get('Backoffice.Shared.RabbitMQConnection'); 25 | await rabbitMQConnection.close(); 26 | return this.server?.stop(); 27 | } 28 | 29 | private async configureEventBus() { 30 | const eventBus = container.get('Backoffice.Shared.domain.EventBus'); 31 | const rabbitMQConnection = container.get('Backoffice.Shared.RabbitMQConnection'); 32 | await rabbitMQConnection.connect(); 33 | 34 | eventBus.addSubscribers(DomainEventSubscribers.from(container)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/command/ConfigureRabbitMQCommand.ts: -------------------------------------------------------------------------------- 1 | import { RabbitMQConfig } from '../../../../Contexts/Backoffice/Courses/infrastructure/RabbitMQ/RabbitMQConfigFactory'; 2 | import { DomainEventSubscribers } from '../../../../Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 3 | import { RabbitMQConfigurer } from '../../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMQConfigurer'; 4 | import { RabbitMqConnection } from '../../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 5 | import { RabbitMQqueueFormatter } from '../../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMQqueueFormatter'; 6 | import container from '../dependency-injection'; 7 | 8 | export class ConfigureRabbitMQCommand { 9 | static async run() { 10 | const connection = container.get('Backoffice.Shared.RabbitMQConnection'); 11 | const nameFormatter = container.get('Backoffice.Shared.RabbitMQQueueFormatter'); 12 | const { exchangeSettings, retryTtl } = container.get('Backoffice.Shared.RabbitMQConfig'); 13 | 14 | await connection.connect(); 15 | 16 | const configurer = new RabbitMQConfigurer(connection, nameFormatter, retryTtl); 17 | const subscribers = DomainEventSubscribers.from(container).items; 18 | 19 | await configurer.configure({ exchange: exchangeSettings.name, subscribers }); 20 | await connection.close(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/command/runConfigureRabbitMQCommand.ts: -------------------------------------------------------------------------------- 1 | import { ConfigureRabbitMQCommand } from './ConfigureRabbitMQCommand'; 2 | 3 | ConfigureRabbitMQCommand.run() 4 | .then(() => { 5 | console.log('RabbitMQ Configuration success'); 6 | process.exit(0); 7 | }) 8 | .catch(error => { 9 | console.log('RabbitMQ Configuration fail', error); 10 | process.exit(1); 11 | }); 12 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/controllers/Controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export interface Controller { 4 | run(req: Request, res: Response): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/controllers/CoursesGetController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { BackofficeCoursesResponse } from '../../../../Contexts/Backoffice/Courses/application/BackofficeCoursesResponse'; 4 | import { SearchCoursesByCriteriaQuery } from '../../../../Contexts/Backoffice/Courses/application/SearchByCriteria/SearchCoursesByCriteriaQuery'; 5 | import { QueryBus } from '../../../../Contexts/Shared/domain/QueryBus'; 6 | import { Controller } from './Controller'; 7 | 8 | type FilterType = { value: string; operator: string; field: string }; 9 | 10 | export class CoursesGetController implements Controller { 11 | constructor(private readonly queryBus: QueryBus) {} 12 | 13 | async run(_req: Request, res: Response) { 14 | const { query: queryParams } = _req; 15 | const { filters, orderBy, order, limit, offset } = queryParams; 16 | 17 | const query = new SearchCoursesByCriteriaQuery( 18 | this.parseFilters(filters as Array), 19 | orderBy as string, 20 | order as string, 21 | limit ? Number(limit) : undefined, 22 | offset ? Number(offset) : undefined 23 | ); 24 | 25 | const response = await this.queryBus.ask(query); 26 | 27 | res.status(httpStatus.OK).send(response.courses); 28 | } 29 | 30 | private parseFilters(params: Array): Array> { 31 | if (!params) { 32 | return new Array>(); 33 | } 34 | 35 | return params.map(filter => { 36 | const field = filter.field; 37 | const value = filter.value; 38 | const operator = filter.operator; 39 | 40 | return new Map([ 41 | ['field', field], 42 | ['operator', operator], 43 | ['value', value] 44 | ]); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/controllers/CoursesPostController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { CreateCourseCommand } from '../../../../Contexts/Mooc/Courses/domain/CreateCourseCommand'; 4 | import { CommandBus } from '../../../../Contexts/Shared/domain/CommandBus'; 5 | import { Controller } from './Controller'; 6 | 7 | type CreateCourseRequest = { 8 | id: string; 9 | name: string; 10 | duration: string; 11 | }; 12 | 13 | export class CoursesPostController implements Controller { 14 | constructor(private readonly commandBus: CommandBus) {} 15 | 16 | async run(req: Request, res: Response) { 17 | await this.createCourse(req); 18 | res.status(httpStatus.OK).send(); 19 | } 20 | 21 | private async createCourse(req: Request) { 22 | const createCourseCommand = new CreateCourseCommand({ 23 | id: req.body.id, 24 | name: req.body.name, 25 | duration: req.body.duration 26 | }); 27 | 28 | await this.commandBus.dispatch(createCourseCommand); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/controllers/StatusGetController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { Controller } from './Controller'; 4 | 5 | export default class StatusGetController implements Controller { 6 | async run(req: Request, res: Response) { 7 | res.status(httpStatus.OK).send(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/application.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./Shared/application.yaml } 3 | - { resource: ./apps/application.yaml } 4 | - { resource: ./Courses/application.yaml } 5 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/application_dev.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/application_production.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/application_staging.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/application_test.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | 4 | services: 5 | Backoffice.EnvironmentArranger: 6 | class: ../../../../../tests/Contexts/Shared/infrastructure/mongo/MongoEnvironmentArranger 7 | arguments: ['@Backoffice.Shared.ConnectionManager'] -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/apps/application.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Apps.Backoffice.Backend.controllers.StatusGetController: 3 | class: ../../controllers/StatusGetController 4 | arguments: ['@Backoffice.Shared.domain.QueryBus'] 5 | 6 | Apps.Backoffice.Backend.controllers.CoursesPostController: 7 | class: ../../controllers/CoursesPostController 8 | arguments: ['@Backoffice.Shared.domain.CommandBus'] 9 | 10 | Apps.Backoffice.Backend.controllers.CoursesGetController: 11 | class: ../../controllers/CoursesGetController 12 | arguments: ['@Backoffice.Shared.domain.QueryBus'] -------------------------------------------------------------------------------- /src/apps/backoffice/backend/dependency-injection/index.ts: -------------------------------------------------------------------------------- 1 | import { ContainerBuilder, YamlFileLoader } from 'node-dependency-injection'; 2 | 3 | const container = new ContainerBuilder(); 4 | const loader = new YamlFileLoader(container); 5 | const env = process.env.NODE_ENV || 'dev'; 6 | 7 | loader.load(`${__dirname}/application_${env}.yaml`); 8 | 9 | export default container; 10 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/routes/courses.route.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | import container from '../dependency-injection'; 3 | import { CoursesPostController } from '../controllers/CoursesPostController'; 4 | 5 | export const register = (app: Express) => { 6 | const coursesPostController: CoursesPostController = container.get( 7 | 'Apps.Backoffice.Backend.controllers.CoursesPostController' 8 | ); 9 | const coursesGetController: CoursesPostController = container.get( 10 | 'Apps.Backoffice.Backend.controllers.CoursesGetController' 11 | ); 12 | 13 | app.post('/courses', coursesPostController.run.bind(coursesPostController)); 14 | app.get('/courses', coursesGetController.run.bind(coursesGetController)); 15 | }; 16 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import glob from 'glob'; 3 | 4 | export function registerRoutes(router: Router) { 5 | const routes = glob.sync(__dirname + '/**/*.route.*'); 6 | routes.map(route => register(route, router)); 7 | } 8 | 9 | function register(routePath: string, app: Router) { 10 | const route = require(routePath); 11 | route.register(app); 12 | } 13 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/routes/status.route.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express'; 2 | import container from '../dependency-injection'; 3 | import StatusController from '../controllers/StatusGetController'; 4 | 5 | export const register = (app: Express) => { 6 | const controller: StatusController = container.get('Apps.Backoffice.Backend.controllers.StatusGetController'); 7 | app.get('/status', controller.run.bind(controller)); 8 | }; 9 | -------------------------------------------------------------------------------- /src/apps/backoffice/backend/start.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeBackendApp } from './BackofficeBackendApp'; 2 | 3 | try { 4 | new BackofficeBackendApp().start().catch(handleError); 5 | } catch (e) { 6 | handleError(e); 7 | } 8 | 9 | process.on('uncaughtException', err => { 10 | console.log('uncaughtException', err); 11 | process.exit(1); 12 | }); 13 | function handleError(e: any) { 14 | console.log(e); 15 | process.exit(1); 16 | } 17 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.4", 7 | "@testing-library/react": "^13.3.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.11.41", 11 | "@types/react": "^18.0.14", 12 | "@types/react-dom": "^18.0.5", 13 | "axios": "^0.27.2", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-helmet": "^6.1.0", 17 | "react-helmet-async": "^1.3.0", 18 | "react-router-dom": "^6.3.0", 19 | "react-scripts": "5.0.1", 20 | "typescript": "^4.7.3", 21 | "uuid": "^8.3.2", 22 | "web-vitals": "^2.1.4" 23 | }, 24 | "scripts": { 25 | "start": "PORT=8032 react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@types/react-helmet": "^6.1.5", 50 | "@types/uuid": "^8.3.4", 51 | "autoprefixer": "^10.4.7", 52 | "postcss": "^8.4.14", 53 | "tailwindcss": "^3.1.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/typescript-ddd-example/94717b07bf35517e17a2c018f13d17681f420af4/src/apps/backoffice/frontend/public/favicon.ico -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/typescript-ddd-example/94717b07bf35517e17a2c018f13d17681f420af4/src/apps/backoffice/frontend/public/logo192.png -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/typescript-ddd-example/94717b07bf35517e17a2c018f13d17681f420af4/src/apps/backoffice/frontend/public/logo512.png -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './App.css'; 3 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 4 | import Header from './components/header/Header'; 5 | import Footer from './components/footer/Footer'; 6 | import Home from './pages/Home'; 7 | import Courses from './pages/Courses'; 8 | 9 | function App() { 10 | return ( 11 |
12 | 13 |
14 | 15 | } /> 16 | } /> 17 | 18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/course-listing/CourseListing.tsx: -------------------------------------------------------------------------------- 1 | import { Course } from '../../services/courses'; 2 | import Table from '../table/Table'; 3 | import TableBody from '../table/TableBody'; 4 | import TableCell from '../table/TableCell'; 5 | import TableHead from '../table/TableHead'; 6 | import TableHeader from '../table/TableHeader'; 7 | import TableRow from '../table/TableRow'; 8 | import FilterManager from './filter/FilterManager'; 9 | import ListingTitle from './ListingTitle'; 10 | 11 | function CourseListing({ courses, onFilter }: { courses: Course[]; onFilter: (courses: Course[]) => void }) { 12 | return ( 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {courses.map((course, index) => ( 26 | 27 | 28 | 29 | 30 | 31 | ))} 32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | export default CourseListing; 39 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/course-listing/ListingTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function ListingTitle({ title }: { title: string }) { 4 | return

{title}

; 5 | } 6 | 7 | export default ListingTitle; 8 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/course-listing/filter/AddFilterButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from 'react'; 2 | 3 | function AddFilterButton({ onAdd }: { onAdd: MouseEventHandler }) { 4 | return ( 5 | 12 | ); 13 | } 14 | 15 | export default AddFilterButton; 16 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/course-listing/filter/FilterButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from 'react'; 2 | 3 | function FilterButton({ onFilter }: { onFilter: MouseEventHandler }) { 4 | return ( 5 | 12 | ); 13 | } 14 | 15 | export default FilterButton; 16 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Footer() { 4 | return ( 5 |
6 |
7 |

8 | 🤙 CodelyTV - El mejor backoffice de la historia 9 |

10 |
11 |
12 | ); 13 | } 14 | 15 | export default Footer; 16 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/form/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEventHandler } from 'react'; 2 | import type FormInput from './FormInput'; 3 | import FormSubmit from './FormSubmit'; 4 | import FormTitle from './FormTitle'; 5 | 6 | function Form({ 7 | id, 8 | title, 9 | submitLabel, 10 | onSubmit, 11 | children, 12 | className 13 | }: { 14 | id: string; 15 | title: string; 16 | submitLabel: string; 17 | onSubmit: FormEventHandler; 18 | children: React.ReactElement | React.ReactElement[]; 19 | className: string; 20 | }) { 21 | return ( 22 |
23 | 24 | {[children].flat().map((child, index) => ( 25 |
{child}
26 | ))} 27 | 28 | 29 | ); 30 | } 31 | 32 | export default Form; 33 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/form/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function FormInput({ 4 | id, 5 | label, 6 | name, 7 | value, 8 | placeholder, 9 | disabled, 10 | error, 11 | onChange 12 | }: { 13 | id: string; 14 | label: string; 15 | name: string; 16 | value?: string | number | readonly string[]; 17 | placeholder: string; 18 | disabled?: boolean 19 | error?: string; 20 | onChange?: React.ChangeEventHandler; 21 | }) { 22 | return ( 23 |
24 | 27 | 28 | 38 | 39 | {error &&

{error}

} 40 |
41 | ); 42 | } 43 | 44 | export default FormInput; 45 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/form/FormSubmit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function FormSubmit({ label }: { label: string }) { 4 | return ( 5 |
6 | 12 |
13 | ); 14 | } 15 | 16 | export default FormSubmit; 17 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/form/FormTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function FormTitle({ title }: { title: string }) { 4 | return

{title}

; 5 | } 6 | 7 | export default FormTitle; 8 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Navigation from './Navigation'; 3 | 4 | function Header() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default Header; 13 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/header/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.svg'; 3 | import { NavLink } from "react-router-dom"; 4 | 5 | function Navigation() { 6 | return ( 7 | 32 | ); 33 | } 34 | 35 | export default Navigation; 36 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/page-container/PageAlert.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function PageAlert({ message }: { message: string }) { 4 | return ( 5 |
6 | 7 | 8 | 9 |

{message}

10 |
11 | ); 12 | } 13 | 14 | export default PageAlert; 15 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/page-container/PageContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageAlert from './PageAlert'; 3 | import PageContent from './PageContent'; 4 | import PageTitle from './PageTitle'; 5 | 6 | function PageContainer({ title, alert, children }: { title: string, alert?: string, children: React.ReactNode }) { 7 | return ( 8 |
9 | {alert && } 10 |
11 | 12 |
13 |
14 | {children} 15 |
16 |
17 | ); 18 | } 19 | 20 | export default PageContainer; 21 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/page-container/PageContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function PageContent({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
{children}
6 | ); 7 | } 8 | 9 | export default PageContent; 10 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/page-container/PageSeparator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function PageSeparator() { 4 | return ( 5 | 6 |
7 |
8 |
9 | ); 10 | } 11 | 12 | export default PageSeparator; 13 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/page-container/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function PageTitle({ title }: { title: string }) { 4 | return ( 5 |

{title}

6 | ); 7 | } 8 | 9 | export default PageTitle; 10 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/Table.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TableBody from './TableBody'; 3 | import TableHeader from './TableHeader'; 4 | 5 | function Table({ 6 | className, 7 | children 8 | }: { 9 | className: string; 10 | children: React.ReactElement[]; 11 | }) { 12 | return {children}
; 13 | } 14 | 15 | export default Table; 16 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/TableBody.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TableCell from './TableCell'; 3 | import TableRow from './TableRow'; 4 | 5 | function TableBody({ children }: { children: React.ReactElement>[] }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | 13 | export default TableBody; 14 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/TableCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function TableCell({ value }: { value: string }) { 4 | return {value}; 5 | } 6 | 7 | export default TableCell; 8 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/TableHead.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function TableHead({ name }: { name: string }) { 4 | return ( 5 | 6 | {name} 7 | 8 | ); 9 | } 10 | 11 | export default TableHead; 12 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/TableHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TableHead from './TableHead'; 3 | import TableRow from './TableRow'; 4 | 5 | function TableHeader({ children }: { children: React.ReactElement> }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | 13 | export default TableHeader; 14 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TableCell from './TableCell'; 3 | import TableHead from './TableHead'; 4 | 5 | function TableRow({ 6 | children 7 | }: { 8 | children: React.ReactElement | React.ReactElement[]; 9 | }) { 10 | return {children}; 11 | } 12 | 13 | export default TableRow; 14 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/components/table/TableTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function TableTitle({ title }: { title: string }) { 4 | return

{title}

; 5 | } 6 | 7 | export default TableTitle; 8 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { HelmetProvider } from 'react-helmet-async'; 4 | import './index.css'; 5 | import App from './App'; 6 | import reportWebVitals from './reportWebVitals'; 7 | 8 | const root = ReactDOM.createRoot( 9 | document.getElementById('root') as HTMLElement 10 | ); 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | // If you want to start measuring performance in your app, pass a function 20 | // to log results (for example: reportWebVitals(console.log)) 21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 22 | reportWebVitals(); 23 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/pages/Courses.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import CourseListing from '../components/course-listing/CourseListing'; 4 | import NewCourseForm from '../components/new-course-form/NewCourseForm'; 5 | import PageContainer from '../components/page-container/PageContainer'; 6 | import PageSeparator from '../components/page-container/PageSeparator'; 7 | import { Course, getAllCourses } from '../services/courses'; 8 | 9 | function Courses() { 10 | const [alert, setAlert] = useState(''); 11 | const [courses, setCourses] = useState([]); 12 | 13 | const handleSuccess = (course: Course) => { 14 | setCourses([...courses, { ...course }]); 15 | setAlert(`Felicidades, el curso ${course.id} ha sido creado correctamente!`); 16 | }; 17 | 18 | useEffect(() => { 19 | const fetchData = async () => { 20 | const courses = await getAllCourses(); 21 | setCourses(courses); 22 | }; 23 | 24 | fetchData(); 25 | }, []); 26 | 27 | return ( 28 |
29 | 30 | CodelyTV | Cursos 31 | 32 | 33 | 34 | setAlert('Lo siento, ha ocurrido un error al crear el curso')} 37 | /> 38 | 39 | 40 | 41 | setCourses(courses)} /> 42 | 43 |
44 | ); 45 | } 46 | 47 | export default Courses; 48 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import PageContainer from '../components/page-container/PageContainer'; 4 | 5 | function Home() { 6 | return ( 7 |
8 | 9 | CodelyTV | Home 10 | 11 | 12 | 13 | ¡Bienvenidos a CodelyTV! 14 | 15 |
16 | ); 17 | } 18 | 19 | export default Home; 20 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/services/courses.ts: -------------------------------------------------------------------------------- 1 | export type Course = { 2 | id: string; 3 | name: string; 4 | duration: string; 5 | }; 6 | 7 | type Query = { 8 | filters: Array<{ field: string; operator: string; value: string }>; 9 | }; 10 | 11 | const post = async (url: string, body: Record) => { 12 | await fetch(url, { 13 | method: 'POST', 14 | body: JSON.stringify({ ...body }), 15 | headers: { 'Content-Type': 'application/json' } 16 | }); 17 | }; 18 | 19 | const get = async (url: string) => { 20 | return await fetch(url, { 21 | method: 'GET', 22 | headers: { 'Content-Type': 'application/json' } 23 | }); 24 | }; 25 | 26 | export const createCourse = (course: Course) => post('http://localhost:3000/courses', course); 27 | 28 | export const getAllCourses = async () => { 29 | const response = await get('http://localhost:3000/courses'); 30 | return (await response.json()) as Course[]; 31 | }; 32 | 33 | export const searchCourses = async (query: Query) => { 34 | const filters = query.filters.map( 35 | (filter, index) => 36 | `filters[${index}][field]=${filter.field}&filters[${index}][operator]=${filter.operator}&filters[${index}][value]=${filter.value}` 37 | ); 38 | 39 | const params = filters.join('&'); 40 | 41 | const response = await get(`http://localhost:3000/courses?${params}`); 42 | return (await response.json()) as Course[]; 43 | }; 44 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/**/*.{js,jsx,ts,tsx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | -------------------------------------------------------------------------------- /src/apps/backoffice/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/MoocBackendApp.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from '../../../Contexts/Shared/domain/EventBus'; 2 | import container from './dependency-injection'; 3 | import { DomainEventSubscribers } from '../../../Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 4 | import { Server } from './server'; 5 | import { RabbitMqConnection } from '../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 6 | 7 | export class MoocBackendApp { 8 | server?: Server; 9 | 10 | async start() { 11 | const port = process.env.PORT || '5001'; 12 | this.server = new Server(port); 13 | 14 | await this.configureEventBus(); 15 | 16 | return this.server.listen(); 17 | } 18 | 19 | get httpServer() { 20 | return this.server?.getHTTPServer(); 21 | } 22 | 23 | async stop() { 24 | const rabbitMQConnection = container.get('Mooc.Shared.RabbitMQConnection'); 25 | await rabbitMQConnection.close(); 26 | return this.server?.stop(); 27 | } 28 | 29 | private async configureEventBus() { 30 | const eventBus = container.get('Mooc.Shared.domain.EventBus'); 31 | const rabbitMQConnection = container.get('Mooc.Shared.RabbitMQConnection'); 32 | await rabbitMQConnection.connect(); 33 | 34 | eventBus.addSubscribers(DomainEventSubscribers.from(container)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/command/ConfigureRabbitMQCommand.ts: -------------------------------------------------------------------------------- 1 | import { RabbitMQConfig } from '../../../../Contexts/Mooc/Shared/infrastructure/RabbitMQ/RabbitMQConfigFactory'; 2 | import { DomainEventSubscribers } from '../../../../Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 3 | import { RabbitMQConfigurer } from '../../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMQConfigurer'; 4 | import { RabbitMqConnection } from '../../../../Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 5 | import container from '../dependency-injection'; 6 | 7 | export class ConfigureRabbitMQCommand { 8 | static async run() { 9 | const connection = container.get('Mooc.Shared.RabbitMQConnection'); 10 | const { name: exchange } = container.get('Mooc.Shared.RabbitMQConfig').exchangeSettings; 11 | await connection.connect(); 12 | 13 | const configurer = container.get('Mooc.Shared.RabbitMQConfigurer'); 14 | const subscribers = DomainEventSubscribers.from(container).items; 15 | 16 | await configurer.configure({ exchange, subscribers }); 17 | await connection.close(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/command/runConfigureRabbitMQCommand.ts: -------------------------------------------------------------------------------- 1 | import { ConfigureRabbitMQCommand } from './ConfigureRabbitMQCommand'; 2 | 3 | ConfigureRabbitMQCommand.run() 4 | .then(() => { 5 | console.log('RabbitMQ Configuration success'); 6 | process.exit(0); 7 | }) 8 | .catch(error => { 9 | console.log('RabbitMQ Configuration fail', error); 10 | process.exit(1); 11 | }); 12 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/controllers/Controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export interface Controller { 4 | run(req: Request, res: Response): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/controllers/CoursePutController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { CreateCourseCommand } from '../../../../Contexts/Mooc/Courses/domain/CreateCourseCommand'; 4 | import { CommandBus } from '../../../../Contexts/Shared/domain/CommandBus'; 5 | import { Controller } from './Controller'; 6 | 7 | type CoursePutRequest = Request & { 8 | body: { 9 | id: string; 10 | name: string; 11 | duration: string; 12 | }; 13 | }; 14 | export class CoursePutController implements Controller { 15 | constructor(private commandBus: CommandBus) {} 16 | 17 | async run(req: CoursePutRequest, res: Response) { 18 | try { 19 | const { id, name, duration } = req.body; 20 | const createCourseCommand = new CreateCourseCommand({ id, name, duration }); 21 | await this.commandBus.dispatch(createCourseCommand); 22 | 23 | res.status(httpStatus.CREATED).send(); 24 | } catch (error) { 25 | res.status(httpStatus.INTERNAL_SERVER_ERROR).send(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/controllers/CoursesCounterGetController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { FindCoursesCounterQuery } from '../../../../Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQuery'; 4 | import { FindCoursesCounterResponse } from '../../../../Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterResponse'; 5 | import { CoursesCounterNotExist } from '../../../../Contexts/Mooc/CoursesCounter/domain/CoursesCounterNotExist'; 6 | import { QueryBus } from '../../../../Contexts/Shared/domain/QueryBus'; 7 | import { Controller } from './Controller'; 8 | 9 | export class CoursesCounterGetController implements Controller { 10 | constructor(private queryBus: QueryBus) {} 11 | async run(req: Request, res: Response): Promise { 12 | try { 13 | const query = new FindCoursesCounterQuery(); 14 | const { total } = await this.queryBus.ask(query); 15 | 16 | res.json({ total }); 17 | } catch (e) { 18 | if (e instanceof CoursesCounterNotExist) { 19 | res.sendStatus(httpStatus.NOT_FOUND); 20 | } else { 21 | res.sendStatus(httpStatus.INTERNAL_SERVER_ERROR); 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/controllers/StatusGetController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import httpStatus from 'http-status'; 3 | import { Controller } from './Controller'; 4 | 5 | export default class StatusGetController implements Controller { 6 | async run(req: Request, res: Response) { 7 | res.status(httpStatus.OK).send(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/Courses/application.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Mooc.Courses.domain.CourseRepository: 3 | class: ../../../../../Contexts/Mooc/Courses/infrastructure/persistence/MongoCourseRepository 4 | arguments: ['@Mooc.Shared.ConnectionManager'] 5 | 6 | Mooc.Courses.application.CourseCreator: 7 | class: ../../../../../Contexts/Mooc/Courses/application/CourseCreator 8 | arguments: ['@Mooc.Courses.domain.CourseRepository', '@Mooc.Shared.domain.EventBus'] 9 | 10 | Mooc.courses.CreateCourseCommandHandler: 11 | class: ../../../../../Contexts/Mooc/Courses/application/Create/CreateCourseCommandHandler 12 | arguments: ['@Mooc.Courses.application.CourseCreator'] 13 | tags: 14 | - { name: 'commandHandler' } 15 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/CoursesCounter/application.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Mooc.CoursesCounter.CoursesCounterRepository: 3 | class: ../../../../../Contexts/Mooc/CoursesCounter/infrastructure/persistence/mongo/MongoCoursesCounterRepository 4 | arguments: ['@Mooc.Shared.ConnectionManager'] 5 | 6 | Mooc.CoursesCounter.CoursesCounterIncrementer: 7 | class: ../../../../../Contexts/Mooc/CoursesCounter/application/Increment/CoursesCounterIncrementer 8 | arguments: ['@Mooc.CoursesCounter.CoursesCounterRepository', '@Mooc.Shared.domain.EventBus'] 9 | 10 | Mooc.CoursesCounter.IncrementCoursesCounterOnCourseCreated: 11 | class: ../../../../../Contexts/Mooc/CoursesCounter/application/Increment/IncrementCoursesCounterOnCourseCreated 12 | arguments: ['@Mooc.CoursesCounter.CoursesCounterIncrementer'] 13 | tags: 14 | - { name: 'domainEventSubscriber' } 15 | 16 | Mooc.CoursesCounter.CoursesCounterFinder: 17 | class: ../../../../../Contexts/Mooc/CoursesCounter/application/Find/CoursesCounterFinder 18 | arguments: ['@Mooc.CoursesCounter.CoursesCounterRepository'] 19 | 20 | Mooc.CoursesCounter.FindCoursesCounterQueryHandler: 21 | class: ../../../../../Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQueryHandler 22 | arguments: ['@Mooc.CoursesCounter.CoursesCounterFinder'] 23 | tags: 24 | - { name: 'queryHandler' } 25 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/application.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./apps/application.yaml } 3 | - { resource: ./Courses/application.yaml } 4 | - { resource: ./CoursesCounter/application.yaml } 5 | - { resource: ./Shared/application.yaml } 6 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/application_dev.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/application_production.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/application_test.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ./application.yaml } 3 | 4 | services: 5 | Mooc.EnvironmentArranger: 6 | class: ../../../../../tests/Contexts/Shared/infrastructure/mongo/MongoEnvironmentArranger 7 | arguments: ['@Mooc.Shared.ConnectionManager'] 8 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/apps/application.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Apps.mooc.controllers.StatusGetController: 3 | class: ../../controllers/StatusGetController 4 | arguments: [] 5 | 6 | Apps.mooc.controllers.CoursePutController: 7 | class: ../../controllers/CoursePutController 8 | arguments: ['@Mooc.Shared.domain.CommandBus'] 9 | 10 | Apps.mooc.controllers.CoursesCounterGetController: 11 | class: ../../controllers/CoursesCounterGetController 12 | arguments: ['@Mooc.Shared.domain.QueryBus'] 13 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/dependency-injection/index.ts: -------------------------------------------------------------------------------- 1 | import { ContainerBuilder, YamlFileLoader } from 'node-dependency-injection'; 2 | 3 | const container = new ContainerBuilder(); 4 | const loader = new YamlFileLoader(container); 5 | const env = process.env.NODE_ENV || 'dev'; 6 | 7 | loader.load(`${__dirname}/application_${env}.yaml`); 8 | 9 | export default container; 10 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/routes/courses-counter.route.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import container from '../dependency-injection'; 3 | 4 | export const register = (router: Router) => { 5 | const coursesCounterGetController = container.get('Apps.mooc.controllers.CoursesCounterGetController'); 6 | router.get('/courses-counter', (req: Request, res: Response) => coursesCounterGetController.run(req, res)); 7 | }; 8 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/routes/courses.route.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import container from '../dependency-injection'; 3 | import { body } from 'express-validator'; 4 | import { validateReqSchema } from '.'; 5 | 6 | export const register = (router: Router) => { 7 | const reqSchema = [ 8 | body('id').exists().isString(), 9 | body('name').exists().isString(), 10 | body('duration').exists().isString() 11 | ]; 12 | 13 | const coursePutController = container.get('Apps.mooc.controllers.CoursePutController'); 14 | router.put('/courses/:id', reqSchema, validateReqSchema, (req: Request, res: Response) => 15 | coursePutController.run(req, res) 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import glob from 'glob'; 3 | import { ValidationError, validationResult } from 'express-validator'; 4 | import httpStatus from 'http-status'; 5 | 6 | export function registerRoutes(router: Router) { 7 | const routes = glob.sync(__dirname + '/**/*.route.*'); 8 | routes.map(route => register(route, router)); 9 | } 10 | 11 | function register(routePath: string, router: Router) { 12 | const route = require(routePath); 13 | route.register(router); 14 | } 15 | 16 | export function validateReqSchema(req: Request, res: Response, next: Function) { 17 | const validationErrors = validationResult(req); 18 | if (validationErrors.isEmpty()) { 19 | return next(); 20 | } 21 | const errors = validationErrors.array().map((err: ValidationError) => ({ [err.param]: err.msg })); 22 | 23 | return res.status(httpStatus.UNPROCESSABLE_ENTITY).json({ 24 | errors 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/routes/status.route.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import container from '../dependency-injection'; 3 | import StatusController from '../controllers/StatusGetController'; 4 | 5 | export const register = (router: Router) => { 6 | const controller: StatusController = container.get('Apps.mooc.controllers.StatusGetController'); 7 | router.get('/status', (req: Request, res: Response) => controller.run(req, res)); 8 | }; 9 | -------------------------------------------------------------------------------- /src/apps/mooc/backend/start.ts: -------------------------------------------------------------------------------- 1 | import { MoocBackendApp } from './MoocBackendApp'; 2 | 3 | try { 4 | new MoocBackendApp().start(); 5 | } catch (e) { 6 | console.log(e); 7 | process.exit(1); 8 | } 9 | 10 | process.on('uncaughtException', err => { 11 | console.log('uncaughtException', err); 12 | process.exit(1); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/__mocks__/BackofficeCourseRepositoryMock.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourse } from "../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourse"; 2 | import { BackofficeCourseRepository } from "../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseRepository"; 3 | import { Criteria } from "../../../../../src/Contexts/Shared/domain/criteria/Criteria"; 4 | 5 | export class BackofficeCourseRepositoryMock implements BackofficeCourseRepository { 6 | private mockSearchAll = jest.fn(); 7 | private mockSave = jest.fn(); 8 | private mockMatching = jest.fn(); 9 | private courses: Array = []; 10 | 11 | returnOnSearchAll(courses: Array) { 12 | this.courses = courses; 13 | } 14 | 15 | returnMatching(courses: Array) { 16 | this.courses = courses; 17 | } 18 | 19 | async searchAll(): Promise { 20 | this.mockSearchAll(); 21 | return this.courses; 22 | } 23 | 24 | assertSearchAll() { 25 | expect(this.mockSearchAll).toHaveBeenCalled(); 26 | } 27 | 28 | async save(course: BackofficeCourse): Promise { 29 | this.mockSave(course); 30 | } 31 | 32 | assertSaveHasBeenCalledWith(course: BackofficeCourse) { 33 | expect(this.mockSave).toHaveBeenCalledWith(course); 34 | } 35 | 36 | async matching(criteria: Criteria): Promise { 37 | this.mockMatching(criteria); 38 | return this.courses; 39 | } 40 | 41 | assertMatchingHasBeenCalledWith() { 42 | expect(this.mockMatching).toHaveBeenCalled(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/application/Create/BackofficeCourseCreator.test.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourseCreator } from '../../../../../../src/Contexts/Backoffice/Courses/application/Create/BackofficeCourseCreator'; 2 | import { BackofficeCourseMother } from '../../domain/BackofficeCourseMother'; 3 | import { BackofficeCourseRepositoryMock } from '../../__mocks__/BackofficeCourseRepositoryMock'; 4 | 5 | describe('BackofficeCourseCreator', () => { 6 | it('creates a backoffice course', async () => { 7 | const course = BackofficeCourseMother.random(); 8 | 9 | const repository = new BackofficeCourseRepositoryMock(); 10 | const applicationService = new BackofficeCourseCreator(repository); 11 | 12 | await applicationService.run(course.id.toString(), course.duration.toString(), course.name.toString()); 13 | 14 | repository.assertSaveHasBeenCalledWith(course); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/application/SearchAll/SearchAllCoursesQueryHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { CoursesFinder } from '../../../../../../src/Contexts/Backoffice/Courses/application/SearchAll/CoursesFinder'; 2 | import { SearchAllCoursesQuery } from '../../../../../../src/Contexts/Backoffice/Courses/application/SearchAll/SearchAllCoursesQuery'; 3 | import { SearchAllCoursesQueryHandler } from '../../../../../../src/Contexts/Backoffice/Courses/application/SearchAll/SearchAllCoursesQueryHandler'; 4 | import { BackofficeCourseMother } from '../../domain/BackofficeCourseMother'; 5 | import { SearchAllCoursesResponseMother } from '../../domain/SearchAllCoursesResponseMother'; 6 | import { BackofficeCourseRepositoryMock } from '../../__mocks__/BackofficeCourseRepositoryMock'; 7 | 8 | describe('SearchAllCourses QueryHandler', () => { 9 | let repository: BackofficeCourseRepositoryMock; 10 | 11 | beforeEach(() => { 12 | repository = new BackofficeCourseRepositoryMock(); 13 | }); 14 | 15 | it('should find an existing courses counter', async () => { 16 | const courses = [BackofficeCourseMother.random(), BackofficeCourseMother.random(), BackofficeCourseMother.random()]; 17 | repository.returnOnSearchAll(courses); 18 | 19 | const handler = new SearchAllCoursesQueryHandler(new CoursesFinder(repository)); 20 | 21 | const query = new SearchAllCoursesQuery(); 22 | const response = await handler.handle(query); 23 | 24 | repository.assertSearchAll(); 25 | 26 | const expected = SearchAllCoursesResponseMother.create(courses); 27 | expect(expected).toEqual(response); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/BackofficeCourseCriteriaMother.ts: -------------------------------------------------------------------------------- 1 | import { Criteria } from '../../../../../src/Contexts/Shared/domain/criteria/Criteria'; 2 | import { Filter } from '../../../../../src/Contexts/Shared/domain/criteria/Filter'; 3 | import { FilterField } from '../../../../../src/Contexts/Shared/domain/criteria/FilterField'; 4 | import { FilterOperator, Operator } from '../../../../../src/Contexts/Shared/domain/criteria/FilterOperator'; 5 | import { Filters } from '../../../../../src/Contexts/Shared/domain/criteria/Filters'; 6 | import { FilterValue } from '../../../../../src/Contexts/Shared/domain/criteria/FilterValue'; 7 | import { Order } from '../../../../../src/Contexts/Shared/domain/criteria/Order'; 8 | 9 | export class BackofficeCourseCriteriaMother { 10 | static whithoutFilter(): Criteria { 11 | return new Criteria(new Filters([]), Order.fromValues()); 12 | } 13 | 14 | static nameAndDurationContainsSortAscById(name: string, duration: string): Criteria { 15 | const filterFieldName = new FilterField('name'); 16 | const filterFieldDuration = new FilterField('duration'); 17 | const filterOperator = new FilterOperator(Operator.CONTAINS); 18 | const valueName = new FilterValue(name); 19 | const valueDuration = new FilterValue(duration); 20 | 21 | const nameFilter = new Filter(filterFieldName, filterOperator, valueName); 22 | const durationFilter = new Filter(filterFieldDuration, filterOperator, valueDuration); 23 | 24 | return new Criteria(new Filters([nameFilter, durationFilter]), Order.asc('id')); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/BackofficeCourseDurationMother.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourseDuration } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseDuration'; 2 | import { WordMother } from '../../../Shared/domain/WordMother'; 3 | 4 | export class BackofficeCourseDurationMother { 5 | static create(value: string): BackofficeCourseDuration { 6 | return new BackofficeCourseDuration(value); 7 | } 8 | 9 | static random(): BackofficeCourseDuration { 10 | return this.create(WordMother.random({ maxLength: 10 })); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/BackofficeCourseIdMother.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourseId } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseId'; 2 | import { UuidMother } from '../../../Shared/domain/UuidMother'; 3 | 4 | export class BackofficeCourseIdMother { 5 | static create(value: string): BackofficeCourseId { 6 | return new BackofficeCourseId(value); 7 | } 8 | 9 | static creator() { 10 | return () => BackofficeCourseIdMother.random(); 11 | } 12 | 13 | static random(): BackofficeCourseId { 14 | return this.create(UuidMother.random()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/BackofficeCourseMother.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourse } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourse'; 2 | import { BackofficeCourseDuration } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseDuration'; 3 | import { BackofficeCourseId } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseId'; 4 | import { BackofficeCourseName } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseName'; 5 | import { BackofficeCourseDurationMother } from './BackofficeCourseDurationMother'; 6 | import { BackofficeCourseIdMother } from './BackofficeCourseIdMother'; 7 | import { BackofficeCourseNameMother } from './BackofficeCourseNameMother'; 8 | 9 | export class BackofficeCourseMother { 10 | static create( 11 | id: BackofficeCourseId, 12 | name: BackofficeCourseName, 13 | duration: BackofficeCourseDuration 14 | ): BackofficeCourse { 15 | return new BackofficeCourse(id, name, duration); 16 | } 17 | 18 | static withNameAndDuration(name: string, duration: string): BackofficeCourse { 19 | return this.create( 20 | BackofficeCourseIdMother.random(), 21 | BackofficeCourseNameMother.create(name), 22 | BackofficeCourseDurationMother.create(duration) 23 | ); 24 | } 25 | 26 | static random(): BackofficeCourse { 27 | return this.create( 28 | BackofficeCourseIdMother.random(), 29 | BackofficeCourseNameMother.random(), 30 | BackofficeCourseDurationMother.random() 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/BackofficeCourseNameMother.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCourseName } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourseName'; 2 | import { WordMother } from '../../../Shared/domain/WordMother'; 3 | 4 | export class BackofficeCourseNameMother { 5 | static create(value: string): BackofficeCourseName { 6 | return new BackofficeCourseName(value); 7 | } 8 | 9 | static random(): BackofficeCourseName { 10 | return this.create(WordMother.random({ maxLength: 10 })); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/SearchAllCoursesResponseMother.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCoursesResponse } from "../../../../../src/Contexts/Backoffice/Courses/application/BackofficeCoursesResponse"; 2 | import { BackofficeCourse } from "../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourse"; 3 | 4 | export class SearchAllCoursesResponseMother { 5 | static create(courses: Array) { 6 | return new BackofficeCoursesResponse(courses); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/Contexts/Backoffice/Courses/domain/SearchCoursesByCriteriaResponseMother.ts: -------------------------------------------------------------------------------- 1 | import { BackofficeCoursesResponse } from '../../../../../src/Contexts/Backoffice/Courses/application/BackofficeCoursesResponse'; 2 | import { BackofficeCourse } from '../../../../../src/Contexts/Backoffice/Courses/domain/BackofficeCourse'; 3 | 4 | export class SearchCoursesByCriteriaResponseMother { 5 | static create(courses: Array) { 6 | return new BackofficeCoursesResponse(courses); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/__mocks__/CourseRepositoryMock.ts: -------------------------------------------------------------------------------- 1 | import { Course } from '../../../../../src/Contexts/Mooc/Courses/domain/Course'; 2 | import { CourseRepository } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseRepository'; 3 | 4 | export class CourseRepositoryMock implements CourseRepository { 5 | private saveMock: jest.Mock; 6 | private searchAllMock: jest.Mock; 7 | private courses: Array = []; 8 | 9 | constructor() { 10 | this.saveMock = jest.fn(); 11 | this.searchAllMock = jest.fn(); 12 | } 13 | 14 | async save(course: Course): Promise { 15 | this.saveMock(course); 16 | } 17 | 18 | assertSaveHaveBeenCalledWith(expected: Course): void { 19 | expect(this.saveMock).toHaveBeenCalledWith(expected); 20 | } 21 | 22 | returnOnSearchAll(courses: Array) { 23 | this.courses = courses; 24 | } 25 | 26 | assertSearchAll() { 27 | expect(this.searchAllMock).toHaveBeenCalled(); 28 | } 29 | 30 | async searchAll(): Promise { 31 | this.searchAllMock(); 32 | return this.courses; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/application/Create/CreateCourseCommandMother.ts: -------------------------------------------------------------------------------- 1 | import { CourseDuration } from "../../../../../../src/Contexts/Mooc/Courses/domain/CourseDuration"; 2 | import { CourseName } from "../../../../../../src/Contexts/Mooc/Courses/domain/CourseName"; 3 | import { CreateCourseCommand } from "../../../../../../src/Contexts/Mooc/Courses/domain/CreateCourseCommand"; 4 | import { CourseId } from "../../../../../../src/Contexts/Mooc/Shared/domain/Courses/CourseId"; 5 | import { CourseIdMother } from "../../../Shared/domain/Courses/CourseIdMother"; 6 | import { CourseDurationMother } from "../../domain/CourseDurationMother"; 7 | import { CourseNameMother } from "../../domain/CourseNameMother"; 8 | 9 | export class CreateCourseCommandMother { 10 | static create(id: CourseId, name: CourseName, duration: CourseDuration): CreateCourseCommand { 11 | return { id: id.value, name: name.value, duration: duration.value }; 12 | } 13 | 14 | static random(): CreateCourseCommand { 15 | return this.create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random()); 16 | } 17 | 18 | static invalid(): CreateCourseCommand { 19 | return { 20 | id: CourseIdMother.random().value, 21 | name: CourseNameMother.invalidName(), 22 | duration: CourseDurationMother.random().value 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/application/CreateCourseCommandMother.ts: -------------------------------------------------------------------------------- 1 | import { CourseDuration } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseDuration'; 2 | import { CourseName } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseName'; 3 | import { CreateCourseCommand } from '../../../../../src/Contexts/Mooc/Courses/domain/CreateCourseCommand'; 4 | import { CourseId } from '../../../../../src/Contexts/Mooc/Shared/domain/Courses/CourseId'; 5 | import { CourseIdMother } from '../../Shared/domain/Courses/CourseIdMother'; 6 | import { CourseDurationMother } from '../domain/CourseDurationMother'; 7 | import { CourseNameMother } from '../domain/CourseNameMother'; 8 | 9 | export class CreateCourseCommandMother { 10 | static create(id: CourseId, name: CourseName, duration: CourseDuration): CreateCourseCommand { 11 | return { id: id.value, name: name.value, duration: duration.value }; 12 | } 13 | 14 | static random(): CreateCourseCommand { 15 | return this.create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random()); 16 | } 17 | 18 | static invalid(): CreateCourseCommand { 19 | return { 20 | id: CourseIdMother.random().value, 21 | name: CourseNameMother.invalidName(), 22 | duration: CourseDurationMother.random().value 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/application/SearchAll/SearchAllCoursesQueryHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { CoursesFinder } from "../../../../../../src/Contexts/Mooc/Courses/application/SearchAll/CoursesFinder"; 2 | import { SearchAllCoursesQuery } from "../../../../../../src/Contexts/Mooc/Courses/application/SearchAll/SearchAllCoursesQuery"; 3 | import { SearchAllCoursesQueryHandler } from "../../../../../../src/Contexts/Mooc/Courses/application/SearchAll/SearchAllCoursesQueryHandler"; 4 | import { CourseMother } from "../../domain/CourseMother"; 5 | import { CourseRepositoryMock } from "../../__mocks__/CourseRepositoryMock"; 6 | import { SearchAllCoursesResponseMother } from "./SearchAllCoursesResponseMother"; 7 | 8 | describe('SearchAllCourses QueryHandler', () => { 9 | let repository: CourseRepositoryMock; 10 | 11 | beforeEach(() => { 12 | repository = new CourseRepositoryMock(); 13 | }); 14 | 15 | it('should find an existing courses', async () => { 16 | const courses = [CourseMother.random(), CourseMother.random(), CourseMother.random()]; 17 | repository.returnOnSearchAll(courses); 18 | 19 | const handler = new SearchAllCoursesQueryHandler(new CoursesFinder(repository)); 20 | 21 | const query = new SearchAllCoursesQuery(); 22 | const response = await handler.handle(query); 23 | 24 | repository.assertSearchAll(); 25 | 26 | const expected = SearchAllCoursesResponseMother.create(courses); 27 | expect(expected).toEqual(response); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/application/SearchAll/SearchAllCoursesResponseMother.ts: -------------------------------------------------------------------------------- 1 | import { CoursesResponse } from "../../../../../../src/Contexts/Mooc/Courses/application/SearchAll/CoursesResponse"; 2 | import { Course } from "../../../../../../src/Contexts/Mooc/Courses/domain/Course"; 3 | 4 | export class SearchAllCoursesResponseMother { 5 | static create(courses: Array) { 6 | return new CoursesResponse(courses); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/domain/CourseCreatedDomainEventMother.ts: -------------------------------------------------------------------------------- 1 | import { CourseCreatedDomainEvent } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseCreatedDomainEvent'; 2 | import { Course } from '../../../../../src/Contexts/Mooc/Courses/domain/Course'; 3 | 4 | export class CourseCreatedDomainEventMother { 5 | static create({ 6 | aggregateId, 7 | eventId, 8 | duration, 9 | name, 10 | occurredOn 11 | }: { 12 | aggregateId: string; 13 | eventId?: string; 14 | duration: string; 15 | name: string; 16 | occurredOn?: Date; 17 | }): CourseCreatedDomainEvent { 18 | return new CourseCreatedDomainEvent({ 19 | aggregateId, 20 | eventId, 21 | duration, 22 | name, 23 | occurredOn 24 | }); 25 | } 26 | 27 | static fromCourse(course: Course): CourseCreatedDomainEvent { 28 | return new CourseCreatedDomainEvent({ 29 | aggregateId: course.id.value, 30 | duration: course.duration.value, 31 | name: course.name.value 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/domain/CourseDurationMother.ts: -------------------------------------------------------------------------------- 1 | import { CourseDuration } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseDuration'; 2 | import { WordMother } from '../../../Shared/domain/WordMother'; 3 | 4 | export class CourseDurationMother { 5 | static create(value: string): CourseDuration { 6 | return new CourseDuration(value); 7 | } 8 | 9 | static random(): CourseDuration { 10 | return this.create(WordMother.random({ maxLength: 30 })); 11 | } 12 | } -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/domain/CourseMother.ts: -------------------------------------------------------------------------------- 1 | import { Course } from '../../../../../src/Contexts/Mooc/Courses/domain/Course'; 2 | import { CourseDuration } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseDuration'; 3 | import { CourseName } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseName'; 4 | import { CreateCourseCommand } from '../../../../../src/Contexts/Mooc/Courses/domain/CreateCourseCommand'; 5 | import { CourseId } from '../../../../../src/Contexts/Mooc/Shared/domain/Courses/CourseId'; 6 | import { CourseIdMother } from '../../Shared/domain/Courses/CourseIdMother'; 7 | import { CourseDurationMother } from './CourseDurationMother'; 8 | import { CourseNameMother } from './CourseNameMother'; 9 | 10 | export class CourseMother { 11 | static create(id: CourseId, name: CourseName, duration: CourseDuration): Course { 12 | return new Course(id, name, duration); 13 | } 14 | 15 | static from(command: CreateCourseCommand): Course { 16 | return this.create( 17 | CourseIdMother.create(command.id), 18 | CourseNameMother.create(command.name), 19 | CourseDurationMother.create(command.duration) 20 | ); 21 | } 22 | 23 | static random(): Course { 24 | return this.create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/domain/CourseNameMother.ts: -------------------------------------------------------------------------------- 1 | import { CourseName } from '../../../../../src/Contexts/Mooc/Courses/domain/CourseName'; 2 | import { WordMother } from '../../../Shared/domain/WordMother'; 3 | 4 | export class CourseNameMother { 5 | static create(value: string): CourseName { 6 | return new CourseName(value); 7 | } 8 | 9 | static random(): CourseName { 10 | return this.create(WordMother.random({ maxLength: 20 })); 11 | } 12 | 13 | static invalidName(): string { 14 | return "a".repeat(40); 15 | } 16 | } -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Courses/infrastructure/persistence/CourseRepository.test.ts: -------------------------------------------------------------------------------- 1 | import container from '../../../../../../src/apps/mooc/backend/dependency-injection'; 2 | import { CourseRepository } from '../../../../../../src/Contexts/Mooc/Courses/domain/CourseRepository'; 3 | import { EnvironmentArranger } from '../../../../Shared/infrastructure/arranger/EnvironmentArranger'; 4 | import { CourseMother } from '../../domain/CourseMother'; 5 | 6 | const repository: CourseRepository = container.get('Mooc.Courses.domain.CourseRepository'); 7 | const environmentArranger: Promise = container.get('Mooc.EnvironmentArranger'); 8 | 9 | beforeEach(async () => { 10 | await (await environmentArranger).arrange(); 11 | }); 12 | 13 | afterAll(async () => { 14 | await (await environmentArranger).arrange(); 15 | await (await environmentArranger).close(); 16 | }); 17 | 18 | describe('CourseRepository', () => { 19 | describe('#save', () => { 20 | it('should save a course', async () => { 21 | const course = CourseMother.random(); 22 | 23 | await repository.save(course); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/CoursesCounter/__mocks__/CoursesCounterRepositoryMock.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounterRepository } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterRepository'; 2 | import { CoursesCounter } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounter'; 3 | import { Nullable } from '../../../../../src/Contexts/Shared/domain/Nullable'; 4 | 5 | export class CoursesCounterRepositoryMock implements CoursesCounterRepository { 6 | private mockSave = jest.fn(); 7 | private mockSearch = jest.fn(); 8 | private coursesCounter: Nullable = null; 9 | 10 | async search(): Promise> { 11 | this.mockSearch(); 12 | return this.coursesCounter; 13 | } 14 | 15 | async save(counter: CoursesCounter): Promise { 16 | this.mockSave(counter); 17 | } 18 | 19 | returnOnSearch(counter: CoursesCounter) { 20 | this.coursesCounter = counter; 21 | } 22 | 23 | assertSearch() { 24 | expect(this.mockSearch).toHaveBeenCalled(); 25 | } 26 | 27 | assertNotSave() { 28 | expect(this.mockSave).toHaveBeenCalledTimes(0); 29 | } 30 | 31 | assertLastCoursesCounterSaved(counter: CoursesCounter) { 32 | const mock = this.mockSave.mock; 33 | const lastCoursesCounter = mock.calls[mock.calls.length - 1][0] as CoursesCounter; 34 | const { id: id1, ...counterPrimitives } = counter.toPrimitives(); 35 | const { id: id2, ...lastSavedPrimitives } = lastCoursesCounter.toPrimitives(); 36 | 37 | expect(lastCoursesCounter).toBeInstanceOf(CoursesCounter); 38 | expect(lastSavedPrimitives).toEqual(counterPrimitives); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQueryHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounterFinder } from '../../../../../../src/Contexts/Mooc/CoursesCounter/application/Find/CoursesCounterFinder'; 2 | import { FindCoursesCounterQuery } from '../../../../../../src/Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQuery'; 3 | import { FindCoursesCounterQueryHandler } from '../../../../../../src/Contexts/Mooc/CoursesCounter/application/Find/FindCoursesCounterQueryHandler'; 4 | import { CoursesCounterNotExist } from '../../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterNotExist'; 5 | import { CoursesCounterMother } from '../../domain/CoursesCounterMother'; 6 | import { CoursesCounterRepositoryMock } from '../../__mocks__/CoursesCounterRepositoryMock'; 7 | 8 | describe('FindCoursesCounterQueryHandler', () => { 9 | let repository: CoursesCounterRepositoryMock; 10 | 11 | beforeEach(() => { 12 | repository = new CoursesCounterRepositoryMock(); 13 | }); 14 | 15 | it('should find an existing courses counter', async () => { 16 | const counter = CoursesCounterMother.random(); 17 | repository.returnOnSearch(counter); 18 | const finder = new CoursesCounterFinder(repository); 19 | const handler = new FindCoursesCounterQueryHandler(finder); 20 | 21 | const response = await handler.handle(new FindCoursesCounterQuery()); 22 | 23 | repository.assertSearch(); 24 | expect(counter.total.value).toEqual(response.total); 25 | }); 26 | 27 | it('should throw an exception when courses counter does not exists', async () => { 28 | const finder = new CoursesCounterFinder(repository); 29 | const handler = new FindCoursesCounterQueryHandler(finder); 30 | 31 | await expect(handler.handle(new FindCoursesCounterQuery())).rejects.toBeInstanceOf(CoursesCounterNotExist); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/CoursesCounter/domain/CoursesCounterIncrementedDomainEventMother.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounter } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounter'; 2 | import { CoursesCounterIncrementedDomainEvent } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterIncrementedDomainEvent'; 3 | import { DomainEvent } from '../../../../../src/Contexts/Shared/domain/DomainEvent'; 4 | import { CoursesCounterMother } from './CoursesCounterMother'; 5 | 6 | export class CoursesCounterIncrementedDomainEventMother { 7 | static create(): DomainEvent { 8 | return CoursesCounterIncrementedDomainEventMother.fromCourseCounter(CoursesCounterMother.random()); 9 | } 10 | 11 | static fromCourseCounter(counter: CoursesCounter): CoursesCounterIncrementedDomainEvent { 12 | return new CoursesCounterIncrementedDomainEvent({ 13 | aggregateId: counter.id.value, 14 | total: counter.total.value 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/CoursesCounter/domain/CoursesCounterMother.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounter } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounter'; 2 | import { CoursesCounterId } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterId'; 3 | import { CoursesCounterTotal } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterTotal'; 4 | import { CourseIdMother } from '../../Shared/domain/Courses/CourseIdMother'; 5 | import { Repeater } from '../../../Shared/domain/Repeater'; 6 | import { CoursesCounterTotalMother } from './CoursesCounterTotalMother'; 7 | import { CourseId } from '../../../../../src/Contexts/Mooc/Shared/domain/Courses/CourseId'; 8 | 9 | export class CoursesCounterMother { 10 | static random() { 11 | const total = CoursesCounterTotalMother.random(); 12 | return new CoursesCounter( 13 | CoursesCounterId.random(), 14 | total, 15 | Repeater.random(CourseIdMother.random.bind(CourseIdMother), total.value) 16 | ); 17 | } 18 | 19 | static withOne(courseId: CourseId) { 20 | return new CoursesCounter(CoursesCounterId.random(), new CoursesCounterTotal(1), [courseId]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/CoursesCounter/domain/CoursesCounterTotalMother.ts: -------------------------------------------------------------------------------- 1 | import { CoursesCounterTotal } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterTotal'; 2 | import { IntegerMother } from '../../../Shared/domain/IntegerMother'; 3 | 4 | export class CoursesCounterTotalMother { 5 | static random() { 6 | return new CoursesCounterTotal(IntegerMother.random()); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/CoursesCounter/infrastructure/CoursesCounterRepository.test.ts: -------------------------------------------------------------------------------- 1 | import container from '../../../../../src/apps/mooc/backend/dependency-injection'; 2 | import { CoursesCounterRepository } from '../../../../../src/Contexts/Mooc/CoursesCounter/domain/CoursesCounterRepository'; 3 | import { EnvironmentArranger } from '../../../Shared/infrastructure/arranger/EnvironmentArranger'; 4 | import { CoursesCounterMother } from '../domain/CoursesCounterMother'; 5 | 6 | const environmentArranger: Promise = container.get('Mooc.EnvironmentArranger'); 7 | const repository: CoursesCounterRepository = container.get('Mooc.CoursesCounter.CoursesCounterRepository'); 8 | 9 | beforeEach(async () => { 10 | await (await environmentArranger).arrange(); 11 | }); 12 | 13 | afterAll(async () => { 14 | await (await environmentArranger).arrange(); 15 | await (await environmentArranger).close(); 16 | }); 17 | 18 | describe('CoursesCounterRepository', () => { 19 | describe('#save', () => { 20 | it('should save a courses counter', async () => { 21 | const course = CoursesCounterMother.random(); 22 | 23 | await repository.save(course); 24 | }); 25 | }); 26 | 27 | describe('#search', () => { 28 | it('should return an existing course', async () => { 29 | const expectedCounter = CoursesCounterMother.random(); 30 | await repository.save(expectedCounter); 31 | 32 | const counter = await repository.search(); 33 | 34 | expect(expectedCounter).toEqual(counter); 35 | }); 36 | 37 | it('should not return null if there is no courses counter', async () => { 38 | expect(await repository.search()).toBeFalsy(); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Shared/domain/Courses/CourseIdMother.ts: -------------------------------------------------------------------------------- 1 | import { CourseId } from '../../../../../../src/Contexts/Mooc/Shared/domain/Courses/CourseId'; 2 | import { UuidMother } from '../../../../Shared/domain/UuidMother'; 3 | 4 | export class CourseIdMother { 5 | static create(value: string): CourseId { 6 | return new CourseId(value); 7 | } 8 | static random(): CourseId { 9 | return this.create(UuidMother.random()); 10 | } 11 | } -------------------------------------------------------------------------------- /tests/Contexts/Mooc/Shared/domain/EventBusMock.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../../../src/Contexts/Shared/domain/DomainEvent'; 2 | import { DomainEventSubscribers } from '../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 3 | import { EventBus } from '../../../../../src/Contexts/Shared/domain/EventBus'; 4 | 5 | export default class EventBusMock implements EventBus { 6 | private publishSpy = jest.fn(); 7 | 8 | async publish(events: DomainEvent[]) { 9 | this.publishSpy(events); 10 | } 11 | 12 | addSubscribers(subscribers: DomainEventSubscribers): void {} 13 | 14 | assertLastPublishedEventIs(expectedEvent: DomainEvent) { 15 | const publishSpyCalls = this.publishSpy.mock.calls; 16 | 17 | expect(publishSpyCalls.length).toBeGreaterThan(0); 18 | 19 | const lastPublishSpyCall = publishSpyCalls[publishSpyCalls.length - 1]; 20 | const lastPublishedEvent = lastPublishSpyCall[0][0]; 21 | 22 | const expected = this.getDataFromDomainEvent(expectedEvent); 23 | const published = this.getDataFromDomainEvent(lastPublishedEvent); 24 | 25 | expect(expected).toMatchObject(published); 26 | } 27 | 28 | private getDataFromDomainEvent(event: DomainEvent) { 29 | const { eventId, occurredOn, ...attributes } = event; 30 | 31 | return attributes; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/domain/IntegerMother.ts: -------------------------------------------------------------------------------- 1 | import { MotherCreator } from './MotherCreator'; 2 | 3 | export class IntegerMother { 4 | static random(max?: number): number { 5 | return MotherCreator.random().random.number(max); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/domain/MotherCreator.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | 3 | export class MotherCreator { 4 | static random(): Faker.FakerStatic { 5 | return faker; 6 | } 7 | } -------------------------------------------------------------------------------- /tests/Contexts/Shared/domain/Repeater.ts: -------------------------------------------------------------------------------- 1 | import { IntegerMother } from './IntegerMother'; 2 | export class Repeater { 3 | static random(callable: Function, iterations: number) { 4 | return Array(iterations || IntegerMother.random(20)) 5 | .fill({}) 6 | .map(() => callable()); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/domain/UuidMother.ts: -------------------------------------------------------------------------------- 1 | import { MotherCreator } from './MotherCreator'; 2 | 3 | export class UuidMother { 4 | static random(): string { 5 | return MotherCreator.random().datatype.uuid(); 6 | } 7 | } -------------------------------------------------------------------------------- /tests/Contexts/Shared/domain/WordMother.ts: -------------------------------------------------------------------------------- 1 | import { MotherCreator } from './MotherCreator'; 2 | 3 | export class WordMother { 4 | static random({ minLength = 1, maxLength }: { minLength?: number; maxLength: number }): string { 5 | return MotherCreator.random().lorem.word(Math.floor(Math.random() * (maxLength - minLength)) + minLength) || 'word'; 6 | } 7 | } -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/CommandBus/InMemoryCommandBus.test.ts: -------------------------------------------------------------------------------- 1 | import { CommandNotRegisteredError } from '../../../../../src/Contexts/Shared/domain/CommandNotRegisteredError'; 2 | import { CommandHandlers } from '../../../../../src/Contexts/Shared/infrastructure/CommandBus/CommandHandlers'; 3 | import { InMemoryCommandBus } from '../../../../../src/Contexts/Shared/infrastructure/CommandBus/InMemoryCommandBus'; 4 | import { CommandHandlerDummy } from './__mocks__/CommandHandlerDummy'; 5 | import { DummyCommand } from './__mocks__/DummyCommand'; 6 | import { UnhandledCommand } from './__mocks__/UnhandledCommand'; 7 | 8 | describe('InMemoryCommandBus', () => { 9 | it('throws an error if dispatches a command without handler', async () => { 10 | const unhandledCommand = new UnhandledCommand(); 11 | const commandHandlers = new CommandHandlers([]); 12 | const commandBus = new InMemoryCommandBus(commandHandlers); 13 | 14 | await expect(commandBus.dispatch(unhandledCommand)).rejects.toBeInstanceOf(CommandNotRegisteredError); 15 | }); 16 | 17 | it('accepts a command with handler', async () => { 18 | const dummyCommand = new DummyCommand(); 19 | const commandHandlerDummy = new CommandHandlerDummy(); 20 | const commandHandlers = new CommandHandlers([commandHandlerDummy]); 21 | const commandBus = new InMemoryCommandBus(commandHandlers); 22 | 23 | await commandBus.dispatch(dummyCommand); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/CommandBus/__mocks__/CommandHandlerDummy.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '../../../../../../src/Contexts/Shared/domain/CommandHandler'; 2 | import { DummyCommand } from './DummyCommand'; 3 | 4 | export class CommandHandlerDummy implements CommandHandler { 5 | subscribedTo(): DummyCommand { 6 | return DummyCommand; 7 | } 8 | 9 | async handle(command: DummyCommand): Promise {} 10 | } 11 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/CommandBus/__mocks__/DummyCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../../../../../../src/Contexts/Shared/domain/Command'; 2 | 3 | export class DummyCommand extends Command { 4 | static COMMAND_NAME = 'handled.command'; 5 | } 6 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/CommandBus/__mocks__/UnhandledCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../../../../../../src/Contexts/Shared/domain/Command'; 2 | 3 | export class UnhandledCommand extends Command { 4 | static COMMAND_NAME = 'unhandled.command'; 5 | } 6 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/DomainEventFailoverPublisher.test.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventFailoverPublisher } from '../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventFailoverPublisher/DomainEventFailoverPublisher'; 2 | import { MongoEnvironmentArranger } from '../mongo/MongoEnvironmentArranger'; 3 | import { DomainEventDeserializerMother } from './__mother__/DomainEventDeserializerMother'; 4 | import { RabbitMQMongoClientMother } from './__mother__/RabbitMQMongoClientMother'; 5 | import { DomainEventDummyMother } from './__mocks__/DomainEventDummy'; 6 | 7 | describe('DomainEventFailoverPublisher test', () => { 8 | let arranger: MongoEnvironmentArranger; 9 | const mongoClient = RabbitMQMongoClientMother.create(); 10 | const deserializer = DomainEventDeserializerMother.create(); 11 | 12 | beforeAll(async () => { 13 | arranger = new MongoEnvironmentArranger(mongoClient); 14 | }); 15 | 16 | beforeEach(async () => { 17 | await arranger.arrange(); 18 | }); 19 | 20 | it('should save the published events', async () => { 21 | const eventBus = new DomainEventFailoverPublisher(mongoClient, deserializer); 22 | const event = DomainEventDummyMother.random(); 23 | 24 | await eventBus.publish(event); 25 | 26 | expect(await eventBus.consume()).toEqual([event]); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mocks__/DomainEventDummy.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../../../../src/Contexts/Shared/domain/DomainEvent'; 2 | import { UuidMother } from '../../../domain/UuidMother'; 3 | 4 | export class DomainEventDummy extends DomainEvent { 5 | static readonly EVENT_NAME = 'dummy'; 6 | 7 | constructor(data: { aggregateId: string; eventId?: string; occurredOn?: Date }) { 8 | const { aggregateId, eventId, occurredOn } = data; 9 | super({ eventName: DomainEventDummy.EVENT_NAME, aggregateId, eventId, occurredOn }); 10 | } 11 | 12 | toPrimitives() { 13 | return {}; 14 | } 15 | 16 | static fromPrimitives(params: { aggregateId: string; attributes: {}; eventId: string; occurredOn: Date }) { 17 | const { aggregateId, eventId, occurredOn } = params; 18 | return new DomainEventDummy({ 19 | aggregateId, 20 | eventId, 21 | occurredOn 22 | }); 23 | } 24 | } 25 | 26 | export class DomainEventDummyMother { 27 | static random() { 28 | return new DomainEventDummy({ 29 | aggregateId: UuidMother.random(), 30 | eventId: UuidMother.random(), 31 | occurredOn: new Date() 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mocks__/DomainEventFailoverPublisherDouble.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '../../../../../../src/Contexts/Shared/domain/DomainEvent'; 2 | import { DomainEventFailoverPublisher } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventFailoverPublisher/DomainEventFailoverPublisher'; 3 | import { DomainEventDeserializerMother } from '../__mother__/DomainEventDeserializerMother'; 4 | import { RabbitMQMongoClientMother } from '../__mother__/RabbitMQMongoClientMother'; 5 | 6 | export class DomainEventFailoverPublisherDouble extends DomainEventFailoverPublisher { 7 | private publishMock: jest.Mock; 8 | constructor() { 9 | super(RabbitMQMongoClientMother.create(), DomainEventDeserializerMother.create()); 10 | this.publishMock = jest.fn(); 11 | } 12 | 13 | async publish(event: DomainEvent): Promise { 14 | this.publishMock(event); 15 | } 16 | 17 | assertEventHasBeenPublished(event: DomainEvent) { 18 | expect(this.publishMock).toHaveBeenCalledWith(event); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mocks__/RabbitMQConnectionDouble.ts: -------------------------------------------------------------------------------- 1 | import { RabbitMqConnection } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 2 | 3 | export class RabbitMQConnectionDouble extends RabbitMqConnection { 4 | 5 | async publish(params: any): Promise { 6 | throw new Error(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mother__/DomainEventDeserializerMother.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventDeserializer } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventDeserializer'; 2 | import { DomainEventSubscribers } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 3 | import { DomainEventSubscriberDummy } from '../__mocks__/DomainEventSubscriberDummy'; 4 | 5 | export class DomainEventDeserializerMother { 6 | static create() { 7 | const dummySubscriber = new DomainEventSubscriberDummy(); 8 | const subscribers = new DomainEventSubscribers([dummySubscriber]); 9 | return DomainEventDeserializer.configure(subscribers); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mother__/DomainEventFailoverPublisherMother.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventFailoverPublisher } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventFailoverPublisher/DomainEventFailoverPublisher'; 2 | import { DomainEventFailoverPublisherDouble } from '../__mocks__/DomainEventFailoverPublisherDouble'; 3 | import { DomainEventDeserializerMother } from './DomainEventDeserializerMother'; 4 | import { RabbitMQMongoClientMother } from './RabbitMQMongoClientMother'; 5 | 6 | 7 | export class DomainEventFailoverPublisherMother { 8 | 9 | static create() { 10 | const mongoClient = RabbitMQMongoClientMother.create(); 11 | return new DomainEventFailoverPublisher(mongoClient, DomainEventDeserializerMother.create()); 12 | } 13 | 14 | static failOverDouble() { 15 | return new DomainEventFailoverPublisherDouble(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mother__/RabbitMQConnectionConfigurationMother.ts: -------------------------------------------------------------------------------- 1 | export class RabbitMQConnectionConfigurationMother { 2 | static create() { 3 | return { 4 | connectionSettings: { 5 | username: 'guest', 6 | password: 'guest', 7 | vhost: '/', 8 | connection: { 9 | secure: false, 10 | hostname: 'localhost', 11 | port: 5672 12 | } 13 | }, 14 | exchangeSettings: { name: '' } 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mother__/RabbitMQConnectionMother.ts: -------------------------------------------------------------------------------- 1 | import { RabbitMqConnection } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/RabbitMQ/RabbitMqConnection'; 2 | import { RabbitMQConnectionDouble } from '../__mocks__/RabbitMQConnectionDouble'; 3 | import { RabbitMQConnectionConfigurationMother } from './RabbitMQConnectionConfigurationMother'; 4 | 5 | export class RabbitMQConnectionMother { 6 | static async create() { 7 | const config = RabbitMQConnectionConfigurationMother.create(); 8 | const connection = new RabbitMqConnection(config); 9 | await connection.connect(); 10 | return connection; 11 | } 12 | 13 | static failOnPublish() { 14 | return new RabbitMQConnectionDouble(RabbitMQConnectionConfigurationMother.create()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/EventBus/__mother__/RabbitMQMongoClientMother.ts: -------------------------------------------------------------------------------- 1 | import { MongoClientFactory } from '../../../../../../src/Contexts/Shared/infrastructure/persistence/mongo/MongoClientFactory'; 2 | 3 | export class RabbitMQMongoClientMother { 4 | static async create() { 5 | return MongoClientFactory.createClient('shared', { 6 | url: 'mongodb://localhost:27017/mooc-backend-test1' 7 | }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/MongoClientFactory.test.ts: -------------------------------------------------------------------------------- 1 | import { MongoClientFactory } from '../../../../src/Contexts/Shared/infrastructure/persistence/mongo/MongoClientFactory'; 2 | import { MongoClient } from 'mongodb'; 3 | 4 | describe('MongoClientFactory', () => { 5 | const factory = MongoClientFactory; 6 | let client: MongoClient; 7 | 8 | beforeEach(async () => { 9 | client = await factory.createClient('test', { url: 'mongodb://localhost:27017/mooc-backend-test1' }); 10 | }); 11 | 12 | afterEach(async () => { 13 | await client.close(); 14 | }); 15 | 16 | it('creates a new client with the connection already established', () => { 17 | expect(client).toBeInstanceOf(MongoClient); 18 | }); 19 | 20 | it('creates a new client if it does not exist a client with the given name', async () => { 21 | const newClient = await factory.createClient('test2', { url: 'mongodb://localhost:27017/mooc-backend-test2' }); 22 | 23 | expect(newClient).not.toBe(client); 24 | 25 | await newClient.close(); 26 | }); 27 | 28 | it('returns a client if it already exists', async () => { 29 | const newClient = await factory.createClient('test', { url: 'mongodb://localhost:27017/mooc-backend-test3' }); 30 | 31 | expect(newClient).toBe(client); 32 | 33 | await newClient.close(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/QueryBus/InMemoryQueryBus.test.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../../../../../src/Contexts/Shared/domain/Query'; 2 | import { QueryHandlers } from '../../../../../src/Contexts/Shared/infrastructure/QueryBus/QueryHandlers'; 3 | import { QueryNotRegisteredError } from '../../../../../src/Contexts/Shared/domain/QueryNotRegisteredError'; 4 | import { QueryHandler } from '../../../../../src/Contexts/Shared/domain/QueryHandler'; 5 | import { Response } from '../../../../../src/Contexts/Shared/domain/Response'; 6 | import { InMemoryQueryBus } from '../../../../../src/Contexts/Shared/infrastructure/QueryBus/InMemoryQueryBus'; 7 | 8 | class UnhandledQuery extends Query { 9 | static QUERY_NAME = 'unhandled.query'; 10 | } 11 | 12 | class HandledQuery extends Query { 13 | static QUERY_NAME = 'handled.query'; 14 | } 15 | 16 | class MyQueryHandler implements QueryHandler { 17 | subscribedTo(): HandledQuery { 18 | return HandledQuery; 19 | } 20 | 21 | async handle(query: HandledQuery): Promise { 22 | return {}; 23 | } 24 | } 25 | 26 | describe('InMemoryQueryBus', () => { 27 | it('throws an error if dispatches a query without handler', async () => { 28 | const unhandledQuery = new UnhandledQuery(); 29 | const queryHandlers = new QueryHandlers([]); 30 | const queryBus = new InMemoryQueryBus(queryHandlers); 31 | 32 | expect(queryBus.ask(unhandledQuery)).rejects.toBeInstanceOf(QueryNotRegisteredError); 33 | }); 34 | 35 | it('accepts a query with handler', async () => { 36 | const handledQuery = new HandledQuery(); 37 | const myQueryHandler = new MyQueryHandler(); 38 | const queryHandlers = new QueryHandlers([myQueryHandler]); 39 | const queryBus = new InMemoryQueryBus(queryHandlers); 40 | 41 | await queryBus.ask(handledQuery); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/TypeOrmClientFactory.test.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'typeorm'; 2 | import { TypeOrmClientFactory } from '../../../../src/Contexts/Shared/infrastructure/persistence/typeorm/TypeOrmClientFactory'; 3 | 4 | describe('TypeOrmClientFactory', () => { 5 | const factory = TypeOrmClientFactory; 6 | let client: Connection; 7 | 8 | beforeEach(async () => { 9 | client = await factory.createClient('test', { host:"localhost", port: 5432, username: "codely", password:"codely",database: 'mooc-backend-dev' }); 10 | }); 11 | 12 | afterEach(async () => { 13 | await client.close(); 14 | }); 15 | 16 | it('creates a new client with the connection already established', () => { 17 | expect(client).toBeInstanceOf(Connection); 18 | expect(client.isConnected).toBe(true); 19 | }); 20 | 21 | it('creates a new client if it does not exist a client with the given name', async () => { 22 | const newClient = await factory.createClient('test2', { host:"localhost", port: 5432, username: "codely", password:"codely",database: 'mooc-backend-dev' }); 23 | 24 | expect(newClient).not.toBe(client); 25 | expect(newClient.isConnected).toBeTruthy(); 26 | 27 | await newClient.close(); 28 | }); 29 | 30 | it('returns a client if it already exists', async () => { 31 | const newClient = await factory.createClient('test', { host:"localhost", port: 5432, username: "codely", password:"codely",database: 'mooc-backend-dev' }); 32 | 33 | expect(newClient).toBe(client); 34 | expect(newClient.isConnected).toBeTruthy(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/arranger/EnvironmentArranger.ts: -------------------------------------------------------------------------------- 1 | export abstract class EnvironmentArranger { 2 | public abstract arrange(): Promise; 3 | 4 | public abstract close(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/mongo/MongoEnvironmentArranger.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb'; 2 | import { EnvironmentArranger } from '../arranger/EnvironmentArranger'; 3 | 4 | export class MongoEnvironmentArranger extends EnvironmentArranger { 5 | constructor(private _client: Promise) { 6 | super(); 7 | } 8 | 9 | public async arrange(): Promise { 10 | await this.cleanDatabase(); 11 | } 12 | 13 | protected async cleanDatabase(): Promise { 14 | const collections = await this.collections(); 15 | const client = await this.client(); 16 | 17 | for (const collection of collections) { 18 | await client.db().collection(collection).deleteMany({}); 19 | } 20 | } 21 | 22 | private async collections(): Promise { 23 | const client = await this.client(); 24 | const collections = await client.db().listCollections(undefined, { nameOnly: true }).toArray(); 25 | 26 | return collections.map(collection => collection.name); 27 | } 28 | 29 | protected client(): Promise { 30 | return this._client; 31 | } 32 | 33 | public async close(): Promise { 34 | return (await this.client()).close(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Contexts/Shared/infrastructure/typeorm/TypeOrmEnvironmentArranger.ts: -------------------------------------------------------------------------------- 1 | import { Connection, EntityMetadata } from 'typeorm'; 2 | import { EnvironmentArranger } from '../arranger/EnvironmentArranger'; 3 | 4 | export class TypeOrmEnvironmentArranger extends EnvironmentArranger { 5 | constructor(private _client: Promise) { 6 | super(); 7 | } 8 | 9 | public async arrange(): Promise { 10 | await this.cleanDatabase(); 11 | } 12 | 13 | protected async cleanDatabase(): Promise { 14 | const entities = await this.entities(); 15 | 16 | try { 17 | for (const entity of entities) { 18 | const repository = (await this._client).getRepository(entity.name); 19 | await repository.query(`TRUNCATE TABLE ${entity.tableName};`); 20 | } 21 | } catch (error) { 22 | throw new Error(`Unable to clean test database: ${error}`); 23 | } 24 | } 25 | 26 | private async entities(): Promise { 27 | return (await this._client).entityMetadatas; 28 | } 29 | 30 | protected client(): Promise { 31 | return this._client; 32 | } 33 | 34 | public async close(): Promise { 35 | return (await this.client()).close(); 36 | } 37 | } -------------------------------------------------------------------------------- /tests/apps/backoffice/backend/features/courses/get-courses.feature: -------------------------------------------------------------------------------- 1 | Feature: Get courses 2 | As a user with permissions 3 | I want to get courses 4 | 5 | Scenario: All existing courses 6 | Given the following event is received: 7 | """ 8 | { 9 | "data": { 10 | "id": "c77fa036-cbc7-4414-996b-c6a7a93cae09", 11 | "type": "course.created", 12 | "occurred_on": "2019-08-08T08:37:32+00:00", 13 | "aggregateId": "8c900b20-e04a-4777-9183-32faab6d2fb5", 14 | "attributes": { 15 | "name": "DDD en PHP!", 16 | "duration": "25 hours" 17 | }, 18 | "meta" : { 19 | "host": "111.26.06.93" 20 | } 21 | } 22 | } 23 | """ 24 | And the following event is received: 25 | """ 26 | { 27 | "data": { 28 | "id": "353baf48-56e4-4eb2-91a0-b8f826135e6a", 29 | "type": "course.created", 30 | "occurred_on": "2019-08-08T08:37:32+00:00", 31 | "aggregateId": "8c4a4ed8-9458-489e-a167-b099d81fa096", 32 | "attributes": { 33 | "name": "DDD en Java!", 34 | "duration": "24 hours" 35 | }, 36 | "meta" : { 37 | "host": "111.26.06.93" 38 | } 39 | } 40 | } 41 | """ 42 | And I send a GET request to "/courses" 43 | Then the response status code should be 200 44 | And the response should be: 45 | """ 46 | [ 47 | { 48 | "id": "8c900b20-e04a-4777-9183-32faab6d2fb5", 49 | "name": "DDD en PHP!", 50 | "duration": "25 hours" 51 | }, 52 | { 53 | "id": "8c4a4ed8-9458-489e-a167-b099d81fa096", 54 | "name": "DDD en Java!", 55 | "duration": "24 hours" 56 | } 57 | ] 58 | """ 59 | -------------------------------------------------------------------------------- /tests/apps/backoffice/backend/features/status.feature: -------------------------------------------------------------------------------- 1 | Feature: Api status 2 | In order to know the server is up and running 3 | As a health check 4 | I want to check the api status 5 | 6 | Scenario: Check the api status 7 | Given I send a GET request to "/status" 8 | Then the response status code should be 200 9 | -------------------------------------------------------------------------------- /tests/apps/backoffice/backend/features/step_definitions/controller.steps.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Given, Then } from 'cucumber'; 3 | import request from 'supertest'; 4 | import { application } from './hooks.steps'; 5 | 6 | let _request: request.Test; 7 | let _response: request.Response; 8 | 9 | Given('I send a GET request to {string}', (route: string) => { 10 | _request = request(application.httpServer).get(route); 11 | }); 12 | 13 | Then('the response status code should be {int}', async (status: number) => { 14 | _response = await _request.expect(status); 15 | }); 16 | 17 | Then('the response should be:', async response => { 18 | const expectedResponse = JSON.parse(response); 19 | _response = await _request; 20 | assert.deepStrictEqual(_response.body, expectedResponse); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/apps/backoffice/backend/features/step_definitions/eventBus.steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from 'cucumber'; 2 | import container from '../../../../../../src/apps/backoffice/backend/dependency-injection'; 3 | import { DomainEventDeserializer } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventDeserializer'; 4 | import { DomainEventSubscribers } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 5 | import { eventBus } from './hooks.steps'; 6 | 7 | const deserializer = buildDeserializer(); 8 | 9 | Given('the following event is received:', async (event: any) => { 10 | const domainEvent = deserializer.deserialize(event)!; 11 | 12 | await eventBus.publish([domainEvent]); 13 | await wait(500); 14 | }); 15 | 16 | function buildDeserializer() { 17 | const subscribers = DomainEventSubscribers.from(container); 18 | return DomainEventDeserializer.configure(subscribers); 19 | } 20 | 21 | function wait(milliseconds: number) { 22 | return new Promise(resolve => setTimeout(resolve, milliseconds)); 23 | } 24 | -------------------------------------------------------------------------------- /tests/apps/backoffice/backend/features/step_definitions/hooks.steps.ts: -------------------------------------------------------------------------------- 1 | import { AfterAll, BeforeAll } from 'cucumber'; 2 | import { BackofficeBackendApp } from '../../../../../../src/apps/backoffice/backend/BackofficeBackendApp'; 3 | import { ConfigureRabbitMQCommand } from '../../../../../../src/apps/backoffice/backend/command/ConfigureRabbitMQCommand'; 4 | import container from '../../../../../../src/apps/backoffice/backend/dependency-injection'; 5 | import { EventBus } from '../../../../../../src/Contexts/Shared/domain/EventBus'; 6 | import { EnvironmentArranger } from '../../../../../Contexts/Shared/infrastructure/arranger/EnvironmentArranger'; 7 | 8 | let application: BackofficeBackendApp; 9 | let environmentArranger: EnvironmentArranger; 10 | let eventBus: EventBus; 11 | 12 | BeforeAll(async () => { 13 | await ConfigureRabbitMQCommand.run(); 14 | 15 | environmentArranger = await container.get>('Backoffice.EnvironmentArranger'); 16 | eventBus = container.get('Backoffice.Shared.domain.EventBus'); 17 | await environmentArranger.arrange(); 18 | 19 | application = new BackofficeBackendApp(); 20 | await application.start(); 21 | }); 22 | 23 | AfterAll(async () => { 24 | await environmentArranger.arrange(); 25 | await environmentArranger.close(); 26 | 27 | await application.stop(); 28 | }); 29 | 30 | export { application, environmentArranger, eventBus }; 31 | -------------------------------------------------------------------------------- /tests/apps/backoffice/backend/features/step_definitions/repository.steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from 'cucumber'; 2 | import container from '../../../../../../src/apps/backoffice/backend/dependency-injection'; 3 | import { Course } from '../../../../../../src/Contexts/Mooc/Courses/domain/Course'; 4 | import { CourseDuration } from '../../../../../../src/Contexts/Mooc/Courses/domain/CourseDuration'; 5 | import { CourseName } from '../../../../../../src/Contexts/Mooc/Courses/domain/CourseName'; 6 | import { CourseRepository } from '../../../../../../src/Contexts/Mooc/Courses/domain/CourseRepository'; 7 | import { CourseId } from '../../../../../../src/Contexts/Mooc/Shared/domain/Courses/CourseId'; 8 | 9 | const courseRepository: CourseRepository = container.get('Backoffice.Courses.domain.BackofficeCourseRepository'); 10 | 11 | Given('there is the course:', async (course: any) => { 12 | const { id, name, duration } = JSON.parse(course); 13 | await courseRepository.save(new Course(new CourseId(id), new CourseName(name), new CourseDuration(duration))); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/apps/mooc/backend/features/courses/create-course.feature: -------------------------------------------------------------------------------- 1 | Feature: Create a new course 2 | In order to have courses in the platform 3 | As a user with admin permissions 4 | I want to create a new course 5 | 6 | Scenario: A valid non existing course 7 | Given I send a PUT request to "/courses/ef8ac118-8d7f-49cc-abec-78e0d05af80a" with body: 8 | """ 9 | { 10 | "id": "ef8ac118-8d7f-49cc-abec-78e0d05af80a", 11 | "name": "The best course", 12 | "duration": "5 hours" 13 | } 14 | """ 15 | Then the response status code should be 201 16 | And the response should be empty 17 | 18 | Scenario: An invalid non existing course 19 | Given I send a PUT request to "/courses/ef8ac118-8d7f-49cc-abec-78e0d05af80a" with body: 20 | """ 21 | { 22 | "id": "ef8ac118-8d7f-49cc-abec-78e0d05af80a", 23 | "name": 5, 24 | "duration": "5 hours" 25 | } 26 | """ 27 | Then the response status code should be 422 28 | -------------------------------------------------------------------------------- /tests/apps/mooc/backend/features/status.feature: -------------------------------------------------------------------------------- 1 | Feature: Api status 2 | In order to know the server is up and running 3 | As a health check 4 | I want to check the api status 5 | 6 | Scenario: Check the api status 7 | Given I send a GET request to "/status" 8 | Then the response status code should be 200 9 | -------------------------------------------------------------------------------- /tests/apps/mooc/backend/features/step_definitions/controller.steps.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Given, Then } from 'cucumber'; 3 | import request from 'supertest'; 4 | import { application } from './hooks.steps'; 5 | 6 | let _request: request.Test; 7 | let _response: request.Response; 8 | 9 | Given('I send a GET request to {string}', (route: string) => { 10 | _request = request(application.httpServer).get(route); 11 | }); 12 | 13 | Then('the response status code should be {int}', async (status: number) => { 14 | _response = await _request.expect(status); 15 | }); 16 | 17 | Given('I send a PUT request to {string} with body:', (route: string, body: string) => { 18 | _request = request(application.httpServer).put(route).send(JSON.parse(body)); 19 | }); 20 | 21 | Then('the response should be empty', () => { 22 | assert.deepStrictEqual(_response.body, {}); 23 | }); 24 | 25 | Then('the response content should be:', response => { 26 | assert.deepStrictEqual(_response.body, JSON.parse(response)); 27 | }); 28 | 29 | -------------------------------------------------------------------------------- /tests/apps/mooc/backend/features/step_definitions/eventBus.steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from 'cucumber'; 2 | import container from '../../../../../../src/apps/mooc/backend/dependency-injection'; 3 | import { DomainEventDeserializer } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventDeserializer'; 4 | import { DomainEventSubscribers } from '../../../../../../src/Contexts/Shared/infrastructure/EventBus/DomainEventSubscribers'; 5 | import { eventBus } from './hooks.steps'; 6 | 7 | const deserializer = buildDeserializer(); 8 | 9 | Given('I send an event to the event bus:', async (event: any) => { 10 | const domainEvent = deserializer.deserialize(event); 11 | 12 | await eventBus.publish([domainEvent!]); 13 | await wait(500); 14 | }); 15 | 16 | function buildDeserializer() { 17 | const subscribers = DomainEventSubscribers.from(container); 18 | 19 | return DomainEventDeserializer.configure(subscribers); 20 | } 21 | 22 | function wait(milliseconds: number) { 23 | return new Promise(resolve => setTimeout(resolve, milliseconds)); 24 | } 25 | -------------------------------------------------------------------------------- /tests/apps/mooc/backend/features/step_definitions/hooks.steps.ts: -------------------------------------------------------------------------------- 1 | import { AfterAll, BeforeAll } from 'cucumber'; 2 | import { ConfigureRabbitMQCommand } from '../../../../../../src/apps/mooc/backend/command/ConfigureRabbitMQCommand'; 3 | import container from '../../../../../../src/apps/mooc/backend/dependency-injection'; 4 | import { MoocBackendApp } from '../../../../../../src/apps/mooc/backend/MoocBackendApp'; 5 | import { EventBus } from '../../../../../../src/Contexts/Shared/domain/EventBus'; 6 | import { EnvironmentArranger } from '../../../../../Contexts/Shared/infrastructure/arranger/EnvironmentArranger'; 7 | 8 | let application: MoocBackendApp; 9 | let environmentArranger: EnvironmentArranger; 10 | let eventBus: EventBus; 11 | 12 | BeforeAll(async () => { 13 | await ConfigureRabbitMQCommand.run(); 14 | 15 | environmentArranger = await container.get>('Mooc.EnvironmentArranger'); 16 | eventBus = container.get('Mooc.Shared.domain.EventBus'); 17 | await environmentArranger.arrange(); 18 | 19 | application = new MoocBackendApp(); 20 | await application.start(); 21 | }); 22 | 23 | AfterAll(async () => { 24 | await environmentArranger.close(); 25 | 26 | await application.stop(); 27 | }); 28 | 29 | export { application, environmentArranger, eventBus }; 30 | -------------------------------------------------------------------------------- /tests/apps/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["es2015", "dom"], 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "sourceMap": false, 9 | "rootDir": ".", 10 | "strict": true, 11 | "noEmit": false, 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": true, 14 | "outDir": "./dist" 15 | }, 16 | "include": [ 17 | "src/**/**.ts", 18 | "tests/**/**.ts" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "src/apps/backoffice/frontend" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "tests"] 4 | } 5 | --------------------------------------------------------------------------------