Добавлен REST API для мобильного приложения (этап 3, без Telegram)
Токен-аутентификация Sanctum (login/logout по устройствам), эндпоинты /api/v1 для зон/устройств/правил автоматизации с тем же RBAC (owner/viewer), что и в веб-версии — контроллеры переиспользуют существующие Policy и FormRequest. Устройства отдают живой статус из Device Shadow (Redis) и историю телеметрии из ClickHouse, плюс управление (turn-on/turn-off/set-level) через device-control-service.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_issues_a_token(): void
|
||||
{
|
||||
$user = User::factory()->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AutomationRuleApiTest 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)->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\User;
|
||||
use App\Models\Zone;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ZoneApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_owner_can_create_zone(): void
|
||||
{
|
||||
$owner = User::factory()->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user