Compare commits

...
Author SHA1 Message Date
cacto e739ef5d38 Добавлен 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.
2026-08-12 00:21:38 +05:00
15 changed files with 872 additions and 4 deletions
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Issue a Sanctum personal access token for a mobile client. Each
* client names its own token (device_name) so a user can see/revoke
* per-device sessions later without logging everyone out at once.
*/
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'],
]);
$user = User::where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['Неверный email или пароль.'],
]);
}
return response()->json([
'token' => $user->createToken($credentials['device_name'])->plainTextToken,
'user' => new UserResource($user),
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
public function me(Request $request)
{
return new UserResource($request->user());
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\AutomationRuleRequest;
use App\Http\Resources\AutomationRuleResource;
use App\Models\AutomationRule;
class AutomationRuleController extends Controller
{
public function index()
{
$this->authorize('viewAny', AutomationRule::class);
$rules = AutomationRule::with(['zone', 'targetDevice', 'conditionSourceDevice'])
->orderByDesc('id')
->get();
return AutomationRuleResource::collection($rules);
}
public function store(AutomationRuleRequest $request)
{
$rule = new AutomationRule($this->mapActionParams($request->validated()));
$rule->user()->associate($request->user());
$rule->save();
return (new AutomationRuleResource($rule))->response()->setStatusCode(201);
}
public function update(AutomationRuleRequest $request, AutomationRule $automation_rule)
{
$automation_rule->update($this->mapActionParams($request->validated()));
return new AutomationRuleResource($automation_rule);
}
public function destroy(AutomationRule $automation_rule)
{
$this->authorize('delete', $automation_rule);
$automation_rule->delete();
return response()->noContent();
}
/**
* Mirrors AutomationRuleController@mapActionParams on the web side
* "level" is a friendlier stand-in for action_params over the wire too,
* so mobile clients don't need to know the {"level": ...} shape.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function mapActionParams(array $data): array
{
$data['action_params'] = $data['action_type'] === 'set_level'
? ['level' => $data['level']]
: [];
unset($data['level']);
return $data;
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeviceRequest;
use App\Http\Resources\DeviceResource;
use App\Models\Device;
use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Http\Request;
class DeviceController extends Controller
{
public function index(DeviceShadow $shadow)
{
$this->authorize('viewAny', Device::class);
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
$statuses = $shadow->statuses($devices->pluck('external_id')->all());
$devices->each(fn (Device $d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
return DeviceResource::collection($devices);
}
public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse)
{
$this->authorize('view', $device);
$device->load(['zone', 'deviceType']);
$snapshot = $shadow->snapshot($device->external_id);
$device->live_status = $snapshot['status'];
$telemetry = [];
if ($device->deviceType->category->value === 'sensor') {
$telemetry = $clickHouse->query(
'SELECT sensor_type, value, recorded_at FROM telemetry
WHERE device_id = {device_id:String}
ORDER BY recorded_at DESC LIMIT 50',
['device_id' => $device->external_id],
);
}
return (new DeviceResource($device))->additional([
'shadow' => [
'last_seen' => $snapshot['last_seen'],
'desired_state' => $snapshot['desired_state'],
'reported_state' => $snapshot['reported_state'],
],
'telemetry' => $telemetry,
]);
}
public function store(DeviceRequest $request)
{
$device = new Device($request->validated());
$device->user()->associate($request->user());
$device->save();
return (new DeviceResource($device))->response()->setStatusCode(201);
}
public function update(DeviceRequest $request, Device $device)
{
$device->update($request->validated());
return new DeviceResource($device);
}
public function destroy(Device $device)
{
$this->authorize('delete', $device);
$device->delete();
return response()->noContent();
}
public function turnOn(Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'turn_on');
return $this->respondToCommand($client->turnOn($device->external_id));
}
public function turnOff(Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'turn_off');
return $this->respondToCommand($client->turnOff($device->external_id));
}
public function setLevel(Request $request, Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'set_level');
$validated = $request->validate(['level' => ['required', 'numeric']]);
return $this->respondToCommand($client->setLevel($device->external_id, (float) $validated['level']));
}
private function ensureCapability(Device $device, string $capability): void
{
abort_unless(
in_array($capability, $device->deviceType->capabilities ?? [], true),
422,
"Устройство не поддерживает действие «{$capability}».",
);
}
/**
* @param array{success: bool, error: ?string} $result
*/
private function respondToCommand(array $result)
{
if (! $result['success']) {
return response()->json(['message' => $result['error'] ?? 'Команда не выполнена.'], 422);
}
return response()->json(['message' => 'Команда отправлена.']);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\DeviceTypeResource;
use App\Models\DeviceType;
class DeviceTypeController extends Controller
{
public function index()
{
return DeviceTypeResource::collection(DeviceType::orderBy('code')->get());
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ZoneRequest;
use App\Http\Resources\ZoneResource;
use App\Models\Zone;
class ZoneController extends Controller
{
public function index()
{
$this->authorize('viewAny', Zone::class);
return ZoneResource::collection(Zone::withCount('devices')->orderBy('name')->get());
}
public function store(ZoneRequest $request)
{
$zone = $request->user()->zones()->create($request->validated());
return (new ZoneResource($zone))->response()->setStatusCode(201);
}
public function update(ZoneRequest $request, Zone $zone)
{
$zone->update($request->validated());
return new ZoneResource($zone);
}
public function destroy(Zone $zone)
{
$this->authorize('delete', $zone);
$zone->delete();
return response()->noContent();
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use App\Models\AutomationRule;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin AutomationRule */
class AutomationRuleResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'target_device' => new DeviceResource($this->whenLoaded('targetDevice')),
'condition_source_device' => new DeviceResource($this->whenLoaded('conditionSourceDevice')),
'condition_sensor_type' => $this->condition_sensor_type,
'condition_operator' => $this->condition_operator->value,
'condition_value' => $this->condition_value,
'action_type' => $this->action_type,
'action_params' => $this->action_params,
'is_active' => $this->is_active,
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use App\Models\Device;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Device
*
* `devices.status` in Postgres is only a stale snapshot (see migration
* comment) Redis (Device Shadow) is authoritative. The controller stamps
* the live value onto the model as `live_status` before wrapping it here;
* `status` in the JSON always prefers that when present.
*/
class DeviceResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'external_id' => $this->external_id,
'protocol' => $this->protocol,
'status' => $this->live_status ?? $this->status,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'device_type' => new DeviceTypeResource($this->whenLoaded('deviceType')),
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use App\Models\DeviceType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin DeviceType */
class DeviceTypeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'category' => $this->category->value,
'capabilities' => $this->capabilities,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class UserResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role->value,
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use App\Models\Zone;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Zone */
class ZoneResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'devices_count' => $this->whenCounted('devices'),
'created_at' => $this->created_at,
];
}
}
+34 -4
View File
@@ -1,8 +1,38 @@
<?php
use Illuminate\Http\Request;
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\AutomationRuleController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\DeviceTypeController;
use App\Http\Controllers\Api\V1\ZoneController;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::prefix('v1')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
Route::get('/zones', [ZoneController::class, 'index']);
Route::post('/zones', [ZoneController::class, 'store']);
Route::put('/zones/{zone}', [ZoneController::class, 'update']);
Route::delete('/zones/{zone}', [ZoneController::class, 'destroy']);
Route::get('/device-types', [DeviceTypeController::class, 'index']);
Route::get('/devices', [DeviceController::class, 'index']);
Route::post('/devices', [DeviceController::class, 'store']);
Route::get('/devices/{device}', [DeviceController::class, 'show']);
Route::put('/devices/{device}', [DeviceController::class, 'update']);
Route::delete('/devices/{device}', [DeviceController::class, 'destroy']);
Route::post('/devices/{device}/turn-on', [DeviceController::class, 'turnOn']);
Route::post('/devices/{device}/turn-off', [DeviceController::class, 'turnOff']);
Route::post('/devices/{device}/set-level', [DeviceController::class, 'setLevel']);
Route::get('/automation-rules', [AutomationRuleController::class, 'index']);
Route::post('/automation-rules', [AutomationRuleController::class, 'store']);
Route::put('/automation-rules/{automation_rule}', [AutomationRuleController::class, 'update']);
Route::delete('/automation-rules/{automation_rule}', [AutomationRuleController::class, 'destroy']);
});
});
@@ -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();
}
}