Токен-аутентификация Sanctum (login/logout по устройствам), эндпоинты /api/v1 для зон/устройств/правил автоматизации с тем же RBAC (owner/viewer), что и в веб-версии — контроллеры переиспользуют существующие Policy и FormRequest. Устройства отдают живой статус из Device Shadow (Redis) и историю телеметрии из ClickHouse, плюс управление (turn-on/turn-off/set-level) через device-control-service.
139 lines
5.1 KiB
PHP
139 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\V1;
|
|
|
|
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 DeviceApiTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function makeDevice(User $owner, array $capabilities, string $category = DeviceCategory::Actuator->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');
|
|
}
|
|
}
|