├── .editorconfig ├── .env.example ├── .github └── workflows │ └── run-tests.yml ├── .gitignore ├── .php_cs.dist ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Caddyfile ├── LICENSE.md ├── README.md ├── SECURITY.md ├── app ├── Console │ ├── Commands │ │ └── StorageLinkCommand.php │ └── Kernel.php ├── Events │ └── Event.php ├── Exceptions │ └── Handler.php ├── Http │ ├── Controllers │ │ └── Controller.php │ └── Middleware │ │ └── Authenticate.php ├── Jobs │ └── Job.php ├── Models │ └── .gitkeep └── Providers │ └── AuthServiceProvider.php ├── artisan ├── bootstrap └── app.php ├── composer.json ├── composer.lock ├── config ├── accounts.php ├── auth.php ├── links.php └── mail.php ├── database ├── factories │ └── .gitkeep ├── migrations │ └── .gitkeep └── seeders │ └── DatabaseSeeder.php ├── docker-compose.override-example.yaml ├── docker-compose.yaml ├── domains ├── Accounts │ ├── AccountsServiceProvider.php │ ├── Controllers │ │ ├── AccountsLoginController.php │ │ ├── AccountsLogoutController.php │ │ ├── AccountsProfileController.php │ │ ├── AccountsStoreController.php │ │ └── VerifyEmailController.php │ ├── Database │ │ ├── Factories │ │ │ └── UserFactory.php │ │ └── Migrations │ │ │ ├── 2020_10_04_140743_create_users_table.php │ │ │ └── 2020_10_14_133643_add_account_type_to_users_table.php │ ├── Enums │ │ └── AccountTypeEnum.php │ ├── Middleware │ │ └── RedirectIfAuthenticated.php │ ├── Models │ │ └── User.php │ ├── Notifications │ │ └── VerifyEmailNotification.php │ ├── Resources │ │ ├── UserResource.php │ │ └── Views │ │ │ └── users │ │ │ └── verify-email.blade.php │ ├── Tests │ │ ├── Feature │ │ │ ├── AccountsLoginTest.php │ │ │ ├── AccountsLogoutTest.php │ │ │ ├── AccountsProfileTest.php │ │ │ ├── EmailVerificationTest.php │ │ │ └── UserCreateTest.php │ │ └── Unit │ │ │ ├── HasRolesTraitTest.php │ │ │ └── UserModelTest.php │ ├── Traits │ │ └── HasRoles.php │ └── routes.php ├── Discussions │ ├── Controllers │ │ ├── AnswersIndexController.php │ │ ├── AnswersStoreController.php │ │ ├── AnswersUpdateController.php │ │ ├── QuestionsDeleteController.php │ │ ├── QuestionsIndexController.php │ │ ├── QuestionsStoreController.php │ │ ├── QuestionsUpdateController.php │ │ └── QuestionsViewController.php │ ├── Database │ │ ├── Factories │ │ │ ├── AnswerFactory.php │ │ │ └── QuestionFactory.php │ │ └── Migrations │ │ │ ├── 2020_10_12_104113_create_questions_table.php │ │ │ └── 2020_10_19_175943_create_question_answers_table.php │ ├── DiscussionsServiceProvider.php │ ├── Models │ │ ├── Answer.php │ │ └── Question.php │ ├── Observers │ │ └── QuestionObserver.php │ ├── Policies │ │ ├── AnswerPolicy.php │ │ └── QuestionPolicy.php │ ├── Resources │ │ ├── AnswerResource.php │ │ └── QuestionResource.php │ ├── Tests │ │ ├── Feature │ │ │ ├── AnswersIndexControllerTest.php │ │ │ ├── AnswersStoreTest.php │ │ │ ├── AnswersUpdateTest.php │ │ │ ├── QuestionsDeleteTest.php │ │ │ ├── QuestionsIndexTest.php │ │ │ ├── QuestionsStoreTest.php │ │ │ ├── QuestionsUpdateTest.php │ │ │ └── QuestionsViewTest.php │ │ └── Unit │ │ │ ├── AnswerModelTest.php │ │ │ └── QuestionModelTest.php │ └── routes.php ├── Links │ ├── Controllers │ │ ├── LinksIndexController.php │ │ └── LinksStoreController.php │ ├── Database │ │ ├── Factories │ │ │ └── LinkFactory.php │ │ ├── Migrations │ │ │ ├── 2020_03_19_201343_add_links_table.php │ │ │ ├── 2020_03_19_202800_add_link_tag_table.php │ │ │ ├── 2020_09_19_173727_add_title_column_to_links_table.php │ │ │ └── 2020_10_10_152640_alter_timestamps_columns_to_links_table.php │ │ └── Seeders │ │ │ └── LinksTableSeeder.php │ ├── Exceptions │ │ └── UnapprovedLinkLimitReachedException.php │ ├── LinksServiceProvider.php │ ├── Models │ │ └── Link.php │ ├── Policies │ │ └── LinkPolicy.php │ ├── Resources │ │ └── LinkResource.php │ ├── Tests │ │ ├── Feature │ │ │ ├── Database │ │ │ │ └── Seeders │ │ │ │ │ └── LinksTableSeederTest.php │ │ │ ├── LinksIndexTest.php │ │ │ ├── LinksStoreLimitTest.php │ │ │ └── LinksStoreTest.php │ │ └── Unit │ │ │ └── LinkModelTest.php │ └── routes.php └── Tags │ ├── Controllers │ └── TagsIndexController.php │ ├── Database │ ├── Factories │ │ └── TagFactory.php │ ├── Migrations │ │ ├── 2020_03_15_093618_add_tags_table.php │ │ └── 2020_10_07_160608_alter_timestamps_columns_to_tags_table.php │ └── Seeders │ │ └── TagsTableSeeder.php │ ├── Models │ └── Tag.php │ ├── Resources │ └── TagResource.php │ ├── TagsServiceProvider.php │ ├── Tests │ ├── Features │ │ ├── Database │ │ │ └── Seeders │ │ │ │ └── TagsTableSeederTest.php │ │ └── TagsIndexTest.php │ └── Unit │ │ └── TagModelTest.php │ └── routes.php ├── phpunit.xml ├── public ├── .htaccess └── index.php ├── resources └── views │ └── .gitkeep ├── routes └── web.php ├── storage ├── app │ └── .gitignore ├── debugbar │ └── .gitignore ├── framework │ ├── .gitignore │ ├── cache │ │ ├── .gitignore │ │ └── data │ │ │ └── .gitignore │ ├── testing │ │ └── .gitignore │ └── views │ │ └── .gitignore └── logs │ └── .gitignore └── tests ├── Feature └── Database │ └── Seeders │ └── DatabaseSeederTest.php └── TestCase.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME="Laravel-Portugal API" 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://api.laravel.pt 6 | APP_TIMEZONE=UTC 7 | 8 | LOG_CHANNEL=stack 9 | LOG_SLACK_WEBHOOK_URL= 10 | 11 | DB_CONNECTION=mysql 12 | DB_HOST=127.0.0.1 13 | DB_PORT=3306 14 | DB_DATABASE=homestead 15 | DB_USERNAME=homestead 16 | DB_PASSWORD=secret 17 | 18 | CACHE_DRIVER=file 19 | QUEUE_CONNECTION=sync 20 | 21 | MAIL_MAILER=smtp 22 | MAIL_HOST=smtp.mailtrap.io 23 | MAIL_PORT=2525 24 | MAIL_USERNAME= 25 | MAIL_PASSWORD= 26 | MAIL_ENCRYPTION=tls 27 | MAIL_FROM_ADDRESS=hello@example.com 28 | MAIL_FROM_NAME="Example app" 29 | 30 | JWT_SECRET= 31 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Run tests" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | testing: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: true 16 | matrix: 17 | php: [7.4] 18 | 19 | name: For PHP ${{ matrix.php }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v1 27 | with: 28 | path: ~/.composer/cache/files 29 | key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: ${{ matrix.php }} 35 | coverage: xdebug 36 | 37 | - name: Install dependencies 38 | run: composer install --no-interaction --no-suggest 39 | 40 | - name: Execute tests 41 | run: vendor/bin/phpunit --testdox --coverage-clover build/logs/clover.xml 42 | 43 | - name: Store test coverage report 44 | run: vendor/bin/php-coveralls -v 45 | if: github.event_name == 'push' 46 | env: 47 | COVERALLS_RUN_LOCALLY: 1 48 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | .php_cs.cache 4 | .phpunit.result.cache 5 | Homestead.json 6 | Homestead.yaml 7 | database/database.sqlite 8 | docker-compose.override.yaml 9 | public/storage 10 | vendor 11 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | setRules([ 5 | '@PSR2' => true, 6 | 'array_syntax' => ['syntax' => 'short'], 7 | 'no_multiline_whitespace_before_semicolons' => true, 8 | 'no_short_echo_tag' => true, 9 | 'no_unused_imports' => true, 10 | 'no_useless_else' => true, 11 | 'ordered_imports' => [ 12 | 'sortAlgorithm' => 'alpha', 13 | ], 14 | 'phpdoc_indent' => true, 15 | 'phpdoc_order' => true, 16 | 'phpdoc_separation' => true, 17 | 'phpdoc_single_line_var_spacing' => true, 18 | 'phpdoc_trim' => true, 19 | 'phpdoc_var_without_name' => true, 20 | 'single_quote' => true, 21 | 'ternary_operator_spaces' => true, 22 | 'trailing_comma_in_multiline_array' => true, 23 | 'trim_array_spaces' => true, 24 | ]) 25 | ->setFinder( 26 | PhpCsFixer\Finder::create() 27 | ->in(__DIR__) 28 | ->exclude([ 29 | 'vendor', 30 | 'storage', 31 | 'node_modules', 32 | ]) 33 | ->notName('README.md') 34 | ->notName('*.xml') 35 | ->notName('*.yml') 36 | ->notName('_ide_helper.php') 37 | ); 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-portugal/api` will be documented in this file 4 | 5 | ## [Unreleased] 6 | 7 | ### Added 8 | 9 | - Require `josepostiga/larabeat` package to support health-check endpoints. 10 | 11 | ### Changed 12 | 13 | - N/A 14 | 15 | ### Deprecated 16 | 17 | - N/A 18 | 19 | ### Removed 20 | 21 | - N/A 22 | 23 | ### Fixed 24 | 25 | - N/A 26 | 27 | ### Security 28 | 29 | - Update dependencies 30 | 31 | ## 2.1.0 - 2021-03-07 32 | 33 | ### Added 34 | 35 | - An authenticated user can delete a question (#30) 36 | - A guest or an authenticated user can see details of a question (#48) 37 | - A guest or an authenticated user can list questions (#26) 38 | - Guest cannot submit Links for existing author_email (#52) 39 | - An authenticated user can update an answer of a given question (#33) 40 | - A guest or an authenticated user can list answers of a question (#32) 41 | - Ported `storage:link` command from Laravel 42 | 43 | ### Changed 44 | 45 | - Switched from response status 202 to 204 on successful accounts' logout operation 46 | 47 | ### Fixed 48 | 49 | - Link cover image should be stored publicly (#58) 50 | 51 | ## 2.0.0 - 2020-10-25 52 | 53 | ### Added 54 | 55 | - First version of the API documentation 56 | - A guest should be able to login and logout (#37) 57 | - Add account types and permissions (#42) 58 | - An authenticated user can post an answer to a question (#31) 59 | - An authenticated user can update a question (#27) 60 | 61 | ### Changed 62 | 63 | - Updated Accounts' domain endpoints structure 64 | - Changed timestamps columns on links and tags tables (#28, #29) 65 | 66 | ## 1.1.0 - 2020-10-08 67 | 68 | ### Added 69 | 70 | - Add account creation (#22) 71 | 72 | ### Fixed 73 | 74 | - Using active_url validation rule breaks tests on Links domain (#35) 75 | 76 | ## 1.0.5 - 2020-10-05 77 | 78 | ### Added 79 | 80 | - Add hard limit of unnapproved submissions per e-mail (#17) 81 | 82 | ### Fixed 83 | 84 | - Verify link is valid URL (#23) 85 | 86 | ## 1.0.4 - 2020-09-19 87 | 88 | ### Added 89 | 90 | - Title to links 91 | 92 | ## 1.0.3 - 2020-09-19 93 | 94 | ### Changed 95 | 96 | - Updated Laravel to v8.x. 97 | 98 | ## 1.0.2 - 2020-06-16 99 | 100 | ### Changed 101 | 102 | - Updated Laravel to v7.15.0. 103 | 104 | ## 1.0.1 - 2020-04-02 105 | 106 | ### Security 107 | 108 | - Bump symfony/http-foundation from 5.0.5 to 5.0.7 due to security fixes 109 | 110 | ## 1.0.0 - 2020-03-28 111 | 112 | ### Added 113 | 114 | - Support to receive link's submissions with tag's relation for grouping purposes 115 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | Contributions are welcome and contributors will be fully credited. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Guidelines 8 | 9 | * Please follow the [PSR-2 Coding Style Guide](http://www.php-fig.org/psr/psr-2/), enforced by [StyleCI](https://styleci.io/). 10 | * Ensure that the current tests pass, and if you've added something new, add the relevant tests. 11 | * Send a coherent commit history, making sure each individual commit in your pull request is meaningful. 12 | * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. 13 | * If you are changing the behavior, or the public api, you may need to update the docs. 14 | * Don't forget to update the [CHANGELOG](CHANGELOG.md), specially if you added new functionality. 15 | * Please remember that we follow [SemVer](http://semver.org/). 16 | 17 | ## Procedure 18 | 19 | Before filing an issue: 20 | 21 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 22 | - Check to make sure your feature suggestion isn't already present within the project. 23 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 24 | - Check the pull requests tab to ensure that the feature isn't already in progress. 25 | 26 | Before submitting a pull request: 27 | 28 | - Check the codebase to ensure that your feature doesn't already exist. 29 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 30 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | http://api.laravel.test, 2 | api.laravel.pt 3 | 4 | root * /var/www/html/public 5 | php_fastcgi app:9000 6 | file_server 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 The Laravel-Portugal Community 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel-Portugal API 2 | 3 | ![Run tests](https://github.com/laravel-portugal/api/workflows/Run%20tests/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/laravel-portugal/api/badge.svg?branch=master)](https://coveralls.io/github/laravel-portugal/api?branch=master) 5 | 6 | ## Installation 7 | 8 | **Requirements** 9 | 10 | - PHP >= 7.4 11 | - MySQL >= 8, MariaDB >= 10 or PostgreSQL >= 11 12 | 13 | **Activation** 14 | 15 | 1. Clone this repository. 16 | 2. Run `composer install` to install all dependencies (add `--no-dev` if you're using this in production). 17 | 3. Run `cp .env.example .env` to create an `.env` file based on the distributed `.env.example` file. 18 | 4. Update the `.env` file with a new `APP_KEY` and the connection details for the database. 19 | 5. Run `php artisan migrate` to create the database schema. 20 | 21 | **Deployment** 22 | 23 | This project is prepared to be run with Docker. There are docker-compose files that try to provide an easy way to deploy this project to your infrastructure. You should create a `docker-compose.override.yml` (probably based on the example file provided), and change it to your specific needs. 24 | 25 | ## Documentation 26 | 27 | This service API is organized around REST and has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs. 28 | 29 | ### Authentication 30 | 31 | Authentication to the API is performed via checking for the presence of a Bearer token on the Authorization header of a request. This token is an encoded JWT that's generated by exchanging the username and the password, of a previously registered user account, on the `/login` endpoint. 32 | 33 | ### Include relationship data 34 | 35 | Many objects allow you to request additional relationship information as an expanded response, by using the `include` request parameter. This parameter is available on all API requests that have related data available to be included and applies to the response of that request only. You can, however, include multiple objects at once by identifying multiple items in the `include` query parameter separated by a comma. 36 | 37 | ### Errors 38 | 39 | This API uses conventional HTTP response codes to indicate the success or failure of a request. Codes in the `2xx` range indicate success, codes in the `4xx` range indicate a validation error (e.g., a required parameter was omitted) and codes in the `5xx` range indicate an error with the service internal programming (these should be very rare). Check the table below for a list of possible status codes and their meaning: 40 | 41 | Code | Status | Description 42 | ---- | ------ | ----------- 43 | 200 | OK | Everything worked as expected. 44 | 401 | Unauthorized | No valid authorization header value provided. 45 | 403 | Forbidden | The supplied authorization key doesn't have permission to perform the request. 46 | 404 | Not Found | The requested resource doesn't exist. 47 | 422 | Unprocessable Entity | The request was unacceptable, often due to missing a required parameter. 48 | 429 | Too Many Requests | Too many requests hit the API too quickly. 49 | 5xx | Server Errors | Something went wrong on the internal service programming. 50 | 51 | ## Support 52 | 53 | If you have any problems and need assistance, feel free to use the issue tracker to ask for support. However, be patient! Your request might take time to be answered. 54 | 55 | ## Changelog 56 | 57 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 58 | 59 | ## Contributing 60 | 61 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 62 | 63 | ## Testing 64 | 65 | This project is fully tested. We have an [automatic pipeline](https://github.com/laravel-portugal/api/actions) and an [automatic code quality analysis](https://coveralls.io/github/laravel-portugal/api) tool set up to continuously test and assert the quality of all code published in this repository, but you can execute the test suite yourself by running the following command: 66 | 67 | ``` bash 68 | vendor/bin/phpunit 69 | ``` 70 | 71 | _Note: This assumes you've run `composer install` (without the `--no-dev` option)._ 72 | 73 | **We aim to keep the master branch always deployable.** Exceptions may happen, but they should be extremely rare. 74 | 75 | ## Security 76 | 77 | Please see [SECURITY](SECURITY.md) for details. 78 | 79 | ## Credits 80 | 81 | - [José Postiga](https://github.com/josepostiga) 82 | - [All Contributors](../../contributors) 83 | 84 | ## License 85 | 86 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 87 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | --------- | 7 | | [Latest](../../releases) | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you found a security vulnerability don't use the issue tracker. Instead, send your report directly [here](https://t.me/josepostiga). 12 | -------------------------------------------------------------------------------- /app/Console/Commands/StorageLinkCommand.php: -------------------------------------------------------------------------------- 1 | laravel->basePath('public/storage'); 16 | 17 | if (file_exists($publicPath)) { 18 | $this->error('The "public/storage" directory already exists.'); 19 | return; 20 | } 21 | 22 | $this->laravel->make('files')->link( 23 | storage_path('app/public'), 24 | $publicPath 25 | ); 26 | 27 | $this->info('The [public/storage] directory has been linked.'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 26 | } 27 | 28 | /** 29 | * Handle an incoming request. 30 | * 31 | * @param \Illuminate\Http\Request $request 32 | * @param \Closure $next 33 | * @param string|null $guard 34 | * @return mixed 35 | */ 36 | public function handle($request, Closure $next, $guard = null) 37 | { 38 | if ($this->auth->guard($guard)->guest()) { 39 | return response('Unauthorized.', 401); 40 | } 41 | 42 | return $next($request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Jobs/Job.php: -------------------------------------------------------------------------------- 1 | app['auth']->viaRequest('api', function ($request) { 34 | if ($request->input('api_token')) { 35 | return User::where('api_token', $request->input('api_token'))->first(); 36 | } 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make( 32 | 'Illuminate\Contracts\Console\Kernel' 33 | ); 34 | 35 | exit($kernel->handle(new ArgvInput, new ConsoleOutput)); 36 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | bootstrap(); 8 | 9 | date_default_timezone_set(env('APP_TIMEZONE', 'UTC')); 10 | 11 | /* 12 | |-------------------------------------------------------------------------- 13 | | Create The Application 14 | |-------------------------------------------------------------------------- 15 | | 16 | | Here we will load the environment and create the application instance 17 | | that serves as the central piece of this framework. We'll use this 18 | | application as an "IoC" container and router for this framework. 19 | | 20 | */ 21 | 22 | $app = new Laravel\Lumen\Application( 23 | dirname(__DIR__) 24 | ); 25 | 26 | $app->withFacades(); 27 | $app->withEloquent(); 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Register Container Bindings 32 | |-------------------------------------------------------------------------- 33 | | 34 | | Now we will register a few bindings in the service container. We will 35 | | register the exception handler and the console kernel. You may add 36 | | your own bindings here if you like or you can make another file. 37 | | 38 | */ 39 | 40 | $app->singleton( 41 | Illuminate\Contracts\Debug\ExceptionHandler::class, 42 | App\Exceptions\Handler::class 43 | ); 44 | 45 | $app->singleton( 46 | Illuminate\Contracts\Console\Kernel::class, 47 | App\Console\Kernel::class 48 | ); 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Register Config Files 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Now we will register the "app" configuration file. If the file exists in 56 | | your configuration directory it will be loaded; otherwise, we'll load 57 | | the default version. You may register other files below as needed. 58 | | 59 | */ 60 | 61 | $app->configure('app'); 62 | $app->configure('mail'); 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Register Alias 67 | |-------------------------------------------------------------------------- 68 | | 69 | */ 70 | $app->alias('mail.manager', Illuminate\Mail\MailManager::class); 71 | $app->alias('mail.manager', Illuminate\Contracts\Mail\Factory::class); 72 | $app->alias('mailer', Illuminate\Mail\Mailer::class); 73 | $app->alias('mailer', Illuminate\Contracts\Mail\Mailer::class); 74 | $app->alias('mailer', Illuminate\Contracts\Mail\MailQueue::class); 75 | 76 | /* 77 | |-------------------------------------------------------------------------- 78 | | Register Middleware 79 | |-------------------------------------------------------------------------- 80 | | 81 | | Next, we will register the middleware with the application. These can 82 | | be global middleware that run before and after each request into a 83 | | route or middleware that'll be assigned to some specific routes. 84 | | 85 | */ 86 | 87 | $app->routeMiddleware([ 88 | 'auth' => App\Http\Middleware\Authenticate::class, 89 | ]); 90 | 91 | /* 92 | |-------------------------------------------------------------------------- 93 | | Register Service Providers 94 | |-------------------------------------------------------------------------- 95 | | 96 | | Here we will register all of the application's service providers which 97 | | are used to bind services into the container. Service providers are 98 | | totally optional, so you are not required to uncomment this line. 99 | | 100 | */ 101 | 102 | $app->register(\App\Providers\AuthServiceProvider::class); 103 | $app->register(\Domains\Accounts\AccountsServiceProvider::class); 104 | $app->register(\Domains\Discussions\DiscussionsServiceProvider::class); 105 | $app->register(\Domains\Links\LinksServiceProvider::class); 106 | $app->register(\Domains\Tags\TagsServiceProvider::class); 107 | $app->register(\GrahamCampbell\Throttle\ThrottleServiceProvider::class); 108 | $app->register(\Illuminate\Mail\MailServiceProvider::class); 109 | $app->register(\Illuminate\Notifications\NotificationServiceProvider::class); 110 | $app->register(\JosePostiga\Larabeat\LarabeatServiceProvider::class); 111 | $app->register(\Tymon\JWTAuth\Providers\LumenServiceProvider::class); 112 | 113 | /* 114 | |-------------------------------------------------------------------------- 115 | | Load The Application Routes 116 | |-------------------------------------------------------------------------- 117 | | 118 | | Next we will include the routes file so that they can all be added to 119 | | the application. This will provide all of the URLs the application 120 | | can respond to, as well as the controllers that may handle them. 121 | | 122 | */ 123 | 124 | $app->router->group([ 125 | 'namespace' => 'App\Http\Controllers', 126 | ], function ($router) { 127 | require __DIR__ . '/../routes/web.php'; 128 | }); 129 | 130 | return $app; 131 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/lumen", 3 | "description": "The Laravel Lumen Framework.", 4 | "keywords": ["framework", "laravel", "lumen"], 5 | "license": "MIT", 6 | "type": "project", 7 | "require": { 8 | "php": "^7.4", 9 | "ext-json": "*", 10 | "doctrine/dbal": "^2.11", 11 | "graham-campbell/throttle": "8.1", 12 | "illuminate/mail": "^8.8", 13 | "illuminate/notifications": "^8.8", 14 | "josepostiga/larabeat": "^1.0", 15 | "laravel/lumen-framework": "^8.0", 16 | "league/flysystem": "^1.1", 17 | "tymon/jwt-auth": "^1.0" 18 | }, 19 | "require-dev": { 20 | "roave/security-advisories": "dev-master", 21 | "fzaninotto/faker": "^1.9.1", 22 | "mockery/mockery": "^1.3.1", 23 | "php-coveralls/php-coveralls": "^2.2", 24 | "phpunit/phpunit": "^9.3" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "App\\": "app/", 29 | "Database\\Factories\\": "database/factories/", 30 | "Database\\Seeders\\": "database/seeders/", 31 | "Domains\\":"domains/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Tests\\": "tests/" 37 | } 38 | }, 39 | "config": { 40 | "preferred-install": "dist", 41 | "sort-packages": true, 42 | "optimize-autoloader": true 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true, 46 | "scripts": { 47 | "post-root-package-install": [ 48 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/accounts.php: -------------------------------------------------------------------------------- 1 | env('ACCOUNTS_LOGIN_URL', 'https://laravel.pt') 5 | ]; 6 | -------------------------------------------------------------------------------- /config/auth.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'guard' => 'api', 6 | 'passwords' => 'users', 7 | ], 8 | 9 | 'guards' => [ 10 | 'api' => [ 11 | 'driver' => 'jwt', 12 | 'provider' => 'users', 13 | ], 14 | ], 15 | 16 | 'providers' => [ 17 | 'users' => [ 18 | 'driver' => 'eloquent', 19 | 'model' => \Domains\Accounts\Models\User::class, 20 | ], 21 | ], 22 | ]; 23 | -------------------------------------------------------------------------------- /config/links.php: -------------------------------------------------------------------------------- 1 | env('MAX_UNAPPROVED_LINKS', 5) 5 | ]; 6 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | env('MAIL_DRIVER', 'smtp'), 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | SMTP Host Address 25 | |-------------------------------------------------------------------------- 26 | | 27 | | Here you may provide the host address of the SMTP server used by your 28 | | applications. A default option is provided that is compatible with 29 | | the Mailgun mail service which will provide reliable deliveries. 30 | | 31 | */ 32 | 33 | 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | SMTP Host Port 38 | |-------------------------------------------------------------------------- 39 | | 40 | | This is the SMTP port used by your application to deliver e-mails to 41 | | users of the application. Like the host we have set this value to 42 | | stay compatible with the Mailgun e-mail application by default. 43 | | 44 | */ 45 | 46 | 'port' => env('MAIL_PORT', 587), 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Global "From" Address 51 | |-------------------------------------------------------------------------- 52 | | 53 | | You may wish for all e-mails sent by your application to be sent from 54 | | the same address. Here, you may specify a name and address that is 55 | | used globally for all e-mails that are sent by your application. 56 | | 57 | */ 58 | 59 | 'from' => [ 60 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 61 | 'name' => env('MAIL_FROM_NAME', 'Example'), 62 | ], 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | E-Mail Encryption Protocol 67 | |-------------------------------------------------------------------------- 68 | | 69 | | Here you may specify the encryption protocol that should be used when 70 | | the application send e-mail messages. A sensible default using the 71 | | transport layer security protocol should provide great security. 72 | | 73 | */ 74 | 75 | 'encryption' => env('MAIL_ENCRYPTION', 'tls'), 76 | 77 | /* 78 | |-------------------------------------------------------------------------- 79 | | SMTP Server Username 80 | |-------------------------------------------------------------------------- 81 | | 82 | | If your SMTP server requires a username for authentication, you should 83 | | set it here. This will get used to authenticate with your server on 84 | | connection. You may also set the "password" value below this one. 85 | | 86 | */ 87 | 88 | 'username' => env('MAIL_USERNAME'), 89 | 90 | 'password' => env('MAIL_PASSWORD'), 91 | 92 | /* 93 | |-------------------------------------------------------------------------- 94 | | Sendmail System Path 95 | |-------------------------------------------------------------------------- 96 | | 97 | | When using the "sendmail" driver to send e-mails, we will need to know 98 | | the path to where Sendmail lives on this server. A default path has 99 | | been provided here, which will work well on most of your systems. 100 | | 101 | */ 102 | 103 | 'sendmail' => '/usr/sbin/sendmail -bs', 104 | 105 | /* 106 | |-------------------------------------------------------------------------- 107 | | Markdown Mail Settings 108 | |-------------------------------------------------------------------------- 109 | | 110 | | If you are using Markdown based email rendering, you may configure your 111 | | theme and component paths here, allowing you to customize the design 112 | | of the emails. Or, you may simply stick with the Laravel defaults! 113 | | 114 | */ 115 | 116 | 'markdown' => [ 117 | 'theme' => 'default', 118 | 119 | 'paths' => [ 120 | resource_path('views/vendor/mail'), 121 | ], 122 | ], 123 | 124 | /* 125 | |-------------------------------------------------------------------------- 126 | | Log Channel 127 | |-------------------------------------------------------------------------- 128 | | 129 | | If you are using the "log" driver, you may specify the logging channel 130 | | if you prefer to keep mail messages separate from other log entries 131 | | for simpler reading. Otherwise, the default channel will be used. 132 | | 133 | */ 134 | 135 | 'log_channel' => env('MAIL_LOG_CHANNEL'), 136 | 137 | ]; 138 | -------------------------------------------------------------------------------- /database/factories/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-portugal/api/32985e132d8350ea3b25733b0554558d595eee76/database/factories/.gitkeep -------------------------------------------------------------------------------- /database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-portugal/api/32985e132d8350ea3b25733b0554558d595eee76/database/migrations/.gitkeep -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | container['config']->get('app.env') === 'production') { 14 | throw new Exception('This is not allowed when in production environment.'); 15 | } 16 | 17 | $this->call(LinksTableSeeder::class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.override-example.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | server: 5 | labels: 6 | - "traefik.enable=true" 7 | - "traefik.http.routers.laravel-portugal.entrypoints=https" 8 | - "traefik.http.routers.laravel-portugal.rule=Host(`api.laravel.pt`)" 9 | - "traefik.http.services.laravel-portugal.loadbalancer.healthcheck.path=/health-check" 10 | - "traefik.http.services.laravel-portugal.loadbalancer.healthcheck.interval=60s" 11 | networks: 12 | - web 13 | 14 | app: 15 | image: docker.pkg.github.com/laravel-portugal/infrastructure/php:7.4-fpm-dev 16 | networks: 17 | - web 18 | 19 | db: 20 | ports: 21 | - 5432:5432 22 | 23 | networks: 24 | web: 25 | external: true 26 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | server: 5 | image: caddy 6 | expose: 7 | - 80 8 | volumes: 9 | - ./Caddyfile:/etc/caddy/Caddyfile 10 | - .:/var/www/html 11 | - caddy_data:/data 12 | - caddy_config:/config 13 | restart: unless-stopped 14 | networks: 15 | - net 16 | 17 | app: 18 | image: docker.pkg.github.com/laravel-portugal/infrastructure/php:7.4-fpm 19 | user: "1000:1000" 20 | expose: 21 | - 9000 22 | volumes: 23 | - .:/var/www/html 24 | restart: unless-stopped 25 | networks: 26 | - net 27 | - data 28 | 29 | db: 30 | image: postgres:13 31 | expose: 32 | - 5432 33 | volumes: 34 | - postgres_data:/var/lib/postgresql/data 35 | environment: 36 | POSTGRES_PASSWORD: root 37 | POSTGRES_USER: root 38 | POSTGRES_DB: laravel_portugal_db 39 | restart: unless-stopped 40 | networks: 41 | - data 42 | 43 | networks: 44 | net: 45 | data: 46 | 47 | volumes: 48 | caddy_data: 49 | caddy_config: 50 | postgres_data: 51 | -------------------------------------------------------------------------------- /domains/Accounts/AccountsServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/Database/Migrations'); 15 | $this->loadViewsFrom(__DIR__ . '/Resources/Views', 'accounts'); 16 | $this->loadConfig(); 17 | $this->bootRoutes(); 18 | $this->routeMiddleware(); 19 | } 20 | 21 | private function bootRoutes(): void 22 | { 23 | Route::group( 24 | [ 25 | 'prefix' => 'accounts', 26 | 'as' => 'accounts', 27 | ], 28 | fn () => $this->loadRoutesFrom(__DIR__ . '/routes.php') 29 | ); 30 | } 31 | 32 | private function loadConfig(): void 33 | { 34 | $this->app->configure('accounts'); 35 | $this->app->configure('auth'); 36 | } 37 | 38 | private function routeMiddleware(): void 39 | { 40 | $this->app->routeMiddleware( 41 | [ 42 | 'guest' => RedirectIfAuthenticated::class, 43 | 'throttle' => ThrottleMiddleware::class, 44 | ] 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /domains/Accounts/Controllers/AccountsLoginController.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 17 | } 18 | 19 | public function __invoke(Request $request): Response 20 | { 21 | $credentials = $this->validate($request, [ 22 | 'email' => ['required', 'email'], 23 | 'password' => ['required', 'string'], 24 | ]); 25 | 26 | if (!$token = $this->auth->attempt($credentials)) { 27 | return new Response([ 28 | 'message' => 'Credentials are incorrect or user doesn\'t exist', 29 | ], Response::HTTP_UNPROCESSABLE_ENTITY); 30 | } 31 | 32 | return new Response([ 33 | 'access_token' => $token, 34 | 'token_type' => 'bearer', 35 | 'expires_in' => $this->auth->factory()->getTTL() * 60, 36 | ], Response::HTTP_OK); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /domains/Accounts/Controllers/AccountsLogoutController.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 16 | } 17 | 18 | public function __invoke(): Response 19 | { 20 | $this->auth->logout(); 21 | 22 | return new Response('', Response::HTTP_NO_CONTENT); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /domains/Accounts/Controllers/AccountsProfileController.php: -------------------------------------------------------------------------------- 1 | user()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /domains/Accounts/Controllers/AccountsStoreController.php: -------------------------------------------------------------------------------- 1 | user = $user; 19 | } 20 | 21 | public function __invoke(Request $request): Response 22 | { 23 | $this->validate($request, [ 24 | 'name' => ['required', 'string'], 25 | 'email' => ['required', 'email', 'unique:users'], 26 | 'password' => ['required', 'string'], 27 | ]); 28 | 29 | $this->user->forceFill([ 30 | 'name' => $request->input('name'), 31 | 'email' => $request->input('email'), 32 | 'password' => Hash::make($request->input('password')), 33 | ])->save(); 34 | 35 | $this->user->notify(new VerifyEmailNotification()); 36 | 37 | return new Response('', Response::HTTP_NO_CONTENT); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /domains/Accounts/Controllers/VerifyEmailController.php: -------------------------------------------------------------------------------- 1 | user = User::findOrFail($request->route('id')); 19 | $hash = \base64_decode($request->route('hash')); 20 | 21 | if (!$this->user || !$this->check($hash)) { 22 | abort(Response::HTTP_FORBIDDEN); 23 | } 24 | 25 | if ($this->user->hasVerifiedEmail()) { 26 | return view('accounts::users.verify-email') 27 | ->with('alreadyValidated', true); 28 | } 29 | 30 | $this->user->markEmailAsVerified(); 31 | 32 | return view('accounts::users.verify-email') 33 | ->with('alreadyValidated', false); 34 | } 35 | 36 | private function check(string $hash): bool 37 | { 38 | return Crypt::decrypt($hash) === $this->user->email; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /domains/Accounts/Database/Factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | AccountTypeEnum::USER, 19 | 'name' => $this->faker->name, 20 | 'email' => $this->faker->safeEmail, 21 | 'password' => Hash::make($this->faker->password(8)), 22 | 'email_verified_at' => Carbon::now(), 23 | 'trusted' => false, 24 | 'created_at' => Carbon::now(), 25 | 'updated_at' => Carbon::now(), 26 | ]; 27 | } 28 | 29 | public function unverified(): self 30 | { 31 | return $this->state([ 32 | 'email_verified_at' => null, 33 | ]); 34 | } 35 | 36 | public function editor(): self 37 | { 38 | return $this->state([ 39 | 'account_type' => AccountTypeEnum::EDITOR, 40 | ]); 41 | } 42 | 43 | public function admin(): self 44 | { 45 | return $this->state([ 46 | 'account_type' => AccountTypeEnum::ADMIN, 47 | ]); 48 | } 49 | 50 | public function deleted(): self 51 | { 52 | return $this->state([ 53 | 'deleted_at' => Carbon::now(), 54 | ]); 55 | } 56 | 57 | public function trusted(): self 58 | { 59 | return $this->state([ 60 | 'trusted' => true, 61 | ]); 62 | } 63 | 64 | public function withRole(string $role): self 65 | { 66 | return $this->state([ 67 | 'account_type' => $role, 68 | ]); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /domains/Accounts/Database/Migrations/2020_10_04_140743_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name')->index(); 14 | $table->string('email')->unique(); 15 | $table->string('password'); 16 | $table->boolean('trusted')->default(false); 17 | $table->timestampTz('email_verified_at')->nullable(); 18 | 19 | $table->timestampsTz(); 20 | $table->softDeletesTz(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domains/Accounts/Database/Migrations/2020_10_14_133643_add_account_type_to_users_table.php: -------------------------------------------------------------------------------- 1 | enum('account_type', ['user', 'editor', 'admin'])->default('user')->after('id'); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /domains/Accounts/Enums/AccountTypeEnum.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 15 | } 16 | 17 | public function handle($request, Closure $next, $guard = null) 18 | { 19 | if (!$this->auth->guard($guard)->guest()) { 20 | return response('Unauthorized.', 401); 21 | } 22 | 23 | return $next($request); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /domains/Accounts/Models/User.php: -------------------------------------------------------------------------------- 1 | 'date', 31 | ]; 32 | 33 | public function isTrusted(): bool 34 | { 35 | return $this->trusted; 36 | } 37 | 38 | public function getJWTIdentifier(): int 39 | { 40 | return $this->getKey(); 41 | } 42 | 43 | public function getJWTCustomClaims(): array 44 | { 45 | return []; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /domains/Accounts/Notifications/VerifyEmailNotification.php: -------------------------------------------------------------------------------- 1 | getEmailForVerification())); 38 | $link = URL::route( 39 | 'accounts.users.verify', 40 | [ 41 | 'id' => $notifiable->getKey(), 42 | 'hash' => $hash, 43 | ] 44 | ); 45 | 46 | return (new MailMessage) 47 | ->subject(__('Verify Email Address')) 48 | ->line(__('Please click the button below to verify your email address.')) 49 | ->action(__('Verify Email Address'), $link) 50 | ->line(__('If you did not create an account, no further action is required.')); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /domains/Accounts/Resources/UserResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | 'name' => $this->name, 14 | 'email' => $this->email, 15 | 'trusted' => $this->trusted, 16 | 'created_at' => $this->created_at, 17 | 'updated_at' => $this->updated_at, 18 | 'deleted_at' => $this->deleted_at, 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /domains/Accounts/Resources/Views/users/verify-email.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @if($alreadyValidated ?? false) 3 |

{{ __('Your email was already verified, but thanks anyway for re-confirming it.') }}

4 | @else 5 |

{{ __('Thank you! Your email is now verified!') }}

6 | @endif 7 |

You may login at Laravel Portugal.

8 |
9 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Feature/AccountsLoginTest.php: -------------------------------------------------------------------------------- 1 | user = UserFactory::new(['password' => Hash::make('greatpassword')])->create(); 25 | $this->faker = Factory::create(); 26 | } 27 | 28 | /** @test */ 29 | public function it_fails_to_login_on_validation_errors(): void 30 | { 31 | $this->post(route('accounts.login'), [ 32 | 'email' => $this->faker->safeEmail, 33 | ]) 34 | ->assertResponseStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 35 | } 36 | 37 | /** @test */ 38 | public function guest_fail_login_with_not_exist_user(): void 39 | { 40 | $this->post(route('accounts.login'), [ 41 | 'email' => $this->faker->safeEmail, 42 | 'password' => $this->faker->password, 43 | ]) 44 | ->assertResponseStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 45 | } 46 | 47 | /** @test */ 48 | public function guest_fail_login_with_wrong_credential(): void 49 | { 50 | $this->post(route('accounts.login'), [ 51 | 'email' => $this->user->email, 52 | 'password' => $this->faker->password, 53 | ]) 54 | ->assertResponseStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 55 | } 56 | 57 | /** @test */ 58 | public function guest_blocked_for_many_attempts(): void 59 | { 60 | for ($attempt = 0; $attempt < 10; ++$attempt) { 61 | $this->post(route('accounts.login'), [ 62 | 'email' => $this->user->email, 63 | 'password' => $this->faker->password, 64 | ]); 65 | } 66 | 67 | $this->post(route('accounts.login'), [ 68 | 'email' => $this->user->email, 69 | 'password' => $this->faker->password, 70 | ]) 71 | ->assertResponseStatus(Response::HTTP_TOO_MANY_REQUESTS); 72 | } 73 | 74 | /** @test */ 75 | public function guest_can_make_login_with_correct_credential(): void 76 | { 77 | $this->post(route('accounts.login'), [ 78 | 'email' => $this->user->email, 79 | 'password' => 'greatpassword', 80 | ]) 81 | ->seeJsonStructure([ 82 | 'access_token', 83 | 'token_type', 84 | 'expires_in', 85 | ]) 86 | ->assertResponseStatus(Response::HTTP_OK); 87 | } 88 | 89 | /** @test */ 90 | public function authenticated_user_cannot_make_another_login(): void 91 | { 92 | $this->actingAs($this->user) 93 | ->post(route('accounts.login')) 94 | ->assertResponseStatus(Response::HTTP_UNAUTHORIZED); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Feature/AccountsLogoutTest.php: -------------------------------------------------------------------------------- 1 | user = UserFactory::new()->create(); 21 | } 22 | 23 | /** @test */ 24 | public function it_fails_to_logout_on_wrong_token(): void 25 | { 26 | $this->post(route('accounts.logout'), [], ['Authorization' => 'Bearer ']) 27 | ->assertResponseStatus(Response::HTTP_UNAUTHORIZED); 28 | } 29 | 30 | /** @test */ 31 | public function authenticated_user_can_make_logout(): void 32 | { 33 | $token = auth()->login($this->user); 34 | 35 | self::assertTrue(auth()->check()); 36 | 37 | $this->post(route('accounts.logout'), [], ['Authorization' => "Bearer {$token}"]) 38 | ->assertResponseStatus(Response::HTTP_NO_CONTENT); 39 | 40 | self::assertTrue(auth()->guest()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Feature/AccountsProfileTest.php: -------------------------------------------------------------------------------- 1 | user = UserFactory::new()->create(); 21 | } 22 | 23 | /** @test */ 24 | public function guest_cannot_see_profile(): void 25 | { 26 | $this->get(route('accounts.me')) 27 | ->assertResponseStatus(Response::HTTP_UNAUTHORIZED); 28 | } 29 | 30 | /** @test */ 31 | public function authenticated_user_can_see_profile(): void 32 | { 33 | $this->actingAs($this->user) 34 | ->get(route('accounts.me')) 35 | ->seeJson([ 36 | 'id' => $this->user->id, 37 | 'name' => $this->user->name, 38 | 'email' => $this->user->email, 39 | 'trusted' => $this->user->trusted, 40 | 'created_at' => $this->user->created_at, 41 | 'updated_at' => $this->user->updated_at, 42 | 'deleted_at' => $this->user->deleted_at, 43 | ]) 44 | ->assertResponseStatus(Response::HTTP_CREATED); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Feature/EmailVerificationTest.php: -------------------------------------------------------------------------------- 1 | user = UserFactory::new()->unverified()->create(); 28 | $this->faker = Factory::create(); 29 | } 30 | 31 | /** @test */ 32 | public function it_fails_to_validate_a_users_email_on_link_hash_mismatch(): void 33 | { 34 | $this->get(route( 35 | 'accounts.users.verify', 36 | $this->user->id, 37 | base64_encode(Hash::make($this->faker->safeEmail)) 38 | ))->assertResponseStatus(Response::HTTP_NOT_FOUND); 39 | 40 | $this->seeInDatabase('users', [ 41 | 'id' => $this->user->id, 42 | 'email_verified_at' => null, 43 | ]); 44 | } 45 | 46 | /** @test */ 47 | public function it_validates_a_users_email_with_correct_link_hash(): void 48 | { 49 | $response = $this->get(route('accounts.users.verify', [ 50 | 'id' => $this->user->id, 51 | 'hash' => \base64_encode(Crypt::encrypt($this->user->email)), 52 | ])); 53 | 54 | $response->assertResponseStatus(Response::HTTP_OK); 55 | $response->response->assertViewIs('accounts::users.verify-email'); 56 | 57 | self::assertInstanceOf(Carbon::class, $this->user->refresh()->email_verified_at); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Feature/UserCreateTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 25 | Notification::fake(); 26 | } 27 | 28 | /** @test */ 29 | public function it_fails_to_create_a_user_on_validation_errors(): void 30 | { 31 | $this->post(route('accounts.users.store'), []) 32 | ->seeJsonStructure([ 33 | 'name', 34 | 'email', 35 | 'password', 36 | ]) 37 | ->assertResponseStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 38 | 39 | Notification::assertNothingSent(); 40 | } 41 | 42 | /** @test */ 43 | public function it_creates_a_user_with_pending_email_verification(): void 44 | { 45 | $payload = [ 46 | 'name' => $this->faker->name, 47 | 'email' => $this->faker->safeEmail, 48 | 'password' => $this->faker->password, 49 | ]; 50 | 51 | $response = $this->post(route('accounts.users.store'), $payload); 52 | 53 | $response->assertResponseStatus(Response::HTTP_NO_CONTENT); 54 | self::assertTrue($response->response->isEmpty()); 55 | 56 | $this->seeInDatabase('users', [ 57 | 'name' => $payload['name'], 58 | 'email' => $payload['email'], 59 | 'email_verified_at' => null, 60 | ]); 61 | 62 | Notification::assertSentTo(User::firstOrFail(), VerifyEmailNotification::class); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Unit/HasRolesTraitTest.php: -------------------------------------------------------------------------------- 1 | model = UserFactory::new()->unverified()->make(); 19 | } 20 | 21 | /** @test */ 22 | public function it_has_user_role(): void 23 | { 24 | self::assertTrue($this->model->isOfRole(AccountTypeEnum::USER)); 25 | self::assertTrue($this->model->hasRole(AccountTypeEnum::USER)); 26 | 27 | self::assertFalse($this->model->isOfRole(AccountTypeEnum::EDITOR)); 28 | self::assertFalse($this->model->isOfRole(AccountTypeEnum::ADMIN)); 29 | self::assertFalse($this->model->hasRole(AccountTypeEnum::EDITOR)); 30 | self::assertFalse($this->model->hasRole(AccountTypeEnum::ADMIN)); 31 | } 32 | 33 | /** @test */ 34 | public function it_has_editor_role(): void 35 | { 36 | $this->model = UserFactory::new()->unverified()->editor()->make(); 37 | 38 | self::assertTrue($this->model->isOfRole(AccountTypeEnum::EDITOR)); 39 | self::assertTrue($this->model->hasRole(AccountTypeEnum::EDITOR)); 40 | self::assertTrue($this->model->hasRole(AccountTypeEnum::USER)); 41 | 42 | self::assertFalse($this->model->isOfRole(AccountTypeEnum::USER)); 43 | self::assertFalse($this->model->isOfRole(AccountTypeEnum::ADMIN)); 44 | self::assertFalse($this->model->hasRole(AccountTypeEnum::ADMIN)); 45 | } 46 | 47 | /** @test */ 48 | public function it_has_admin_role(): void 49 | { 50 | $this->model = UserFactory::new()->unverified()->admin()->make(); 51 | 52 | self::assertTrue($this->model->isOfRole(AccountTypeEnum::ADMIN)); 53 | self::assertTrue($this->model->hasRole(AccountTypeEnum::ADMIN)); 54 | self::assertTrue($this->model->hasRole(AccountTypeEnum::EDITOR)); 55 | self::assertTrue($this->model->hasRole(AccountTypeEnum::USER)); 56 | 57 | self::assertFalse($this->model->isOfRole(AccountTypeEnum::EDITOR)); 58 | self::assertFalse($this->model->isOfRole(AccountTypeEnum::USER)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /domains/Accounts/Tests/Unit/UserModelTest.php: -------------------------------------------------------------------------------- 1 | model = UserFactory::new()->unverified()->make(); 21 | } 22 | 23 | /** @test */ 24 | public function it_contains_required_properties(): void 25 | { 26 | self::assertIsString($this->model->name); 27 | self::assertIsString($this->model->email); 28 | self::assertIsString($this->model->password); 29 | self::assertNull($this->model->email_verified_at); 30 | self::assertFalse($this->model->trusted); 31 | 32 | self::assertInstanceOf(Carbon::class, $this->model->created_at); 33 | self::assertInstanceOf(Carbon::class, $this->model->updated_at); 34 | self::assertNull($this->model->deleted_at); 35 | } 36 | 37 | /** @test */ 38 | public function it_uses_correct_table_name(): void 39 | { 40 | self::assertEquals('users', $this->model->getTable()); 41 | } 42 | 43 | /** @test */ 44 | public function it_uses_correct_primary_key(): void 45 | { 46 | self::assertEquals('id', $this->model->getKeyName()); 47 | } 48 | 49 | /** @test */ 50 | public function it_uses_soft_deletes(): void 51 | { 52 | self::assertArrayHasKey(SoftDeletingScope::class, $this->model->getGlobalScopes()); 53 | } 54 | 55 | /** @test */ 56 | public function it_uses_timestamps(): void 57 | { 58 | self::assertTrue($this->model->usesTimestamps()); 59 | } 60 | 61 | /** @test */ 62 | public function it_has_a_user_account_type(): void 63 | { 64 | self::assertEquals(AccountTypeEnum::USER, $this->model->account_type); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /domains/Accounts/Traits/HasRoles.php: -------------------------------------------------------------------------------- 1 | account_type === $role; 12 | } 13 | 14 | public function hasRole(string $role): bool 15 | { 16 | $roles = []; 17 | switch ($this->account_type) { 18 | case AccountTypeEnum::ADMIN: 19 | $roles[] = AccountTypeEnum::ADMIN; 20 | case AccountTypeEnum::EDITOR: 21 | $roles[] = AccountTypeEnum::EDITOR; 22 | case AccountTypeEnum::USER: 23 | $roles[] = AccountTypeEnum::USER; 24 | } 25 | 26 | return in_array($role, $roles); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /domains/Accounts/routes.php: -------------------------------------------------------------------------------- 1 | 'users.store', 13 | 'uses' => AccountsStoreController::class, 14 | ]); 15 | 16 | Route::get('/users/verify/{id}/{hash}', [ 17 | 'as' => 'users.verify', 18 | 'uses' => VerifyEmailController::class, 19 | ]); 20 | 21 | Route::post('/login', [ 22 | 'as' => 'login', 23 | 'uses' => AccountsLoginController::class, 24 | 'middleware' => 'throttle:10,1|guest' 25 | ]); 26 | 27 | Route::post('/logout', [ 28 | 'as' => 'logout', 29 | 'uses' => AccountsLogoutController::class, 30 | 'middleware' => 'auth:api' 31 | ]); 32 | 33 | Route::get('/me', [ 34 | 'as' => 'me', 35 | 'uses' => AccountsProfileController::class, 36 | 'middleware' => 'auth:api' 37 | ]); 38 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/AnswersIndexController.php: -------------------------------------------------------------------------------- 1 | guest()) { 20 | $this->middleware('throttle:30,1'); 21 | } 22 | 23 | $this->question = $question; 24 | } 25 | 26 | public function __invoke(Request $request, int $questionId): AnonymousResourceCollection 27 | { 28 | $this->validate($request, [ 29 | 'author' => ['sometimes', 'integer', 'exists:users,id'], 30 | 'created' => ['sometimes', 'array', 'size:2'], 31 | 'created.from' => ['required_with:created', 'date'], 32 | 'created.to' => ['required_with:created', 'date', 'afterOrEqual:created.from'] 33 | ]); 34 | 35 | $answers = $this->question 36 | ->findOrFail($questionId) 37 | ->answers() 38 | ->when($authorId = $request->input('author'), 39 | static fn(Builder $answers) => $answers->whereAuthorId($authorId)) 40 | ->when($created = $request->input('created'), 41 | static fn(Builder $answers) => $answers->whereBetween('created_at', [$created['from'], $created['to']])) 42 | ->latest() 43 | ->simplePaginate(15); 44 | 45 | return AnswerResource::collection($answers); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/AnswersStoreController.php: -------------------------------------------------------------------------------- 1 | answer = $answer; 19 | $this->question = $question; 20 | } 21 | 22 | public function __invoke(Request $request, int $questionId): Response 23 | { 24 | $this->validate($request, [ 25 | 'content' => ['required', 'string'], 26 | ]); 27 | 28 | $question = $this->question->findOrFail($questionId); 29 | 30 | $this->answer 31 | ->forceFill([ 32 | 'author_id' => $request->user()->id, 33 | 'question_id' => $question->id, 34 | 'content' => $request->input('content'), 35 | ]) 36 | ->save(); 37 | 38 | return new Response('', Response::HTTP_NO_CONTENT); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/AnswersUpdateController.php: -------------------------------------------------------------------------------- 1 | question = $question; 19 | $this->answer = $answer; 20 | } 21 | 22 | public function __invoke(Request $request, int $questionId, int $answerId): Response 23 | { 24 | $answer = $this->answer->ofQuestion($questionId)->findOrFail($answerId); 25 | 26 | $this->authorize('update', $answer); 27 | 28 | $this->validate($request, [ 29 | 'content' => ['required', 'string'], 30 | ]); 31 | 32 | $answer->update([ 33 | 'content' => $request->input('content'), 34 | ]); 35 | 36 | return new Response('', Response::HTTP_NO_CONTENT); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/QuestionsDeleteController.php: -------------------------------------------------------------------------------- 1 | questions = $questions; 17 | } 18 | 19 | public function __invoke(Request $request, int $questionId): Response 20 | { 21 | $question = $this->questions->findOrFail($questionId); 22 | 23 | $this->authorize('delete', $question); 24 | 25 | $question->delete(); 26 | 27 | return new Response('', Response::HTTP_NO_CONTENT); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/QuestionsIndexController.php: -------------------------------------------------------------------------------- 1 | guard()->guest()) { 20 | $this->middleware('throttle:30,1'); 21 | } 22 | 23 | $this->question = $question; 24 | } 25 | 26 | public function __invoke(Request $request): AnonymousResourceCollection 27 | { 28 | $this->validate($request, [ 29 | 'author' => ['sometimes', 'integer'], 30 | 'title' => ['sometimes', 'string'], 31 | 'created' => ['sometimes', 'array', 'size:2'], 32 | 'created.from' => ['required_with:created', 'date'], 33 | 'created.to' => ['required_with:created', 'date', 'afterOrEqual:created.from'], 34 | 'resolved' => ['sometimes', 'boolean'], 35 | ]); 36 | 37 | $question = $this->question 38 | ->when($request->input('author'), 39 | fn(Builder $query, int $authorId) => $query->findByAuthorId($authorId)) 40 | ->when($request->input('title'), 41 | fn(Builder $query, string $title) => $query->findByTitle($title)) 42 | ->when($request->input('created'), 43 | fn(Builder $query, array $created) => $query->findByCreatedDate([$created['from'], $created['to']])) 44 | ->when($request->boolean('resolved'), 45 | fn(Builder $query) => $query->resolved()) 46 | ->when(!$request->boolean('resolved') && $request->input('resolved') != null, 47 | fn(Builder $query) => $query->nonResolved()) 48 | ->latest() 49 | ->simplePaginate(15); 50 | 51 | return QuestionResource::collection($question); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/QuestionsStoreController.php: -------------------------------------------------------------------------------- 1 | question = $question; 17 | } 18 | 19 | public function __invoke(Request $request): Response 20 | { 21 | $this->validate($request, [ 22 | 'title' => ['required', 'string', 'max:255'], 23 | 'description' => ['required', 'string'], 24 | ]); 25 | 26 | $this->question 27 | ->forceFill([ 28 | 'author_id' => $request->user()->id, 29 | 'title' => $request->input('title'), 30 | 'description' => $request->input('description'), 31 | ]) 32 | ->save(); 33 | 34 | return new Response('', Response::HTTP_NO_CONTENT); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/QuestionsUpdateController.php: -------------------------------------------------------------------------------- 1 | questions = $questions; 17 | } 18 | 19 | public function __invoke(Request $request, int $questionId): Response 20 | { 21 | $question = $this->questions->findOrFail($questionId); 22 | 23 | $this->authorize('update', $question); 24 | 25 | $this->validate($request, [ 26 | 'title' => ['required', 'string', 'max:255'], 27 | 'description' => ['nullable', 'string'], 28 | ]); 29 | 30 | $question->update([ 31 | 'title' => $request->input('title'), 32 | 'description' => $request->input('description', $question->description), 33 | ]); 34 | 35 | return new Response('', Response::HTTP_NO_CONTENT); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /domains/Discussions/Controllers/QuestionsViewController.php: -------------------------------------------------------------------------------- 1 | question = $question; 17 | 18 | if ($auth->guard()->guest()) { 19 | $this->middleware('throttle:30,1'); 20 | } 21 | } 22 | 23 | public function __invoke(int $questionId): QuestionResource 24 | { 25 | return QuestionResource::make($this->question->findOrFail($questionId)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /domains/Discussions/Database/Factories/AnswerFactory.php: -------------------------------------------------------------------------------- 1 | UserFactory::new(), 18 | 'question_id' => QuestionFactory::new(), 19 | 'content' => $this->faker->paragraph, 20 | 'created_at' => Carbon::now(), 21 | 'updated_at' => Carbon::now(), 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /domains/Discussions/Database/Factories/QuestionFactory.php: -------------------------------------------------------------------------------- 1 | UserFactory::new(), 18 | 'title' => $this->faker->title, 19 | 'description' => $this->faker->paragraph, 20 | 'created_at' => Carbon::now(), 21 | 'updated_at' => Carbon::now(), 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /domains/Discussions/Database/Migrations/2020_10_12_104113_create_questions_table.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->foreignIdFor(User::class, 'author_id')->constrained('users'); 15 | $table->string('title')->index(); 16 | $table->string('slug'); 17 | $table->text('description'); 18 | $table->timestampsTz(); 19 | $table->softDeletesTz(); 20 | $table->timestampTz('resolved_at')->nullable(); 21 | 22 | $table->index('author_id'); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /domains/Discussions/Database/Migrations/2020_10_19_175943_create_question_answers_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->foreignIdFor(User::class, 'author_id'); 16 | $table->foreignIdFor(Question::class, 'question_id'); 17 | $table->text('content'); 18 | $table->timestampsTz(); 19 | $table->softDeletesTz(); 20 | 21 | $table->index('question_id'); 22 | $table->index('author_id'); 23 | $table->index('created_at'); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /domains/Discussions/DiscussionsServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/Database/Migrations'); 19 | $this->bootRoutes(); 20 | $this->bootObservers(); 21 | $this->bootPolicies(); 22 | } 23 | 24 | private function bootRoutes(): void 25 | { 26 | Route::group( 27 | [ 28 | 'prefix' => 'discussions', 29 | 'as' => 'discussions', 30 | ], 31 | fn() => $this->loadRoutesFrom(__DIR__ . '/routes.php') 32 | ); 33 | } 34 | 35 | private function bootObservers(): void 36 | { 37 | Question::observe(QuestionObserver::class); 38 | } 39 | 40 | private function bootPolicies(): void 41 | { 42 | Gate::policy(Question::class, QuestionPolicy::class); 43 | Gate::policy(Answer::class, AnswerPolicy::class); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /domains/Discussions/Models/Answer.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class) 21 | ->withTrashed(); 22 | } 23 | 24 | public function question(): BelongsTo 25 | { 26 | return $this->belongsTo(Question::class) 27 | ->withTrashed(); 28 | } 29 | 30 | public function scopeOfQuestion($query, int $questionId) 31 | { 32 | return $query->where('question_id', $questionId); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /domains/Discussions/Models/Question.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class) 23 | ->withTrashed(); 24 | } 25 | 26 | public function answers(): HasMany 27 | { 28 | return $this->hasMany(Answer::class); 29 | } 30 | 31 | public function scopeFindByAuthorId(Builder $query, int $term): Builder 32 | { 33 | return $query->where('author_id', $term); 34 | } 35 | 36 | public function scopeFindByTitle(Builder $query, string $term): Builder 37 | { 38 | return $query->where('title', 'like', '%'.strtoupper($term).'%'); 39 | } 40 | 41 | public function scopeFindByCreatedDate(Builder $query, array $term): Builder 42 | { 43 | return $query->whereBetween('created_at', [$term[0], $term[1]]); 44 | } 45 | 46 | public function scopeResolved(Builder $query): Builder 47 | { 48 | return $query->whereNotNull('resolved_at'); 49 | } 50 | 51 | public function scopeNonResolved(Builder $query): Builder 52 | { 53 | return $query->whereNull('resolved_at'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /domains/Discussions/Observers/QuestionObserver.php: -------------------------------------------------------------------------------- 1 | calculateSlug($question); 13 | } 14 | 15 | public function updating(Question $question): void 16 | { 17 | $this->calculateSlug($question); 18 | } 19 | 20 | private function calculateSlug(Question $question): void 21 | { 22 | $question->slug = Str::slug($question->title); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /domains/Discussions/Policies/AnswerPolicy.php: -------------------------------------------------------------------------------- 1 | author->is($user); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /domains/Discussions/Policies/QuestionPolicy.php: -------------------------------------------------------------------------------- 1 | author->is($user); 17 | } 18 | 19 | public function delete(User $user, Question $question): bool 20 | { 21 | return $question->author->is($user) || $user->hasRole(AccountTypeEnum::ADMIN); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domains/Discussions/Resources/AnswerResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 14 | 'content' => $this->content, 15 | 'question_id' => $this->question_id, 16 | 'author_id' => $this->author_id, 17 | 'created_at' => $this->created_at, 18 | 'updated_at' => $this->updated_at, 19 | 'deleted_at' => $this->deleted_at, 20 | 'question' => QuestionResource::collection( 21 | $this->whenLoaded('question') 22 | ), 23 | 'author' => UserResource::collection( 24 | $this->whenLoaded('author') 25 | ), 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /domains/Discussions/Resources/QuestionResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 14 | 'title' => $this->title, 15 | 'slug' => $this->slug, 16 | 'description' => $this->description, 17 | 'author' => UserResource::make($this->author), 18 | 'created_at' => $this->created_at, 19 | 'updated_at' => $this->updated_at, 20 | 'resolved_at' => $this->resolved_at, 21 | 'deleted_at' => $this->deleted_at, 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/AnswersIndexControllerTest.php: -------------------------------------------------------------------------------- 1 | user = UserFactory::new()->create(); 30 | 31 | $this->question = QuestionFactory::new([ 32 | 'author_id' => $this->user->id 33 | ])->create(); 34 | 35 | $this->answer = AnswerFactory::new([ 36 | 'question_id' => $this->question->id, 37 | 'author_id' => $this->user->id, 38 | 'created_at' => Carbon::now()->subWeek()->toDateTimeString() 39 | ])->create(); 40 | 41 | $this->secondAnswer = AnswerFactory::new([ 42 | 'question_id' => $this->question->id, 43 | 'created_at' => Carbon::now()->toDateTimeString() 44 | ])->create(); 45 | } 46 | 47 | /** @test */ 48 | public function it_gets_paginated_answers_for_a_question(): void 49 | { 50 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id])) 51 | ->seeJsonStructure([ 52 | 'data' => [ 53 | [ 54 | 'id', 55 | 'content', 56 | 'question_id', 57 | 'author_id', 58 | 'created_at', 59 | 'updated_at', 60 | 'deleted_at' 61 | ] 62 | ] 63 | ]) 64 | ->seeJsonContains(['id' => $this->answer->id]) 65 | ->seeJsonContains(['content' => $this->answer->content]) 66 | ->seeJsonContains(['id' => $this->secondAnswer->id]) 67 | ->seeJsonContains(['content' => $this->secondAnswer->content]); 68 | } 69 | 70 | /** @test * */ 71 | public function it_gets_paginated_answers_for_a_question_from_a_particular_author(): void 72 | { 73 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id, 'author' => $this->user->id])) 74 | ->seeJson(['id' => $this->answer->id]) 75 | ->dontSeeJson(['id' => $this->secondAnswer->id]); 76 | } 77 | 78 | /** @test */ 79 | public function it_gets_paginated_answers_for_a_question_from_a_particular_time_frame(): void 80 | { 81 | $aWeekAgo = Carbon::now()->subDays(8); 82 | $yesterday = Carbon::yesterday(); 83 | 84 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id]) . '?created[from]=' . $aWeekAgo->format('Y-m-d') . '&created[to]=' . $yesterday->format('Y-m-d')) 85 | ->seeJsonContains(['id' => $this->answer->id]) 86 | ->seeJsonContains(['content' => $this->answer->content]) 87 | ->seeJsonDoesntContains([ 88 | "id" => $this->secondAnswer->id 89 | ]); 90 | } 91 | 92 | /** @test */ 93 | public function it_gets_paginated_answers_for_a_question_from_a_particular_time_frame_and_user(): void 94 | { 95 | $thirdAnswer = AnswerFactory::new([ 96 | 'question_id' => $this->question->id, 97 | 'author_id' => $this->user->id, 98 | 'created_at' => Carbon::now()->subWeek()->toDateTimeString() 99 | ])->create(); 100 | 101 | $aWeekAgo = Carbon::now()->subDays(8); 102 | $yesterday = Carbon::yesterday(); 103 | 104 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id]) . '?created[from]=' . $aWeekAgo->format('Y-m-d') . '&created[to]=' . $yesterday->format('Y-m-d') . '&author=1') 105 | ->seeJsonContains(['id' => $this->answer->id]) 106 | ->seeJsonContains(['content' => $this->answer->content]) 107 | ->seeJsonContains(['id' => $thirdAnswer->id]) 108 | ->seeJsonContains(['content' => $thirdAnswer->content]) 109 | ->dontSeeJson([ 110 | "id" => $this->secondAnswer->id 111 | ]); 112 | } 113 | 114 | /** @test */ 115 | public function it_blocks_guest_for_many_attempts(): void 116 | { 117 | for ($attempt = 0; $attempt < 30; ++$attempt) { 118 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id])) 119 | ->assertResponseStatus(Response::HTTP_OK); 120 | } 121 | 122 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id])) 123 | ->assertResponseStatus(Response::HTTP_TOO_MANY_REQUESTS); 124 | } 125 | 126 | /** @test */ 127 | public function it_not_blocks_authenticated_user_for_many_attempts(): void 128 | { 129 | $this->actingAs($this->user); 130 | 131 | for ($attempt = 0; $attempt < 30; ++$attempt) { 132 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id])); 133 | } 134 | 135 | $this->get(route('discussions.questions.answers.list', ['questionId' => $this->question->id])) 136 | ->assertResponseStatus(Response::HTTP_OK); 137 | } 138 | 139 | /** @test */ 140 | public function it_gets_question_and_author_when_loaded(): void 141 | { 142 | $answer = AnswerFactory::new([ 143 | 'question_id' => $this->question->id, 144 | 'author_id' => $this->user->id, 145 | 'created_at' => Carbon::now()->subWeek()->toDateTimeString() 146 | ])->create()->load('question', 'author'); 147 | 148 | $this->assertArrayHasKey('author', $answer->relationsToArray()); 149 | $this->assertArrayHasKey('question', $answer->relationsToArray()); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/AnswersStoreTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 28 | $this->user = UserFactory::new()->create(); 29 | $this->question = QuestionFactory::new(['author_id' => $this->user->id])->create(); 30 | } 31 | 32 | /** @test */ 33 | public function it_stores_answer(): void 34 | { 35 | $payload = [ 36 | 'content' => $this->faker->paragraph, 37 | ]; 38 | 39 | $response = $this->actingAs($this->user) 40 | ->call('POST', route('discussions.questions.answers', ['questionId' => $this->question->id]), $payload) 41 | ->assertStatus(Response::HTTP_NO_CONTENT); 42 | 43 | self::assertTrue($response->isEmpty()); 44 | 45 | $this->seeInDatabase('question_answers', [ 46 | 'author_id' => $this->user->id, 47 | 'question_id' => $this->question->id, 48 | 'content' => $payload['content'] 49 | ]); 50 | } 51 | 52 | /** @test */ 53 | public function it_forbids_guests_to_store_answer(): void 54 | { 55 | $this->post(route('discussions.questions.answers', ['questionId' => $this->question->id])) 56 | ->assertResponseStatus(Response::HTTP_UNAUTHORIZED); 57 | } 58 | 59 | /** @test */ 60 | public function it_fails_to_store_answer_on_validation_errors(): void 61 | { 62 | $this->actingAs($this->user) 63 | ->post(route('discussions.questions.answers', ['questionId' => $this->question->id])) 64 | ->seeJsonStructure([ 65 | 'content', 66 | ]); 67 | } 68 | 69 | /** @test */ 70 | public function it_fails_to_store_answer_on_invalid_question(): void 71 | { 72 | $payload = [ 73 | 'content' => $this->faker->paragraph, 74 | ]; 75 | 76 | $this->actingAs($this->user) 77 | ->post(route('discussions.questions.answers', ['questionId' => 1000]), $payload) 78 | ->assertResponseStatus(Response::HTTP_NOT_FOUND); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/AnswersUpdateTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 31 | $this->answer = AnswerFactory::new()->create(); 32 | $this->user = $this->answer->author; 33 | $this->question = $this->answer->question; 34 | } 35 | 36 | /** @test */ 37 | public function it_updates_answer(): void 38 | { 39 | Carbon::setTestNow(); 40 | 41 | $payload = [ 42 | 'content' => $this->faker->paragraph, 43 | ]; 44 | 45 | $request = $this->actingAs($this->user) 46 | ->patch( 47 | route('discussions.questions.answers.update', ['questionId' => $this->question->id, 'answerId' => $this->answer->id]), 48 | $payload 49 | ); 50 | 51 | $this->assertResponseStatus(Response::HTTP_NO_CONTENT); 52 | 53 | $this->assertTrue($request->response->isEmpty()); 54 | 55 | $this->seeInDatabase('question_answers', [ 56 | 'author_id' => $this->user->id, 57 | 'question_id' => $this->question->id, 58 | 'content' => $payload['content'], 59 | ]); 60 | } 61 | 62 | /** @test */ 63 | public function it_forbids_guests_to_update_answer(): void 64 | { 65 | $this->patch(route('discussions.questions.answers.update', ['questionId' => $this->question->id, 'answerId' => $this->answer->id])) 66 | ->assertResponseStatus(Response::HTTP_UNAUTHORIZED); 67 | } 68 | 69 | /** @test */ 70 | public function it_fails_to_update_answer_on_validation_errors(): void 71 | { 72 | $this->actingAs($this->user) 73 | ->patch(route('discussions.questions.answers.update', ['questionId' => $this->question->id, 'answerId' => $this->answer->id])) 74 | ->seeJsonStructure([ 75 | 'content', 76 | ]); 77 | } 78 | 79 | /** @test */ 80 | public function it_fails_to_update_answer_on_invalid_question(): void 81 | { 82 | $payload = [ 83 | 'content' => $this->faker->paragraph, 84 | ]; 85 | 86 | $this->actingAs($this->user) 87 | ->patch(route('discussions.questions.answers.update', ['questionId' => 1000, 'answerId' => $this->answer->id]), $payload) 88 | ->assertResponseStatus(Response::HTTP_NOT_FOUND); 89 | } 90 | 91 | /** @test */ 92 | public function it_fails_to_update_answer_on_invalid_answer(): void 93 | { 94 | $payload = [ 95 | 'content' => $this->faker->paragraph, 96 | ]; 97 | 98 | $this->actingAs($this->user) 99 | ->patch(route('discussions.questions.answers.update', ['questionId' => $this->question->id, 'answerId' => 1000]), $payload) 100 | ->assertResponseStatus(Response::HTTP_NOT_FOUND); 101 | } 102 | 103 | /** @test */ 104 | public function it_fails_to_update_on_invalid_author() 105 | { 106 | $payload = [ 107 | 'content' => $this->faker->paragraph, 108 | ]; 109 | 110 | $this->actingAs(UserFactory::new()->make()) 111 | ->patch( 112 | route('discussions.questions.answers.update', ['questionId' => $this->question->id, 'answerId' => $this->answer->id]), 113 | $payload 114 | )->assertResponseStatus(Response::HTTP_FORBIDDEN); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/QuestionsDeleteTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 30 | $this->user = UserFactory::new()->create(); 31 | $this->question = QuestionFactory::new(['author_id' => $this->user->id])->create(); 32 | 33 | Carbon::setTestNow(); 34 | } 35 | 36 | /** @test */ 37 | public function it_soft_deletes_a_question_i_own(): void 38 | { 39 | $response = $this->actingAs($this->user) 40 | ->delete(route('discussions.questions.delete', ['questionId' => $this->question->id])); 41 | 42 | $this->assertResponseStatus(Response::HTTP_NO_CONTENT); 43 | self::assertTrue($response->response->isEmpty()); 44 | $this->seeInDatabase('questions', [ 45 | 'id' => $this->question->id, 46 | 'updated_at' => Carbon::now(), 47 | 'deleted_at' => Carbon::now(), 48 | ]); 49 | } 50 | 51 | /** @test */ 52 | public function it_allows_admin_to_soft_delete_another_users_question(): void 53 | { 54 | $response = $this->actingAs(UserFactory::new()->withRole(AccountTypeEnum::ADMIN)->make()) 55 | ->delete(route('discussions.questions.update', ['questionId' => $this->question->id])); 56 | 57 | $this->assertResponseStatus(Response::HTTP_NO_CONTENT); 58 | self::assertTrue($response->response->isEmpty()); 59 | $this->seeInDatabase('questions', [ 60 | 'id' => $this->question->id, 61 | 'updated_at' => Carbon::now(), 62 | 'deleted_at' => Carbon::now(), 63 | ]); 64 | } 65 | 66 | /** @test */ 67 | public function it_forbids_a_non_admin_to_soft_delete_a_question_he_doesnt_own(): void 68 | { 69 | $this->actingAs(UserFactory::new()->make()) 70 | ->delete(route('discussions.questions.update', ['questionId' => $this->question->id])); 71 | 72 | $this->assertResponseStatus(Response::HTTP_FORBIDDEN); 73 | $this->seeInDatabase('questions', [ 74 | 'id' => $this->question->id, 75 | 'updated_at' => $this->question->updated_at, 76 | 'deleted_at' => null, 77 | ]); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/QuestionsIndexTest.php: -------------------------------------------------------------------------------- 1 | [ 23 | ['author' => 'author'], 24 | ], 25 | 'Search resolved with int' => [ 26 | ['resolved' => 21333], 27 | ], 28 | 'Search create only from date' => [ 29 | ['created[from]' => Carbon::now()->subMonth()->subYears(2)->toDateString()], 30 | ], 31 | 'Search with a "to" date less than "from"' => [ 32 | [ 33 | 'created[from]' => Carbon::now()->subMonth()->subYears(2)->toDateString(), 34 | 'created[to]' => Carbon::now()->subMonth()->subYears(3)->toDateString(), 35 | ], 36 | ], 37 | ]; 38 | } 39 | 40 | protected function setUp(): void 41 | { 42 | parent::setUp(); 43 | 44 | $this->user = UserFactory::new()->create(); 45 | QuestionFactory::times(10)->create(); 46 | } 47 | 48 | /** @test */ 49 | public function it_list_non_deleted_question(): void 50 | { 51 | $deleteQuestion = QuestionFactory::new([ 52 | 'deleted_at' => Carbon::now(), 53 | ]) 54 | ->create(); 55 | 56 | $this->json('GET', route('discussions.questions.index')) 57 | ->seeJsonStructure([ 58 | 'data' => [ 59 | [ 60 | 'id', 61 | 'title', 62 | 'slug', 63 | 'description', 64 | 'author', 65 | 'created_at', 66 | 'updated_at', 67 | 'resolved_at', 68 | 'deleted_at', 69 | ], 70 | ], 71 | 'links' => [ 72 | 'first', 'prev', 'next', 'last', 73 | ], 74 | ]) 75 | ->seeJsonDoesntContains([ 76 | 'email' => $deleteQuestion->author->email, 77 | ]) 78 | ->seeJsonContains([ 79 | 'to' => 10, 80 | ]) 81 | ->assertResponseOk(); 82 | } 83 | 84 | /** @test */ 85 | public function it_blocks_guest_for_many_attempts(): void 86 | { 87 | for ($attempt = 0; $attempt < 30; ++$attempt) { 88 | $this->get(route('discussions.questions.index')) 89 | ->assertResponseStatus(Response::HTTP_OK); 90 | } 91 | 92 | $this->get(route('discussions.questions.index')) 93 | ->assertResponseStatus(Response::HTTP_TOO_MANY_REQUESTS); 94 | } 95 | 96 | /** @test */ 97 | public function it_not_blocks_authenticated_user_for_many_attempts(): void 98 | { 99 | $this->actingAs($this->user); 100 | 101 | for ($attempt = 0; $attempt < 30; ++$attempt) { 102 | $this->get(route('discussions.questions.index')); 103 | } 104 | 105 | $this->get(route('discussions.questions.index')) 106 | ->assertResponseStatus(Response::HTTP_OK); 107 | } 108 | 109 | /** @test */ 110 | public function it_navigates_to_next_page(): void 111 | { 112 | $this->json('GET', route('discussions.questions.index', [ 113 | 'page' => 2, 114 | ])) 115 | ->seeJsonContains([ 116 | 'current_page' => 2, 117 | ]); 118 | } 119 | 120 | /** @test */ 121 | public function it_searches_by_author(): void 122 | { 123 | $user = UserFactory::new()->create(); 124 | 125 | QuestionFactory::new([ 126 | 'author_id' => $user->id, 127 | ]) 128 | ->count(3) 129 | ->create(); 130 | 131 | $this->json('GET', route('discussions.questions.index', [ 132 | 'author' => $user->id, 133 | ])) 134 | ->seeJsonContains([ 135 | 'email' => $user->email, 136 | ]) 137 | ->seeJsonContains([ 138 | 'to' => 3, 139 | ]); 140 | } 141 | 142 | /** @test */ 143 | public function it_searches_by_title(): void 144 | { 145 | QuestionFactory::new([ 146 | 'title' => 'LARAVEL-PT', 147 | ]) 148 | ->create(); 149 | 150 | QuestionFactory::new([ 151 | 'title' => 'laravel-Pt', 152 | ]) 153 | ->create(); 154 | 155 | $this->json('GET', route('discussions.questions.index', [ 156 | 'title' => 'LArAvEL-pT', 157 | ])) 158 | ->seeJsonContains([ 159 | 'to' => 2, 160 | ]); 161 | } 162 | 163 | /** @test */ 164 | public function it_searches_by_created_date(): void 165 | { 166 | QuestionFactory::new([ 167 | 'created_at' => Carbon::now()->subYears(2), 168 | ]) 169 | ->create(); 170 | 171 | QuestionFactory::new([ 172 | 'created_at' => Carbon::now()->subYears(3), 173 | ]) 174 | ->create(); 175 | 176 | $this->json('GET', route('discussions.questions.index', [ 177 | 'created[from]' => Carbon::now()->subMonth()->subYears(2)->toDateString(), 178 | 'created[to]' => Carbon::now()->addMonth()->subYears(2)->toDateString(), 179 | ])) 180 | ->seeJsonContains([ 181 | 'to' => 1, 182 | ]); 183 | 184 | $this->json('GET', route('discussions.questions.index', [ 185 | 'created[from]' => Carbon::now()->subMonth()->subYears(3)->toDateString(), 186 | 'created[to]' => Carbon::now()->addMonth()->subYears(2)->toDateString(), 187 | ])) 188 | ->seeJsonContains([ 189 | 'to' => 2, 190 | ]); 191 | } 192 | 193 | /** @test */ 194 | public function it_searches_by_resolved_flag(): void 195 | { 196 | $questionsResolved = QuestionFactory::new([ 197 | 'resolved_at' => Carbon::now(), 198 | ]) 199 | ->create(); 200 | 201 | $this->json('GET', route('discussions.questions.index', [ 202 | 'resolved' => true, 203 | ])) 204 | ->seeJsonContains([ 205 | 'resolved_at' => $questionsResolved->resolved_at, 206 | ]) 207 | ->seeJsonDoesntContains([ 208 | 'resolved_at' => null, 209 | ]); 210 | 211 | $this->json('GET', route('discussions.questions.index', [ 212 | 'resolved' => false, 213 | ])) 214 | ->seeJsonDoesntContains([ 215 | 'resolved_at' => $questionsResolved->resolved_at, 216 | ]) 217 | ->seeJsonContains([ 218 | 'resolved_at' => null, 219 | ]); 220 | } 221 | 222 | /** 223 | * @test 224 | * @dataProvider invalidSearchablePropertiesValuesProvider 225 | * 226 | * @param array $searchParamAndValue 227 | */ 228 | public function it_fails_search_if_given_invalid_search_params_values(array $searchParamAndValue): void 229 | { 230 | $this->get(route('discussions.questions.index', $searchParamAndValue)) 231 | ->assertResponseStatus(Response::HTTP_UNPROCESSABLE_ENTITY, ); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/QuestionsStoreTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 26 | $this->user = UserFactory::new()->create(); 27 | } 28 | 29 | /** @test */ 30 | public function it_stores_questions(): void 31 | { 32 | $payload = [ 33 | 'title' => $this->faker->title, 34 | 'description' => $this->faker->paragraph, 35 | ]; 36 | 37 | $response = $this->actingAs($this->user) 38 | ->call('POST', route('discussions.questions.store'), $payload) 39 | ->assertStatus(Response::HTTP_NO_CONTENT); 40 | 41 | self::assertTrue($response->isEmpty()); 42 | 43 | $this->seeInDatabase('questions', [ 44 | 'author_id' => $this->user->id, 45 | 'title' => $payload['title'], 46 | 'description' => $payload['description'], 47 | 'slug' => Str::slug($payload['title']), 48 | ]); 49 | } 50 | 51 | /** @test */ 52 | public function it_forbids_guests_to_store_questions(): void 53 | { 54 | $this->post(route('discussions.questions.store')) 55 | ->assertResponseStatus(Response::HTTP_UNAUTHORIZED); 56 | } 57 | 58 | /** @test */ 59 | public function it_fails_to_store_questions_on_validation_errors(): void 60 | { 61 | $this->actingAs($this->user) 62 | ->post(route('discussions.questions.store')) 63 | ->seeJsonStructure([ 64 | 'title', 65 | 'description', 66 | ]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/QuestionsUpdateTest.php: -------------------------------------------------------------------------------- 1 | faker = Factory::create(); 30 | $this->user = UserFactory::new()->create(); 31 | $this->question = QuestionFactory::new(['author_id' => $this->user->id])->create(); 32 | } 33 | 34 | /** @test */ 35 | public function it_updates_questions(): void 36 | { 37 | Carbon::setTestNow(); 38 | 39 | $payload = [ 40 | 'title' => $this->faker->title, 41 | 'description' => $this->faker->paragraph, 42 | ]; 43 | 44 | $response = $this->actingAs($this->user) 45 | ->call( 46 | 'PATCH', 47 | route('discussions.questions.update', ['questionId' => $this->question->id]), 48 | $payload 49 | ); 50 | 51 | $this->assertResponseStatus(Response::HTTP_NO_CONTENT); 52 | self::assertTrue($response->isEmpty()); 53 | $this->seeInDatabase('questions', [ 54 | 'id' => $this->question->id, 55 | 'author_id' => $this->user->id, 56 | 'title' => $payload['title'], 57 | 'slug' => Str::slug($payload['title']), 58 | 'description' => $payload['description'], 59 | 'updated_at' => Carbon::now(), 60 | ]); 61 | } 62 | 63 | /** @test */ 64 | public function it_fails_to_update_if_title_is_missing(): void 65 | { 66 | $this->actingAs($this->user) 67 | ->patch(route('discussions.questions.update', ['questionId' => $this->question->id])) 68 | ->seeJsonStructure([ 69 | 'title', 70 | ]); 71 | } 72 | 73 | /** @test */ 74 | public function it_keeps_previous_description_if_none_is_sent(): void 75 | { 76 | $response = $this->actingAs($this->user) 77 | ->call( 78 | 'PATCH', 79 | route('discussions.questions.update', ['questionId' => $this->question->id]), 80 | [ 81 | 'title' => $this->faker->title, 82 | ] 83 | ); 84 | 85 | self::assertTrue($response->isEmpty()); 86 | self::assertEquals($this->question->description, $this->question->refresh()->description); 87 | } 88 | 89 | /** @test */ 90 | public function it_forbids_non_owner_to_update_questions(): void 91 | { 92 | $this->actingAs(UserFactory::new()->make()) 93 | ->patch( 94 | route('discussions.questions.update', ['questionId' => $this->question->id]), 95 | [ 96 | 'title' => $this->faker->title, 97 | ] 98 | ) 99 | ->assertResponseStatus(Response::HTTP_FORBIDDEN); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Feature/QuestionsViewTest.php: -------------------------------------------------------------------------------- 1 | user = UserFactory::new()->create(); 25 | $this->question = QuestionFactory::new(['author_id' => $this->user->id])->create(); 26 | } 27 | 28 | /** @test */ 29 | public function it_allow_guest_see_a_question(): void 30 | { 31 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])) 32 | ->seeJson([ 33 | 'data' => [ 34 | 'id' => $this->question->id, 35 | 'title' => $this->question->title, 36 | 'slug' => $this->question->slug, 37 | 'description' => $this->question->description, 38 | 'author' => [ 39 | 'id' => $this->user->id, 40 | 'name' => $this->user->name, 41 | 'email' => $this->user->email, 42 | 'trusted' => $this->user->trusted ? "1" : "0", 43 | 'created_at' => $this->user->created_at, 44 | 'updated_at' => $this->user->updated_at, 45 | 'deleted_at' => $this->user->deleted_at 46 | ], 47 | 'created_at' => $this->question->created_at, 48 | 'updated_at' => $this->question->updated_at, 49 | 'resolved_at' => $this->question->resolved_at, 50 | 'deleted_at' => $this->question->deleted_at 51 | ] 52 | ]); 53 | } 54 | 55 | /** @test */ 56 | public function it_allow_authenticated_user_see_a_question(): void 57 | { 58 | $this->actingAs($this->user); 59 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])) 60 | ->seeJsonStructure([ 61 | 'data' => [ 62 | 'id', 63 | 'title', 64 | 'slug', 65 | 'description', 66 | 'author', 67 | 'created_at', 68 | 'updated_at', 69 | 'resolved_at', 70 | 'deleted_at' 71 | ] 72 | ]); 73 | } 74 | 75 | /** @test */ 76 | public function it_blocked_guest_for_many_attempts(): void 77 | { 78 | for ($attempt = 0; $attempt < 30; ++$attempt) { 79 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])) 80 | ->assertResponseStatus(Response::HTTP_OK); 81 | } 82 | 83 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])) 84 | ->assertResponseStatus(Response::HTTP_TOO_MANY_REQUESTS); 85 | } 86 | 87 | /** @test */ 88 | public function it_not_blocked_authenticated_user_for_many_attempts(): void 89 | { 90 | $this->actingAs($this->user); 91 | 92 | for ($attempt = 0; $attempt < 30; ++$attempt) { 93 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])); 94 | } 95 | 96 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])) 97 | ->assertResponseStatus(Response::HTTP_OK); 98 | } 99 | 100 | /** @test */ 101 | public function it_fails_on_invalid_question(): void 102 | { 103 | $this->get(route('discussions.questions.view', ['questionId' => 1000])) 104 | ->assertResponseStatus(Response::HTTP_NOT_FOUND); 105 | } 106 | 107 | /** @test */ 108 | public function it_fails_on_view_a_deleted_question(): void 109 | { 110 | $this->question->delete(); 111 | $this->get(route('discussions.questions.view', ['questionId' => $this->question->id])) 112 | ->assertResponseStatus(Response::HTTP_NOT_FOUND); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Unit/AnswerModelTest.php: -------------------------------------------------------------------------------- 1 | model = AnswerFactory::new()->make(); 25 | } 26 | 27 | /** @test */ 28 | public function it_contains_required_properties(): void 29 | { 30 | self::assertIsInt($this->model->author_id); 31 | self::assertIsInt($this->model->question_id); 32 | self::assertIsString($this->model->content); 33 | self::assertInstanceOf(Carbon::class, $this->model->created_at); 34 | self::assertInstanceOf(Carbon::class, $this->model->updated_at); 35 | } 36 | 37 | /** @test */ 38 | public function it_uses_correct_table_name(): void 39 | { 40 | self::assertEquals('question_answers', $this->model->getTable()); 41 | } 42 | 43 | /** @test */ 44 | public function it_uses_correct_primary_key(): void 45 | { 46 | self::assertEquals('id', $this->model->getKeyName()); 47 | } 48 | 49 | /** @test */ 50 | public function it_uses_soft_deletes(): void 51 | { 52 | self::assertArrayHasKey(SoftDeletingScope::class, $this->model->getGlobalScopes()); 53 | } 54 | 55 | /** @test */ 56 | public function it_uses_timestamps(): void 57 | { 58 | self::assertTrue($this->model->usesTimestamps()); 59 | } 60 | 61 | /** @test */ 62 | public function it_has_author_relation(): void 63 | { 64 | self::assertInstanceOf(User::class, $this->model->author()->getModel()); 65 | } 66 | 67 | /** @test */ 68 | public function it_has_question_relation(): void 69 | { 70 | self::assertInstanceOf(Question::class, $this->model->question()->getModel()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /domains/Discussions/Tests/Unit/QuestionModelTest.php: -------------------------------------------------------------------------------- 1 | model = QuestionFactory::new()->make(); 24 | } 25 | 26 | /** @test */ 27 | public function it_contains_required_properties(): void 28 | { 29 | self::assertIsInt($this->model->author_id); 30 | self::assertIsString($this->model->title); 31 | self::assertIsString($this->model->description); 32 | self::assertNull($this->model->slug); 33 | self::assertNull($this->model->resolved_at); 34 | self::assertInstanceOf(Carbon::class, $this->model->created_at); 35 | self::assertInstanceOf(Carbon::class, $this->model->updated_at); 36 | } 37 | 38 | /** @test */ 39 | public function it_uses_correct_table_name(): void 40 | { 41 | self::assertEquals('questions', $this->model->getTable()); 42 | } 43 | 44 | /** @test */ 45 | public function it_uses_correct_primary_key(): void 46 | { 47 | self::assertEquals('id', $this->model->getKeyName()); 48 | } 49 | 50 | /** @test */ 51 | public function it_uses_soft_deletes(): void 52 | { 53 | self::assertArrayHasKey(SoftDeletingScope::class, $this->model->getGlobalScopes()); 54 | } 55 | 56 | /** @test */ 57 | public function it_uses_timestamps(): void 58 | { 59 | self::assertTrue($this->model->usesTimestamps()); 60 | } 61 | 62 | /** @test */ 63 | public function it_has_author_relation(): void 64 | { 65 | self::assertInstanceOf(User::class, $this->model->author()->getModel()); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /domains/Discussions/routes.php: -------------------------------------------------------------------------------- 1 | 'auth'], function () { 14 | Route::post('/questions', [ 15 | 'as' => 'questions.store', 16 | 'uses' => QuestionsStoreController::class, 17 | ]); 18 | 19 | Route::patch('/questions/{questionId}', [ 20 | 'as' => 'questions.update', 21 | 'uses' => QuestionsUpdateController::class, 22 | ]); 23 | 24 | Route::delete('/questions/{questionId}', [ 25 | 'as' => 'questions.delete', 26 | 'uses' => QuestionsDeleteController::class, 27 | ]); 28 | 29 | Route::post('/questions/{questionId}/answers', [ 30 | 'as' => 'questions.answers', 31 | 'uses' => AnswersStoreController::class, 32 | ]); 33 | 34 | Route::patch('questions/{questionId}/answers/{answerId}', [ 35 | 'as' => 'questions.answers.update', 36 | 'uses' => AnswersUpdateController::class, 37 | ]); 38 | }); 39 | 40 | Route::get('questions', [ 41 | 'as' => 'questions.index', 42 | 'uses' => QuestionsIndexController::class, 43 | ]); 44 | 45 | Route::get('questions/{questionId}', [ 46 | 'as' => 'questions.view', 47 | 'uses' => QuestionsViewController::class, 48 | ]); 49 | 50 | Route::get('/questions/{questionId}/answers', [ 51 | 'as' => 'questions.answers.list', 52 | 'uses' => AnswersIndexController::class 53 | ]); 54 | -------------------------------------------------------------------------------- /domains/Links/Controllers/LinksIndexController.php: -------------------------------------------------------------------------------- 1 | links = $links; 19 | } 20 | 21 | public function __invoke(Request $request): AnonymousResourceCollection 22 | { 23 | $links = $this->links 24 | ->when($request->input('include'), static function (Builder $query, string $includes) { 25 | return $query->with( 26 | explode(',', $includes) 27 | ); 28 | }) 29 | ->approved() 30 | ->simplePaginate(); 31 | 32 | return LinkResource::collection($links); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /domains/Links/Controllers/LinksStoreController.php: -------------------------------------------------------------------------------- 1 | links = $links; 19 | } 20 | 21 | public function __invoke(Request $request): Response 22 | { 23 | $this->validate($request, [ 24 | 'link' => ['required', 'string', 'url'], 25 | 'title' => ['required', 'string'], 26 | 'description' => ['required', 'string'], 27 | 'author_name' => ['required', 'string'], 28 | 'author_email' => ['required', 'email', $request->user() ? null : 'unique:users,email'], 29 | 'cover_image' => ['required', 'image'], 30 | 'tags' => ['required', 'array'], 31 | 'tags.*.id' => ['required', 'integer', 'exists:tags'], 32 | ]); 33 | 34 | throw_unless( 35 | Gate::allows('create', [Link::class, $request->input('author_email')]), 36 | new UnapprovedLinkLimitReachedException() 37 | ); 38 | 39 | $link = $this->links->create([ 40 | 'link' => $request->input('link'), 41 | 'title' => $request->input('title'), 42 | 'description' => $request->input('description'), 43 | 'author_name' => optional($request->user())->name ?? $request->input('author_name'), 44 | 'author_email' => optional($request->user())->email ?? $request->input('author_email'), 45 | 'cover_image' => $request->file('cover_image')->store('cover_images', 'public'), 46 | ]); 47 | 48 | $link->tags()->attach($request->input('tags.*.id')); 49 | 50 | return new Response('', Response::HTTP_NO_CONTENT); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /domains/Links/Database/Factories/LinkFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->url, 19 | 'title' => $this->faker->title, 20 | 'description' => $this->faker->paragraph, 21 | 'cover_image' => 'cover_images/' . UploadedFile::fake()->image('cover_image')->getFilename(), 22 | 'author_name' => $this->faker->name, 23 | 'author_email' => $this->faker->safeEmail, 24 | 'created_at' => Carbon::now(), 25 | 'approved_at' => null, 26 | ]; 27 | } 28 | 29 | public function configure(): self 30 | { 31 | return $this->afterCreating( 32 | fn (Link $link) => $link->tags()->attach( 33 | TagFactory::new()->create() 34 | ) 35 | ); 36 | } 37 | 38 | public function approved(): self 39 | { 40 | return $this->state([ 41 | 'approved_at' => Carbon::now(), 42 | ]); 43 | } 44 | 45 | public function withAuthorEmail(string $email): self 46 | { 47 | return $this->state([ 48 | 'author_email' => $email, 49 | ]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /domains/Links/Database/Migrations/2020_03_19_201343_add_links_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->text('link'); 14 | $table->text('description'); 15 | $table->string('author_name'); 16 | $table->string('author_email'); 17 | $table->string('cover_image'); 18 | $table->timestamps(); 19 | $table->softDeletes(); 20 | $table->timestamp('approved_at')->nullable(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domains/Links/Database/Migrations/2020_03_19_202800_add_link_tag_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('link_id')->constrained(); 14 | $table->foreignId('tag_id')->constrained(); 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /domains/Links/Database/Migrations/2020_09_19_173727_add_title_column_to_links_table.php: -------------------------------------------------------------------------------- 1 | string('title')->nullable()->index(); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /domains/Links/Database/Migrations/2020_10_10_152640_alter_timestamps_columns_to_links_table.php: -------------------------------------------------------------------------------- 1 | dateTimeTz("approved_at")->nullable()->change(); 13 | $table->dateTimeTz("created_at")->change(); 14 | $table->dateTimeTz("updated_at")->change(); 15 | $table->dateTimeTz("deleted_at")->change(); 16 | }); 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /domains/Links/Database/Seeders/LinksTableSeeder.php: -------------------------------------------------------------------------------- 1 | approved() 14 | ->create(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /domains/Links/Exceptions/UnapprovedLinkLimitReachedException.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/Database/Migrations'); 16 | $this->loadConfig(); 17 | 18 | $this->bootRoutes(); 19 | $this->bootPolicies(); 20 | } 21 | 22 | private function bootRoutes(): void 23 | { 24 | $this->loadRoutesFrom(__DIR__ . '/routes.php'); 25 | } 26 | 27 | private function loadConfig(): void 28 | { 29 | $this->app->configure('links'); 30 | } 31 | 32 | private function bootPolicies(): void 33 | { 34 | Gate::policy(Link::class, LinkPolicy::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /domains/Links/Models/Link.php: -------------------------------------------------------------------------------- 1 | 'datetime', 17 | ]; 18 | 19 | protected $fillable = [ 20 | 'link', 21 | 'title', 22 | 'description', 23 | 'author_name', 24 | 'author_email', 25 | 'cover_image', 26 | ]; 27 | 28 | public function tags(): BelongsToMany 29 | { 30 | return $this->belongsToMany(Tag::class); 31 | } 32 | 33 | public function scopeApproved(Builder $query): Builder 34 | { 35 | return $query->whereNotNull('approved_at'); 36 | } 37 | 38 | public function scopeUnapproved(Builder $query): Builder 39 | { 40 | return $query->whereNull('approved_at'); 41 | } 42 | 43 | public function scopeForAuthorWithEmail(Builder $query, string $email): Builder 44 | { 45 | return $query->where('author_email', $email); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /domains/Links/Policies/LinkPolicy.php: -------------------------------------------------------------------------------- 1 | isTrusted() || $user->hasRole(AccountTypeEnum::EDITOR))) { 17 | return true; 18 | } 19 | 20 | $pendingCount = Link::forAuthorWithEmail($authorEmail) 21 | ->unapproved() 22 | ->count(); 23 | 24 | return $pendingCount < config('links.max_unapproved_links'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /domains/Links/Resources/LinkResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 14 | 'link' => $this->link, 15 | 'title' => $this->title, 16 | 'description' => $this->description, 17 | 'author_name' => $this->author_name, 18 | 'author_email' => $this->author_email, 19 | 'cover_image' => $this->cover_image, 20 | 'created_at' => $this->created_at, 21 | 'updated_at' => $this->updated_at, 22 | 'approved_at' => $this->approved_at, 23 | 'tags' => TagResource::collection( 24 | $this->whenLoaded('tags') 25 | ), 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /domains/Links/Tests/Feature/Database/Seeders/LinksTableSeederTest.php: -------------------------------------------------------------------------------- 1 | artisan('db:seed', ['--class' => LinksTableSeeder::class]); 18 | 19 | self::assertEquals(20, Link::count()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /domains/Links/Tests/Feature/LinksIndexTest.php: -------------------------------------------------------------------------------- 1 | approved()->create(); 18 | } 19 | 20 | /** @test */ 21 | public function it_lists_resources(): void 22 | { 23 | $this->get('/links') 24 | ->seeJsonStructure([ 25 | 'data' => [ 26 | [ 27 | 'id', 28 | 'link', 29 | 'title', 30 | 'description', 31 | 'cover_image', 32 | 'author_name', 33 | 'author_email', 34 | 'created_at', 35 | ], 36 | ], 37 | 'links' => [ 38 | 'first', 39 | 'last', 40 | 'prev', 41 | 'next', 42 | ], 43 | ]) 44 | ->assertResponseOk(); 45 | } 46 | 47 | /** @test */ 48 | public function it_includes_tags_relation(): void 49 | { 50 | $response = $this->get('/links?include=tags') 51 | ->seeJsonStructure([ 52 | 'data' => [ 53 | [ 54 | 'tags', 55 | ], 56 | ], 57 | ]); 58 | 59 | $response->assertResponseOk(); 60 | 61 | self::assertCount(1, $response->decodedJsonResponse()['data'][0]['tags']); 62 | } 63 | 64 | /** @test */ 65 | public function it_doesnt_include_relations_if_not_required(): void 66 | { 67 | $response = $this->get('/links'); 68 | 69 | $response->assertResponseOk(); 70 | 71 | self::assertArrayNotHasKey('tags', $response->decodedJsonResponse()['data'][0]); 72 | } 73 | 74 | /** @test */ 75 | public function it_supports_pagination_navigation(): void 76 | { 77 | $response = $this->get('/links?page=2'); 78 | 79 | $response->assertResponseOk(); 80 | 81 | self::assertEquals(2, $response->decodedJsonResponse()['meta']['current_page']); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /domains/Links/Tests/Feature/LinksStoreLimitTest.php: -------------------------------------------------------------------------------- 1 | [AccountTypeEnum::EDITOR], 34 | 'Admin Role' => [AccountTypeEnum::ADMIN], 35 | ]; 36 | } 37 | 38 | protected function setUp(): void 39 | { 40 | parent::setUp(); 41 | 42 | Storage::fake('local'); 43 | 44 | $this->faker = Factory::create(); 45 | $this->authorEmail = $this->faker->safeEmail; 46 | $this->tag = TagFactory::new()->create(); 47 | 48 | // set a random limit 49 | $this->limit = $this->faker->numberBetween(1, 10); 50 | config(['links.max_unapproved_links' => $this->limit]); 51 | 52 | // create $limit number of Links 53 | LinkFactory::times($this->limit) 54 | ->withAuthorEmail($this->authorEmail) 55 | ->create(); 56 | 57 | // prepare the requests' payload and files 58 | $this->payload = [ 59 | 'link' => $this->faker->url, 60 | 'title' => $this->faker->title, 61 | 'description' => $this->faker->paragraph, 62 | 'author_name' => $this->faker->name, 63 | 'author_email' => $this->authorEmail, 64 | 'tags' => [ 65 | ['id' => $this->tag->id], 66 | ], 67 | ]; 68 | 69 | $this->files = [ 70 | 'cover_image' => UploadedFile::fake()->image('cover_image.jpg'), 71 | ]; 72 | } 73 | 74 | /** @test */ 75 | public function it_fails_to_store_links_when_exceeding_unapproved_limit(): void 76 | { 77 | $response = $this->call('POST', route('links.store'), $this->payload, [], $this->files); 78 | 79 | self::assertEquals(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); 80 | self::assertEquals($this->limit, Link::count()); 81 | } 82 | 83 | /** @test */ 84 | public function it_stores_links_above_unapproved_limit_when_from_another_author(): void 85 | { 86 | // use another author_email 87 | $this->payload['author_email'] = $this->faker->safeEmail; 88 | 89 | $response = $this->call('POST', route('links.store'), $this->payload, [], $this->files); 90 | 91 | self::assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); 92 | self::assertEquals($this->limit + 1, Link::count()); 93 | } 94 | 95 | /** @test */ 96 | public function it_stores_links_above_unapproved_limit_when_user_is_trusted(): void 97 | { 98 | $response = $this->actingAs(UserFactory::new()->trusted()->make()) 99 | ->call('POST', route('links.store'), $this->payload, [], $this->files); 100 | 101 | self::assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); 102 | self::assertEquals($this->limit + 1, Link::count()); 103 | } 104 | 105 | /** 106 | * @test 107 | * @dataProvider unrestrictedUserRolesProvider 108 | * 109 | * @param string $role 110 | */ 111 | public function it_stores_links_above_unapproved_limit_when_user_has_unrestricted_role(string $role): void 112 | { 113 | $response = $this->actingAs(UserFactory::new()->withRole($role)->make()) 114 | ->call('POST', route('links.store'), $this->payload, [], $this->files); 115 | 116 | self::assertEquals(Response::HTTP_NO_CONTENT, $response->getStatusCode()); 117 | 118 | self::assertEquals($this->limit + 1, Link::count()); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /domains/Links/Tests/Feature/LinksStoreTest.php: -------------------------------------------------------------------------------- 1 | tag = TagFactory::new()->create(); 29 | $this->faker = Factory::create(); 30 | 31 | $this->payload = [ 32 | 'link' => $this->faker->url, 33 | 'title' => $this->faker->title, 34 | 'description' => $this->faker->paragraph, 35 | 'author_name' => $this->faker->name, 36 | 'author_email' => $this->faker->safeEmail, 37 | 'tags' => [ 38 | ['id' => $this->tag->id], 39 | ], 40 | ]; 41 | $this->files = [ 42 | 'cover_image' => UploadedFile::fake()->image('cover_image.jpg'), 43 | ]; 44 | 45 | Storage::fake('public'); 46 | } 47 | 48 | /** @test */ 49 | public function it_stores_resources(): void 50 | { 51 | $response = $this->call('POST', '/links', $this->payload, [], $this->files); 52 | self::assertTrue($response->isEmpty()); 53 | 54 | $this->seeInDatabase('links', [ 55 | 'link' => $this->payload['link'], 56 | 'title' => $this->payload['title'], 57 | 'description' => $this->payload['description'], 58 | 'author_name' => $this->payload['author_name'], 59 | 'author_email' => $this->payload['author_email'], 60 | 'cover_image' => 'cover_images/' . $this->files['cover_image']->hashName(), 61 | 'approved_at' => null, 62 | ]); 63 | 64 | $this->seeInDatabase( 65 | 'link_tag', 66 | [ 67 | 'tag_id' => $this->tag->id, 68 | ] 69 | ); 70 | 71 | Storage::disk('public')->assertExists('cover_images/' . $this->files['cover_image']->hashName()); 72 | } 73 | 74 | /** @test */ 75 | public function it_fails_to_store_resources_on_validation_errors(): void 76 | { 77 | $this->post('/links') 78 | ->seeJsonStructure([ 79 | 'link', 80 | 'title', 81 | 'description', 82 | 'author_name', 83 | 'author_email', 84 | 'cover_image', 85 | 'tags', 86 | ]); 87 | } 88 | 89 | /** @test */ 90 | public function it_fails_to_store_resources_with_invalid_link(): void 91 | { 92 | $this->payload['link'] = 'this_is_not_a_valid_url'; 93 | 94 | $this->post('/links', $this->payload) 95 | ->seeJsonStructure([ 96 | 'link', 97 | ]); 98 | } 99 | 100 | /** @test */ 101 | public function it_stores_resources_with_unregistered_link_domain(): void 102 | { 103 | $this->payload['link'] = 'http://unregistered.laravel.pt'; 104 | 105 | $response = $this->call('POST', '/links', $this->payload, [], $this->files); 106 | 107 | self::assertEquals(204, $response->getStatusCode()); 108 | self::assertTrue($response->isEmpty()); 109 | } 110 | 111 | /** @test */ 112 | public function it_forbids_guest_to_use_a_registered_users_email_when_submitting_a_link(): void 113 | { 114 | $user = UserFactory::new(['email' => $this->faker->safeEmail]) 115 | ->create(); 116 | 117 | $this->payload['author_email'] = $user->email; 118 | 119 | $this->post('/links', $this->payload) 120 | ->seeJsonStructure(['author_email']); 121 | } 122 | 123 | /** @test */ 124 | public function it_uses_logged_in_user_email_and_name_when_submitting_a_link(): void 125 | { 126 | // create a random user 127 | $randomUser = UserFactory::new(['email' => $this->faker->safeEmail])->create(); 128 | // create a user and login 129 | $user = UserFactory::new(['email' => $this->faker->safeEmail])->create(); 130 | $this->actingAs($user); 131 | 132 | // use an existing user's email and it should go OK since we're logged in. 133 | $this->payload['author_email'] = $randomUser->email; 134 | 135 | $response = $this->call('POST', '/links', $this->payload, [], $this->files); 136 | 137 | self::assertEquals(204, $response->getStatusCode()); 138 | $this->seeInDatabase( 139 | 'links', 140 | [ 141 | 'author_email' => $user->email, 142 | 'author_name' => $user->name, 143 | ] 144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /domains/Links/Tests/Unit/LinkModelTest.php: -------------------------------------------------------------------------------- 1 | model = LinkFactory::new()->make(); 21 | } 22 | 23 | /** @test */ 24 | public function it_contains_required_properties(): void 25 | { 26 | self::assertIsString($this->model->link); 27 | self::assertIsString($this->model->title); 28 | self::assertIsString($this->model->description); 29 | self::assertIsString($this->model->cover_image); 30 | self::assertIsString($this->model->author_name); 31 | self::assertIsString($this->model->author_email); 32 | 33 | self::assertNull($this->model->approved_at); 34 | 35 | self::assertInstanceOf(Carbon::class, $this->model->created_at); 36 | } 37 | 38 | /** @test */ 39 | public function it_uses_correct_table_name(): void 40 | { 41 | self::assertEquals('links', $this->model->getTable()); 42 | } 43 | 44 | /** @test */ 45 | public function it_uses_correct_primary_key(): void 46 | { 47 | self::assertEquals('id', $this->model->getKeyName()); 48 | } 49 | 50 | /** @test */ 51 | public function it_uses_soft_deletes(): void 52 | { 53 | self::assertArrayHasKey(SoftDeletingScope::class, $this->model->getGlobalScopes()); 54 | } 55 | 56 | /** @test */ 57 | public function it_uses_timestamps(): void 58 | { 59 | self::assertTrue($this->model->usesTimestamps()); 60 | } 61 | 62 | /** @test */ 63 | public function it_has_tags_relation(): void 64 | { 65 | self::assertInstanceOf(Tag::class, $this->model->tags()->getModel()); 66 | } 67 | 68 | /** @test */ 69 | public function it_has_approved_scope(): void 70 | { 71 | self::assertTrue(method_exists($this->model, 'scopeApproved')); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /domains/Links/routes.php: -------------------------------------------------------------------------------- 1 | 'links.index', 9 | 'uses' => LinksIndexController::class, 10 | ]); 11 | 12 | Route::post('/links', [ 13 | 'as' => 'links.store', 14 | 'uses' => LinksStoreController::class, 15 | ]); 16 | -------------------------------------------------------------------------------- /domains/Tags/Controllers/TagsIndexController.php: -------------------------------------------------------------------------------- 1 | all()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /domains/Tags/Database/Factories/TagFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 17 | 'created_at' => Carbon::now(), 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /domains/Tags/Database/Migrations/2020_03_15_093618_add_tags_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->timestamps(); 15 | $table->softDeletes(); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /domains/Tags/Database/Migrations/2020_10_07_160608_alter_timestamps_columns_to_tags_table.php: -------------------------------------------------------------------------------- 1 | dateTimeTz("created_at")->change(); 13 | $table->dateTimeTz("updated_at")->change(); 14 | $table->dateTimeTz("deleted_at")->change(); 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /domains/Tags/Database/Seeders/TagsTableSeeder.php: -------------------------------------------------------------------------------- 1 | createMany([ 14 | ['name' => 'Eloquent'], 15 | ['name' => 'Livewire'], 16 | ['name' => 'Vue'], 17 | ['name' => 'Testing'], 18 | ]); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /domains/Tags/Models/Tag.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | 'name' => $this->name, 14 | 'created_at' => $this->created_at, 15 | 'updated_at' => $this->updated_at, 16 | 'deleted_at' => $this->deleted_at, 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /domains/Tags/TagsServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/Database/Migrations'); 12 | 13 | $this->bootRoutes(); 14 | } 15 | 16 | private function bootRoutes(): void 17 | { 18 | $this->loadRoutesFrom(__DIR__ . '/routes.php'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /domains/Tags/Tests/Features/Database/Seeders/TagsTableSeederTest.php: -------------------------------------------------------------------------------- 1 | artisan('db:seed', ['--class' => TagsTableSeeder::class]); 17 | 18 | $this->seeInDatabase('tags', ['name' => 'Eloquent']) 19 | ->seeInDatabase('tags', ['name' => 'Livewire']) 20 | ->seeInDatabase('tags', ['name' => 'Vue']) 21 | ->seeInDatabase('tags', ['name' => 'Testing']); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domains/Tags/Tests/Features/TagsIndexTest.php: -------------------------------------------------------------------------------- 1 | create(); 18 | } 19 | 20 | /** @test */ 21 | public function it_lists_all_resources(): void 22 | { 23 | $response = $this->get('/tags') 24 | ->seeJsonStructure([ 25 | 'data' => [ 26 | [ 27 | 'id', 'name', 'created_at', 28 | ], 29 | ], 30 | ]); 31 | 32 | $response->assertResponseOk(); 33 | 34 | self::assertCount(2, $response->decodedJsonResponse()['data']); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /domains/Tags/Tests/Unit/TagModelTest.php: -------------------------------------------------------------------------------- 1 | model = TagFactory::new()->make(); 20 | } 21 | 22 | /** @test */ 23 | public function it_contains_required_properties(): void 24 | { 25 | self::assertNotNull($this->model->name); 26 | self::assertIsString($this->model->name); 27 | 28 | self::assertNotNull($this->model->created_at); 29 | self::assertInstanceOf(Carbon::class, $this->model->created_at); 30 | } 31 | 32 | /** @test */ 33 | public function it_uses_correct_table_name(): void 34 | { 35 | self::assertEquals('tags', $this->model->getTable()); 36 | } 37 | 38 | /** @test */ 39 | public function it_uses_correct_primary_key(): void 40 | { 41 | self::assertEquals('id', $this->model->getKeyName()); 42 | } 43 | 44 | /** @test */ 45 | public function it_uses_soft_deletes(): void 46 | { 47 | self::assertArrayHasKey(SoftDeletingScope::class, $this->model->getGlobalScopes()); 48 | } 49 | 50 | /** @test */ 51 | public function it_uses_timestamps(): void 52 | { 53 | self::assertTrue($this->model->usesTimestamps()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /domains/Tags/routes.php: -------------------------------------------------------------------------------- 1 | 'tags.index', 8 | 'uses' => TagsIndexController::class, 9 | ]); 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./app 10 | ./domains 11 | 12 | 13 | ./domains/*/Database 14 | ./domains/*/Routes 15 | ./domains/*/Tests 16 | ./domains/*/Resources 17 | ./tests 18 | 19 | 20 | 21 | 22 | ./domains/Accounts/Tests 23 | 24 | 25 | ./domains/Discussions/Tests 26 | 27 | 28 | ./domains/Links/Tests 29 | 30 | 31 | ./domains/Tags/Tests 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Handle Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); 29 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-portugal/api/32985e132d8350ea3b25733b0554558d595eee76/resources/views/.gitkeep -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | get('/', function () use ($router) { 17 | return $router->app->version(); 18 | }); 19 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/debugbar/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | routes.php 3 | schedule-* 4 | compiled.php 5 | services.json 6 | events.scanned.php 7 | routes.scanned.php 8 | down 9 | views 10 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tests/Feature/Database/Seeders/DatabaseSeederTest.php: -------------------------------------------------------------------------------- 1 | expectException(\Exception::class); 16 | 17 | $this->app['config']->set('app.env', 'production'); 18 | 19 | $this->artisan('db:seed'); 20 | } 21 | 22 | /** @test */ 23 | public function it_can_seed_database(): void 24 | { 25 | self::assertEquals(0, $this->artisan('db:seed')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | response->getContent(), true, 512, JSON_THROW_ON_ERROR); 22 | } 23 | } 24 | --------------------------------------------------------------------------------