From e739ef5d38de2a6bf2d0319bc476486f44be4189 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Wed, 12 Aug 2026 00:21:38 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20REST=20API=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=BE=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20(=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=203,=20=D0=B1=D0=B5=D0=B7=20Telegram)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Токен-аутентификация Sanctum (login/logout по устройствам), эндпоинты /api/v1 для зон/устройств/правил автоматизации с тем же RBAC (owner/viewer), что и в веб-версии — контроллеры переиспользуют существующие Policy и FormRequest. Устройства отдают живой статус из Device Shadow (Redis) и историю телеметрии из ClickHouse, плюс управление (turn-on/turn-off/set-level) через device-control-service. --- .../Controllers/Api/V1/AuthController.php | 52 +++++++ .../Api/V1/AutomationRuleController.php | 66 +++++++++ .../Controllers/Api/V1/DeviceController.php | 126 ++++++++++++++++ .../Api/V1/DeviceTypeController.php | 15 ++ .../Controllers/Api/V1/ZoneController.php | 41 ++++++ .../Http/Resources/AutomationRuleResource.php | 29 ++++ .../app/Http/Resources/DeviceResource.php | 33 +++++ .../app/Http/Resources/DeviceTypeResource.php | 22 +++ .../app/Http/Resources/UserResource.php | 22 +++ .../app/Http/Resources/ZoneResource.php | 23 +++ laravel-app/routes/api.php | 38 ++++- laravel-app/tests/Feature/Api/V1/AuthTest.php | 82 +++++++++++ .../Feature/Api/V1/AutomationRuleApiTest.php | 124 ++++++++++++++++ .../tests/Feature/Api/V1/DeviceApiTest.php | 138 ++++++++++++++++++ .../tests/Feature/Api/V1/ZoneApiTest.php | 65 +++++++++ 15 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 laravel-app/app/Http/Controllers/Api/V1/AuthController.php create mode 100644 laravel-app/app/Http/Controllers/Api/V1/AutomationRuleController.php create mode 100644 laravel-app/app/Http/Controllers/Api/V1/DeviceController.php create mode 100644 laravel-app/app/Http/Controllers/Api/V1/DeviceTypeController.php create mode 100644 laravel-app/app/Http/Controllers/Api/V1/ZoneController.php create mode 100644 laravel-app/app/Http/Resources/AutomationRuleResource.php create mode 100644 laravel-app/app/Http/Resources/DeviceResource.php create mode 100644 laravel-app/app/Http/Resources/DeviceTypeResource.php create mode 100644 laravel-app/app/Http/Resources/UserResource.php create mode 100644 laravel-app/app/Http/Resources/ZoneResource.php create mode 100644 laravel-app/tests/Feature/Api/V1/AuthTest.php create mode 100644 laravel-app/tests/Feature/Api/V1/AutomationRuleApiTest.php create mode 100644 laravel-app/tests/Feature/Api/V1/DeviceApiTest.php create mode 100644 laravel-app/tests/Feature/Api/V1/ZoneApiTest.php diff --git a/laravel-app/app/Http/Controllers/Api/V1/AuthController.php b/laravel-app/app/Http/Controllers/Api/V1/AuthController.php new file mode 100644 index 0000000..3437bb8 --- /dev/null +++ b/laravel-app/app/Http/Controllers/Api/V1/AuthController.php @@ -0,0 +1,52 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['required', 'string', 'max:255'], + ]); + + $user = User::where('email', $credentials['email'])->first(); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['Неверный email или пароль.'], + ]); + } + + return response()->json([ + 'token' => $user->createToken($credentials['device_name'])->plainTextToken, + 'user' => new UserResource($user), + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->noContent(); + } + + public function me(Request $request) + { + return new UserResource($request->user()); + } +} diff --git a/laravel-app/app/Http/Controllers/Api/V1/AutomationRuleController.php b/laravel-app/app/Http/Controllers/Api/V1/AutomationRuleController.php new file mode 100644 index 0000000..91daffb --- /dev/null +++ b/laravel-app/app/Http/Controllers/Api/V1/AutomationRuleController.php @@ -0,0 +1,66 @@ +authorize('viewAny', AutomationRule::class); + + $rules = AutomationRule::with(['zone', 'targetDevice', 'conditionSourceDevice']) + ->orderByDesc('id') + ->get(); + + return AutomationRuleResource::collection($rules); + } + + public function store(AutomationRuleRequest $request) + { + $rule = new AutomationRule($this->mapActionParams($request->validated())); + $rule->user()->associate($request->user()); + $rule->save(); + + return (new AutomationRuleResource($rule))->response()->setStatusCode(201); + } + + public function update(AutomationRuleRequest $request, AutomationRule $automation_rule) + { + $automation_rule->update($this->mapActionParams($request->validated())); + + return new AutomationRuleResource($automation_rule); + } + + public function destroy(AutomationRule $automation_rule) + { + $this->authorize('delete', $automation_rule); + + $automation_rule->delete(); + + return response()->noContent(); + } + + /** + * Mirrors AutomationRuleController@mapActionParams on the web side — + * "level" is a friendlier stand-in for action_params over the wire too, + * so mobile clients don't need to know the {"level": ...} shape. + * + * @param array $data + * @return array + */ + private function mapActionParams(array $data): array + { + $data['action_params'] = $data['action_type'] === 'set_level' + ? ['level' => $data['level']] + : []; + + unset($data['level']); + + return $data; + } +} diff --git a/laravel-app/app/Http/Controllers/Api/V1/DeviceController.php b/laravel-app/app/Http/Controllers/Api/V1/DeviceController.php new file mode 100644 index 0000000..ee6b10b --- /dev/null +++ b/laravel-app/app/Http/Controllers/Api/V1/DeviceController.php @@ -0,0 +1,126 @@ +authorize('viewAny', Device::class); + + $devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get(); + $statuses = $shadow->statuses($devices->pluck('external_id')->all()); + $devices->each(fn (Device $d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown'); + + return DeviceResource::collection($devices); + } + + public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse) + { + $this->authorize('view', $device); + + $device->load(['zone', 'deviceType']); + $snapshot = $shadow->snapshot($device->external_id); + $device->live_status = $snapshot['status']; + + $telemetry = []; + if ($device->deviceType->category->value === 'sensor') { + $telemetry = $clickHouse->query( + 'SELECT sensor_type, value, recorded_at FROM telemetry + WHERE device_id = {device_id:String} + ORDER BY recorded_at DESC LIMIT 50', + ['device_id' => $device->external_id], + ); + } + + return (new DeviceResource($device))->additional([ + 'shadow' => [ + 'last_seen' => $snapshot['last_seen'], + 'desired_state' => $snapshot['desired_state'], + 'reported_state' => $snapshot['reported_state'], + ], + 'telemetry' => $telemetry, + ]); + } + + public function store(DeviceRequest $request) + { + $device = new Device($request->validated()); + $device->user()->associate($request->user()); + $device->save(); + + return (new DeviceResource($device))->response()->setStatusCode(201); + } + + public function update(DeviceRequest $request, Device $device) + { + $device->update($request->validated()); + + return new DeviceResource($device); + } + + public function destroy(Device $device) + { + $this->authorize('delete', $device); + + $device->delete(); + + return response()->noContent(); + } + + public function turnOn(Device $device, DeviceControlClient $client) + { + $this->authorize('update', $device); + $this->ensureCapability($device, 'turn_on'); + + return $this->respondToCommand($client->turnOn($device->external_id)); + } + + public function turnOff(Device $device, DeviceControlClient $client) + { + $this->authorize('update', $device); + $this->ensureCapability($device, 'turn_off'); + + return $this->respondToCommand($client->turnOff($device->external_id)); + } + + public function setLevel(Request $request, Device $device, DeviceControlClient $client) + { + $this->authorize('update', $device); + $this->ensureCapability($device, 'set_level'); + + $validated = $request->validate(['level' => ['required', 'numeric']]); + + return $this->respondToCommand($client->setLevel($device->external_id, (float) $validated['level'])); + } + + private function ensureCapability(Device $device, string $capability): void + { + abort_unless( + in_array($capability, $device->deviceType->capabilities ?? [], true), + 422, + "Устройство не поддерживает действие «{$capability}».", + ); + } + + /** + * @param array{success: bool, error: ?string} $result + */ + private function respondToCommand(array $result) + { + if (! $result['success']) { + return response()->json(['message' => $result['error'] ?? 'Команда не выполнена.'], 422); + } + + return response()->json(['message' => 'Команда отправлена.']); + } +} diff --git a/laravel-app/app/Http/Controllers/Api/V1/DeviceTypeController.php b/laravel-app/app/Http/Controllers/Api/V1/DeviceTypeController.php new file mode 100644 index 0000000..9a5b472 --- /dev/null +++ b/laravel-app/app/Http/Controllers/Api/V1/DeviceTypeController.php @@ -0,0 +1,15 @@ +get()); + } +} diff --git a/laravel-app/app/Http/Controllers/Api/V1/ZoneController.php b/laravel-app/app/Http/Controllers/Api/V1/ZoneController.php new file mode 100644 index 0000000..0d9f93e --- /dev/null +++ b/laravel-app/app/Http/Controllers/Api/V1/ZoneController.php @@ -0,0 +1,41 @@ +authorize('viewAny', Zone::class); + + return ZoneResource::collection(Zone::withCount('devices')->orderBy('name')->get()); + } + + public function store(ZoneRequest $request) + { + $zone = $request->user()->zones()->create($request->validated()); + + return (new ZoneResource($zone))->response()->setStatusCode(201); + } + + public function update(ZoneRequest $request, Zone $zone) + { + $zone->update($request->validated()); + + return new ZoneResource($zone); + } + + public function destroy(Zone $zone) + { + $this->authorize('delete', $zone); + + $zone->delete(); + + return response()->noContent(); + } +} diff --git a/laravel-app/app/Http/Resources/AutomationRuleResource.php b/laravel-app/app/Http/Resources/AutomationRuleResource.php new file mode 100644 index 0000000..cc3eb10 --- /dev/null +++ b/laravel-app/app/Http/Resources/AutomationRuleResource.php @@ -0,0 +1,29 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'zone' => new ZoneResource($this->whenLoaded('zone')), + 'target_device' => new DeviceResource($this->whenLoaded('targetDevice')), + 'condition_source_device' => new DeviceResource($this->whenLoaded('conditionSourceDevice')), + 'condition_sensor_type' => $this->condition_sensor_type, + 'condition_operator' => $this->condition_operator->value, + 'condition_value' => $this->condition_value, + 'action_type' => $this->action_type, + 'action_params' => $this->action_params, + 'is_active' => $this->is_active, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/laravel-app/app/Http/Resources/DeviceResource.php b/laravel-app/app/Http/Resources/DeviceResource.php new file mode 100644 index 0000000..aab193d --- /dev/null +++ b/laravel-app/app/Http/Resources/DeviceResource.php @@ -0,0 +1,33 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'external_id' => $this->external_id, + 'protocol' => $this->protocol, + 'status' => $this->live_status ?? $this->status, + 'zone' => new ZoneResource($this->whenLoaded('zone')), + 'device_type' => new DeviceTypeResource($this->whenLoaded('deviceType')), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/laravel-app/app/Http/Resources/DeviceTypeResource.php b/laravel-app/app/Http/Resources/DeviceTypeResource.php new file mode 100644 index 0000000..477ce7e --- /dev/null +++ b/laravel-app/app/Http/Resources/DeviceTypeResource.php @@ -0,0 +1,22 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'code' => $this->code, + 'category' => $this->category->value, + 'capabilities' => $this->capabilities, + ]; + } +} diff --git a/laravel-app/app/Http/Resources/UserResource.php b/laravel-app/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..d5e0876 --- /dev/null +++ b/laravel-app/app/Http/Resources/UserResource.php @@ -0,0 +1,22 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'role' => $this->role->value, + ]; + } +} diff --git a/laravel-app/app/Http/Resources/ZoneResource.php b/laravel-app/app/Http/Resources/ZoneResource.php new file mode 100644 index 0000000..19ee7c1 --- /dev/null +++ b/laravel-app/app/Http/Resources/ZoneResource.php @@ -0,0 +1,23 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'devices_count' => $this->whenCounted('devices'), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/laravel-app/routes/api.php b/laravel-app/routes/api.php index ccc387f..a80bf1a 100644 --- a/laravel-app/routes/api.php +++ b/laravel-app/routes/api.php @@ -1,8 +1,38 @@ user(); -})->middleware('auth:sanctum'); +Route::prefix('v1')->group(function () { + Route::post('/login', [AuthController::class, 'login']); + + Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/me', [AuthController::class, 'me']); + + Route::get('/zones', [ZoneController::class, 'index']); + Route::post('/zones', [ZoneController::class, 'store']); + Route::put('/zones/{zone}', [ZoneController::class, 'update']); + Route::delete('/zones/{zone}', [ZoneController::class, 'destroy']); + + Route::get('/device-types', [DeviceTypeController::class, 'index']); + + Route::get('/devices', [DeviceController::class, 'index']); + Route::post('/devices', [DeviceController::class, 'store']); + Route::get('/devices/{device}', [DeviceController::class, 'show']); + Route::put('/devices/{device}', [DeviceController::class, 'update']); + Route::delete('/devices/{device}', [DeviceController::class, 'destroy']); + Route::post('/devices/{device}/turn-on', [DeviceController::class, 'turnOn']); + Route::post('/devices/{device}/turn-off', [DeviceController::class, 'turnOff']); + Route::post('/devices/{device}/set-level', [DeviceController::class, 'setLevel']); + + Route::get('/automation-rules', [AutomationRuleController::class, 'index']); + Route::post('/automation-rules', [AutomationRuleController::class, 'store']); + Route::put('/automation-rules/{automation_rule}', [AutomationRuleController::class, 'update']); + Route::delete('/automation-rules/{automation_rule}', [AutomationRuleController::class, 'destroy']); + }); +}); diff --git a/laravel-app/tests/Feature/Api/V1/AuthTest.php b/laravel-app/tests/Feature/Api/V1/AuthTest.php new file mode 100644 index 0000000..edd8581 --- /dev/null +++ b/laravel-app/tests/Feature/Api/V1/AuthTest.php @@ -0,0 +1,82 @@ +create([ + 'role' => UserRole::Owner, + 'password' => bcrypt('secret1234'), + ]); + + $response = $this->postJson('/api/v1/login', [ + 'email' => $user->email, + 'password' => 'secret1234', + 'device_name' => 'iphone-15', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['token', 'user' => ['id', 'name', 'email', 'role']]); + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'name' => 'iphone-15', + ]); + } + + public function test_login_rejects_wrong_password(): void + { + $user = User::factory()->create(['password' => bcrypt('secret1234')]); + + $response = $this->postJson('/api/v1/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + 'device_name' => 'iphone-15', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } + + public function test_authenticated_token_can_reach_protected_route(): void + { + $user = User::factory()->create(['password' => bcrypt('secret1234')]); + + $token = $this->postJson('/api/v1/login', [ + 'email' => $user->email, + 'password' => 'secret1234', + 'device_name' => 'iphone-15', + ])->json('token'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/v1/me') + ->assertOk() + ->assertJsonPath('data.email', $user->email); + } + + public function test_logout_revokes_the_current_token(): void + { + $user = User::factory()->create(['password' => bcrypt('secret1234')]); + + $tokenModel = $user->createToken('iphone-15'); + + $this->withHeader('Authorization', "Bearer {$tokenModel->plainTextToken}") + ->postJson('/api/v1/logout') + ->assertNoContent(); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenModel->accessToken->id]); + } + + public function test_guest_cannot_reach_protected_route(): void + { + $this->getJson('/api/v1/me')->assertUnauthorized(); + } +} diff --git a/laravel-app/tests/Feature/Api/V1/AutomationRuleApiTest.php b/laravel-app/tests/Feature/Api/V1/AutomationRuleApiTest.php new file mode 100644 index 0000000..83421d6 --- /dev/null +++ b/laravel-app/tests/Feature/Api/V1/AutomationRuleApiTest.php @@ -0,0 +1,124 @@ + $owner->id, 'name' => 'Гроубокс']); + + $sensorType = DeviceType::create([ + 'code' => 'sensor_temp_humidity', + 'category' => DeviceCategory::Sensor, + 'capabilities' => ['temperature', 'humidity'], + ]); + $fanType = DeviceType::create([ + 'code' => 'fan', + 'category' => DeviceCategory::Actuator, + 'capabilities' => ['turn_on', 'turn_off', 'set_level'], + ]); + + $sensor = Device::forceCreate([ + 'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $sensorType->id, + 'name' => 'Датчик', 'external_id' => 'sensor-1', 'protocol' => 'mqtt', + ]); + $fan = Device::forceCreate([ + 'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $fanType->id, + 'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt', + ]); + + return [$zone, $sensor, $fan]; + } + + public function test_owner_can_create_rule_with_turn_on_action(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + [$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner); + + $response = $this->actingAs($owner)->postJson('/api/v1/automation-rules', [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'turn_on', + 'is_active' => true, + ]); + + $response->assertCreated(); + // '{}', not '[]' — same PHP/Go empty-array-vs-object gotcha as the web controller. + $this->assertDatabaseHas('automation_rules', [ + 'target_device_id' => $fan->id, + 'action_type' => 'turn_on', + 'action_params' => '{}', + ]); + } + + public function test_set_level_action_requires_and_stores_level_param(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + [$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner); + + $missingLevel = $this->actingAs($owner)->postJson('/api/v1/automation-rules', [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'set_level', + ]); + $missingLevel->assertJsonValidationErrors('level'); + + $response = $this->actingAs($owner)->postJson('/api/v1/automation-rules', [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'set_level', + 'level' => 42, + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('automation_rules', [ + 'target_device_id' => $fan->id, + 'action_type' => 'set_level', + 'action_params' => '{"level":42}', + ]); + } + + public function test_viewer_cannot_create_rule(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + [$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner); + + $response = $this->actingAs($viewer)->postJson('/api/v1/automation-rules', [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'turn_on', + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('automation_rules', 0); + } +} diff --git a/laravel-app/tests/Feature/Api/V1/DeviceApiTest.php b/laravel-app/tests/Feature/Api/V1/DeviceApiTest.php new file mode 100644 index 0000000..aa3ab1b --- /dev/null +++ b/laravel-app/tests/Feature/Api/V1/DeviceApiTest.php @@ -0,0 +1,138 @@ +value): Device + { + $zone = Zone::forceCreate(['user_id' => $owner->id, 'name' => 'Гроубокс']); + $type = DeviceType::create([ + 'code' => 'fan', + 'category' => $category, + 'capabilities' => $capabilities, + ]); + + return Device::forceCreate([ + 'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id, + 'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt', + ]); + } + + public function test_index_includes_live_status_from_shadow(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $device = $this->makeDevice($owner, ['turn_on']); + + $this->mock(DeviceShadow::class, function ($mock) { + $mock->shouldReceive('statuses')->once()->with(['fan-1'])->andReturn(['fan-1' => 'online']); + }); + + $response = $this->actingAs($owner)->getJson('/api/v1/devices'); + + $response->assertOk(); + $response->assertJsonPath('data.0.status', 'online'); + $response->assertJsonPath('data.0.external_id', $device->external_id); + } + + public function test_show_includes_shadow_snapshot_and_telemetry(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $device = $this->makeDevice($owner, ['temperature'], DeviceCategory::Sensor->value); + + $this->mock(DeviceShadow::class, function ($mock) { + $mock->shouldReceive('snapshot')->once()->with('fan-1')->andReturn([ + 'status' => 'online', + 'last_seen' => null, + 'desired_state' => [], + 'reported_state' => ['temperature' => 24.5], + ]); + }); + $this->mock(ClickHouseClient::class, function ($mock) { + $mock->shouldReceive('query')->once() + ->andReturn([['sensor_type' => 'temperature', 'value' => 24.5, 'recorded_at' => '2026-01-01 00:00:00']]); + }); + + $response = $this->actingAs($owner)->getJson("/api/v1/devices/{$device->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.status', 'online'); + $response->assertJsonPath('shadow.reported_state.temperature', 24.5); + $response->assertJsonPath('telemetry.0.sensor_type', 'temperature'); + } + + public function test_owner_can_turn_on_device(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $device = $this->makeDevice($owner, ['turn_on', 'turn_off']); + + $this->mock(DeviceControlClient::class, function ($mock) { + $mock->shouldReceive('turnOn')->once()->with('fan-1') + ->andReturn(['success' => true, 'error' => null]); + }); + + $response = $this->actingAs($owner)->postJson("/api/v1/devices/{$device->id}/turn-on"); + + $response->assertOk(); + $response->assertJsonPath('message', 'Команда отправлена.'); + } + + public function test_turn_on_rejected_when_device_lacks_capability(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $device = $this->makeDevice($owner, ['turn_off']); // no turn_on + + $this->mock(DeviceControlClient::class, function ($mock) { + $mock->shouldNotReceive('turnOn'); + }); + + $response = $this->actingAs($owner)->postJson("/api/v1/devices/{$device->id}/turn-on"); + + $response->assertStatus(422); + } + + public function test_viewer_cannot_turn_on_device(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + $device = $this->makeDevice($owner, ['turn_on']); + + $this->mock(DeviceControlClient::class, function ($mock) { + $mock->shouldNotReceive('turnOn'); + }); + + $response = $this->actingAs($viewer)->postJson("/api/v1/devices/{$device->id}/turn-on"); + + $response->assertForbidden(); + } + + public function test_failed_command_returns_422_with_message(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $device = $this->makeDevice($owner, ['turn_on']); + + $this->mock(DeviceControlClient::class, function ($mock) { + $mock->shouldReceive('turnOn')->once() + ->andReturn(['success' => false, 'error' => 'device offline']); + }); + + $response = $this->actingAs($owner)->postJson("/api/v1/devices/{$device->id}/turn-on"); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'device offline'); + } +} diff --git a/laravel-app/tests/Feature/Api/V1/ZoneApiTest.php b/laravel-app/tests/Feature/Api/V1/ZoneApiTest.php new file mode 100644 index 0000000..e71a135 --- /dev/null +++ b/laravel-app/tests/Feature/Api/V1/ZoneApiTest.php @@ -0,0 +1,65 @@ +create(['role' => UserRole::Owner]); + + $response = $this->actingAs($owner)->postJson('/api/v1/zones', [ + 'name' => 'Гостиная', + 'description' => 'Тест', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.name', 'Гостиная'); + $this->assertDatabaseHas('zones', ['name' => 'Гостиная', 'user_id' => $owner->id]); + } + + public function test_viewer_cannot_create_zone(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + + $response = $this->actingAs($viewer)->postJson('/api/v1/zones', ['name' => 'Гостиная']); + + $response->assertForbidden(); + $this->assertDatabaseMissing('zones', ['name' => 'Гостиная']); + } + + public function test_viewer_can_list_zones(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']); + + $response = $this->actingAs($viewer)->getJson('/api/v1/zones'); + + $response->assertOk(); + $response->assertJsonPath('data.0.name', 'Гроубокс'); + } + + public function test_viewer_cannot_delete_zone(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + $zone = Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']); + + $response = $this->actingAs($viewer)->deleteJson("/api/v1/zones/{$zone->id}"); + + $response->assertForbidden(); + $this->assertDatabaseHas('zones', ['id' => $zone->id]); + } + + public function test_guest_gets_401(): void + { + $this->getJson('/api/v1/zones')->assertUnauthorized(); + } +}