доп фукнции апи

This commit is contained in:
2026-07-28 20:42:23 +04:00
parent c007a755fe
commit 6c18cad816
13 changed files with 894 additions and 6 deletions
+3
View File
@@ -124,6 +124,9 @@ MOONWELL_RATE_REPUTATION=x1
MOONWELL_CLIENT_DISK=s3
MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"
MOONWELL_CLIENT_URL_TTL=30
MOONWELL_REALM_STATUS_HOST=play.moon-well.online
MOONWELL_REALM_STATUS_TIMEOUT=0.5
MOONWELL_REALM_STATUS_CACHE=15
VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\BalanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class LauncherAccountController extends Controller
{
#[OA\Get(
path: '/api/launcher/account',
summary: 'Получить информацию об аккаунте',
description: 'Возвращает имя и текущий баланс авторизованного игрового аккаунта.',
tags: ['Launcher'],
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'Информация об аккаунте',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'username', type: 'string', example: 'PLAYER'),
new OA\Property(property: 'balance', type: 'string', example: '1250.00'),
],
),
),
new OA\Response(
response: 401,
description: 'Не авторизован',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Unauthenticated.'),
],
),
),
],
)]
public function show(Request $request, BalanceService $balances): JsonResponse
{
$account = $request->user();
return response()->json([
'username' => (string) $account->username,
'balance' => $balances->balance((int) $account->id),
]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\LauncherRealmService;
use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;
use Throwable;
class LauncherRealmController extends Controller
{
#[OA\Get(
path: '/api/launcher/realms',
summary: 'Получить список реалмов и их статус',
description: 'Возвращает реалмы из таблицы realmlist. Статус online означает, что игровой порт реалма доступен по TCP.',
tags: ['Launcher'],
responses: [
new OA\Response(
response: 200,
description: 'Список реалмов',
content: new OA\JsonContent(
properties: [
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'name', type: 'string', example: 'MoonWell'),
new OA\Property(property: 'address', type: 'string', example: 'logon.moon-well.online'),
new OA\Property(property: 'port', type: 'integer', example: 8085),
new OA\Property(property: 'icon', type: 'integer', example: 1),
new OA\Property(property: 'flag', type: 'integer', example: 0),
new OA\Property(property: 'timezone', type: 'integer', example: 4),
new OA\Property(property: 'population', type: 'number', format: 'float', example: 0.5),
new OA\Property(property: 'online', type: 'boolean', example: true),
new OA\Property(property: 'status', type: 'string', enum: ['online', 'offline'], example: 'online'),
],
),
),
new OA\Property(property: 'checked_at', type: 'string', format: 'date-time'),
],
),
),
new OA\Response(
response: 503,
description: 'Не удалось получить список реалмов',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Не удалось получить список реалмов.'),
],
),
),
new OA\Response(response: 429, description: 'Слишком много запросов'),
],
)]
public function index(LauncherRealmService $realms): JsonResponse
{
try {
return response()->json([
'data' => $realms->realms(),
'checked_at' => now()->toIso8601String(),
]);
} catch (Throwable $exception) {
report($exception);
return response()->json([
'message' => 'Не удалось получить список реалмов.',
], 503);
}
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Api;
use App\Exceptions\DuplicateGameAccountException;
use App\Exceptions\InviteCodeException;
use App\Http\Controllers\Controller;
use App\Http\Requests\RegisterGameAccountRequest;
use App\Services\AzerothCoreAccountRegistrar;
use App\Services\InviteCodeService;
use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;
use Throwable;
class LauncherRegistrationController extends Controller
{
#[OA\Post(
path: '/api/launcher/register',
summary: 'Зарегистрировать игровой аккаунт',
description: 'Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.',
tags: ['Launcher Auth'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['username', 'email', 'password', 'password_confirmation', 'terms'],
properties: [
new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9]+$', example: 'Player'),
new OA\Property(property: 'email', type: 'string', format: 'email', maxLength: 255, example: 'player@example.com'),
new OA\Property(property: 'invite_code', type: 'string', minLength: 6, maxLength: 32, nullable: true, example: 'ABCD-EFGH-IJKL'),
new OA\Property(property: 'password', type: 'string', minLength: 8, maxLength: 32, example: 'Secret123'),
new OA\Property(property: 'password_confirmation', type: 'string', minLength: 8, maxLength: 32, example: 'Secret123'),
new OA\Property(property: 'terms', type: 'boolean', example: true, description: 'Согласие с правилами сервера и пользовательским соглашением'),
],
),
),
responses: [
new OA\Response(
response: 201,
description: 'Аккаунт создан',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Игровой аккаунт успешно создан.'),
new OA\Property(
property: 'account',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 77),
new OA\Property(property: 'username', type: 'string', example: 'PLAYER'),
],
type: 'object',
),
],
),
),
new OA\Response(
response: 422,
description: 'Ошибка валидации, занятый логин или неверный инвайт-код',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Такой игровой аккаунт уже существует.'),
new OA\Property(
property: 'errors',
type: 'object',
additionalProperties: new OA\AdditionalProperties(
type: 'array',
items: new OA\Items(type: 'string'),
),
),
],
),
),
new OA\Response(
response: 500,
description: 'Внутренняя ошибка регистрации',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Не удалось создать аккаунт. Попробуй снова позже.'),
],
),
),
new OA\Response(response: 429, description: 'Слишком много запросов'),
],
)]
public function store(
RegisterGameAccountRequest $request,
AzerothCoreAccountRegistrar $registrar,
InviteCodeService $inviteCodes,
): JsonResponse {
try {
$register = fn (): array => $registrar->register(
$request->string('username')->toString(),
$request->string('email')->toString(),
$request->string('password')->toString(),
);
$account = config('moonwell.registration.require_invite_code')
? $inviteCodes->redeemForRegistration(
$request->string('invite_code')->toString(),
$request->string('username')->toString(),
$register,
)
: $register();
} catch (DuplicateGameAccountException|InviteCodeException $exception) {
return response()->json([
'message' => $exception->getMessage(),
'errors' => [
$exception->field => [$exception->getMessage()],
],
], 422);
} catch (Throwable $exception) {
report($exception);
return response()->json([
'message' => 'Не удалось создать аккаунт. Попробуй снова позже.',
], 500);
}
return response()->json([
'message' => 'Игровой аккаунт успешно создан.',
'account' => [
'id' => $account['id'],
'username' => $account['username'],
],
], 201);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ use OpenApi\Attributes as OA;
#[OA\Info(
version: '1.0.0',
title: 'Moonwell Launcher API',
description: 'API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.',
description: 'API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.',
)]
#[OA\SecurityScheme(
securityScheme: 'bearerAuth',
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class LauncherRealmService
{
public function __construct(
private readonly RealmStatusProbe $statusProbe,
) {
}
/**
* @return Collection<int, array<string, bool|float|int|string>>
*/
public function realms(): Collection
{
$cacheSeconds = max(0, (int) config('moonwell.launcher.realm_status_cache', 15));
if ($cacheSeconds === 0) {
return $this->loadRealms();
}
return Cache::remember(
'launcher.realm-status',
now()->addSeconds($cacheSeconds),
fn (): Collection => $this->loadRealms(),
);
}
/**
* @return Collection<int, array<string, bool|float|int|string>>
*/
private function loadRealms(): Collection
{
return DB::connection(config('moonwell.auth_connection'))
->table('realmlist')
->orderBy('id')
->get([
'id',
'name',
'address',
'port',
'icon',
'flag',
'timezone',
'population',
])
->map(function (object $realm): array {
$probeAddress = trim((string) config('moonwell.launcher.realm_status_host'));
$online = $this->statusProbe->isOnline(
$probeAddress !== '' ? $probeAddress : (string) $realm->address,
(int) $realm->port,
);
return [
'id' => (int) $realm->id,
'name' => (string) $realm->name,
'address' => (string) $realm->address,
'port' => (int) $realm->port,
'icon' => (int) $realm->icon,
'flag' => (int) $realm->flag,
'timezone' => (int) $realm->timezone,
'population' => (float) $realm->population,
'online' => $online,
'status' => $online ? 'online' : 'offline',
];
})
->values();
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services;
class RealmStatusProbe
{
public function isOnline(string $address, int $port): bool
{
if ($address === '' || $port < 1 || $port > 65535) {
return false;
}
$host = str_contains($address, ':') && ! str_starts_with($address, '[')
? '['.$address.']'
: $address;
$timeout = max(0.1, min(3.0, (float) config('moonwell.launcher.realm_status_timeout', 0.5)));
$socket = @stream_socket_client(
'tcp://'.$host.':'.$port,
$errorCode,
$errorMessage,
$timeout,
STREAM_CLIENT_CONNECT,
);
if ($socket === false) {
return false;
}
fclose($socket);
return true;
}
}
+3
View File
@@ -39,6 +39,9 @@ return [
'base_path' => env('MOONWELL_LAUNCHER_BASE_PATH', 'World of Warcraft'),
'manifest_key' => env('MOONWELL_LAUNCHER_MANIFEST_KEY', 'manifest.json'),
'download_ttl' => (int) env('MOONWELL_LAUNCHER_DOWNLOAD_TTL', 60),
'realm_status_host' => env('MOONWELL_REALM_STATUS_HOST'),
'realm_status_timeout' => (float) env('MOONWELL_REALM_STATUS_TIMEOUT', 0.5),
'realm_status_cache' => (int) env('MOONWELL_REALM_STATUS_CACHE', 15),
],
'realm' => [
+299 -5
View File
@@ -2,10 +2,63 @@
"openapi": "3.0.0",
"info": {
"title": "Moonwell Launcher API",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.",
"version": "1.0.0"
},
"paths": {
"/api/launcher/account": {
"get": {
"tags": [
"Launcher"
],
"summary": "Получить информацию об аккаунте",
"description": "Возвращает имя и текущий баланс авторизованного игрового аккаунта.",
"operationId": "9a18d29372e8de08e1d24237580f19e5",
"responses": {
"200": {
"description": "Информация об аккаунте",
"content": {
"application/json": {
"schema": {
"properties": {
"username": {
"type": "string",
"example": "PLAYER"
},
"balance": {
"type": "string",
"example": "1250.00"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Не авторизован",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Unauthenticated."
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/launcher/login": {
"post": {
"tags": [
@@ -425,6 +478,247 @@
}
]
}
},
"/api/launcher/realms": {
"get": {
"tags": [
"Launcher"
],
"summary": "Получить список реалмов и их статус",
"description": "Возвращает реалмы из таблицы realmlist. Статус online означает, что игровой порт реалма доступен по TCP.",
"operationId": "6d142b37a7b9a121b7ad02fd6405e054",
"responses": {
"200": {
"description": "Список реалмов",
"content": {
"application/json": {
"schema": {
"properties": {
"data": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer",
"example": 1
},
"name": {
"type": "string",
"example": "MoonWell"
},
"address": {
"type": "string",
"example": "logon.moon-well.online"
},
"port": {
"type": "integer",
"example": 8085
},
"icon": {
"type": "integer",
"example": 1
},
"flag": {
"type": "integer",
"example": 0
},
"timezone": {
"type": "integer",
"example": 4
},
"population": {
"type": "number",
"format": "float",
"example": 0.5
},
"online": {
"type": "boolean",
"example": true
},
"status": {
"type": "string",
"example": "online",
"enum": [
"online",
"offline"
]
}
},
"type": "object"
}
},
"checked_at": {
"type": "string",
"format": "date-time"
}
},
"type": "object"
}
}
}
},
"503": {
"description": "Не удалось получить список реалмов",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Не удалось получить список реалмов."
}
},
"type": "object"
}
}
}
},
"429": {
"description": "Слишком много запросов"
}
}
}
},
"/api/launcher/register": {
"post": {
"tags": [
"Launcher Auth"
],
"summary": "Зарегистрировать игровой аккаунт",
"description": "Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.",
"operationId": "bfc1dbd42cbcc339bc8a13dbf189c525",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"username",
"email",
"password",
"password_confirmation",
"terms"
],
"properties": {
"username": {
"type": "string",
"pattern": "^[A-Za-z0-9]+$",
"example": "Player",
"maxLength": 32,
"minLength": 3
},
"email": {
"type": "string",
"format": "email",
"example": "player@example.com",
"maxLength": 255
},
"invite_code": {
"type": "string",
"example": "ABCD-EFGH-IJKL",
"nullable": true,
"maxLength": 32,
"minLength": 6
},
"password": {
"type": "string",
"example": "Secret123",
"maxLength": 32,
"minLength": 8
},
"password_confirmation": {
"type": "string",
"example": "Secret123",
"maxLength": 32,
"minLength": 8
},
"terms": {
"description": "Согласие с правилами сервера и пользовательским соглашением",
"type": "boolean",
"example": true
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Аккаунт создан",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Игровой аккаунт успешно создан."
},
"account": {
"properties": {
"id": {
"type": "integer",
"example": 77
},
"username": {
"type": "string",
"example": "PLAYER"
}
},
"type": "object"
}
},
"type": "object"
}
}
}
},
"422": {
"description": "Ошибка валидации, занятый логин или неверный инвайт-код",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Такой игровой аккаунт уже существует."
},
"errors": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"type": "object"
}
}
}
},
"500": {
"description": "Внутренняя ошибка регистрации",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Не удалось создать аккаунт. Попробуй снова позже."
}
},
"type": "object"
}
}
}
},
"429": {
"description": "Слишком много запросов"
}
}
}
}
},
"components": {
@@ -444,14 +738,14 @@
}
},
"tags": [
{
"name": "Launcher Auth",
"description": "Launcher Auth"
},
{
"name": "Launcher",
"description": "Launcher"
},
{
"name": "Launcher Auth",
"description": "Launcher Auth"
},
{
"name": "Launcher Service",
"description": "Launcher Service"
+8
View File
@@ -1,16 +1,24 @@
<?php
use App\Http\Controllers\Api\GameBalanceController;
use App\Http\Controllers\Api\LauncherAccountController;
use App\Http\Controllers\Api\LauncherAuthController;
use App\Http\Controllers\Api\LauncherController;
use App\Http\Controllers\Api\LauncherNewsController;
use App\Http\Controllers\Api\LauncherRealmController;
use App\Http\Controllers\Api\LauncherRegistrationController;
use App\Http\Controllers\Api\RobokassaController;
use Illuminate\Support\Facades\Route;
Route::post('launcher/login', [LauncherAuthController::class, 'login'])
->middleware('throttle:10,1');
Route::post('launcher/register', [LauncherRegistrationController::class, 'store'])
->middleware('throttle:8,1');
Route::get('launcher/realms', [LauncherRealmController::class, 'index'])
->middleware('throttle:60,1');
Route::middleware('auth:api')->prefix('launcher')->group(function () {
Route::get('account', [LauncherAccountController::class, 'show']);
Route::get('manifest', [LauncherController::class, 'manifest']);
Route::get('download/{path}', [LauncherController::class, 'download'])
->where('path', '.*');
@@ -0,0 +1,40 @@
<?php
namespace Tests\Feature;
use App\Models\GameAccount;
use App\Services\BalanceService;
use Laravel\Passport\Passport;
use Mockery\MockInterface;
use Tests\TestCase;
class LauncherAccountFeatureTest extends TestCase
{
public function test_authenticated_launcher_can_get_username_and_balance(): void
{
$account = new GameAccount;
$account->id = 7;
$account->username = 'PLAYERONE';
Passport::actingAs($account);
$this->mock(BalanceService::class, function (MockInterface $mock): void {
$mock->shouldReceive('balance')
->once()
->with(7)
->andReturn('1250.00');
});
$this->getJson('/api/launcher/account')
->assertOk()
->assertExactJson([
'username' => 'PLAYERONE',
'balance' => '1250.00',
]);
}
public function test_launcher_account_endpoint_requires_authentication(): void
{
$this->getJson('/api/launcher/account')
->assertUnauthorized();
}
}
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Services\RealmStatusProbe;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
use Tests\TestCase;
class LauncherRealmsFeatureTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'moonwell.auth_connection' => 'azerothcore_auth',
'moonwell.launcher.realm_status_cache' => 0,
'moonwell.launcher.realm_status_host' => null,
'database.connections.azerothcore_auth' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
]);
DB::purge('azerothcore_auth');
Schema::connection('azerothcore_auth')->create('realmlist', function (Blueprint $table): void {
$table->unsignedInteger('id')->primary();
$table->string('name');
$table->string('address');
$table->unsignedInteger('port');
$table->unsignedInteger('icon')->default(0);
$table->unsignedInteger('flag')->default(0);
$table->unsignedInteger('timezone')->default(0);
$table->float('population')->default(0);
$table->unsignedInteger('gamebuild')->default(12340);
});
}
public function test_launcher_can_get_realms_and_their_status_without_authentication(): void
{
DB::connection('azerothcore_auth')->table('realmlist')->insert([
[
'id' => 1,
'name' => 'MoonWell',
'address' => 'realm.moon-well.online',
'port' => 8085,
'icon' => 1,
'flag' => 0,
'timezone' => 4,
'population' => 0.5,
'gamebuild' => 12340,
],
[
'id' => 2,
'name' => 'MoonWell PTR',
'address' => 'ptr.moon-well.online',
'port' => 8085,
'icon' => 1,
'flag' => 0,
'timezone' => 4,
'population' => 0,
'gamebuild' => 12340,
],
]);
$this->mock(RealmStatusProbe::class, function (MockInterface $mock): void {
$mock->shouldReceive('isOnline')
->once()
->with('realm.moon-well.online', 8085)
->andReturnTrue();
$mock->shouldReceive('isOnline')
->once()
->with('ptr.moon-well.online', 8085)
->andReturnFalse();
});
$this->getJson('/api/launcher/realms')
->assertOk()
->assertJsonPath('data.0.name', 'MoonWell')
->assertJsonPath('data.0.online', true)
->assertJsonPath('data.0.status', 'online')
->assertJsonPath('data.1.status', 'offline')
->assertJsonStructure(['data', 'checked_at']);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature;
use App\Exceptions\DuplicateGameAccountException;
use App\Services\AzerothCoreAccountRegistrar;
use App\Services\InviteCodeService;
use Closure;
use Mockery\MockInterface;
use Tests\TestCase;
class LauncherRegistrationFeatureTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['moonwell.registration.require_invite_code' => true]);
}
public function test_launcher_can_register_game_account(): void
{
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
$mock->shouldReceive('redeemForRegistration')
->once()
->with('INVITE-1234', 'playerone', \Mockery::type(Closure::class))
->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback());
});
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
$mock->shouldReceive('register')
->once()
->with('playerone', 'player@example.com', 'Pass1234')
->andReturn(['id' => 77, 'username' => 'PLAYERONE']);
});
$this->postJson('/api/launcher/register', [
'username' => 'playerone',
'email' => 'player@example.com',
'invite_code' => 'INVITE-1234',
'password' => 'Pass1234',
'password_confirmation' => 'Pass1234',
'terms' => true,
])
->assertCreated()
->assertJson([
'message' => 'Игровой аккаунт успешно создан.',
'account' => [
'id' => 77,
'username' => 'PLAYERONE',
],
]);
}
public function test_launcher_registration_returns_validation_errors_as_json(): void
{
$this->postJson('/api/launcher/register', [
'username' => 'игрок!',
'email' => 'invalid',
'password' => '123',
'password_confirmation' => '456',
'terms' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors([
'username',
'email',
'invite_code',
'password',
'terms',
]);
}
public function test_launcher_registration_reports_duplicate_username(): void
{
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
$mock->shouldReceive('redeemForRegistration')
->once()
->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback());
});
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
$mock->shouldReceive('register')
->once()
->andThrow(DuplicateGameAccountException::forUsername());
});
$this->postJson('/api/launcher/register', [
'username' => 'playerone',
'email' => 'player@example.com',
'invite_code' => 'INVITE-1234',
'password' => 'Pass1234',
'password_confirmation' => 'Pass1234',
'terms' => true,
])
->assertUnprocessable()
->assertJsonPath('errors.username.0', 'Такой игровой аккаунт уже существует.');
}
}