Добавлен 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,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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,38 @@
|
|||||||
<?php
|
<?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;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/user', function (Request $request) {
|
Route::prefix('v1')->group(function () {
|
||||||
return $request->user();
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
})->middleware('auth:sanctum');
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user