├── .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 | 
4 | [](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 |
--------------------------------------------------------------------------------