device-control-service: HTTP/JSON API (internal/httpapi) рядом с gRPC —
POST /devices/{id}/turn-on|turn-off|set-level, тот же server.Server внутри,
без дублирования логики. Решение вместо gRPC-клиента на PHP: grpc/grpc
через PECL компилируется в Alpine 10-20+ минут и утяжеляет образ, а
диаграмма архитектуры в ТЗ и так допускала HTTP для Laravel→device-control.
Laravel: DeviceShadow (чтение Device Shadow из Redis, MGET одним запросом
для списка устройств), ClickHouseClient (HTTP-интерфейс ClickHouse,
параметризованные {name:Type}-запросы), DeviceControlClient (HTTP-вызовы
к новому Go-эндпоинту). Redis-клиент — predis (чистый PHP), а не phpredis,
по той же причине, что и решение по gRPC — не добавлять ещё одну
C-компиляцию в образ.
DeviceController::show — страница устройства: live-статус/last_seen/
desired-reported state из Redis, история показаний из ClickHouse (для
сенсоров), кнопки ручного управления (для актуаторов, только owner,
с проверкой capability устройства). В devices/index — бейдж online/
offline/unknown. Дашборд дополнен счётчиком онлайн-устройств.
Два реальных бага найдены и исправлены при сквозной проверке:
1. Пустой action_params сериализовался в JSON-массив "[]" (PHP не
различает пустой список и пустой объект), а Go ждёт объект —
rule-engine-service падал на unmarshal. Фикс — JsonObjectCast
(JSON_FORCE_OBJECT) на AutomationRule::action_params.
2. Redis-ключи device shadow — общее пространство имён с Go-сервisами
(сырые ключи без префикса), а Laravel по умолчанию добавляет ко всем
ключам префикс "app-name-database-" — Laravel никогда не видел
реальные данные. Фикс — REDIS_PREFIX="" в окружении контейнера
(важно: пустое значение в docker-compose YAML нужно задавать явно
через "", просто "KEY:" означает "взять из окружения хоста").
Проверено сквозным тестом через docker compose: полный цикл телеметрия →
правило → команда воспроизведён вживую с реальным исправлением на лету;
ручное управление (turn_on/turn_off/set_level) из Laravel UI подтверждено
через браузер — HTTP-вызов к device-control-service, обновление
desired_state (merge-patch), реальная MQTT-команда поймана мониторингом
топика. 38/38 тестов Laravel, все Go-тесты device-control-service зелёные.
130 lines
4.5 KiB
PHP
130 lines
4.5 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Enums\DeviceCategory;
|
|
use App\Enums\UserRole;
|
|
use App\Models\Device;
|
|
use App\Models\DeviceType;
|
|
use App\Models\User;
|
|
use App\Models\Zone;
|
|
use App\Services\ClickHouseClient;
|
|
use App\Services\DeviceControlClient;
|
|
use App\Services\DeviceShadow;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class DeviceControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function makeDevice(User $owner, array $capabilities): Device
|
|
{
|
|
$zone = Zone::forceCreate(['user_id' => $owner->id, 'name' => 'Гроубокс']);
|
|
$type = DeviceType::create([
|
|
'code' => 'fan',
|
|
'category' => DeviceCategory::Actuator,
|
|
'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_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)->post(route('devices.turn-on', $device));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHas('status');
|
|
}
|
|
|
|
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)->post(route('devices.turn-on', $device));
|
|
|
|
$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)->post(route('devices.turn-on', $device));
|
|
|
|
$response->assertForbidden();
|
|
}
|
|
|
|
public function test_failed_command_flashes_error(): 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)->post(route('devices.turn-on', $device));
|
|
|
|
$response->assertSessionHas('error', 'device offline');
|
|
}
|
|
|
|
public function test_show_page_renders_shadow_and_telemetry(): void
|
|
{
|
|
$owner = User::factory()->create(['role' => UserRole::Owner]);
|
|
$zone = Zone::forceCreate(['user_id' => $owner->id, 'name' => 'Гроубокс']);
|
|
$type = DeviceType::create([
|
|
'code' => 'sensor_temp_humidity',
|
|
'category' => DeviceCategory::Sensor,
|
|
'capabilities' => ['temperature'],
|
|
]);
|
|
$device = Device::forceCreate([
|
|
'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id,
|
|
'name' => 'Датчик', 'external_id' => 'sensor-1', 'protocol' => 'mqtt',
|
|
]);
|
|
|
|
$this->mock(DeviceShadow::class, function ($mock) {
|
|
$mock->shouldReceive('snapshot')->once()->with('sensor-1')->andReturn([
|
|
'status' => 'online',
|
|
'last_seen' => null,
|
|
'desired_state' => [],
|
|
'reported_state' => [],
|
|
]);
|
|
});
|
|
$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)->get(route('devices.show', $device));
|
|
|
|
$response->assertOk();
|
|
$response->assertSee('online');
|
|
$response->assertSee('24.5');
|
|
}
|
|
}
|