diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php deleted file mode 100644 index 297a8c3..0000000 --- a/app/Http/Controllers/Api/V1/AuthController.php +++ /dev/null @@ -1,53 +0,0 @@ -validate([ - 'login' => 'required|string|min:4|max:255|unique:users,login', - 'password' => [ - 'required', - 'string', - 'min:12', - 'max:255', - 'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/' - ], - ]); - - $user = User::create([ - 'login' => $req->login, - 'password' => Hash::make($req->password), - 'full_name' => '-', - 'telegram_id' => null, - 'phone' => null, - 'role' => Role::EMPLOYEE->value, - ]); - - $token = $user->createToken('user-token')->plainTextToken; - - return response()->json([ - 'message' => 'Пользователь успешно зарегистрирован', - 'token' => $token, - 'user' => [ - 'id' => $user->id, - 'login' => $user->login, - 'full_name' => $user->full_name, - 'role' => $user->role, - 'dealership_id' => $user->dealership_id, - 'telegram_id' => $user->telegram_id, - 'phone' => $user->phone, - ], - ]); - } -} diff --git a/app/Http/Controllers/Api/V1/ImportantLinkController.php b/app/Http/Controllers/Api/V1/ImportantLinkController.php new file mode 100644 index 0000000..82320b2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ImportantLinkController.php @@ -0,0 +1,125 @@ +query('per_page', '15'); + $dealershipId = $request->query('dealership_id'); + $isActive = $request->query('is_active'); + + $query = ImportantLink::with(['creator', 'dealership']); + + if ($dealershipId !== null) { + $query->where('dealership_id', $dealershipId); + } + + if ($isActive !== null) { + $query->where('is_active', (bool) $isActive); + } + + $links = $query->orderBy('sort_order') + ->orderBy('created_at', 'desc') + ->paginate($perPage); + + return response()->json($links); + } + + public function show($id) + { + $link = ImportantLink::with(['creator', 'dealership'])->find($id); + + if (!$link) { + return response()->json([ + 'message' => 'Ссылка не найдена' + ], 404); + } + + return response()->json($link); + } + + public function store(Request $request) + { + Log::info("Request ImportantLink Store: " . json_encode($request->all())); + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'url' => 'required|string|max:1000|url', + 'description' => 'nullable|string', + 'dealership_id' => 'nullable|integer|exists:auto_dealerships,id', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]); + + // Устанавливаем creator_id из текущего пользователя + $validated['creator_id'] = $request->user()->id; + + $link = ImportantLink::create($validated); + + // Загружаем связи для ответа + $link->load(['creator', 'dealership']); + + return response()->json($link, 201); + } + + public function update(Request $request, $id) + { + $link = ImportantLink::find($id); + + if (!$link) { + return response()->json([ + 'message' => 'Ссылка не найдена' + ], 404); + } + + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'url' => 'sometimes|required|string|max:1000|url', + 'description' => 'nullable|string', + 'dealership_id' => 'nullable|integer|exists:auto_dealerships,id', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]); + + $link->update($validated); + + // Загружаем связи для ответа + $link->load(['creator', 'dealership']); + + return response()->json($link); + } + + public function destroy($id) + { + $link = ImportantLink::find($id); + + if (!$link) { + return response()->json([ + 'message' => 'Ссылка не найдена' + ], 404); + } + + try { + $link->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Ссылка успешно удалена' + ], 200); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Ошибка при удалении ссылки', + 'error' => $e->getMessage() + ], 500); + } + } +} diff --git a/app/Http/Controllers/Api/V1/UserRegistrationController.php b/app/Http/Controllers/Api/V1/UserRegistrationController.php deleted file mode 100644 index 501b089..0000000 --- a/app/Http/Controllers/Api/V1/UserRegistrationController.php +++ /dev/null @@ -1,116 +0,0 @@ -all(), [ - 'login' => [ - 'required', - 'string', - 'min:4', - 'max:255', - 'unique:users,login' - ], - 'password' => [ - 'required', - 'string', - 'min:8', - 'max:255', - 'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d@$!%*?&]/' - ], - 'full_name' => [ - 'required', - 'string', - 'min:2', - 'max:255' - ], - 'phone' => [ - 'required', - 'string', - 'regex:/^\+?[\d\s\-\(\)]+$/', - 'max:20' - ], - 'role' => [ - 'required', - 'string', - Rule::in(Role::values()) - ], - 'telegram_id' => [ - 'nullable', - 'integer', - 'unique:users,telegram_id' - ], - 'dealership_id' => [ - 'nullable', - 'integer', - 'exists:auto_dealerships,id' - ] - ], [ - 'login.required' => 'Логин обязателен', - 'login.min' => 'Логин должен содержать минимум 4 символа', - 'login.unique' => 'Такой логин уже существует', - 'password.required' => 'Пароль обязателен', - 'password.min' => 'Пароль должен содержать минимум 8 символов', - 'password.regex' => 'Пароль должен содержать минимум одну заглавную букву, одну строчную букву и одну цифру', - 'full_name.required' => 'Полное имя обязательно', - 'full_name.min' => 'Полное имя должно содержать минимум 2 символа', - 'phone.required' => 'Телефон обязателен', - 'phone.regex' => 'Некорректный формат телефона', - 'role.required' => 'Роль обязательна', - 'role.in' => 'Некорректная роль', - 'telegram_id.unique' => 'Этот Telegram ID уже используется', - 'dealership_id.exists' => 'Автосалон не найден', - ]); - - if ($validator->fails()) { - return response()->json([ - 'success' => false, - 'message' => 'Ошибка валидации', - 'errors' => $validator->errors() - ], 422); - } - - $validated = $validator->validated(); - - try { - $user = User::create([ - 'login' => $validated['login'], - 'password' => Hash::make($validated['password']), - 'full_name' => $validated['full_name'], - 'phone' => $validated['phone'], - 'role' => $validated['role'], - 'telegram_id' => $validated['telegram_id'] ?? 0, - 'dealership_id' => $validated['dealership_id'] ?? null, - ]); - - return response()->json([ - 'success' => true, - 'message' => 'Сотрудник успешно создан', - 'data' => new UserResource($user) - ], 201); - - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Ошибка при создании сотрудника', - 'error' => config('app.debug') ? $e->getMessage() : 'Внутренняя ошибка сервера' - ], 500); - } - } -} \ No newline at end of file diff --git a/database/factories/ImportantLinkFactory.php b/database/factories/ImportantLinkFactory.php new file mode 100644 index 0000000..cc389f4 --- /dev/null +++ b/database/factories/ImportantLinkFactory.php @@ -0,0 +1,42 @@ + fake()->sentence(3), + 'url' => fake()->url(), + 'description' => fake()->optional()->paragraph(), + 'dealership_id' => AutoDealership::factory(), + 'creator_id' => User::factory(), + 'sort_order' => fake()->numberBetween(0, 100), + 'is_active' => true, + ]; + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + public function global(): static + { + return $this->state(fn (array $attributes) => [ + 'dealership_id' => null, + ]); + } +} diff --git a/routes/api.php b/routes/api.php index 9fef9ae..0a399a5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,15 +2,14 @@ declare(strict_types=1); -use App\Http\Controllers\Api\V1\AuthController; use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\DealershipController; +use App\Http\Controllers\Api\V1\ImportantLinkController; use App\Http\Controllers\Api\V1\SessionController; use App\Http\Controllers\Api\V1\SettingsController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\TaskController; use App\Http\Controllers\Api\V1\UserApiController; -use App\Http\Controllers\Api\V1\UserRegistrationController; use App\Http\Controllers\FrontController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -91,6 +90,18 @@ Route::delete('/tasks/{id}', [TaskController::class, 'destroy']) ->middleware('role:manager,owner'); + // Important Links + Route::get('/links', [ImportantLinkController::class, 'index']); + Route::get('/links/{id}', [ImportantLinkController::class, 'show']); + + // Only managers and owners can manage links + Route::post('/links', [ImportantLinkController::class, 'store']) + ->middleware('role:manager,owner'); + Route::put('/links/{id}', [ImportantLinkController::class, 'update']) + ->middleware('role:manager,owner'); + Route::delete('/links/{id}', [ImportantLinkController::class, 'destroy']) + ->middleware('role:manager,owner'); + // Dashboard Route::get('/dashboard', [DashboardController::class, 'index']); diff --git a/swagger.yaml b/swagger.yaml index 763bb3c..4291967 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -24,6 +24,8 @@ tags: description: Управление сменами - name: Tasks description: Управление задачами + - name: Links + description: Управление важными ссылками - name: Dashboard description: Дашборд для менеджеров - name: Settings @@ -2269,6 +2271,329 @@ paths: schema: $ref: '#/components/schemas/Error' + /links: + get: + tags: + - Links + summary: Получить список важных ссылок + description: Получение списка важных ссылок с пагинацией и фильтрацией + operationId: listLinks + security: + - bearerAuth: [] + parameters: + - name: per_page + in: query + description: Количество элементов на странице + schema: + type: integer + default: 15 + example: 20 + - name: page + in: query + description: Номер страницы + schema: + type: integer + default: 1 + example: 1 + - name: dealership_id + in: query + description: Фильтр по автосалону + schema: + type: integer + example: 1 + - name: is_active + in: query + description: Фильтр по активности + schema: + type: boolean + example: true + responses: + '200': + description: Список ссылок + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ImportantLink' + current_page: + type: integer + example: 1 + per_page: + type: integer + example: 15 + total: + type: integer + example: 42 + '401': + description: Неавторизован + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + post: + tags: + - Links + summary: Создать новую ссылку + description: Создание новой важной ссылки (только для менеджеров и владельцев) + operationId: createLink + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + - url + properties: + title: + type: string + maxLength: 255 + example: Внутренний портал + url: + type: string + maxLength: 1000 + example: https://portal.company.com + description: + type: string + nullable: true + example: Доступ к корпоративным ресурсам + dealership_id: + type: integer + nullable: true + example: 1 + sort_order: + type: integer + default: 0 + example: 10 + is_active: + type: boolean + default: true + example: true + responses: + '201': + description: Ссылка успешно создана + content: + application/json: + schema: + $ref: '#/components/schemas/ImportantLink' + '401': + description: Неавторизован + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Доступ запрещен (недостаточно прав) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Ошибка валидации + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /links/{id}: + get: + tags: + - Links + summary: Получить ссылку по ID + description: Получение информации об одной ссылке с связями + operationId: getLink + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: ID ссылки + schema: + type: integer + example: 1 + responses: + '200': + description: Информация о ссылке + content: + application/json: + schema: + $ref: '#/components/schemas/ImportantLink' + '401': + description: Неавторизован + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Ссылка не найдена + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Ссылка не найдена + + put: + tags: + - Links + summary: Обновить ссылку + description: Обновление существующей ссылки (только для менеджеров и владельцев) + operationId: updateLink + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: ID ссылки + schema: + type: integer + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + maxLength: 255 + example: Обновленный портал + url: + type: string + maxLength: 1000 + example: https://new-portal.company.com + description: + type: string + nullable: true + example: Новое описание + dealership_id: + type: integer + nullable: true + example: 1 + sort_order: + type: integer + example: 20 + is_active: + type: boolean + example: false + responses: + '200': + description: Ссылка успешно обновлена + content: + application/json: + schema: + $ref: '#/components/schemas/ImportantLink' + '401': + description: Неавторизован + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Доступ запрещен (недостаточно прав) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Ссылка не найдена + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Ссылка не найдена + '422': + description: Ошибка валидации + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + delete: + tags: + - Links + summary: Удалить ссылку + description: Удаление ссылки (только для менеджеров и владельцев) + operationId: deleteLink + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: ID ссылки + schema: + type: integer + example: 1 + responses: + '200': + description: Ссылка успешно удалена + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: Ссылка успешно удалена + '401': + description: Неавторизован + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Доступ запрещен (недостаточно прав) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Ссылка не найдена + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Ссылка не найдена + '500': + description: Ошибка сервера + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + example: Ошибка при удалении ссылки + error: + type: string + example: Database error + /dashboard: get: tags: diff --git a/tests/Feature/Api/AuthenticationTest.php b/tests/Feature/Api/AuthenticationTest.php deleted file mode 100644 index c0c2917..0000000 --- a/tests/Feature/Api/AuthenticationTest.php +++ /dev/null @@ -1,323 +0,0 @@ - 'testuser123', - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200) - ->assertJsonStructure([ - 'message', - 'token', - ]) - ->assertJson([ - 'message' => 'Пользователь успешно зарегистрирован', - ]); - - // Verify user was created in database - $user = User::where('login', 'testuser123')->first(); - expect($user) - ->not->toBeNull() - ->and($user->login)->toBe('testuser123') - ->and($user->role)->toBe(Role::EMPLOYEE->value) - ->and($user->full_name)->toBe('-') - ->and($user->telegram_id)->toBeNull() - ->and($user->phone)->toBeNull(); - - // Verify password is hashed - expect(Hash::check('SecurePass123!@#', $user->password))->toBeTrue(); - - // Verify token is valid - expect($response->json('token'))->toBeString()->not->toBeEmpty(); - }); - - it('validates login is required', function () { - // Arrange - $userData = [ - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['login']); - }); - - it('validates login minimum length', function () { - // Arrange - $userData = [ - 'login' => 'abc', // Less than 4 characters - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['login']); - }); - - it('validates login maximum length', function () { - // Arrange - $userData = [ - 'login' => str_repeat('a', 256), // More than 255 characters - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['login']); - }); - - it('validates login uniqueness', function () { - // Arrange - User::factory()->create(['login' => 'existinguser']); - - $userData = [ - 'login' => 'existinguser', - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['login']); - }); - - it('validates password is required', function () { - // Arrange - $userData = [ - 'login' => 'testuser123', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['password']); - }); - - it('validates password minimum length', function () { - // Arrange - $userData = [ - 'login' => 'testuser123', - 'password' => 'Short1!@', // Less than 12 characters - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['password']); - }); - - it('validates password maximum length', function () { - // Arrange - $userData = [ - 'login' => 'testuser123', - 'password' => str_repeat('A1@', 100), // More than 255 characters - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['password']); - }); - - it('validates password complexity requirements', function () { - // Arrange - Test various invalid password patterns - $invalidPasswords = [ - 'onlylowercase123!', // Missing uppercase - 'ONLYUPPERCASE123!', // Missing lowercase - 'OnlyLettersNoNumbers!', // Missing digits - 'OnlyAlphanumeric123', // Missing special characters - 'NoNumbers!@#', // Missing digits and case variety - ]; - - foreach ($invalidPasswords as $password) { - $userData = [ - 'login' => 'testuser' . rand(1000, 9999), - 'password' => $password, - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['password']); - } - }); - - it('accepts valid password with all required complexity', function () { - // Arrange - $userData = [ - 'login' => 'validuser123', - 'password' => 'ValidPass123!@#', // Has all requirements - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200); - }); - - it('handles invalid JSON gracefully', function () { - // Act - Send malformed JSON content - $response = $this->postJson('/api/v1/register', [], ['CONTENT_TYPE' => 'application/json']); - - // Assert - $response->assertStatus(422); // Laravel returns 422 for validation errors - }); - - it('handles empty request body', function () { - // Act - $response = $this->postJson('/api/v1/register', []); - - // Assert - $response->assertStatus(422) - ->assertJsonValidationErrors(['login', 'password']); - }); - - it('creates user with correct default values', function () { - // Arrange - $userData = [ - 'login' => 'defaultstest', - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200); - - $user = User::where('login', 'defaultstest')->first(); - expect($user->role)->toBe(Role::EMPLOYEE->value) - ->and($user->full_name)->toBe('-') - ->and($user->telegram_id)->toBeNull() - ->and($user->phone)->toBeNull(); - }); - - it('returns valid Sanctum token', function () { - // Arrange - $userData = [ - 'login' => 'tokentest', - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200); - - $token = $response->json('token'); - expect($token)->toBeString()->not->toBeEmpty(); - - // Verify token works for authenticated requests - $user = User::where('login', 'tokentest')->first(); - $this->actingAs($user, 'sanctum'); - - // This would work if there were authenticated endpoints to test - // $authResponse = $this->getJson('/api/v1/user'); - // $authResponse->assertStatus(200); - }); - }); - - describe('Registration Edge Cases', function () { - it('handles very long valid login', function () { - // Arrange - $userData = [ - 'login' => str_repeat('a', 255), // Exactly 255 characters (max allowed) - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200); - }); - - it('handles minimum valid password', function () { - // Arrange - $userData = [ - 'login' => 'minpasstest', - 'password' => 'ValidPass1!@#', // Exactly 13 characters with all requirements - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200); - }); - - it('handles maximum valid password', function () { - // Arrange - Create a 255 character password with all requirements - $basePattern = 'ValidPass1!@#'; // 13 chars with all requirements - $validLongPassword = str_repeat($basePattern, 19) . 'A1@'; // 13*19 + 3 = 250 chars - $userData = [ - 'login' => 'maxpasstest', - 'password' => $validLongPassword, - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - $response->assertStatus(200); - }); - - it('prevents SQL injection in login field', function () { - // Arrange - $userData = [ - 'login' => "'; DROP TABLE users; --", - 'password' => 'SecurePass123!@#', - ]; - - // Act - $response = $this->postJson('/api/v1/register', $userData); - - // Assert - Should handle gracefully without breaking - // Could be 422 (validation error) or 200 (successful with sanitized input) - expect($response->status())->toBeIn([200, 422]); - - // Ensure users table still exists by creating another user - $safeUserData = [ - 'login' => 'safeuser', - 'password' => 'SecurePass123!@#', - ]; - $safeResponse = $this->postJson('/api/v1/register', $safeUserData); - $safeResponse->assertStatus(200); - }); - }); -}); diff --git a/tests/Feature/Api/ImportantLinkTest.php b/tests/Feature/Api/ImportantLinkTest.php new file mode 100644 index 0000000..88281b8 --- /dev/null +++ b/tests/Feature/Api/ImportantLinkTest.php @@ -0,0 +1,402 @@ +manager = User::factory()->create(['role' => Role::MANAGER->value]); + $this->employee = User::factory()->create(['role' => Role::EMPLOYEE->value]); + $this->dealership = AutoDealership::factory()->create(); + }); + + describe('GET /api/v1/links', function () { + it('returns paginated list of links', function () { + // Arrange + ImportantLink::factory()->count(5)->create(['creator_id' => $this->manager->id]); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->getJson('/api/v1/links'); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'title', 'url', 'description', 'sort_order', 'is_active', 'creator', 'dealership'], + ], + 'current_page', + 'per_page', + 'total', + ]); + }); + + it('filters links by dealership_id', function () { + // Arrange + $dealership1 = AutoDealership::factory()->create(); + $dealership2 = AutoDealership::factory()->create(); + + ImportantLink::factory()->count(3)->create([ + 'creator_id' => $this->manager->id, + 'dealership_id' => $dealership1->id, + ]); + ImportantLink::factory()->count(2)->create([ + 'creator_id' => $this->manager->id, + 'dealership_id' => $dealership2->id, + ]); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->getJson('/api/v1/links?dealership_id=' . $dealership1->id); + + // Assert + $response->assertStatus(200); + expect($response->json('data'))->toHaveCount(3); + }); + + it('filters links by is_active status', function () { + // Arrange + ImportantLink::factory()->count(3)->create([ + 'creator_id' => $this->manager->id, + 'is_active' => true, + ]); + ImportantLink::factory()->count(2)->create([ + 'creator_id' => $this->manager->id, + 'is_active' => false, + ]); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->getJson('/api/v1/links?is_active=1'); + + // Assert + $response->assertStatus(200); + expect($response->json('data'))->toHaveCount(3); + }); + + it('requires authentication', function () { + // Act + $response = $this->getJson('/api/v1/links'); + + // Assert + $response->assertStatus(401); + }); + }); + + describe('GET /api/v1/links/{id}', function () { + it('returns a single link with relations', function () { + // Arrange + $link = ImportantLink::factory()->create([ + 'creator_id' => $this->manager->id, + 'dealership_id' => $this->dealership->id, + ]); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->getJson('/api/v1/links/' . $link->id); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'id', + 'title', + 'url', + 'description', + 'sort_order', + 'is_active', + 'creator', + 'dealership', + ]) + ->assertJson([ + 'id' => $link->id, + 'title' => $link->title, + 'url' => $link->url, + ]); + }); + + it('returns 404 for non-existent link', function () { + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->getJson('/api/v1/links/99999'); + + // Assert + $response->assertStatus(404) + ->assertJson(['message' => 'Ссылка не найдена']); + }); + + it('requires authentication', function () { + // Arrange + $link = ImportantLink::factory()->create(['creator_id' => $this->manager->id]); + + // Act + $response = $this->getJson('/api/v1/links/' . $link->id); + + // Assert + $response->assertStatus(401); + }); + }); + + describe('POST /api/v1/links', function () { + it('creates a new link with valid data', function () { + // Arrange + $linkData = [ + 'title' => 'Internal Portal', + 'url' => 'https://portal.example.com', + 'description' => 'Company internal portal', + 'dealership_id' => $this->dealership->id, + 'sort_order' => 10, + 'is_active' => true, + ]; + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(201) + ->assertJsonStructure(['id', 'title', 'url', 'description', 'creator', 'dealership']) + ->assertJson([ + 'title' => 'Internal Portal', + 'url' => 'https://portal.example.com', + 'description' => 'Company internal portal', + ]); + + // Verify in database + $link = ImportantLink::where('title', 'Internal Portal')->first(); + expect($link)->not->toBeNull() + ->and($link->creator_id)->toBe($this->manager->id); + }); + + it('creates global link when dealership_id is null', function () { + // Arrange + $linkData = [ + 'title' => 'Global Resource', + 'url' => 'https://global.example.com', + 'sort_order' => 0, + ]; + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(201) + ->assertJson(['title' => 'Global Resource']); + + $link = ImportantLink::where('title', 'Global Resource')->first(); + expect($link->dealership_id)->toBeNull(); + }); + + it('validates required fields', function () { + // Arrange + $linkData = [ + 'description' => 'Missing required fields', + ]; + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(422) + ->assertJsonValidationErrors(['title', 'url']); + }); + + it('validates url format', function () { + // Arrange + $linkData = [ + 'title' => 'Invalid URL', + 'url' => 'not-a-valid-url', + ]; + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(422) + ->assertJsonValidationErrors(['url']); + }); + + it('validates dealership exists', function () { + // Arrange + $linkData = [ + 'title' => 'Test Link', + 'url' => 'https://example.com', + 'dealership_id' => 99999, // Non-existent + ]; + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(422) + ->assertJsonValidationErrors(['dealership_id']); + }); + + it('requires manager or owner role', function () { + // Arrange + $linkData = [ + 'title' => 'Test Link', + 'url' => 'https://example.com', + ]; + + // Act - Try as employee + $response = $this->actingAs($this->employee, 'sanctum') + ->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(403); + }); + + it('requires authentication', function () { + // Arrange + $linkData = [ + 'title' => 'Test Link', + 'url' => 'https://example.com', + ]; + + // Act + $response = $this->postJson('/api/v1/links', $linkData); + + // Assert + $response->assertStatus(401); + }); + }); + + describe('PUT /api/v1/links/{id}', function () { + it('updates link with valid data', function () { + // Arrange + $link = ImportantLink::factory()->create([ + 'creator_id' => $this->manager->id, + 'title' => 'Original Title', + ]); + + $updateData = [ + 'title' => 'Updated Title', + 'url' => 'https://updated.example.com', + 'is_active' => false, + ]; + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->putJson('/api/v1/links/' . $link->id, $updateData); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'id' => $link->id, + 'title' => 'Updated Title', + 'url' => 'https://updated.example.com', + 'is_active' => false, + ]); + + // Verify in database + $link->refresh(); + expect($link->title)->toBe('Updated Title') + ->and($link->is_active)->toBeFalse(); + }); + + it('returns 404 for non-existent link', function () { + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->putJson('/api/v1/links/99999', ['title' => 'Test']); + + // Assert + $response->assertStatus(404); + }); + + it('validates url format on update', function () { + // Arrange + $link = ImportantLink::factory()->create(['creator_id' => $this->manager->id]); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->putJson('/api/v1/links/' . $link->id, ['url' => 'invalid-url']); + + // Assert + $response->assertStatus(422) + ->assertJsonValidationErrors(['url']); + }); + + it('requires manager or owner role', function () { + // Arrange + $link = ImportantLink::factory()->create(['creator_id' => $this->manager->id]); + + // Act + $response = $this->actingAs($this->employee, 'sanctum') + ->putJson('/api/v1/links/' . $link->id, ['title' => 'Updated']); + + // Assert + $response->assertStatus(403); + }); + }); + + describe('DELETE /api/v1/links/{id}', function () { + it('deletes link successfully', function () { + // Arrange + $link = ImportantLink::factory()->create(['creator_id' => $this->manager->id]); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->deleteJson('/api/v1/links/' . $link->id); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Ссылка успешно удалена', + ]); + + // Verify deleted from database + expect(ImportantLink::find($link->id))->toBeNull(); + }); + + it('returns 404 for non-existent link', function () { + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->deleteJson('/api/v1/links/99999'); + + // Assert + $response->assertStatus(404); + }); + + it('requires manager or owner role', function () { + // Arrange + $link = ImportantLink::factory()->create(['creator_id' => $this->manager->id]); + + // Act + $response = $this->actingAs($this->employee, 'sanctum') + ->deleteJson('/api/v1/links/' . $link->id); + + // Assert + $response->assertStatus(403); + }); + }); + + describe('Link Ordering', function () { + it('returns links ordered by sort_order', function () { + // Arrange + ImportantLink::factory()->create(['creator_id' => $this->manager->id, 'sort_order' => 30, 'title' => 'Third']); + ImportantLink::factory()->create(['creator_id' => $this->manager->id, 'sort_order' => 10, 'title' => 'First']); + ImportantLink::factory()->create(['creator_id' => $this->manager->id, 'sort_order' => 20, 'title' => 'Second']); + + // Act + $response = $this->actingAs($this->manager, 'sanctum') + ->getJson('/api/v1/links'); + + // Assert + $response->assertStatus(200); + $titles = collect($response->json('data'))->pluck('title')->toArray(); + expect($titles[0])->toBe('First') + ->and($titles[1])->toBe('Second') + ->and($titles[2])->toBe('Third'); + }); + }); +}); diff --git a/tests/Feature/Api/UserRegistrationTest.php b/tests/Feature/Api/UserRegistrationTest.php deleted file mode 100644 index 95204de..0000000 --- a/tests/Feature/Api/UserRegistrationTest.php +++ /dev/null @@ -1,91 +0,0 @@ - 'testuser', - 'password' => 'TestPass123', - 'full_name' => 'Test User', - 'phone' => '+79991234567', - 'role' => Role::EMPLOYEE->value, - ]; - - $response = $this->postJson('/api/v1/users/create', $userData); - - $response->assertStatus(201) - ->assertJson([ - 'success' => true, - 'message' => 'Сотрудник успешно создан' - ]); - - $this->assertDatabaseHas('users', [ - 'login' => 'testuser', - 'full_name' => 'Test User', - 'phone' => '+79991234567', - 'role' => Role::EMPLOYEE->value, - ]); - } - - public function test_user_registration_validation_fails_with_invalid_data(): void - { - $invalidData = [ - 'login' => 'ab', // too short - 'password' => '123', // too short and doesn't match regex - 'full_name' => '', // empty - 'phone' => 'invalid', // invalid format - 'role' => 'invalid_role', // invalid role - ]; - - $response = $this->postJson('/api/v1/users/create', $invalidData); - - $response->assertStatus(422) - ->assertJson([ - 'success' => false, - 'message' => 'Ошибка валидации' - ]); - } - - public function test_user_registration_fails_with_duplicate_login(): void - { - // Create first user - $userData = [ - 'login' => 'testuser', - 'password' => 'TestPass123', - 'full_name' => 'Test User', - 'phone' => '+79991234567', - 'role' => Role::EMPLOYEE->value, - ]; - - $this->postJson('/api/v1/users/create', $userData)->assertStatus(201); - - // Try to create second user with same login - $duplicateUserData = [ - 'login' => 'testuser', // duplicate - 'password' => 'TestPass123', - 'full_name' => 'Another User', - 'phone' => '+79991234568', - 'role' => Role::EMPLOYEE->value, - ]; - - $response = $this->postJson('/api/v1/users/create', $duplicateUserData); - - $response->assertStatus(422) - ->assertJson([ - 'success' => false, - 'message' => 'Ошибка валидации' - ]); - } -} \ No newline at end of file