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'); } }