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 зелёные.
128 lines
4.7 KiB
PHP
128 lines
4.7 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 Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class AutomationRuleControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function makeZoneWithDevices(User $owner): array
|
|
{
|
|
$zone = Zone::forceCreate(['user_id' => $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)->post(route('automation-rules.store'), [
|
|
'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' => '1',
|
|
]);
|
|
|
|
$response->assertRedirect(route('automation-rules.index'));
|
|
// '{}', not '[]' — Go's json.Unmarshal into map[string]any rejects an
|
|
// empty JSON array, so empty action_params must serialize as an object.
|
|
$this->assertDatabaseHas('automation_rules', [
|
|
'condition_source_device_id' => $sensor->id,
|
|
'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);
|
|
|
|
// Missing level should fail validation.
|
|
$missingLevel = $this->actingAs($owner)->post(route('automation-rules.store'), [
|
|
'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->assertSessionHasErrors('level');
|
|
|
|
$response = $this->actingAs($owner)->post(route('automation-rules.store'), [
|
|
'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->assertRedirect(route('automation-rules.index'));
|
|
$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)->post(route('automation-rules.store'), [
|
|
'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);
|
|
}
|
|
}
|