├── .flake8 ├── .github └── workflows │ └── pr.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── .env.example ├── .gitignore ├── app │ ├── Console │ │ ├── Commands │ │ │ └── .gitkeep │ │ └── Kernel.php │ ├── Exceptions │ │ └── Handler.php │ ├── Http │ │ └── Controllers │ │ │ ├── BackupController.php │ │ │ ├── ClusterController.php │ │ │ ├── ConnectionController.php │ │ │ ├── Controller.php │ │ │ └── FileSystemController.php │ └── Models │ │ ├── Backup.php │ │ ├── Cluster.php │ │ ├── Connection.php │ │ └── FileSystem.php ├── artisan ├── bootstrap │ └── app.php ├── composer.json ├── composer.lock ├── database │ ├── migrations │ │ ├── .gitkeep │ │ ├── 2021_03_31_105654_cluster.php │ │ ├── 2021_03_31_105718_file_system.php │ │ ├── 2021_03_31_105727_connection.php │ │ └── 2021_03_31_105735_backup.php │ └── seeders │ │ └── DatabaseSeeder.php ├── public │ ├── .htaccess │ └── index.php ├── resources │ └── views │ │ └── .gitkeep ├── routes │ └── web.php └── storage │ ├── app │ └── .gitignore │ ├── framework │ ├── cache │ │ ├── .gitignore │ │ └── data │ │ │ └── .gitignore │ └── views │ │ └── .gitignore │ └── logs │ └── .gitignore ├── docker-compose.yml ├── docker ├── apache2.conf ├── app.dockerfile ├── docker-php-ext-xdebug.ini └── vhost.conf └── tests ├── __init__.py ├── api ├── __init__.py ├── test_methods.py └── test_request_body │ ├── __init__.py │ └── test_post.py ├── conftest.py ├── pytest.ini ├── requirements.txt ├── steps ├── __init__.py ├── asserts.py └── common.py ├── test_data ├── __init__.py ├── db_filler.py ├── generators.py └── getters.py └── utils ├── __init__.py ├── api_objects.py ├── data_classes.py ├── docker.py ├── endpoints.py ├── methods.py ├── tools.py └── types.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | ignore = W503, F811 4 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | linters: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python 3.9 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.9' 21 | architecture: 'x64' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install flake8 flake8-pytest-style black 27 | 28 | - name: Lint tests source with flake8 29 | run: | 30 | flake8 ./tests --count --show-source --statistics 31 | 32 | - name: Lint tests source with black 33 | run: | 34 | black ./tests --check 35 | 36 | func_tests: 37 | runs-on: ubuntu-latest 38 | 39 | needs: 40 | - linters 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | 45 | - name: Extract branch name 46 | shell: bash 47 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 48 | id: extract_branch 49 | 50 | - name: Set up Python 3.9 51 | uses: actions/setup-python@v2 52 | with: 53 | python-version: '3.9' 54 | architecture: 'x64' 55 | 56 | - name: Install dependencies 57 | run: | 58 | python -m pip install --upgrade pip 59 | python -m pip install -r tests/requirements.txt 60 | 61 | - name: Build app image 62 | run: | 63 | docker build --tag app:${{ steps.extract_branch.outputs.branch }} . 64 | 65 | - name: Run tests 66 | run: | 67 | pytest ./tests -s -v -n auto --alluredir=allure-results \ 68 | --app-image=app:${{ steps.extract_branch.outputs.branch }} 69 | 70 | - name: Get Allure history 71 | uses: actions/checkout@v2 72 | if: always() 73 | continue-on-error: true 74 | with: 75 | ref: gh-pages 76 | path: gh-pages 77 | 78 | - name: Allure Report action from marketplace 79 | uses: simple-elf/allure-report-action@master 80 | if: always() 81 | with: 82 | allure_results: allure-results 83 | allure_history: allure-history 84 | 85 | - name: Deploy report to Github Pages 86 | uses: peaceiris/actions-gh-pages@v2 87 | if: always() 88 | env: 89 | PERSONAL_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | PUBLISH_BRANCH: gh-pages 91 | PUBLISH_DIR: allure-history 92 | 93 | - name: Post the link to the report 94 | if: always() 95 | uses: Sibz/github-status-action@v1 96 | with: 97 | authToken: ${{secrets.GITHUB_TOKEN}} 98 | context: 'Test report' 99 | state: 'success' 100 | sha: ${{ github.event.pull_request.head.sha }} 101 | target_url: https://arenadata.github.io/truly_automated_tests/${{ github.run_number }}/ 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Official PHP image 2 | FROM php:apache 3 | # Install other requirements 4 | RUN apt-get update \ 5 | && apt-get install -y \ 6 | unzip 7 | # Install composer 8 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer 9 | # Configure apache 10 | RUN rm /etc/apache2/sites-available/000-default.conf \ 11 | && rm /etc/apache2/sites-enabled/000-default.conf 12 | COPY ./docker/apache2.conf /etc/apache2/apache2.conf 13 | # Enable rewrite module 14 | RUN a2enmod rewrite 15 | # Copy sources 16 | COPY ./app /var/www/html 17 | WORKDIR /var/www/html 18 | # Installing dependecies 19 | RUN composer install --optimize-autoloader --no-scripts --no-dev --profile --ignore-platform-reqs -vv 20 | # Init database 21 | RUN touch storage/app/arenadata_db.sqlite 22 | ENV DB_CONNECTION=sqlite 23 | ENV DB_DATABASE=/var/www/html/storage/app/arenadata_db.sqlite 24 | ENV DB_FOREIGN_KEYS=true 25 | RUN php artisan migrate --force 26 | # Fix permissions 27 | RUN chgrp -R www-data storage /var/www/html/storage 28 | RUN chmod -R ug+rwx storage /var/www/html/storage 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Arenadata 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Truly automated tests example 2 | Sample project for "Truly automated testing" concept demo 3 | 4 | 5 | ## API Spec 6 | 7 | ### Methods 8 | 9 | - **/endpoint/** 10 | - **GET** - List all objects. Will be called **LIST** in further mentions 11 | - **POST** - Create an object. Will be called **POST** in further mentions 12 | - **/endpoint/\/** 13 | - **GET** - Get exact object data. Will be called **GET** in further mentions 14 | 15 | ### Types 16 | 17 | - `integer` 18 | - Positive integer value < 231 19 | - `string` 20 | - String up to 255 symbols 21 | - `text` 22 | - String with to 2000 symbols 23 | 24 | ### Cluster 25 | 26 | DB cluster object 27 | 28 | **Endpoint:** _/cluster/_ 29 | 30 | #### Allowed methods 31 | 32 | Name | Allowed 33 | ---: | --- 34 | GET | `True` 35 | LIST | `True` 36 | POST | `True` 37 | 38 | #### Fields 39 | 40 | Name | Type | Required | Description 41 | ---: | --- | --- | --- 42 | id | `integer` | `False` | Auto generated Object Id 43 | name | `string` | `True` | Cluster name 44 | description | `text` | `False` | Cluster description for UI 45 | 46 | 47 | ### File system 48 | 49 | File system for backup storage 50 | 51 | Endpoint: _/file-system/_ 52 | 53 | #### Allowed methods 54 | 55 | Name | Allowed 56 | ---: | --- 57 | GET | `True` 58 | LIST | `True` 59 | POST | `True` 60 | 61 | #### Fields 62 | 63 | Name | Type | Required | Description 64 | ---: | --- | --- | --- 65 | id | `integer` | `False` | Auto generated Object Id 66 | name | `string` | `True` | File system name 67 | description | `text` | `False` | File system description for UI 68 | 69 | 70 | ### Connection 71 | 72 | Connection between a cluster and filesystem 73 | 74 | Endpoint: _/connection/_ 75 | 76 | #### Allowed methods 77 | 78 | Name | Allowed 79 | ---: | --- 80 | GET | `True` 81 | LIST | `True` 82 | POST | `True` 83 | 84 | #### Fields 85 | 86 | Name | Type | Required | Description 87 | ---: | --- | --- | --- 88 | id | `integer` | `False` | Auto generated Object Id 89 | name | `string` | `True` | Connection name 90 | cluster_id | `integer` | `True` | FK to [Cluster](#cluster) 91 | filesystem_id | `integer` | `True` | FK to [File system](#file-system) 92 | 93 | 94 | ### Backup 95 | 96 | Create backup of cluster to file system. 97 | 98 | > [Connection](#connection) for given [Cluster](#cluster) and [File system](#file-system) 99 | > should exist before backup creation 100 | 101 | Endpoint: _/backup/_ 102 | 103 | #### Allowed methods 104 | 105 | Name | Allowed 106 | ---: | --- 107 | GET | `True` 108 | LIST | `True` 109 | POST | `True` 110 | 111 | #### Fields 112 | 113 | Name | Type | Required | Description 114 | ---: | --- | --- | --- 115 | id | `integer` | `False` | Auto generated Object Id 116 | name | `string` | `True` | Backup name 117 | cluster_id | `integer` | `True` | FK to [Cluster](#cluster) 118 | filesystem_id | `integer` | `True` | FK to [File system](#file-system) 119 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Lumen 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | APP_TIMEZONE=UTC 7 | 8 | DB_CONNECTION=mysql 9 | DB_HOST=127.0.0.1 10 | DB_PORT=3306 11 | DB_DATABASE=homestead 12 | DB_USERNAME=homestead 13 | DB_PASSWORD=secret 14 | 15 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.idea 3 | Homestead.json 4 | Homestead.yaml 5 | .env 6 | .phpunit.result.cache 7 | -------------------------------------------------------------------------------- /app/app/Console/Commands/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/app/app/Console/Commands/.gitkeep -------------------------------------------------------------------------------- /app/app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | validate($request, [ 19 | 'name' => 'required|string|max:255|unique:backups', 20 | 'cluster_id' => 'required|integer|exists:clusters,id', 21 | 'filesystem_id' => 'required|integer|exists:file_systems,id', 22 | ]); 23 | 24 | $connection = Connection::where([ 25 | 'cluster_id' => $validated['cluster_id'], 26 | 'filesystem_id' => $validated['filesystem_id'] 27 | ])->first(); 28 | 29 | if (!empty($connection)) { 30 | return response()->json(Backup::create($validated), 201); 31 | } else { 32 | return response()->json(['connection' => ['Connection does not exist']], 404); 33 | } 34 | } 35 | 36 | public function show($id) 37 | { 38 | return Backup::findOrFail($id); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/app/Http/Controllers/ClusterController.php: -------------------------------------------------------------------------------- 1 | validate($request, [ 18 | 'name' => 'required|string|max:255|unique:clusters', 19 | 'description' => 'nullable|string|max:2000' 20 | ]); 21 | 22 | return response()->json(Cluster::create($validated), 201); 23 | } 24 | 25 | public function show($id) 26 | { 27 | return Cluster::findOrFail($id); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/app/Http/Controllers/ConnectionController.php: -------------------------------------------------------------------------------- 1 | validate($request, [ 19 | 'name' => 'required|string|max:255|unique:connections', 20 | 'cluster_id' => 'required|integer|exists:clusters,id', 21 | 'filesystem_id' => 'required|integer|exists:file_systems,id', 22 | ]); 23 | 24 | return response()->json(Connection::create($validated), 201); 25 | } 26 | 27 | public function show($id) 28 | { 29 | return Connection::findOrFail($id); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | validate($request, [ 18 | 'name' => 'required|string|max:255|unique:file_systems', 19 | 'description' => 'nullable|string|max:2000' 20 | ]); 21 | 22 | return response()->json(FileSystem::create($validated), 201); 23 | } 24 | 25 | public function show($id) 26 | { 27 | return FileSystem::findOrFail($id); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/app/Models/Backup.php: -------------------------------------------------------------------------------- 1 | belongsTo(Cluster::class); 25 | } 26 | 27 | public function filesystem() 28 | { 29 | return $this->belongsTo(FileSystem::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/app/Models/Cluster.php: -------------------------------------------------------------------------------- 1 | 'no description provided', 23 | ]; 24 | 25 | public $timestamps = false; 26 | 27 | public function filesystems() 28 | { 29 | return $this->hasManyThrough(FileSystem::class, Connection::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/app/Models/Connection.php: -------------------------------------------------------------------------------- 1 | belongsTo(Cluster::class); 25 | } 26 | 27 | public function filesystem() 28 | { 29 | return $this->belongsTo(FileSystem::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/app/Models/FileSystem.php: -------------------------------------------------------------------------------- 1 | 'no description provided', 22 | ]; 23 | 24 | public $timestamps = false; 25 | 26 | public function clusters() 27 | { 28 | return $this->hasManyThrough(Cluster::class, Connection::class); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make( 32 | 'Illuminate\Contracts\Console\Kernel' 33 | ); 34 | 35 | exit($kernel->handle(new ArgvInput, new ConsoleOutput)); 36 | -------------------------------------------------------------------------------- /app/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 | 28 | $app->withEloquent(); 29 | 30 | /* 31 | |-------------------------------------------------------------------------- 32 | | Register Container Bindings 33 | |-------------------------------------------------------------------------- 34 | | 35 | | Now we will register a few bindings in the service container. We will 36 | | register the exception handler and the console kernel. You may add 37 | | your own bindings here if you like or you can make another file. 38 | | 39 | */ 40 | 41 | $app->singleton( 42 | Illuminate\Contracts\Debug\ExceptionHandler::class, 43 | App\Exceptions\Handler::class 44 | ); 45 | 46 | $app->singleton( 47 | Illuminate\Contracts\Console\Kernel::class, 48 | App\Console\Kernel::class 49 | ); 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Register Config Files 54 | |-------------------------------------------------------------------------- 55 | | 56 | | Now we will register the "app" configuration file. If the file exists in 57 | | your configuration directory it will be loaded; otherwise, we'll load 58 | | the default version. You may register other files below as needed. 59 | | 60 | */ 61 | 62 | $app->configure('app'); 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Register Middleware 67 | |-------------------------------------------------------------------------- 68 | | 69 | | Next, we will register the middleware with the application. These can 70 | | be global middleware that run before and after each request into a 71 | | route or middleware that'll be assigned to some specific routes. 72 | | 73 | */ 74 | 75 | // $app->middleware([ 76 | // App\Http\Middleware\ExampleMiddleware::class 77 | // ]); 78 | 79 | // $app->routeMiddleware([ 80 | // 'auth' => App\Http\Middleware\Authenticate::class, 81 | // ]); 82 | 83 | /* 84 | |-------------------------------------------------------------------------- 85 | | Register Service Providers 86 | |-------------------------------------------------------------------------- 87 | | 88 | | Here we will register all of the application's service providers which 89 | | are used to bind services into the container. Service providers are 90 | | totally optional, so you are not required to uncomment this line. 91 | | 92 | */ 93 | 94 | // $app->register(App\Providers\AppServiceProvider::class); 95 | // $app->register(App\Providers\AuthServiceProvider::class); 96 | // $app->register(App\Providers\EventServiceProvider::class); 97 | 98 | /* 99 | |-------------------------------------------------------------------------- 100 | | Load The Application Routes 101 | |-------------------------------------------------------------------------- 102 | | 103 | | Next we will include the routes file so that they can all be added to 104 | | the application. This will provide all of the URLs the application 105 | | can respond to, as well as the controllers that may handle them. 106 | | 107 | */ 108 | 109 | $app->router->group([ 110 | 'namespace' => 'App\Http\Controllers', 111 | ], function ($router) { 112 | require __DIR__.'/../routes/web.php'; 113 | }); 114 | 115 | return $app; 116 | -------------------------------------------------------------------------------- /app/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.3|^8.0", 9 | "laravel/lumen-framework": "^8.0" 10 | }, 11 | "require-dev": { 12 | "fakerphp/faker": "^1.9.1", 13 | "mockery/mockery": "^1.3.1", 14 | "phpunit/phpunit": "^9.3" 15 | }, 16 | "autoload": { 17 | "psr-4": { 18 | "App\\": "app/", 19 | "Database\\Factories\\": "database/factories/", 20 | "Database\\Seeders\\": "database/seeders/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "classmap": [ 25 | "tests/" 26 | ] 27 | }, 28 | "config": { 29 | "preferred-install": "dist", 30 | "sort-packages": true, 31 | "optimize-autoloader": true 32 | }, 33 | "minimum-stability": "dev", 34 | "prefer-stable": true, 35 | "scripts": { 36 | "post-root-package-install": [ 37 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/app/database/migrations/.gitkeep -------------------------------------------------------------------------------- /app/database/migrations/2021_03_31_105654_cluster.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->text('description'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::drop('clusters'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/database/migrations/2021_03_31_105718_file_system.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->text('description'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::drop('file_systems'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/database/migrations/2021_03_31_105727_connection.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->foreignId('cluster_id')->constrained('clusters'); 20 | $table->foreignId('filesystem_id')->constrained('file_systems'); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::drop('connections'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/database/migrations/2021_03_31_105735_backup.php: -------------------------------------------------------------------------------- 1 | id(); 19 | $table->string('name'); 20 | $table->foreignId('cluster_id')->constrained('clusters'); 21 | $table->foreignId('filesystem_id')->constrained('file_systems'); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::drop('backups'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call('UsersTableSeeder'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/public/index.php: -------------------------------------------------------------------------------- 1 | run(); 29 | -------------------------------------------------------------------------------- /app/resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/app/resources/views/.gitkeep -------------------------------------------------------------------------------- /app/routes/web.php: -------------------------------------------------------------------------------- 1 | get('/', function () use ($router) { 17 | return $router->app->version(); 18 | }); 19 | 20 | $router->group(['prefix' => 'cluster'], function () use ($router) { 21 | $router->get('/', 'ClusterController@index'); 22 | $router->post('/', 'ClusterController@store'); 23 | $router->get('/{id}', 'ClusterController@show'); 24 | }); 25 | 26 | $router->group(['prefix' => 'file-system'], function () use ($router) { 27 | $router->get('/', 'FileSystemController@index'); 28 | $router->post('/', 'FileSystemController@store'); 29 | $router->get('/{id}', 'FileSystemController@show'); 30 | }); 31 | 32 | $router->group(['prefix' => 'connection'], function () use ($router) { 33 | $router->get('/', 'ConnectionController@index'); 34 | $router->post('/', 'ConnectionController@store'); 35 | $router->get('/{id}', 'ConnectionController@show'); 36 | }); 37 | 38 | $router->group(['prefix' => 'backup'], function () use ($router) { 39 | $router->get('/', 'BackupController@index'); 40 | $router->post('/', 'BackupController@store'); 41 | $router->get('/{id}', 'BackupController@show'); 42 | }); 43 | -------------------------------------------------------------------------------- /app/storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /app/storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /app/storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /app/storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /app/storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | # Web Server 5 | web: 6 | image: nginx 7 | working_dir: /var/www 8 | volumes: 9 | - ./:/var/www 10 | - ./docker/vhost.conf:/etc/nginx/conf.d/default.conf 11 | ports: 12 | - 8000:80 13 | 14 | # Application 15 | app: 16 | build: 17 | context: ./docker 18 | dockerfile: app.dockerfile 19 | working_dir: /var/www 20 | volumes: 21 | - ./app:/var/www 22 | - ./docker/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 23 | environment: 24 | APP_NAME: SQA Demo 25 | APP_ENV: local 26 | APP_KEY: 27 | APP_DEBUG: 1 28 | DB_CONNECTION: mysql 29 | DB_HOST: database 30 | DB_PORT: 3306 31 | DB_DATABASE: arenadata_db 32 | DB_USERNAME: arenadata 33 | DB_PASSWORD: qwert 34 | 35 | # Database Server 36 | database: 37 | image: mysql 38 | volumes: 39 | - db_data:/var/lib/mysql 40 | environment: 41 | MYSQL_ROOT_PASSWORD: qwert123 42 | MYSQL_DATABASE: arenadata_db 43 | MYSQL_USER: arenadata 44 | MYSQL_PASSWORD: qwert 45 | ports: 46 | - 33060:3306 47 | 48 | volumes: 49 | db_data: 50 | -------------------------------------------------------------------------------- /docker/apache2.conf: -------------------------------------------------------------------------------- 1 | # see http://sources.debian.net/src/apache2/2.4.10-1/debian/config-dir/apache2.conf 2 | 3 | Mutex file:/var/lock/apache2 default 4 | PidFile /var/run/apache2/apache2.pid 5 | Timeout 300 6 | KeepAlive On 7 | MaxKeepAliveRequests 100 8 | KeepAliveTimeout 5 9 | User www-data 10 | Group www-data 11 | HostnameLookups Off 12 | ErrorLog /proc/self/fd/2 13 | LogLevel warn 14 | 15 | IncludeOptional mods-enabled/*.load 16 | IncludeOptional mods-enabled/*.conf 17 | 18 | # Include list of ports to listen on 19 | Include ports.conf 20 | 21 | 22 | Options FollowSymLinks 23 | AllowOverride None 24 | Require all denied 25 | 26 | 27 | 28 | Options Indexes FollowSymLinks 29 | AllowOverride None 30 | Require all granted 31 | 32 | 33 | DocumentRoot /var/www/html/public 34 | 35 | AllowOverride None 36 | Require all granted 37 | 38 | 39 | Options -MultiViews 40 | RewriteEngine On 41 | RewriteCond %{REQUEST_FILENAME} !-f 42 | RewriteRule ^(.*)$ index.php [QSA,L] 43 | 44 | RewriteCond %{HTTP:Authorization} ^(.*) 45 | RewriteRule .* - [e=HTTP_AUTHORIZATION:%1] 46 | 47 | 48 | 49 | 50 | ErrorLog /var/log/apache2/error.log 51 | CustomLog /var/log/apache2/access.log combined 52 | 53 | AccessFileName .htaccess 54 | 55 | Require all denied 56 | 57 | 58 | LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined 59 | LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined 60 | LogFormat "%h %l %u %t \"%r\" %>s %O" common 61 | LogFormat "%{Referer}i -> %U" referer 62 | LogFormat "%{User-agent}i" agent 63 | 64 | CustomLog /proc/self/fd/1 combined 65 | 66 | 67 | SetHandler application/x-httpd-php 68 | 69 | 70 | IncludeOptional conf-enabled/*.conf 71 | IncludeOptional sites-enabled/*.conf 72 | -------------------------------------------------------------------------------- /docker/app.dockerfile: -------------------------------------------------------------------------------- 1 | # Official PHP image 2 | FROM php:fpm 3 | # Install other requirements 4 | RUN apt-get update \ 5 | && apt-get install -y \ 6 | unzip 7 | # Install composer 8 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer 9 | # Install required extensions 10 | RUN docker-php-ext-install pdo_mysql 11 | RUN pecl install xdebug \ 12 | && docker-php-ext-enable xdebug 13 | # Add user for an application 14 | RUN groupadd -g 1000 www 15 | RUN useradd -u 1000 -ms /bin/bash -g www www 16 | # Change user 17 | USER www 18 | -------------------------------------------------------------------------------- /docker/docker-php-ext-xdebug.ini: -------------------------------------------------------------------------------- 1 | zend_extension=xdebug 2 | 3 | [xdebug] 4 | xdebug.mode=develop,debug 5 | xdebug.client_host=172.17.0.1 6 | ;xdebug.start_with_request=yes 7 | -------------------------------------------------------------------------------- /docker/vhost.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name sqa-demo.local; 4 | index index.php index.html; 5 | root /var/www; 6 | 7 | client_max_body_size 10M; 8 | 9 | location / { 10 | try_files $uri /public/index.php?$args; 11 | } 12 | 13 | location ~ \.php$ { 14 | fastcgi_split_path_info ^(.+\.php)(/.+)$; 15 | fastcgi_pass app:9000; 16 | fastcgi_index index.php; 17 | include fastcgi_params; 18 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 19 | fastcgi_param PATH_INFO $fastcgi_path_info; 20 | fastcgi_read_timeout 9999; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/tests/__init__.py -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Set package-wise test properties""" 2 | import allure 3 | 4 | pytestmark = [allure.parent_suite("API")] 5 | -------------------------------------------------------------------------------- /tests/api/test_methods.py: -------------------------------------------------------------------------------- 1 | """APP API methods checks""" 2 | 3 | from typing import List 4 | 5 | import pytest 6 | import allure 7 | 8 | from tests.test_data.generators import TestData, get_data_for_methods_check 9 | from tests.test_data.db_filler import DbFiller 10 | 11 | pytestmark = [allure.suite("Allowed methods tests")] 12 | 13 | 14 | @pytest.fixture(params=get_data_for_methods_check()) 15 | def prepare_data(request, app_fs): 16 | """ 17 | Generate request body here since it depends on actual APP instance 18 | and can't be determined when generating 19 | """ 20 | test_data_list: List[TestData] = request.param 21 | for test_data in test_data_list: 22 | request_data = DbFiller(app=app_fs).generate_valid_request_data( 23 | endpoint=test_data.request.endpoint, method=test_data.request.method 24 | ) 25 | 26 | test_data.request.data = request_data["data"] 27 | test_data.request.object_id = request_data.get("object_id") 28 | 29 | return app_fs, test_data_list 30 | 31 | 32 | def test_methods(prepare_data): 33 | """ 34 | Test allowed methods 35 | Generate request and response pairs depending on allowed and not allowed methods 36 | for all api endpoints 37 | """ 38 | app, test_data_list = prepare_data 39 | for test_data in test_data_list: 40 | app.exec_request( 41 | request=test_data.request, expected_response=test_data.response 42 | ) 43 | -------------------------------------------------------------------------------- /tests/api/test_request_body/__init__.py: -------------------------------------------------------------------------------- 1 | """Set package-wise test properties""" 2 | import allure 3 | 4 | pytestmark = [allure.parent_suite("API"), allure.suite("Body tests")] 5 | -------------------------------------------------------------------------------- /tests/api/test_request_body/test_post.py: -------------------------------------------------------------------------------- 1 | """APP API POST body tests""" 2 | from typing import List 3 | from copy import deepcopy 4 | 5 | import allure 6 | import pytest 7 | 8 | from tests.test_data.generators import ( 9 | get_positive_data_for_post_body_check, 10 | get_negative_data_for_post_body_check, 11 | TestData, 12 | TestDataWithPreparedBody, 13 | ) 14 | from tests.test_data.db_filler import DbFiller 15 | 16 | from tests.utils.methods import Methods 17 | from tests.utils.types import get_fields 18 | from tests.utils.api_objects import APPApi 19 | 20 | 21 | pytestmark = [ 22 | allure.sub_suite("POST"), 23 | ] 24 | 25 | 26 | @pytest.fixture() 27 | def prepare_post_body_data(request, app_fs: APPApi): 28 | """ 29 | Fixture for preparing test data for POST request, depending on generated test datasets 30 | """ 31 | test_data_list: List[TestDataWithPreparedBody] = request.param 32 | valid_request_data = DbFiller(app=app_fs).generate_valid_request_data( 33 | endpoint=test_data_list[0].test_data.request.endpoint, method=Methods.POST 34 | ) 35 | final_test_data_list: List[TestData] = [] 36 | for test_data_with_prepared_values in test_data_list: 37 | test_data, prepared_field_values = test_data_with_prepared_values 38 | test_data.request.data = deepcopy(valid_request_data["data"]) 39 | for field in get_fields(test_data.request.endpoint.data_class): 40 | if field.name in prepared_field_values: 41 | if not prepared_field_values[field.name].drop_key: 42 | 43 | valid_field_value = None 44 | if field.name in test_data.request.data: 45 | valid_field_value = test_data.request.data[field.name] 46 | test_data.request.data[field.name] = prepared_field_values[ 47 | field.name 48 | ].return_value(valid_field_value) 49 | 50 | else: 51 | if field.name in test_data.request.data: 52 | del test_data.request.data[field.name] 53 | final_test_data_list.append(test_data) 54 | 55 | return app_fs, final_test_data_list 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "prepare_post_body_data", get_positive_data_for_post_body_check(), indirect=True 60 | ) 61 | def test_post_body_positive(prepare_post_body_data): 62 | """ 63 | Positive cases of request body testing 64 | Includes sets of correct field values - boundary values, nullable and required if possible. 65 | """ 66 | app, test_data_list = prepare_post_body_data 67 | for test_data in test_data_list: 68 | with allure.step(f"Assert - {test_data.description}"): 69 | app.exec_request( 70 | request=test_data.request, expected_response=test_data.response 71 | ) 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "prepare_post_body_data", get_negative_data_for_post_body_check(), indirect=True 76 | ) 77 | def test_post_body_negative(prepare_post_body_data): 78 | """ 79 | Negative cases of request body testing 80 | Includes sets of invalid field values - out of boundary values, 81 | nullable and required if not possible, fields with incorrect types etc. 82 | """ 83 | app, test_data_list = prepare_post_body_data 84 | for test_data in test_data_list: 85 | with allure.step(title=f"Assert - {test_data.description}"): 86 | app.exec_request( 87 | request=test_data.request, expected_response=test_data.response 88 | ) 89 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """APP fixtures""" 2 | 3 | import time 4 | 5 | import allure 6 | import docker 7 | import pytest 8 | import requests 9 | 10 | from docker.errors import NotFound 11 | from retry.api import retry_call 12 | 13 | from .utils.docker import ( 14 | APP, 15 | DockerWrapper, 16 | get_initialized_app_image, 17 | gather_app_data_from_container, 18 | ) 19 | from .utils.api_objects import APPApi 20 | from .utils.tools import split_tag 21 | 22 | 23 | @pytest.hookimpl(tryfirst=True, hookwrapper=True) 24 | def pytest_runtest_makereport(item, call): 25 | """ 26 | There is no default info about test stages execution available in pytest 27 | This hook is meant to store such info in metadata 28 | """ 29 | # execute all other hooks to obtain the report object 30 | outcome = yield 31 | rep = outcome.get_result() 32 | 33 | # set a report attribute for each phase of a call, which can 34 | # be "setup", "call", "teardown" 35 | 36 | setattr(item, "rep_" + rep.when, rep) 37 | 38 | 39 | def pytest_addoption(parser): 40 | """ 41 | Additional options for APP testing 42 | """ 43 | # Do not stop APP container after test execution 44 | # It is really useful for debugging 45 | parser.addoption("--dontstop", action="store_true", default=False) 46 | 47 | parser.addoption( 48 | "--app-image", 49 | action="store", 50 | default=None, 51 | help="Ex: app:latest", 52 | ) 53 | 54 | 55 | @pytest.fixture(scope="session") 56 | def cmd_opts(request): 57 | """Returns pytest request options object""" 58 | return request.config.option 59 | 60 | 61 | def _image(request, cmd_opts): 62 | """This fixture creates APP container, waits until 63 | database becomes initialised and store that as images 64 | with random tag and name local/app 65 | 66 | That can be useful to use that fixture to make APP 67 | container startup time shorter. 68 | 69 | Fixture returns list: 70 | repo, tag 71 | """ 72 | 73 | dc = docker.from_env() 74 | params = dict() 75 | 76 | if cmd_opts.app_image: 77 | params["app_repo"], params["app_tag"] = split_tag(image_name=cmd_opts.app_image) 78 | 79 | init_image = get_initialized_app_image(dc=dc, **params) 80 | 81 | if not cmd_opts.dontstop: 82 | 83 | def fin(): 84 | image_name = "{}:{}".format(*init_image.values()) 85 | for container in dc.containers.list(filters=dict(ancestor=image_name)): 86 | try: 87 | container.wait(condition="removed", timeout=30) 88 | except ConnectionError: 89 | # https://github.com/docker/docker-py/issues/1966 workaround 90 | pass 91 | dc.images.remove(image_name, force=True) 92 | containers = dc.containers.list(filters=dict(ancestor=image_name)) 93 | if len(containers) > 0: 94 | raise RuntimeWarning(f"There are containers left! {containers}") 95 | 96 | request.addfinalizer(fin) 97 | 98 | return init_image["repo"], init_image["tag"] 99 | 100 | 101 | def _app(image, request) -> APP: 102 | repo, tag = image 103 | dw = DockerWrapper() 104 | app = dw.run_app(image=repo, tag=tag) 105 | 106 | def fin(): 107 | if not request.config.option.dontstop: 108 | gather = True 109 | try: 110 | if not request.node.rep_call.failed: 111 | gather = False 112 | except AttributeError: 113 | # There is no rep_call attribute. Presumably test setup failed, 114 | # or fixture scope is not function. Will collect /adcm/data anyway 115 | pass 116 | if gather: 117 | with allure.step( 118 | f"Gather /var/www/html/storage/app/ from APP container: {app.container.id}" 119 | ): 120 | file_name = f"{request.node.name}_{time.time()}" 121 | try: 122 | with gather_app_data_from_container(app) as data: 123 | allure.attach( 124 | data, name="{}.tgz".format(file_name), extension="tgz" 125 | ) 126 | except NotFound: 127 | pass 128 | 129 | try: 130 | retry_call( 131 | app.container.kill, 132 | exceptions=requests.exceptions.ConnectionError, 133 | tries=5, 134 | delay=5, 135 | ) 136 | except NotFound: 137 | pass 138 | 139 | request.addfinalizer(fin) 140 | 141 | return app 142 | 143 | 144 | @pytest.fixture(scope="session") 145 | def image(request, cmd_opts): 146 | """ 147 | Image fixture (session scope) 148 | """ 149 | return _image(request, cmd_opts) 150 | 151 | 152 | @pytest.fixture() 153 | def app_fs(image, request) -> APPApi: 154 | """Runs APP container with a previously initialized image. 155 | Returns authorized instance of APPApi object 156 | """ 157 | return _app(image, request).api 158 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | negative: marks tests as negative (deselect with '-m "not negative"') 4 | positive: marks tests as positive (select only positive with '-m "positive"') 5 | python_classes = *Test 6 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-xdist 3 | allure-pytest 4 | docker 5 | retry 6 | multipledispatch 7 | requests_toolbelt -------------------------------------------------------------------------------- /tests/steps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/tests/steps/__init__.py -------------------------------------------------------------------------------- /tests/steps/asserts.py: -------------------------------------------------------------------------------- 1 | """Some asserts with allure steps""" 2 | import json 3 | from http import HTTPStatus 4 | 5 | import allure 6 | from requests import Response 7 | 8 | 9 | class BodyAssertionError(AssertionError): 10 | """Raised when body is not as expected""" 11 | 12 | 13 | @allure.step("Response status code should be equal {status_code}") 14 | def status_code_should_be(response: Response, status_code=HTTPStatus.OK): 15 | """Assert response status code""" 16 | assert ( 17 | response.status_code == status_code 18 | ), f"Expecting status code {status_code} but got {response.status_code}" 19 | 20 | 21 | @allure.step("Response body should be") 22 | def body_should_be(response: Response, expected_body=None): 23 | """Assert response body and attach it""" 24 | actual_body = response.json() 25 | allure.attach( 26 | json.dumps(expected_body, indent=2), 27 | name="Expected body", 28 | attachment_type=allure.attachment_type.JSON, 29 | ) 30 | allure.attach( 31 | json.dumps(actual_body, indent=2), 32 | name="Actual body", 33 | attachment_type=allure.attachment_type.JSON, 34 | ) 35 | try: 36 | assert actual_body == expected_body, "Response body assertion failed!" 37 | except AssertionError as error: 38 | raise BodyAssertionError(error) from error 39 | -------------------------------------------------------------------------------- /tests/steps/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Assume step implementation for allure 3 | """ 4 | from contextlib import suppress 5 | from functools import wraps 6 | 7 | import allure 8 | from _pytest.outcomes import Skipped 9 | 10 | 11 | def assume_step(title, exception=None): 12 | """ 13 | Allows you to suppress exception within the Allure steps. 14 | Depending on the type of allowed exception, step will receive different statuses - 15 | - Skipped if suppress Skipped (from pytest.skip()) 16 | - Failed if suppress AssertionError 17 | - Broken if other 18 | 19 | :param title: same as allure.step() title 20 | :param exception: allowed exception 21 | 22 | Examples: 23 | 24 | with assume_step('Skipped step'): 25 | pytest.skip() 26 | with assume_step('Failed step', exception=AssertionError): 27 | raise AssertionError("This assert don't fail test") 28 | with assume_step('Broken step', exception=ValueError): 29 | raise ValueError("This is expected exception") 30 | 31 | """ 32 | if callable(title): 33 | return AssumeStepContext(title.__name__, exception)(title) 34 | else: 35 | return AssumeStepContext(title, exception) 36 | 37 | 38 | class AssumeStepContext: 39 | """ 40 | Step context class 41 | """ 42 | 43 | def __init__(self, title, exception=None): 44 | self.title = title 45 | self.exceptions = (Skipped, exception) if exception else Skipped 46 | self.allure_cm = allure.step(title) 47 | self.suppress = suppress(self.exceptions) 48 | 49 | def __call__(self, func): 50 | @wraps(func) 51 | def decorator(*args, **kwargs): 52 | with self.suppress: 53 | return allure.step(self.title)(func)(*args, **kwargs) 54 | 55 | return decorator 56 | 57 | def __enter__(self): 58 | return self.allure_cm.__enter__() 59 | 60 | def __exit__(self, exc_type, exc_val, exc_tb): 61 | self.allure_cm.__exit__(exc_type, exc_val, exc_tb) 62 | return self.suppress.__exit__(exc_type, exc_val, exc_tb) 63 | -------------------------------------------------------------------------------- /tests/test_data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/tests/test_data/__init__.py -------------------------------------------------------------------------------- /tests/test_data/db_filler.py: -------------------------------------------------------------------------------- 1 | """Fill DB methods""" 2 | 3 | import random 4 | from collections import defaultdict 5 | 6 | import allure 7 | 8 | from tests.test_data.getters import get_endpoint_data, get_object_data 9 | from tests.utils.api_objects import Request, ExpectedResponse, APPApi 10 | from tests.utils.endpoints import Endpoints 11 | from tests.utils.methods import Methods 12 | from tests.utils.types import ( 13 | get_fields, 14 | Field, 15 | is_fk_field, 16 | ForeignKey, 17 | get_field_name_by_fk_dataclass, 18 | ) 19 | 20 | 21 | class DbFiller: 22 | """Utils to prepare data in DB before test""" 23 | 24 | __slots__ = ("app", "_available_fkeys", "_used_fkeys") 25 | 26 | def __init__(self, app: APPApi): 27 | self.app = app 28 | self._available_fkeys = defaultdict(set) 29 | self._used_fkeys = {} 30 | 31 | @allure.step("Generate valid request data") 32 | def generate_valid_request_data(self, endpoint: Endpoints, method: Methods) -> dict: 33 | """ 34 | Return valid request body and url params for endpoint and method combination 35 | """ 36 | # POST 37 | if method == Methods.POST: 38 | return { 39 | "data": self._get_or_create_data_for_endpoint( 40 | endpoint=endpoint, 41 | force=True, 42 | prepare_data_only=True, 43 | )[0], 44 | "url_params": {}, 45 | } 46 | # LIST 47 | if method == Methods.LIST: 48 | self._get_or_create_multiple_data_for_endpoint(endpoint=endpoint, count=3) 49 | return {"data": None, "url_params": {}} 50 | 51 | full_item = get_object_data( 52 | app=self.app, 53 | endpoint=endpoint, 54 | object_id=self._get_or_create_data_for_endpoint(endpoint=endpoint)[0]["id"], 55 | ) 56 | # GET 57 | if method == Methods.GET: 58 | return {"data": None, "url_params": {}, "object_id": full_item["id"]} 59 | raise ValueError(f"No such method {method}") 60 | 61 | def _get_or_create_data_for_endpoint( 62 | self, endpoint: Endpoints, force=False, prepare_data_only=False 63 | ): 64 | """ 65 | Get data for endpoint with data preparation 66 | """ 67 | if force and not prepare_data_only and Methods.POST not in endpoint.methods: 68 | if current_ep_data := get_endpoint_data(app=self.app, endpoint=endpoint): 69 | return current_ep_data 70 | raise ValueError( 71 | f"Force data creation is not available for {endpoint.path}" 72 | "and there is no any existing data" 73 | ) 74 | 75 | if not force: 76 | # try to fetch data from current endpoint 77 | if current_ep_data := get_endpoint_data(app=self.app, endpoint=endpoint): 78 | return current_ep_data 79 | 80 | data = self._prepare_data_for_object_creation(endpoint=endpoint, force=force) 81 | 82 | for data_class in endpoint.data_class.implicitly_depends_on: 83 | self._get_or_create_data_for_endpoint( 84 | endpoint=Endpoints.get_by_data_class(data_class), force=force 85 | ) 86 | 87 | if not prepare_data_only: 88 | response = self.app.exec_request( 89 | request=Request(endpoint=endpoint, method=Methods.POST, data=data), 90 | expected_response=ExpectedResponse( 91 | status_code=Methods.POST.value.default_success_code 92 | ), 93 | ) 94 | return [response.json()] 95 | return [data] 96 | 97 | def _prepare_data_for_object_creation(self, endpoint: Endpoints, force=False): 98 | data = {} 99 | for field in get_fields( 100 | data_class=endpoint.data_class, 101 | predicate=lambda x: x.name != "id" and is_fk_field(x), 102 | ): 103 | fk_data = get_endpoint_data( 104 | app=self.app, 105 | endpoint=Endpoints.get_by_data_class(field.f_type.fk_link), 106 | ) 107 | if not fk_data or force: 108 | fk_data = self._get_or_create_data_for_endpoint( 109 | endpoint=Endpoints.get_by_data_class(field.f_type.fk_link), 110 | force=force, 111 | ) 112 | data[field.name] = self._choose_fk_field_value(field=field, fk_data=fk_data) 113 | for field in get_fields( 114 | data_class=endpoint.data_class, 115 | predicate=lambda x: x.name != "id" and not is_fk_field(x), 116 | ): 117 | data[field.name] = field.f_type.generate() 118 | 119 | return data 120 | 121 | def _get_or_create_multiple_data_for_endpoint( 122 | self, endpoint: Endpoints, count: int 123 | ): 124 | """ 125 | Method for multiple data creation for given endpoint. 126 | For each object new object chain will be created. 127 | If endpoint does not allow data creation of any kind (POST, indirect creation, etc.) 128 | method will proceed without data creation or errors 129 | IMPORTANT: Class context _available_fkeys and _used_fkeys 130 | will be relevant only for the last object in set 131 | """ 132 | current_ep_data = get_endpoint_data(app=self.app, endpoint=endpoint) 133 | if len(current_ep_data) < count: 134 | for _ in range(count - len(current_ep_data)): 135 | # clean up context before generating new element 136 | self._available_fkeys = defaultdict(set) 137 | self._used_fkeys = {} 138 | self._get_or_create_data_for_endpoint( 139 | endpoint=endpoint, 140 | force=True, 141 | prepare_data_only=False, 142 | ) 143 | if len(get_endpoint_data(app=self.app, endpoint=endpoint)) > count: 144 | break 145 | 146 | def _choose_fk_field_value(self, field: Field, fk_data: list): 147 | """Choose a random fk value for the specified field""" 148 | if isinstance(field.f_type, ForeignKey): 149 | fk_class_name = field.f_type.fk_link.__name__ 150 | if fk_class_name in self._available_fkeys: 151 | new_fk = False 152 | fk_vals = self._available_fkeys[fk_class_name] 153 | else: 154 | new_fk = True 155 | fk_vals = {el["id"] for el in fk_data} 156 | 157 | key = random.choice(list(fk_vals)) 158 | result = key 159 | self._used_fkeys[fk_class_name] = key 160 | self._available_fkeys[fk_class_name].add(key) 161 | if new_fk: 162 | self._add_child_fk_values_to_available_fkeys( 163 | fk_ids=[key], fk_data_class=field.f_type.fk_link 164 | ) 165 | return result 166 | # if field is not FK 167 | raise ValueError("Argument field is not FKey!") 168 | 169 | def _add_child_fk_values_to_available_fkeys(self, fk_ids: list, fk_data_class): 170 | """Add information about child FK values to metadata for further consistency""" 171 | for child_fk_field in get_fields( 172 | data_class=fk_data_class, predicate=is_fk_field 173 | ): 174 | fk_field_name = get_field_name_by_fk_dataclass( 175 | data_class=fk_data_class, fk_data_class=child_fk_field.f_type.fk_link 176 | ) 177 | for fk_id in fk_ids: 178 | fk_data = get_object_data( 179 | app=self.app, 180 | endpoint=Endpoints.get_by_data_class(fk_data_class), 181 | object_id=fk_id, 182 | ) 183 | self._available_fkeys[child_fk_field.f_type.fk_link.__name__].add( 184 | fk_data[fk_field_name]["id"] 185 | ) 186 | -------------------------------------------------------------------------------- /tests/test_data/generators.py: -------------------------------------------------------------------------------- 1 | """Methods for generate test data""" 2 | 3 | from collections import ChainMap 4 | from http import HTTPStatus 5 | from typing import NamedTuple, List 6 | 7 | import attr 8 | import pytest 9 | from _pytest.mark.structures import ParameterSet 10 | 11 | from tests.utils.api_objects import Request, ExpectedResponse 12 | from tests.utils.endpoints import Endpoints 13 | from tests.utils.methods import Methods 14 | from tests.utils.tools import fill_lists_by_longest 15 | from tests.utils.types import ( 16 | get_fields, 17 | BaseType, 18 | PreparedFieldValue, 19 | ) 20 | 21 | 22 | class MaxRetriesError(BaseException): 23 | """Raise when limit of retries exceeded""" 24 | 25 | 26 | @attr.dataclass(repr=False) 27 | class TestData: 28 | """Pair of request and expected response for api tests""" 29 | 30 | request: Request 31 | response: ExpectedResponse 32 | description: str = None 33 | 34 | def __repr__(self): 35 | return ( 36 | f"{self.request.method.name} {self.request.endpoint.path} " 37 | f"and expect {self.response.status_code} status code. at {hex(id(self))}" 38 | ) 39 | 40 | 41 | class TestDataWithPreparedBody(NamedTuple): 42 | """ 43 | Class for separating request body and data needed to send and assert it 44 | """ 45 | 46 | test_data: TestData 47 | test_body: dict 48 | 49 | 50 | def _fill_pytest_param( 51 | value: List[TestDataWithPreparedBody] or List[TestData], 52 | endpoint: Endpoints, 53 | method: Methods, 54 | positive=True, 55 | addition=None, 56 | ) -> ParameterSet: 57 | """ 58 | Create pytest.param for each test data set 59 | """ 60 | marks = [] 61 | if positive: 62 | marks.append(pytest.mark.positive) 63 | positive_str = "positive" 64 | else: 65 | marks.append(pytest.mark.negative) 66 | positive_str = "negative" 67 | param_id = f"{endpoint.path}_{method.name}_{positive_str}" 68 | if addition: 69 | param_id += f"_{addition}" 70 | return pytest.param(value, marks=marks, id=param_id) 71 | 72 | 73 | def get_data_for_methods_check(): 74 | """ 75 | Get test data for allowed methods test 76 | """ 77 | test_data = [] 78 | for endpoint in Endpoints: 79 | for method in Methods: 80 | request = Request( 81 | method=method, 82 | endpoint=endpoint, 83 | ) 84 | if method in endpoint.methods: 85 | response = ExpectedResponse(status_code=method.default_success_code) 86 | else: 87 | response = ExpectedResponse(status_code=HTTPStatus.METHOD_NOT_ALLOWED) 88 | 89 | test_data.append( 90 | _fill_pytest_param( 91 | [TestData(request=request, response=response)], 92 | endpoint=endpoint, 93 | method=method, 94 | positive=response.status_code != HTTPStatus.METHOD_NOT_ALLOWED, 95 | ) 96 | ) 97 | return test_data 98 | 99 | 100 | def get_positive_data_for_post_body_check(): 101 | """ 102 | Generates positive datasets for POST method 103 | """ 104 | test_sets = [] 105 | for endpoint in Endpoints: 106 | if Methods.POST in endpoint.methods: 107 | test_sets.append( 108 | ( 109 | endpoint, 110 | [ 111 | _get_special_body_datasets( 112 | endpoint, 113 | desc="All fields with special valid values", 114 | method=Methods.POST, 115 | positive_case=True, 116 | ), 117 | _get_datasets( 118 | endpoint, 119 | desc="All fields with special valid values", 120 | field_conditions=lambda x: True, 121 | value_properties={"generated_value": True}, 122 | ), 123 | ], 124 | ) 125 | ) 126 | return get_data_for_body_check(Methods.POST, test_sets, positive=True) 127 | 128 | 129 | def get_negative_data_for_post_body_check(): 130 | """ 131 | Generates negative datasets for POST method 132 | """ 133 | test_sets = [] 134 | for endpoint in Endpoints: 135 | if Methods.POST in endpoint.methods: 136 | test_sets.append( 137 | ( 138 | endpoint, 139 | [ 140 | _get_datasets( 141 | endpoint, 142 | desc="Drop fields with Required=True", 143 | field_conditions=lambda x: x.required, 144 | value_properties={ 145 | "error_message": BaseType.error_message_required, 146 | "drop_key": True, 147 | }, 148 | ), 149 | _get_special_body_datasets( 150 | endpoint, 151 | desc="Invalid field types and values", 152 | method=Methods.POST, 153 | positive_case=False, 154 | ), 155 | ], 156 | ) 157 | ) 158 | return get_data_for_body_check(Methods.POST, test_sets, positive=False) 159 | 160 | 161 | def get_data_for_body_check( 162 | method: Methods, endpoints_with_test_sets: List[tuple], positive: bool 163 | ): 164 | """ 165 | Collect test sets for body testing 166 | Each test set is set of data params where values are PreparedFieldValue instances 167 | :param method: 168 | :param endpoints_with_test_sets: 169 | :param positive: collect positive or negative datasets. 170 | Negative datasets additionally checks of response body for correct errors. 171 | In positive cases it doesn't make sense 172 | """ 173 | test_data = [] 174 | for endpoint, test_groups in endpoints_with_test_sets: 175 | for test_group, group_name in test_groups: 176 | values: List[TestDataWithPreparedBody] = [] 177 | for test_set in test_group: 178 | status_code = ( 179 | method.default_success_code 180 | if positive 181 | else HTTPStatus.UNPROCESSABLE_ENTITY 182 | ) 183 | # It makes no sense to check with all fields if test_set contains only one field 184 | if positive or len(test_set) > 1: 185 | values.append( 186 | _prepare_test_data_with_all_fields( 187 | endpoint, method, status_code, test_set 188 | ) 189 | ) 190 | 191 | if not positive: 192 | values.extend( 193 | _prepare_test_data_with_one_by_one_fields( 194 | endpoint, method, status_code, test_set 195 | ) 196 | ) 197 | if positive: 198 | for value in values: 199 | test_data.append( 200 | _fill_pytest_param( 201 | [value], 202 | endpoint=endpoint, 203 | method=method, 204 | positive=positive, 205 | addition=group_name, 206 | ) 207 | ) 208 | elif values: 209 | test_data.append( 210 | _fill_pytest_param( 211 | values, 212 | endpoint=endpoint, 213 | method=method, 214 | positive=positive, 215 | addition=group_name, 216 | ) 217 | ) 218 | return test_data 219 | 220 | 221 | def _prepare_test_data_with_all_fields( 222 | endpoint: Endpoints, method: Methods, status_code: int, test_set: dict 223 | ) -> TestDataWithPreparedBody: 224 | request = Request(method=method, endpoint=endpoint) 225 | response = ExpectedResponse(status_code=status_code) 226 | 227 | return TestDataWithPreparedBody( 228 | test_data=TestData( 229 | request=request, 230 | response=response, 231 | description=f"All fields without body checks - {_step_description(test_set)}", 232 | ), 233 | test_body=test_set, 234 | ) 235 | 236 | 237 | def _step_description(test_set: dict): 238 | first_item = next(iter(test_set.values())) 239 | if first_item.generated_value is True: 240 | return "Generated value: " + ", ".join(test_set.keys()) 241 | if first_item.drop_key is True: 242 | return "Missing in request: " + ", ".join(test_set.keys()) 243 | return "Special values: " + ", ".join(test_set.keys()) 244 | 245 | 246 | def _prepare_test_data_with_one_by_one_fields( 247 | endpoint: Endpoints, method: Methods, status_code: int, test_set: dict 248 | ) -> List[TestDataWithPreparedBody]: 249 | test_data_list = [] 250 | for param_name, param_value in test_set.items(): 251 | request_data = {} 252 | if not param_value.error_messages: 253 | continue 254 | else: 255 | param_value.error_messages = list( 256 | map( 257 | lambda x: x.format(name=param_name.replace("_", " ")), 258 | param_value.error_messages, 259 | ) 260 | ) 261 | body = {param_name: param_value.get_error_data()} 262 | request_data[param_name] = param_value 263 | request = Request(method=method, endpoint=endpoint) 264 | response = ExpectedResponse(status_code=status_code, body=body) 265 | test_data_list.append( 266 | TestDataWithPreparedBody( 267 | test_data=TestData( 268 | request=request, 269 | response=response, 270 | description=f"{param_name}: {param_value.error_messages}", 271 | ), 272 | test_body=request_data, 273 | ) 274 | ) 275 | return test_data_list 276 | 277 | 278 | def _get_datasets( 279 | endpoint: Endpoints, 280 | desc, 281 | field_conditions, 282 | value_properties: dict, 283 | ) -> (list, str): 284 | """Generic dataset creator for editing request data later""" 285 | dataset = {} 286 | if "generated_value" in value_properties and "value" in value_properties: 287 | raise ValueError("'generated_value', 'value' properties are not compatible") 288 | for field in get_fields(endpoint.data_class): 289 | if field_conditions(field): 290 | dataset[field.name] = PreparedFieldValue( 291 | value=value_properties.get("value", None), 292 | generated_value=value_properties.get("generated_value", False), 293 | error_messages=[value_properties.get("error_message", None)], 294 | drop_key=value_properties.get("drop_key", False), 295 | f_type=field.f_type, 296 | ) 297 | return [dataset] if dataset else [], desc 298 | 299 | 300 | def _get_special_body_datasets( 301 | endpoint: Endpoints, desc, method: Methods, positive_case: bool 302 | ) -> (list, str): 303 | """Get datasets with based on special values for fields""" 304 | datasets = [] 305 | special_values = {} 306 | for field in get_fields(endpoint.data_class, predicate=lambda x: x.name != "id"): 307 | if method == Methods.POST: 308 | if positive_case: 309 | special_values[field.name] = field.f_type.get_positive_values() 310 | else: 311 | negative_values = get_fields( 312 | endpoint.data_class, 313 | predicate=lambda x: x.f_type.get_negative_values(), 314 | ) 315 | if negative_values: 316 | special_values[field.name] = field.f_type.get_negative_values() 317 | if special_values: 318 | fill_lists_by_longest(special_values.values()) 319 | for name, values in special_values.copy().items(): 320 | special_values[name] = [{name: value} for value in values] 321 | for values in zip(*special_values.values()): 322 | datasets.append(dict(ChainMap(*values))) 323 | return datasets, desc 324 | -------------------------------------------------------------------------------- /tests/test_data/getters.py: -------------------------------------------------------------------------------- 1 | """Methods for get endpoints data""" 2 | 3 | from tests.utils.endpoints import Endpoints 4 | from tests.utils.methods import Methods 5 | from tests.utils.api_objects import Request, ExpectedResponse, APPApi 6 | 7 | 8 | def get_endpoint_data(app: APPApi, endpoint: Endpoints) -> list: 9 | """ 10 | Fetch endpoint data with LIST method 11 | Data of LIST method excludes links to related objects and huge fields 12 | """ 13 | if Methods.LIST not in endpoint.methods: 14 | raise AttributeError( 15 | f"Method {Methods.LIST.name} is not available for endpoint {endpoint.path}" 16 | ) 17 | res = app.exec_request( 18 | request=Request(endpoint=endpoint, method=Methods.LIST), 19 | expected_response=ExpectedResponse( 20 | status_code=Methods.LIST.value.default_success_code 21 | ), 22 | ) 23 | return res.json() 24 | 25 | 26 | def get_object_data(app: APPApi, endpoint: Endpoints, object_id: int) -> dict: 27 | """ 28 | Fetch full object data includes huge field and links to related objects 29 | """ 30 | res = app.exec_request( 31 | request=Request(endpoint=endpoint, method=Methods.GET, object_id=object_id), 32 | expected_response=ExpectedResponse( 33 | status_code=Methods.GET.value.default_success_code 34 | ), 35 | ) 36 | return res.json() 37 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arenadata/truly_automated_tests/8f578803f1990570d61034014572caee9a02238c/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/api_objects.py: -------------------------------------------------------------------------------- 1 | """Module contains api objects for executing and checking requests""" 2 | from urllib.parse import urlencode 3 | 4 | import allure 5 | import attr 6 | 7 | from .endpoints import Endpoints 8 | from .methods import Methods 9 | from .tools import attach_request_log 10 | from ..steps.asserts import status_code_should_be, body_should_be 11 | 12 | 13 | @attr.dataclass 14 | class Request: 15 | """Request for a specific endpoint""" 16 | 17 | method: Methods 18 | endpoint: Endpoints 19 | object_id: int = None 20 | url_params: dict = {} 21 | headers: dict = {} 22 | data: dict = {} 23 | 24 | 25 | @attr.dataclass 26 | class ExpectedResponse: 27 | """Response to be expected. Checking the status code and body if present""" 28 | 29 | status_code: int 30 | body: dict = None 31 | 32 | 33 | class APPApi: 34 | """APP api wrapper""" 35 | 36 | __slots__ = ("_url",) 37 | 38 | _api_prefix = "" 39 | 40 | def __init__(self, url="http://localhost:8000"): 41 | self._url = url 42 | 43 | @property 44 | def _base_url(self): 45 | return f"{self._url}{self._api_prefix}" 46 | 47 | def exec_request(self, request: Request, expected_response: ExpectedResponse): 48 | """ 49 | Execute HTTP request based on "request" argument. 50 | Assert response params amd values based on "expected_response" argument. 51 | """ 52 | url = self.get_url_for_endpoint( 53 | endpoint=request.endpoint, 54 | method=request.method, 55 | object_id=request.object_id, 56 | ) 57 | url_params = request.url_params.copy() 58 | 59 | step_name = f"Send {request.method.name} {url.replace(self._base_url, '')}" 60 | if url_params: 61 | step_name += f"?{urlencode(url_params)}" 62 | with allure.step(step_name): 63 | response = request.method.function( 64 | url=url, 65 | params=url_params, 66 | json=request.data, 67 | headers=request.headers, 68 | ) 69 | 70 | attach_request_log(response) 71 | 72 | status_code_should_be( 73 | response=response, status_code=expected_response.status_code 74 | ) 75 | 76 | if expected_response.body is not None: 77 | body_should_be(response=response, expected_body=expected_response.body) 78 | 79 | return response 80 | 81 | def get_url_for_endpoint( 82 | self, endpoint: Endpoints, method: Methods, object_id: int 83 | ): 84 | """ 85 | Return direct link for endpoint object 86 | """ 87 | if "{id}" in method.url_template: 88 | if object_id is None: 89 | raise ValueError( 90 | "Request template requires 'id', but 'request.object_id' is None" 91 | ) 92 | url = method.url_template.format(name=endpoint.path, id=object_id) 93 | else: 94 | url = method.url_template.format(name=endpoint.path) 95 | 96 | return f"{self._base_url}{url}" 97 | -------------------------------------------------------------------------------- /tests/utils/data_classes.py: -------------------------------------------------------------------------------- 1 | """Endpoint data classes definition""" 2 | 3 | from abc import ABC 4 | from typing import List 5 | 6 | from .types import ( 7 | Field, 8 | PositiveInt, 9 | String, 10 | Text, 11 | ForeignKey, 12 | ) 13 | 14 | 15 | class BaseClass(ABC): 16 | """Base data class""" 17 | 18 | implicitly_depends_on: List["BaseClass"] = [] 19 | 20 | 21 | class ClusterFields(BaseClass): 22 | """ 23 | Data type class for /cluster 24 | """ 25 | 26 | id = Field(name="id", f_type=PositiveInt()) 27 | name = Field(name="name", f_type=String(max_length=255), required=True) 28 | description = Field(name="description", f_type=Text(max_length=2000)) 29 | 30 | 31 | class FileSystemFields(BaseClass): 32 | """ 33 | Data type class for /file-system 34 | """ 35 | 36 | id = Field(name="id", f_type=PositiveInt()) 37 | name = Field(name="name", f_type=String(max_length=255), required=True) 38 | description = Field(name="description", f_type=Text(max_length=2000)) 39 | 40 | 41 | class ConnectionFields(BaseClass): 42 | """ 43 | Data type class for /connection 44 | """ 45 | 46 | id = Field(name="id", f_type=PositiveInt()) 47 | name = Field(name="name", f_type=String(max_length=255), required=True) 48 | cluster = Field( 49 | name="cluster_id", f_type=ForeignKey(fk_link=ClusterFields), required=True 50 | ) 51 | filesystem = Field( 52 | name="filesystem_id", f_type=ForeignKey(fk_link=FileSystemFields), required=True 53 | ) 54 | 55 | 56 | class BackupFields(BaseClass): 57 | """ 58 | Data type class for /backup 59 | """ 60 | 61 | implicitly_depends_on = [ConnectionFields] 62 | 63 | id = Field(name="id", f_type=PositiveInt()) 64 | name = Field(name="name", f_type=String(max_length=255), required=True) 65 | cluster = Field( 66 | name="cluster_id", f_type=ForeignKey(fk_link=ClusterFields), required=True 67 | ) 68 | filesystem = Field( 69 | name="filesystem_id", f_type=ForeignKey(fk_link=FileSystemFields), required=True 70 | ) 71 | -------------------------------------------------------------------------------- /tests/utils/docker.py: -------------------------------------------------------------------------------- 1 | """Module helps to run APP in docker""" 2 | import io 3 | import random 4 | import socket 5 | from contextlib import contextmanager 6 | from gzip import compress 7 | 8 | import allure 9 | import docker 10 | from docker.errors import APIError, ImageNotFound 11 | 12 | from .api_objects import APPApi 13 | from .tools import wait_for_url, random_string 14 | 15 | MIN_DOCKER_PORT = 8000 16 | MAX_DOCKER_PORT = 9000 17 | DEFAULT_IP = "127.0.0.1" 18 | CONTAINER_START_RETRY_COUNT = 20 19 | DEFAULT_IMAGE = "app" 20 | DEFAULT_TAG = "latest" 21 | 22 | 23 | class UnableToBind(Exception): 24 | """Raise when it is impossible to get a free port""" 25 | 26 | 27 | class RetryCountExceeded(Exception): 28 | """Raise when container was not started""" 29 | 30 | 31 | def _port_is_free(ip, port): 32 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 33 | result = sock.connect_ex((ip, port)) 34 | if result == 0: 35 | return False 36 | return True 37 | 38 | 39 | def _find_random_port(ip): 40 | for _ in range(0, 20): 41 | port = random.randint(MIN_DOCKER_PORT, MAX_DOCKER_PORT) 42 | if _port_is_free(ip, port): 43 | return port 44 | raise UnableToBind("There is no free port for Docker after 20 tries.") 45 | 46 | 47 | class APP: 48 | """ 49 | Class that wraps APP Api operation over self.api 50 | and wraps docker over self.container (see docker module for info) 51 | """ 52 | 53 | def __init__(self, container, ip, port): 54 | self.container = container 55 | self.ip = ip 56 | self.port = port 57 | self.url = "http://{}:{}".format(self.ip, self.port) 58 | self.api = APPApi(self.url) 59 | 60 | def stop(self): 61 | """Stops container""" 62 | self.container.stop() 63 | 64 | 65 | class DockerWrapper: 66 | """Allow connecting to local docker daemon and spawn APP instances.""" 67 | 68 | __slots__ = ("client",) 69 | 70 | def __init__(self): 71 | self.client = docker.from_env() 72 | 73 | def run_app( 74 | self, image=None, remove=True, name=None, tag=None, ip=None, volumes=None 75 | ): 76 | """ 77 | Run APP in docker image. 78 | Return APP instance. 79 | """ 80 | if image is None: 81 | image = DEFAULT_IMAGE 82 | if tag is None: 83 | tag = DEFAULT_TAG 84 | if not ip: 85 | ip = DEFAULT_IP 86 | 87 | container, port = self.app_container( 88 | image=image, remove=remove, name=name, tag=tag, ip=ip, volumes=volumes 89 | ) 90 | 91 | wait_for_url("http://{}:{}/api/v1/".format(ip, port), 60) 92 | return APP(container, ip, port) 93 | 94 | def app_container( 95 | self, image=None, remove=True, name=None, tag=None, ip=None, volumes=None 96 | ): 97 | """ 98 | Run APP in docker image. 99 | Return APP container and bind port. 100 | """ 101 | for _ in range(0, CONTAINER_START_RETRY_COUNT): 102 | port = _find_random_port(ip) 103 | try: 104 | with allure.step(f"Run container: {image}:{tag}"): 105 | container = self.client.containers.run( 106 | "{}:{}".format(image, tag), 107 | ports={"80": (ip, port)}, 108 | volumes=volumes, 109 | remove=remove, 110 | detach=True, 111 | name=name, 112 | ) 113 | break 114 | except APIError as err: 115 | if ( 116 | "failed: port is already allocated" in err.explanation 117 | or "bind: address already in use" in err.explanation # noqa: W503 118 | ): 119 | # such error excepting leaves created container and there is 120 | # no way to clean it other than from docker library 121 | pass 122 | else: 123 | raise err 124 | else: 125 | raise RetryCountExceeded( 126 | f"Unable to start container after {CONTAINER_START_RETRY_COUNT} retries" 127 | ) 128 | return container, port 129 | 130 | 131 | @contextmanager 132 | def gather_app_data_from_container(app): 133 | """Get archived data from APP container and return it compressed""" 134 | bits, _ = app.container.get_archive("/var/www/html/storage/app/") 135 | 136 | with io.BytesIO() as stream: 137 | for chunk in bits: 138 | stream.write(chunk) 139 | stream.seek(0) 140 | yield compress(stream.getvalue()) 141 | 142 | 143 | def get_initialized_app_image( 144 | repo="local/app", tag=None, app_repo=None, app_tag=None, dc=None 145 | ) -> dict: 146 | """ 147 | If we don't know tag image must be initialized, tag will be randomly generated. 148 | """ 149 | if not dc: 150 | dc = docker.from_env() 151 | 152 | if tag and image_exists(repo, tag, dc): 153 | return {"repo": repo, "tag": tag} 154 | else: 155 | if not tag: 156 | tag = random_string() 157 | return init_app(repo, tag, app_repo, app_tag) 158 | 159 | 160 | def init_app(repo, tag, app_repo, app_tag): 161 | """Run APP and commit container as a new image""" 162 | dw = DockerWrapper() 163 | app = dw.run_app(image=app_repo, tag=app_tag, remove=False) 164 | # Create a snapshot from initialized container 165 | app.container.stop() 166 | app.container.commit(repository=repo, tag=tag) 167 | app.container.remove() 168 | return {"repo": repo, "tag": tag} 169 | 170 | 171 | def image_exists(repo, tag, dc=None): 172 | """Check that image with repo and tag exists""" 173 | if dc is None: 174 | dc = docker.from_env() 175 | try: 176 | dc.images.get(name="{}:{}".format(repo, tag)) 177 | except ImageNotFound: 178 | return False 179 | return True 180 | -------------------------------------------------------------------------------- /tests/utils/endpoints.py: -------------------------------------------------------------------------------- 1 | """APP Endpoints classes and methods""" 2 | 3 | from enum import Enum 4 | from typing import List, Type, Optional 5 | 6 | import attr 7 | 8 | from .data_classes import ( 9 | ClusterFields, 10 | FileSystemFields, 11 | BackupFields, 12 | ConnectionFields, 13 | BaseClass, 14 | ) 15 | from .methods import Methods 16 | from .types import get_fields 17 | 18 | 19 | @attr.dataclass 20 | class Endpoint: 21 | """ 22 | Endpoint class 23 | :attribute path: endpoint name 24 | :attribute methods: list of allowed methods for endpoint 25 | :attribute data_class: endpoint fields specification 26 | """ 27 | 28 | path: str 29 | methods: List[Methods] 30 | data_class: Type[BaseClass] 31 | 32 | 33 | class Endpoints(Enum): 34 | """All current endpoints""" 35 | 36 | def __init__(self, endpoint: Endpoint): 37 | self.endpoint = endpoint 38 | 39 | @property 40 | def path(self): 41 | """Getter for Endpoint.path attribute""" 42 | return self.endpoint.path 43 | 44 | @property 45 | def methods(self): 46 | """Getter for Endpoint.methods attribute""" 47 | return self.endpoint.methods 48 | 49 | @property 50 | def data_class(self): 51 | """Getter for Endpoint.data_class attribute""" 52 | return self.endpoint.data_class 53 | 54 | @classmethod 55 | def get_by_data_class(cls, data_class: Type[BaseClass]) -> Optional["Endpoints"]: 56 | """Get endpoint instance by data class""" 57 | for endpoint in cls: 58 | if endpoint.data_class == data_class: 59 | return endpoint 60 | return None 61 | 62 | def get_child_endpoint_by_fk_name(self, field_name: str) -> Optional["Endpoints"]: 63 | """Get endpoint instance by data class""" 64 | for field in get_fields(self.value.data_class): 65 | if field.name == field_name: 66 | try: 67 | return self.get_by_data_class(field.f_type.fk_link) 68 | except AttributeError: 69 | raise ValueError( 70 | f"Field {field_name} must be a Foreign Key field type" 71 | ) from AttributeError 72 | return None 73 | 74 | Cluster = Endpoint( 75 | path="cluster", 76 | methods=[ 77 | Methods.GET, 78 | Methods.LIST, 79 | Methods.POST, 80 | ], 81 | data_class=ClusterFields, 82 | ) 83 | 84 | FileSystem = Endpoint( 85 | path="file-system", 86 | methods=[ 87 | Methods.GET, 88 | Methods.LIST, 89 | Methods.POST, 90 | ], 91 | data_class=FileSystemFields, 92 | ) 93 | 94 | Connection = Endpoint( 95 | path="connection", 96 | methods=[ 97 | Methods.GET, 98 | Methods.LIST, 99 | Methods.POST, 100 | ], 101 | data_class=ConnectionFields, 102 | ) 103 | 104 | Backup = Endpoint( 105 | path="backup", 106 | methods=[ 107 | Methods.GET, 108 | Methods.LIST, 109 | Methods.POST, 110 | ], 111 | data_class=BackupFields, 112 | ) 113 | -------------------------------------------------------------------------------- /tests/utils/methods.py: -------------------------------------------------------------------------------- 1 | """Possible Methods specification""" 2 | from collections.abc import Callable 3 | from enum import Enum 4 | from http import HTTPStatus 5 | 6 | import attr 7 | import requests 8 | 9 | 10 | @attr.dataclass 11 | class Method: 12 | """Describe possible methods and how they are used in APP api""" 13 | 14 | function: Callable 15 | url_template: str 16 | default_success_code: int = HTTPStatus.OK 17 | 18 | 19 | class Methods(Enum): 20 | """All possible methods""" 21 | 22 | def __init__(self, method: Method): 23 | self.method = method 24 | 25 | @property 26 | def function(self): 27 | """Getter for Method.function attribute""" 28 | return self.method.function 29 | 30 | @property 31 | def url_template(self): 32 | """Getter for Method.url_template attribute""" 33 | return self.method.url_template 34 | 35 | @property 36 | def default_success_code(self): 37 | """Getter for Method.default_success_code attribute""" 38 | return self.method.default_success_code 39 | 40 | GET = Method(function=requests.get, url_template="/{name}/{id}/") 41 | LIST = Method(function=requests.get, url_template="/{name}/") 42 | POST = Method( 43 | function=requests.post, 44 | url_template="/{name}/", 45 | default_success_code=HTTPStatus.CREATED, 46 | ) 47 | -------------------------------------------------------------------------------- /tests/utils/tools.py: -------------------------------------------------------------------------------- 1 | """Some useful methods""" 2 | import random 3 | import string 4 | from itertools import repeat 5 | from time import sleep 6 | 7 | import requests 8 | import allure 9 | from requests_toolbelt.utils import dump 10 | 11 | 12 | def wait_for_url(url: str, timeout=120) -> None: 13 | """Wait until url becomes available""" 14 | for _ in range(timeout): 15 | try: 16 | requests.get(url) 17 | return 18 | except requests.exceptions.ConnectionError: 19 | sleep(1) 20 | raise TimeoutError(f"No response from APP in {timeout} seconds") 21 | 22 | 23 | def random_string(strlen=10): 24 | """Generating a random string of a certain length""" 25 | return "".join([random.choice(string.ascii_letters) for _ in range(strlen)]) 26 | 27 | 28 | def split_tag(image_name): 29 | """Split docker image by image name and tag""" 30 | image = image_name.split(":", maxsplit=1) 31 | if len(image) > 1: 32 | image_repo = image[0] 33 | image_tag = image[1] 34 | else: 35 | image_repo = image[0] 36 | image_tag = None 37 | return image_repo, image_tag 38 | 39 | 40 | def fill_lists_by_longest(lists): 41 | """ 42 | Fills each list by the longest one, thereby aligning them in length 43 | Last element is used as the fill value 44 | """ 45 | max_len = len(max(lists, key=len)) 46 | for current_list in lists: 47 | current_list.extend(repeat(current_list[-1], max_len - len(current_list))) 48 | 49 | 50 | def nested_set(dictionary: dict, keys: list, value): 51 | """Set value to dict for list of nested keys 52 | 53 | >>> nested_set({'key': {'nested_key': None}}, keys=['key', 'nested_key'], value=123) 54 | {'key': {'nested_key': 123}} 55 | """ 56 | nested_dict = dictionary 57 | for key in keys[:-1]: 58 | nested_dict = nested_dict[key] 59 | nested_dict[keys[-1]] = value 60 | return dictionary 61 | 62 | 63 | def nested_get(dictionary: dict, keys: list): 64 | """Set value to dict for list of nested keys 65 | 66 | >>> nested_get({'key': {'nested_key': 123}}, keys=['key', 'nested_key']) 67 | 123 68 | """ 69 | nested_dict = dictionary 70 | for key in keys[:-1]: 71 | nested_dict = nested_dict[key] 72 | return nested_dict.get(keys[-1]) 73 | 74 | 75 | def create_dicts_by_chain(keys_chain: list): 76 | """ 77 | Create nested dicts by keys chain 78 | >>> create_dicts_by_chain(['some', 'keys']) 79 | {'some': {'keys': {}}} 80 | """ 81 | result = {} 82 | current_dict = result 83 | for key in keys_chain: 84 | current_dict[key] = {} 85 | current_dict = current_dict[key] 86 | return result 87 | 88 | 89 | def attach_request_log(response): 90 | """Attach full HTTP request dump to Allure report""" 91 | allure.attach( 92 | dump.dump_all(response).decode("utf-8"), 93 | name="Full request log", 94 | extension="txt", 95 | ) 96 | -------------------------------------------------------------------------------- /tests/utils/types.py: -------------------------------------------------------------------------------- 1 | """Module contains all field types and special values""" 2 | from abc import ABC, abstractmethod 3 | from collections.abc import Callable 4 | from random import randint 5 | from typing import ClassVar, List, Type, Union 6 | from multipledispatch import dispatch 7 | 8 | import attr 9 | 10 | from tests.utils.tools import random_string 11 | 12 | # There is no circular import, because the import of the module is not yet completed at the moment, 13 | # and this allows you to resolve the conflict. 14 | from tests.utils import data_classes 15 | 16 | 17 | @attr.dataclass 18 | class PreparedFieldValue: 19 | """ 20 | PreparedFieldValue is an object for body testing. Used for both positive and negative cases. 21 | 22 | An important object for generating test data, since it contains a description of what needs 23 | to be done with field value and what we expect as a result of sending it in body. 24 | 25 | 26 | value: Value to be set for field 27 | error_messages: Expected error message 28 | drop_key: If True, key in body request will be dropped 29 | f_type: Field type. Affects value generation 30 | 31 | generated_value: if True, value will be generated according to field type rules 32 | when PreparedFieldValue value is requested via 'return_value' method 33 | """ 34 | 35 | value: object = None 36 | generated_value: bool = False 37 | error_messages: Union[list, dict] = None 38 | f_type: "BaseType" = None 39 | 40 | drop_key: bool = False 41 | 42 | @dispatch(object) 43 | def return_value(self, pre_generated_value): 44 | """ 45 | Return value in final view for fields in POST body tests 46 | :param pre_generated_value: Pre-generated valid value for set POSTable field value 47 | """ 48 | if self.generated_value: 49 | if pre_generated_value is not None: 50 | return pre_generated_value 51 | return self.f_type.generate() 52 | 53 | return self.value 54 | 55 | @dispatch(object, object, object) 56 | def return_value( 57 | self, dbfiller, current_field_value, changed_field_value 58 | ): # noqa: F811 59 | """ 60 | Return value in final view for fields in PUT, PATCH body tests 61 | :param dbfiller: Object of class DbFiller. Required to create non-changeable fk fields 62 | :param current_field_value: Value with which creatable object was created 63 | :param changed_field_value: Valid value to which we can change original if possible 64 | """ 65 | if self.generated_value: 66 | if changed_field_value is not None: 67 | return changed_field_value 68 | if isinstance(self.f_type, ForeignKey): 69 | return dbfiller.generate_new_value_for_unchangeable_fk_field( 70 | f_type=self.f_type, current_field_value=current_field_value 71 | ) 72 | return self.f_type.generate() 73 | 74 | return self.value 75 | 76 | def get_error_data(self): 77 | """Error data is a list by default but fk fields should be nested""" 78 | return self.error_messages 79 | 80 | 81 | @attr.dataclass 82 | class BaseType(ABC): 83 | """ 84 | Base type of field 85 | Contains common methods and attributes for each types 86 | """ 87 | 88 | _sp_vals_positive: list = None 89 | _sp_vals_negative: List[Union[object, Type["BaseType"], PreparedFieldValue]] = None 90 | 91 | error_message_required: ClassVar[str] = "The {name} field is required." 92 | error_message_invalid_data: ClassVar[str] = "" 93 | 94 | @abstractmethod 95 | def generate(self, **kwargs): 96 | """Should generate and return one value for the current child type""" 97 | 98 | def get_positive_values(self): 99 | """Positive values is: 100 | - boundary values 101 | - generated values 102 | - all enum values (if present) 103 | """ 104 | if self._sp_vals_positive: 105 | return [ 106 | PreparedFieldValue(value, f_type=self) 107 | for value in self._sp_vals_positive 108 | ] 109 | return [PreparedFieldValue(generated_value=True, f_type=self)] 110 | 111 | def get_negative_values(self): 112 | """Negative values is: 113 | - out of boundary values 114 | - invalid choice of enum values 115 | - invalid FK values 116 | - invalid type values (generated) 117 | """ 118 | negative_values = ( 119 | self._sp_vals_negative.copy() if self._sp_vals_negative else [] 120 | ) 121 | 122 | final_negative_values = [] 123 | for negative_value in negative_values: 124 | if isinstance(negative_value, PreparedFieldValue): 125 | final_negative_values.append(negative_value) 126 | else: 127 | final_negative_values.append( 128 | PreparedFieldValue( 129 | negative_value, 130 | f_type=self, 131 | error_messages=[self.error_message_invalid_data], 132 | ) 133 | ) 134 | return final_negative_values 135 | 136 | 137 | class PositiveInt(BaseType): 138 | """Positive int field type""" 139 | 140 | _min_int32 = 0 141 | _max_int32 = (2 ** 31) - 1 142 | 143 | def __init__(self, **kwargs): 144 | super().__init__(**kwargs) 145 | self._sp_vals_positive = [self._min_int32, self._max_int32] 146 | self._sp_vals_negative = [ 147 | 3.14, 148 | random_string(), 149 | PreparedFieldValue( 150 | self._min_int32 - 1, 151 | f_type=self, 152 | error_messages=["Ensure this value is greater than or equal to 0."], 153 | ), 154 | PreparedFieldValue( 155 | self._max_int32 + 1, 156 | f_type=self, 157 | error_messages=[ 158 | f"Ensure this value is less than or equal to {self._max_int32}." 159 | ], 160 | ), 161 | ] 162 | self.error_message_invalid_data = "A valid integer is required." 163 | 164 | def generate(self, **kwargs): 165 | return randint(self._min_int32, self._max_int32) 166 | 167 | 168 | class String(BaseType): 169 | """String field type""" 170 | 171 | def __init__(self, max_length=255, **kwargs): 172 | super().__init__(**kwargs) 173 | self.max_length = max_length 174 | self._sp_vals_positive = ["s", r"!@#$%^&*\/{}[]", random_string(max_length)] 175 | 176 | self._sp_vals_negative = [ 177 | PreparedFieldValue( 178 | value=random_string(max_length + 1), 179 | f_type=self, 180 | error_messages=[ 181 | f"The {{name}} may not be greater than {self.max_length} characters." 182 | ], 183 | ), 184 | ] 185 | self.error_message_invalid_data = "Not a valid string." 186 | 187 | def generate(self, **kwargs): 188 | return random_string(randint(1, self.max_length)) 189 | 190 | 191 | class Text(BaseType): 192 | """Text field type""" 193 | 194 | is_huge = True 195 | 196 | def __init__(self, max_length=2000, **kwargs): 197 | super().__init__(**kwargs) 198 | self.max_length = max_length 199 | self._sp_vals_negative = [ 200 | PreparedFieldValue( 201 | value=random_string(max_length + 1), 202 | f_type=self, 203 | error_messages=[ 204 | f"The {{name}} may not be greater than {self.max_length} characters." 205 | ], 206 | ), 207 | ] 208 | self.error_message_invalid_data = "" 209 | 210 | def generate(self, **kwargs): 211 | return random_string(randint(64, 200)) 212 | 213 | 214 | class ForeignKey(BaseType): 215 | """Foreign key field type""" 216 | 217 | fk_link: Type["data_classes.BaseClass"] = None 218 | 219 | def __init__(self, fk_link: Type["data_classes.BaseClass"], **kwargs): 220 | self.fk_link = fk_link 221 | super().__init__(**kwargs) 222 | self._sp_vals_negative = [ 223 | PreparedFieldValue( 224 | 100, 225 | f_type=self, 226 | error_messages=["The selected {name} is invalid."], 227 | ), 228 | PreparedFieldValue( 229 | 2 ** 31, 230 | f_type=self, 231 | error_messages=["The selected {name} is invalid."], 232 | ), 233 | ] 234 | 235 | def generate(self, **kwargs): 236 | pass 237 | 238 | 239 | @attr.dataclass 240 | class Field: 241 | """Field class based on APP spec""" 242 | 243 | name: str 244 | f_type: BaseType = None 245 | required: bool = False 246 | 247 | 248 | def get_fields(data_class: type, predicate: Callable = None) -> List[Field]: 249 | """Get fields by data class and filtered by predicate""" 250 | 251 | def dummy_predicate(_): 252 | return True 253 | 254 | if predicate is None: 255 | predicate = dummy_predicate 256 | return [ 257 | value 258 | for (key, value) in data_class.__dict__.items() 259 | if isinstance(value, Field) and predicate(value) 260 | ] 261 | 262 | 263 | def is_fk_field(field: Field) -> bool: 264 | """Predicate for fk fields selection""" 265 | return isinstance(field.f_type, ForeignKey) 266 | 267 | 268 | def get_field_name_by_fk_dataclass(data_class: type, fk_data_class: type) -> str: 269 | """Get field name in data_class that is FK to another data_class""" 270 | for field in get_fields(data_class, predicate=is_fk_field): 271 | if field.f_type.fk_link == fk_data_class: 272 | return field.name 273 | raise AttributeError(f"No FK field pointing to {fk_data_class} found!") 274 | --------------------------------------------------------------------------------