diff --git a/.env b/.env index 8099dcd37..ea3d53030 100644 --- a/.env +++ b/.env @@ -39,3 +39,5 @@ RABBITMQ_EXCHANGE=domain_events RABBITMQ_MAX_RETRIES=5 # RabbitMQ - Application Specific RABBITMQ_MOOC_VHOST=/ +# Discord - set the real webhook URL in .env.test.local (APP_ENV=test does not load .env.local) +DISCORD_WEBHOOK_URL= diff --git a/Dockerfile b/Dockerfile index dd0f725bf..a2b291d29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app RUN apk --update upgrade \ && apk add --no-cache autoconf automake make gcc g++ git bash icu-dev libzip-dev rabbitmq-c rabbitmq-c-dev linux-headers -RUN pecl install apcu-5.1.23 && pecl install amqp-2.1.1 && pecl install xdebug-3.3.0 +RUN pecl install apcu-5.1.23 && pecl install amqp-2.1.1 && pecl install xdebug-3.4.2 RUN docker-php-ext-install -j$(nproc) \ bcmath \ diff --git a/Makefile b/Makefile index efec06c20..a01df7f05 100644 --- a/Makefile +++ b/Makefile @@ -50,5 +50,5 @@ ping-rabbitmq: @docker exec codely-php_ddd_skeleton-rabbitmq rabbitmqctl ping --silent clean-cache: - @rm -rf apps/*/*/var + @rm -rf apps/*/*/var/* @docker exec codely-php_ddd_skeleton-mooc_backend-php ./apps/mooc/backend/bin/console cache:warmup diff --git a/README.md b/README.md index 85d72efbe..6fbde5dd9 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,18 @@ ### πŸ”₯ Application execution -1. Install all the dependencies and bring up the project with Docker executing: `make build` -2. Then you'll have 3 apps available (2 APIs and 1 Frontend): +1. Install all the dependencies (runs Composer inside Docker, no local PHP needed): `make composer-install` +2. Bring up the project with Docker: `make start` +3. Then you'll have 3 apps available (2 APIs and 1 Frontend): 1. [Mooc Backend](apps/mooc/backend): http://localhost:8030/health-check 2. [Backoffice Backend](apps/backoffice/backend): http://localhost:8040/health-check 3. [Backoffice Frontend](apps/backoffice/frontend): http://localhost:8041/health-check ### βœ… Tests execution -1. Install the dependencies if you haven't done it previously: `make deps` +1. Install the dependencies and start the containers if you haven't done it previously: `make composer-install && make start` 2. Execute PHPUnit and Behat tests: `make test` +3. Stop the containers when you're done: `make stop` ## πŸ‘©β€πŸ’» Project explanation diff --git a/apps/mooc/backend/config/routes/videos.yaml b/apps/mooc/backend/config/routes/videos.yaml new file mode 100644 index 000000000..67bc7e110 --- /dev/null +++ b/apps/mooc/backend/config/routes/videos.yaml @@ -0,0 +1,4 @@ +videos_put: + path: /videos/{id} + controller: CodelyTv\Apps\Mooc\Backend\Controller\Videos\VideosPutController + methods: [PUT] diff --git a/apps/mooc/backend/config/services.yaml b/apps/mooc/backend/config/services.yaml index 7d81f0953..203942293 100644 --- a/apps/mooc/backend/config/services.yaml +++ b/apps/mooc/backend/config/services.yaml @@ -94,5 +94,10 @@ services: CodelyTv\Shared\Infrastructure\Bus\Event\WithMonitoring\WithPrometheusMonitoringEventBus: arguments: ['@CodelyTv\Shared\Infrastructure\Monitoring\PrometheusMonitor', 'mooc_backend', '@CodelyTv\Shared\Infrastructure\Bus\Event\RabbitMq\RabbitMqEventBus'] + # Discord + CodelyTv\Mooc\Videos\Infrastructure\Notification\DiscordVideoPublishedNotifier: + arguments: + $webhookUrl: '%env(DISCORD_WEBHOOK_URL)%' + # -- IMPLEMENTATIONS SELECTOR -- CodelyTv\Shared\Domain\Bus\Event\EventBus: '@CodelyTv\Shared\Infrastructure\Bus\Event\WithMonitoring\WithPrometheusMonitoringEventBus' diff --git a/apps/mooc/backend/src/Controller/Videos/VideosPutController.php b/apps/mooc/backend/src/Controller/Videos/VideosPutController.php new file mode 100644 index 000000000..cd8484b6c --- /dev/null +++ b/apps/mooc/backend/src/Controller/Videos/VideosPutController.php @@ -0,0 +1,33 @@ +dispatch( + new CreateVideoCommand( + $id, + (string) $request->request->get('type'), + (string) $request->request->get('title'), + (string) $request->request->get('url'), + (string) $request->request->get('courseId') + ) + ); + + return new Response('', Response::HTTP_CREATED); + } + + protected function exceptions(): array + { + return []; + } +} diff --git a/apps/mooc/backend/tests/features/videos/video_put.feature b/apps/mooc/backend/tests/features/videos/video_put.feature new file mode 100644 index 000000000..115b17326 --- /dev/null +++ b/apps/mooc/backend/tests/features/videos/video_put.feature @@ -0,0 +1,17 @@ +Feature: Create a new video + In order to have videos on the platform + As a user with admin permissions + I want to create a new video + + Scenario: A valid non existing video + Given I send a PUT request to "/videos/3c5e9a1f-2b7d-4e8a-9f61-0d4b7c2e8a15" with body: + """ + { + "type": "screencast", + "title": "The best video", + "url": "https://codely.com/videos/the-best-video", + "course_id": "1aab45ba-3c7a-4344-8936-78466eca77fa" + } + """ + Then the response status code should be 201 + And the response should be empty diff --git a/apps/mooc/backend/tests/mooc_backend.yml b/apps/mooc/backend/tests/mooc_backend.yml index 75681594e..971e473f9 100644 --- a/apps/mooc/backend/tests/mooc_backend.yml +++ b/apps/mooc/backend/tests/mooc_backend.yml @@ -27,3 +27,9 @@ mooc_backend: contexts: - CodelyTv\Tests\Shared\Infrastructure\Behat\ApplicationFeatureContext - CodelyTv\Tests\Shared\Infrastructure\Behat\ApiContext + + videos: + paths: [ apps/mooc/backend/tests/features/videos ] + contexts: + - CodelyTv\Tests\Shared\Infrastructure\Behat\ApplicationFeatureContext + - CodelyTv\Tests\Shared\Infrastructure\Behat\ApiContext diff --git a/composer.json b/composer.json index 52f696576..def4c2b42 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "elasticsearch/elasticsearch": "^7", "monolog/monolog": "^3", - "promphp/prometheus_client_php": "^2.7.2" + "promphp/prometheus_client_php": "^2.7.2", + "symfony/http-client": "^7.1" }, "require-dev": { "ext-xdebug": "*", diff --git a/composer.lock b/composer.lock index 534d648f6..9b03b6655 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1384ca0a67984f7a0296f15a4373fed1", + "content-hash": "89f2158f57f5dd9a065ed9178e87735a", "packages": [ { "name": "brick/math", @@ -3279,6 +3279,189 @@ ], "time": "2024-07-26T13:24:34+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "121bf3a973bfd852db85ec32d0d64e9fbed4449b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/121bf3a973bfd852db85ec32d0d64e9fbed4449b", + "reference": "121bf3a973bfd852db85ec32d0d64e9fbed4449b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-13T15:31:38+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.1.3", diff --git a/docker-compose.yml b/docker-compose.yml index 19fc0366b..568b3fb10 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,7 +67,7 @@ services: - shared_rabbitmq - shared_prometheus - backoffice_elasticsearch - command: symfony serve --dir=apps/backoffice/backend/public --port=8040 + command: symfony serve --dir=apps/backoffice/backend/public --port=8040 --allow-all-ip backoffice_frontend_php: container_name: codely-php_ddd_skeleton-backoffice_frontend-php @@ -86,7 +86,7 @@ services: - shared_prometheus - backoffice_elasticsearch - mooc_mysql - command: symfony serve --dir=apps/backoffice/frontend/public --port=8041 + command: symfony serve --dir=apps/backoffice/frontend/public --port=8041 --allow-all-ip mooc_backend_php: container_name: codely-php_ddd_skeleton-mooc_backend-php @@ -104,4 +104,4 @@ services: - shared_rabbitmq - shared_prometheus - mooc_mysql - command: symfony serve --dir=apps/mooc/backend/public --port=8030 + command: symfony serve --dir=apps/mooc/backend/public --port=8030 --allow-all-ip diff --git a/etc/databases/mooc.sql b/etc/databases/mooc.sql index 1a094f827..72297bcb2 100644 --- a/etc/databases/mooc.sql +++ b/etc/databases/mooc.sql @@ -80,6 +80,17 @@ CREATE TABLE courses_counter ( INSERT INTO courses_counter (id, total, existing_courses) VALUES ("cdf26d7d-3deb-4e8c-9f73-4ac085a8d6f3", 0, "[]"); +CREATE TABLE videos ( + id CHAR(36) NOT NULL, + type VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + url VARCHAR(255) NOT NULL, + course_id CHAR(36) NOT NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + CREATE TABLE steps ( id CHAR(36) NOT NULL, title VARCHAR(255) NOT NULL, diff --git a/src/Mooc/Videos/Infrastructure/Persistence/Doctrine/VideoType.orm.xml b/src/Mooc/Shared/Infrastructure/Persistence/Doctrine/Videos.VideoUrl.orm.xml similarity index 77% rename from src/Mooc/Videos/Infrastructure/Persistence/Doctrine/VideoType.orm.xml rename to src/Mooc/Shared/Infrastructure/Persistence/Doctrine/Videos.VideoUrl.orm.xml index b05a427cb..f40d2737e 100644 --- a/src/Mooc/Videos/Infrastructure/Persistence/Doctrine/VideoType.orm.xml +++ b/src/Mooc/Shared/Infrastructure/Persistence/Doctrine/Videos.VideoUrl.orm.xml @@ -4,8 +4,8 @@ xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> - - + + diff --git a/src/Mooc/Shared/Infrastructure/Symfony/DependencyInjection/mooc_services.yaml b/src/Mooc/Shared/Infrastructure/Symfony/DependencyInjection/mooc_services.yaml index fa4f5522b..902e5ca51 100644 --- a/src/Mooc/Shared/Infrastructure/Symfony/DependencyInjection/mooc_services.yaml +++ b/src/Mooc/Shared/Infrastructure/Symfony/DependencyInjection/mooc_services.yaml @@ -17,3 +17,6 @@ services: # Courses CodelyTv\Mooc\Courses\Domain\CourseRepository: '@CodelyTv\Mooc\Courses\Infrastructure\Persistence\DoctrineCourseRepository' + + # Videos + CodelyTv\Mooc\Videos\Domain\VideoPublishedNotifier: '@CodelyTv\Mooc\Videos\Infrastructure\Notification\DiscordVideoPublishedNotifier' diff --git a/src/Mooc/Videos/Application/Notify/NotifyOnVideoCreated.php b/src/Mooc/Videos/Application/Notify/NotifyOnVideoCreated.php new file mode 100644 index 000000000..779603cd8 --- /dev/null +++ b/src/Mooc/Videos/Application/Notify/NotifyOnVideoCreated.php @@ -0,0 +1,25 @@ +notificator)(new VideoTitle($event->title()), new VideoUrl($event->url())); + } +} diff --git a/src/Mooc/Videos/Application/Notify/VideoPublishedNotificator.php b/src/Mooc/Videos/Application/Notify/VideoPublishedNotificator.php new file mode 100644 index 000000000..5e99bfbbd --- /dev/null +++ b/src/Mooc/Videos/Application/Notify/VideoPublishedNotificator.php @@ -0,0 +1,19 @@ +notifier->notify($title, $url); + } +} diff --git a/src/Mooc/Videos/Domain/VideoCreatedDomainEvent.php b/src/Mooc/Videos/Domain/VideoCreatedDomainEvent.php index 61cd7f8e8..f2c1637c3 100644 --- a/src/Mooc/Videos/Domain/VideoCreatedDomainEvent.php +++ b/src/Mooc/Videos/Domain/VideoCreatedDomainEvent.php @@ -51,4 +51,14 @@ public function toPrimitives(): array 'course_id' => $this->courseId, ]; } + + public function title(): string + { + return $this->title; + } + + public function url(): string + { + return $this->url; + } } diff --git a/src/Mooc/Videos/Domain/VideoPublishedNotifier.php b/src/Mooc/Videos/Domain/VideoPublishedNotifier.php new file mode 100644 index 000000000..c7c7c388e --- /dev/null +++ b/src/Mooc/Videos/Domain/VideoPublishedNotifier.php @@ -0,0 +1,12 @@ +client->request('POST', $this->webhookUrl, [ + 'json' => ['content' => sprintf('🎬 Nuevo vΓ­deo publicado: **%s**%s%s', $title->value(), PHP_EOL, $url->value())], + ])->getContent(); + } +} diff --git a/src/Mooc/Videos/Infrastructure/Persistence/Doctrine/Video.orm.xml b/src/Mooc/Videos/Infrastructure/Persistence/Doctrine/Video.orm.xml index bd2d9a994..d87cb5662 100644 --- a/src/Mooc/Videos/Infrastructure/Persistence/Doctrine/Video.orm.xml +++ b/src/Mooc/Videos/Infrastructure/Persistence/Doctrine/Video.orm.xml @@ -9,7 +9,7 @@ - + diff --git a/tests/Mooc/Videos/Application/Notify/NotifyOnVideoCreatedTest.php b/tests/Mooc/Videos/Application/Notify/NotifyOnVideoCreatedTest.php new file mode 100644 index 000000000..45b89d2a5 --- /dev/null +++ b/tests/Mooc/Videos/Application/Notify/NotifyOnVideoCreatedTest.php @@ -0,0 +1,42 @@ +notifier = $this->mock(VideoPublishedNotifier::class); + $this->subscriber = new NotifyOnVideoCreated(new VideoPublishedNotificator($this->notifier)); + } + + /** @test */ + public function it_should_notify_a_published_video(): void + { + $event = VideoCreatedDomainEventMother::create(); + + $this->notifier + ->shouldReceive('notify') + ->once() + ->with($this->similarTo(new VideoTitle($event->title())), $this->similarTo(new VideoUrl($event->url()))) + ->andReturnNull(); + + $this->notify($event, $this->subscriber); + } +} diff --git a/tests/Mooc/Videos/Domain/VideoCreatedDomainEventMother.php b/tests/Mooc/Videos/Domain/VideoCreatedDomainEventMother.php new file mode 100644 index 000000000..749f3d667 --- /dev/null +++ b/tests/Mooc/Videos/Domain/VideoCreatedDomainEventMother.php @@ -0,0 +1,24 @@ +value, + $title ?? MotherCreator::random()->sentence(3), + $url ?? MotherCreator::random()->url, + UuidMother::create() + ); + } +} diff --git a/tests/Mooc/Videos/Infrastructure/Notification/DiscordVideoPublishedNotifierTest.php b/tests/Mooc/Videos/Infrastructure/Notification/DiscordVideoPublishedNotifierTest.php new file mode 100644 index 000000000..7bcfbd5f7 --- /dev/null +++ b/tests/Mooc/Videos/Infrastructure/Notification/DiscordVideoPublishedNotifierTest.php @@ -0,0 +1,47 @@ + 204]); + $notifier = new DiscordVideoPublishedNotifier(new MockHttpClient($response), self::WEBHOOK_URL); + + $notifier->notify(new VideoTitle('DDD en PHP'), new VideoUrl('https://codely.com/video')); + + $this->assertSame('POST', $response->getRequestMethod()); + $this->assertSame(self::WEBHOOK_URL, $response->getRequestUrl()); + + $body = json_decode($response->getRequestOptions()['body'], true); + $this->assertStringContainsString('DDD en PHP', $body['content']); + $this->assertStringContainsString('https://codely.com/video', $body['content']); + } + + /** @test */ + public function it_should_fail_when_the_webhook_returns_an_error(): void + { + $notifier = new DiscordVideoPublishedNotifier( + new MockHttpClient(new MockResponse('', ['http_code' => 404])), + self::WEBHOOK_URL + ); + + $this->expectException(ClientException::class); + + $notifier->notify(new VideoTitle('DDD en PHP'), new VideoUrl('https://codely.com/video')); + } +}