From 3ade5c25125e0b2e52dbc30bd31d160346f9d9a4 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Tue, 11 Aug 2026 19:48:17 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20+=20=D1=80?= =?UTF-8?q?=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 зелёные. --- .env.example | 3 +- docker-compose.yml | 16 ++ laravel-app/.env.example | 15 +- laravel-app/app/Casts/JsonObjectCast.php | 26 +++ .../app/Http/Controllers/DeviceController.php | 78 ++++++++- laravel-app/app/Models/AutomationRule.php | 3 +- .../app/Providers/AppServiceProvider.php | 13 +- laravel-app/app/Services/ClickHouseClient.php | 49 ++++++ .../app/Services/DeviceControlClient.php | 63 +++++++ laravel-app/app/Services/DeviceShadow.php | 57 +++++++ laravel-app/composer.json | 3 +- laravel-app/composer.lock | 103 ++++++++--- laravel-app/config/clickhouse.php | 8 + laravel-app/config/services.php | 4 + .../resources/views/dashboard.blade.php | 6 +- .../resources/views/devices/index.blade.php | 19 ++- .../resources/views/devices/show.blade.php | 105 ++++++++++++ laravel-app/routes/web.php | 17 +- .../Feature/AutomationRuleControllerTest.php | 4 +- .../tests/Feature/DeviceControllerTest.php | 129 ++++++++++++++ services/device-control-service/README.md | 15 +- .../cmd/device-control-service/main.go | 28 ++- .../internal/config/config.go | 4 + .../internal/httpapi/router.go | 80 +++++++++ .../internal/httpapi/router_test.go | 161 ++++++++++++++++++ 25 files changed, 970 insertions(+), 39 deletions(-) create mode 100644 laravel-app/app/Casts/JsonObjectCast.php create mode 100644 laravel-app/app/Services/ClickHouseClient.php create mode 100644 laravel-app/app/Services/DeviceControlClient.php create mode 100644 laravel-app/app/Services/DeviceShadow.php create mode 100644 laravel-app/config/clickhouse.php create mode 100644 laravel-app/resources/views/devices/show.blade.php create mode 100644 laravel-app/tests/Feature/DeviceControllerTest.php create mode 100644 services/device-control-service/internal/httpapi/router.go create mode 100644 services/device-control-service/internal/httpapi/router_test.go diff --git a/.env.example b/.env.example index 5553ba0..9bd111f 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,9 @@ RABBITMQ_PASSWORD=change_me MQTT_HOST=mosquitto MQTT_PORT=1883 -# --- device-control-service (gRPC) --- +# --- device-control-service (gRPC + HTTP) --- DEVICE_CONTROL_GRPC_PORT=50051 +DEVICE_CONTROL_HTTP_PORT=8090 DEVICE_CONTROL_MQTT_CLIENT_ID=device-control-service DEVICE_CONTROL_HEALTHCHECK_TIMEOUT=60s DEVICE_CONTROL_HEALTHCHECK_INTERVAL=15s diff --git a/docker-compose.yml b/docker-compose.yml index f7cb7cc..c50e603 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,8 +121,10 @@ services: dockerfile: services/device-control-service/Dockerfile ports: - "${DEVICE_CONTROL_GRPC_PORT}:50051" + - "${DEVICE_CONTROL_HTTP_PORT}:8090" environment: DEVICE_CONTROL_GRPC_PORT: 50051 + DEVICE_CONTROL_HTTP_PORT: 8090 MQTT_HOST: mosquitto MQTT_PORT: 1883 REDIS_HOST: redis @@ -193,15 +195,29 @@ services: SESSION_DRIVER: database CACHE_STORE: database QUEUE_CONNECTION: sync + REDIS_CLIENT: predis REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} + # Device Shadow keys are a cross-service keyspace (device-control-service + # writes them raw) — Laravel's default per-app key prefix would hide them. + REDIS_PREFIX: "" + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_HTTP_PORT: 8123 + CLICKHOUSE_DB: ${CLICKHOUSE_DB} + CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + DEVICE_CONTROL_HTTP_URL: http://device-control-service:8090 LOG_CHANNEL: stderr depends_on: postgres: condition: service_healthy redis: condition: service_started + clickhouse: + condition: service_healthy + device-control-service: + condition: service_started networks: - home-automation restart: unless-stopped diff --git a/laravel-app/.env.example b/laravel-app/.env.example index d52a063..bd020ad 100644 --- a/laravel-app/.env.example +++ b/laravel-app/.env.example @@ -42,11 +42,24 @@ CACHE_STORE=database MEMCACHED_HOST=127.0.0.1 -REDIS_CLIENT=phpredis +REDIS_CLIENT=predis +# Device Shadow keys are a cross-service keyspace (device-control-service +# writes them raw) — Laravel's default per-app key prefix would hide them. +REDIS_PREFIX= REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 +# --- ClickHouse (telemetry history) --- +CLICKHOUSE_HOST=127.0.0.1 +CLICKHOUSE_HTTP_PORT=8123 +CLICKHOUSE_DB=telemetry +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD=change_me + +# --- device-control-service (manual device commands, HTTP transport) --- +DEVICE_CONTROL_HTTP_URL=http://127.0.0.1:8090 + MAIL_MAILER=log MAIL_SCHEME=null MAIL_HOST=127.0.0.1 diff --git a/laravel-app/app/Casts/JsonObjectCast.php b/laravel-app/app/Casts/JsonObjectCast.php new file mode 100644 index 0000000..5111118 --- /dev/null +++ b/laravel-app/app/Casts/JsonObjectCast.php @@ -0,0 +1,26 @@ +authorize('viewAny', Device::class); $devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get(); + $statuses = $shadow->statuses($devices->pluck('external_id')->all()); - return view('devices.index', compact('devices')); + return view('devices.index', compact('devices', 'statuses')); + } + + public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse) + { + $this->authorize('view', $device); + + $device->load(['zone', 'deviceType']); + + $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 view('devices.show', [ + 'device' => $device, + 'shadow' => $shadow->snapshot($device->external_id), + 'telemetry' => $telemetry, + ]); } public function create() @@ -57,6 +85,52 @@ class DeviceController extends Controller return redirect()->route('devices.index')->with('status', 'Устройство удалено.'); } + 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) + { + return back()->with( + $result['success'] ? 'status' : 'error', + $result['success'] ? 'Команда отправлена.' : ($result['error'] ?? 'Команда не выполнена.'), + ); + } + /** * @return array */ diff --git a/laravel-app/app/Models/AutomationRule.php b/laravel-app/app/Models/AutomationRule.php index 05277de..a9a25ee 100644 --- a/laravel-app/app/Models/AutomationRule.php +++ b/laravel-app/app/Models/AutomationRule.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\JsonObjectCast; use App\Enums\ConditionOperator; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; @@ -27,7 +28,7 @@ class AutomationRule extends Model return [ 'condition_operator' => ConditionOperator::class, 'condition_value' => 'float', - 'action_params' => 'array', + 'action_params' => JsonObjectCast::class, 'is_active' => 'boolean', 'created_at' => 'datetime', ]; diff --git a/laravel-app/app/Providers/AppServiceProvider.php b/laravel-app/app/Providers/AppServiceProvider.php index 452e6b6..2a84606 100644 --- a/laravel-app/app/Providers/AppServiceProvider.php +++ b/laravel-app/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Services\ClickHouseClient; +use App\Services\DeviceControlClient; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +13,16 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(ClickHouseClient::class, fn () => new ClickHouseClient( + baseUrl: config('clickhouse.url'), + database: config('clickhouse.database'), + username: config('clickhouse.username'), + password: config('clickhouse.password'), + )); + + $this->app->singleton(DeviceControlClient::class, fn () => new DeviceControlClient( + baseUrl: config('services.device_control.url'), + )); } /** diff --git a/laravel-app/app/Services/ClickHouseClient.php b/laravel-app/app/Services/ClickHouseClient.php new file mode 100644 index 0000000..d29a2cb --- /dev/null +++ b/laravel-app/app/Services/ClickHouseClient.php @@ -0,0 +1,49 @@ + $params Bound to {name:Type} placeholders in $sql. + * @return array> + */ + public function query(string $sql, array $params = []): array + { + $query = array_merge( + ['database' => $this->database, 'default_format' => 'JSONEachRow'], + collect($params)->mapWithKeys(fn ($value, $key) => ["param_{$key}" => $value])->all(), + ); + + $response = Http::withBasicAuth($this->username, $this->password) + ->withBody($sql, 'text/plain') + ->post("{$this->baseUrl}/?".http_build_query($query)); + + if ($response->failed()) { + throw new RuntimeException("ClickHouse query failed ({$response->status()}): {$response->body()}"); + } + + // JSONEachRow: one JSON object per line, not a JSON array. + return collect(explode("\n", trim($response->body()))) + ->filter(fn (string $line) => $line !== '') + ->map(fn (string $line) => json_decode($line, true)) + ->all(); + } +} diff --git a/laravel-app/app/Services/DeviceControlClient.php b/laravel-app/app/Services/DeviceControlClient.php new file mode 100644 index 0000000..d3d2d7d --- /dev/null +++ b/laravel-app/app/Services/DeviceControlClient.php @@ -0,0 +1,63 @@ +post("/devices/{$externalId}/turn-on"); + } + + /** @return array{success: bool, error: ?string} */ + public function turnOff(string $externalId): array + { + return $this->post("/devices/{$externalId}/turn-off"); + } + + /** @return array{success: bool, error: ?string} */ + public function setLevel(string $externalId, float $level): array + { + return $this->post("/devices/{$externalId}/set-level", ['level' => $level]); + } + + /** + * @param array $body + * @return array{success: bool, error: ?string} + */ + private function post(string $path, array $body = []): array + { + try { + $response = Http::timeout(5)->post("{$this->baseUrl}{$path}", $body); + } catch (\Illuminate\Http\Client\ConnectionException $e) { + Log::error('device-control-service unreachable', ['path' => $path, 'error' => $e->getMessage()]); + + return ['success' => false, 'error' => 'device-control-service недоступен']; + } + + if ($response->failed()) { + Log::error('device-control-service returned an error status', ['path' => $path, 'status' => $response->status()]); + + return ['success' => false, 'error' => 'device-control-service вернул ошибку']; + } + + return [ + 'success' => (bool) $response->json('success'), + 'error' => $response->json('error'), + ]; + } +} diff --git a/laravel-app/app/Services/DeviceShadow.php b/laravel-app/app/Services/DeviceShadow.php new file mode 100644 index 0000000..554603a --- /dev/null +++ b/laravel-app/app/Services/DeviceShadow.php @@ -0,0 +1,57 @@ +, reported_state: array} + */ + public function snapshot(string $externalId): array + { + $results = Redis::pipeline(function ($pipe) use ($externalId) { + $pipe->get("device:{$externalId}:status"); + $pipe->get("device:{$externalId}:last_seen"); + $pipe->get("device:{$externalId}:desired_state"); + $pipe->get("device:{$externalId}:reported_state"); + }); + + [$status, $lastSeen, $desired, $reported] = $results; + + return [ + 'status' => $status ?: 'unknown', + 'last_seen' => $lastSeen ? CarbonImmutable::createFromTimestamp((int) $lastSeen) : null, + 'desired_state' => $desired ? json_decode($desired, true) : [], + 'reported_state' => $reported ? json_decode($reported, true) : [], + ]; + } + + /** + * Status for many devices in a single round-trip (MGET), so the device + * list doesn't do one Redis call per row. + * + * @param array $externalIds + * @return array external_id => status ("online"/"offline"/"unknown") + */ + public function statuses(array $externalIds): array + { + if (empty($externalIds)) { + return []; + } + + $keys = array_map(fn (string $id) => "device:{$id}:status", $externalIds); + $values = Redis::mget($keys); + + return array_combine($externalIds, array_map(fn ($v) => $v ?: 'unknown', $values)); + } +} diff --git a/laravel-app/composer.json b/laravel-app/composer.json index b44562a..c5601a4 100644 --- a/laravel-app/composer.json +++ b/laravel-app/composer.json @@ -9,7 +9,8 @@ "php": "^8.3", "laravel/framework": "^13.8", "laravel/sanctum": "^4.0", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "predis/predis": "^3.5" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/laravel-app/composer.lock b/laravel-app/composer.lock index a4020a8..6fbe296 100644 --- a/laravel-app/composer.lock +++ b/laravel-app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f1ab046cb80318325a9735c77c07672f", + "content-hash": "483f1bf17688fcc178b8c1849ff31ddc", "packages": [ { "name": "brick/math", @@ -642,21 +642,21 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.1", + "version": "7.15.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/promises": "^2.5.2", "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", @@ -750,7 +750,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" }, "funding": [ { @@ -766,20 +766,20 @@ "type": "tidelift" } ], - "time": "2026-07-18T11:23:11+00:00" + "time": "2026-08-05T19:48:21+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", "shasum": "" }, "require": { @@ -834,7 +834,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.2" }, "funding": [ { @@ -850,7 +850,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-05T19:30:54+00:00" }, { "name": "guzzlehttp/psr7", @@ -1538,16 +1538,16 @@ }, { "name": "league/commonmark", - "version": "2.8.3", + "version": "2.9.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" + "reference": "72e9a87efcf41a8e83be3ed0866b69d77565cb12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", - "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/72e9a87efcf41a8e83be3ed0866b69d77565cb12", + "reference": "72e9a87efcf41a8e83be3ed0866b69d77565cb12", "shasum": "" }, "require": { @@ -1584,7 +1584,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -1641,7 +1641,7 @@ "type": "tidelift" } ], - "time": "2026-07-12T15:29:16+00:00" + "time": "2026-08-11T00:58:45+00:00" }, { "name": "league/config", @@ -2680,6 +2680,69 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "predis/predis", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/5c996db191ee2d9bafe651f454b1fca16754271b", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-06-11T16:56:53+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/laravel-app/config/clickhouse.php b/laravel-app/config/clickhouse.php new file mode 100644 index 0000000..1d97e99 --- /dev/null +++ b/laravel-app/config/clickhouse.php @@ -0,0 +1,8 @@ + 'http://'.env('CLICKHOUSE_HOST', 'localhost').':'.env('CLICKHOUSE_HTTP_PORT', '8123'), + 'database' => env('CLICKHOUSE_DB', 'telemetry'), + 'username' => env('CLICKHOUSE_USER', 'default'), + 'password' => env('CLICKHOUSE_PASSWORD', ''), +]; diff --git a/laravel-app/config/services.php b/laravel-app/config/services.php index 6a90eb8..d8e7461 100644 --- a/laravel-app/config/services.php +++ b/laravel-app/config/services.php @@ -35,4 +35,8 @@ return [ ], ], + 'device_control' => [ + 'url' => env('DEVICE_CONTROL_HTTP_URL', 'http://localhost:8090'), + ], + ]; diff --git a/laravel-app/resources/views/dashboard.blade.php b/laravel-app/resources/views/dashboard.blade.php index 0416ff9..e0927c3 100644 --- a/laravel-app/resources/views/dashboard.blade.php +++ b/laravel-app/resources/views/dashboard.blade.php @@ -6,7 +6,7 @@
-
+
{{ __('Зоны') }}
{{ $zonesCount }}
@@ -15,6 +15,10 @@
{{ __('Устройства') }}
{{ $devicesCount }}
+ +
{{ __('Онлайн') }}
+
{{ $onlineDevicesCount }}
+
{{ __('Активных правил') }}
{{ $activeRulesCount }}
diff --git a/laravel-app/resources/views/devices/index.blade.php b/laravel-app/resources/views/devices/index.blade.php index cb392e0..6707577 100644 --- a/laravel-app/resources/views/devices/index.blade.php +++ b/laravel-app/resources/views/devices/index.blade.php @@ -18,6 +18,9 @@ @if (session('status'))
{{ session('status') }}
@endif + @if (session('error')) +
{{ session('error') }}
+ @endif @@ -25,19 +28,29 @@ + - @forelse ($devices as $device) + @php $status = $statuses[$device->external_id] ?? 'unknown'; @endphp - + + -
{{ __('Название') }} {{ __('Зона') }} {{ __('Тип') }}{{ __('Статус') }} {{ __('External ID') }}{{ __('Протокол') }}
{{ $device->name }} + {{ $device->name }} + {{ $device->zone->name }} {{ $device->deviceType->code }} + $status === 'online', + 'bg-gray-100 text-gray-600' => $status === 'offline', + 'bg-yellow-100 text-yellow-800' => $status === 'unknown', + ])>{{ $status }} + {{ $device->external_id }}{{ $device->protocol }} @can('update', $device) {{ __('Изменить') }} diff --git a/laravel-app/resources/views/devices/show.blade.php b/laravel-app/resources/views/devices/show.blade.php new file mode 100644 index 0000000..b0cf60e --- /dev/null +++ b/laravel-app/resources/views/devices/show.blade.php @@ -0,0 +1,105 @@ + + +

{{ $device->name }}

+
+ +
+
+ @if (session('status')) +
{{ session('status') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + +
+

{{ __('Живое состояние (Redis device shadow)') }}

+
+
+
{{ __('Статус') }}
+
$shadow['status'] === 'online', + 'bg-gray-100 text-gray-600' => $shadow['status'] === 'offline', + 'bg-yellow-100 text-yellow-800' => $shadow['status'] === 'unknown', + ])>{{ $shadow['status'] }}
+
+
+
{{ __('Последняя активность') }}
+
{{ $shadow['last_seen']?->diffForHumans() ?? __('нет данных') }}
+
+
+
{{ __('Желаемое состояние (desired)') }}
+
{{ json_encode($shadow['desired_state']) }}
+
+
+
{{ __('Подтверждённое состояние (reported)') }}
+
{{ json_encode($shadow['reported_state']) }}
+
+
+
+ + @can('update', $device) + @php $capabilities = $device->deviceType->capabilities ?? []; @endphp + @if (!empty(array_intersect($capabilities, ['turn_on', 'turn_off', 'set_level']))) +
+

{{ __('Ручное управление') }}

+
+ @if (in_array('turn_on', $capabilities)) +
+ @csrf + {{ __('Включить') }} +
+ @endif + @if (in_array('turn_off', $capabilities)) +
+ @csrf + {{ __('Выключить') }} +
+ @endif + @if (in_array('set_level', $capabilities)) +
+ @csrf + + {{ __('Установить уровень') }} +
+ @endif +
+
+ @endif + @endcan + + @if ($device->deviceType->category->value === 'sensor') +
+

{{ __('Последние показания (ClickHouse)') }}

+ + + + + + + + + + @forelse ($telemetry as $row) + + + + + + @empty + + + + @endforelse + +
{{ __('Тип') }}{{ __('Значение') }}{{ __('Время') }}
{{ $row['sensor_type'] }}{{ $row['value'] }}{{ $row['recorded_at'] }}
{{ __('Показаний пока нет.') }}
+
+ @endif + + {{ __('← Назад к устройствам') }} +
+
+
diff --git a/laravel-app/routes/web.php b/laravel-app/routes/web.php index d0af766..7711903 100644 --- a/laravel-app/routes/web.php +++ b/laravel-app/routes/web.php @@ -7,26 +7,37 @@ use App\Http\Controllers\ZoneController; use App\Models\AutomationRule; use App\Models\Device; use App\Models\Zone; +use App\Services\DeviceShadow; use Illuminate\Support\Facades\Route; Route::get('/', function () { return view('welcome'); }); -Route::get('/dashboard', function () { +Route::get('/dashboard', function (DeviceShadow $shadow) { + $devices = Device::all(); + $onlineCount = collect($shadow->statuses($devices->pluck('external_id')->all())) + ->filter(fn (string $status) => $status === 'online') + ->count(); + return view('dashboard', [ 'zonesCount' => Zone::count(), - 'devicesCount' => Device::count(), + 'devicesCount' => $devices->count(), + 'onlineDevicesCount' => $onlineCount, 'activeRulesCount' => AutomationRule::where('is_active', true)->count(), ]); })->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware(['auth', 'verified'])->group(function () { Route::resource('zones', ZoneController::class)->except('show'); - Route::resource('devices', DeviceController::class)->except('show'); + Route::resource('devices', DeviceController::class); Route::resource('automation-rules', AutomationRuleController::class) ->except('show') ->parameters(['automation-rules' => 'automation_rule']); + + Route::post('/devices/{device}/turn-on', [DeviceController::class, 'turnOn'])->name('devices.turn-on'); + Route::post('/devices/{device}/turn-off', [DeviceController::class, 'turnOff'])->name('devices.turn-off'); + Route::post('/devices/{device}/set-level', [DeviceController::class, 'setLevel'])->name('devices.set-level'); }); Route::middleware('auth')->group(function () { diff --git a/laravel-app/tests/Feature/AutomationRuleControllerTest.php b/laravel-app/tests/Feature/AutomationRuleControllerTest.php index 20b4231..c6eb500 100644 --- a/laravel-app/tests/Feature/AutomationRuleControllerTest.php +++ b/laravel-app/tests/Feature/AutomationRuleControllerTest.php @@ -59,11 +59,13 @@ class AutomationRuleControllerTest extends TestCase ]); $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' => '[]', + 'action_params' => '{}', ]); } diff --git a/laravel-app/tests/Feature/DeviceControllerTest.php b/laravel-app/tests/Feature/DeviceControllerTest.php new file mode 100644 index 0000000..4eace4e --- /dev/null +++ b/laravel-app/tests/Feature/DeviceControllerTest.php @@ -0,0 +1,129 @@ + $owner->id, 'name' => 'Гроубокс']); + $type = DeviceType::create([ + 'code' => 'fan', + 'category' => DeviceCategory::Actuator, + '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_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)->post(route('devices.turn-on', $device)); + + $response->assertRedirect(); + $response->assertSessionHas('status'); + } + + 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)->post(route('devices.turn-on', $device)); + + $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)->post(route('devices.turn-on', $device)); + + $response->assertForbidden(); + } + + public function test_failed_command_flashes_error(): 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)->post(route('devices.turn-on', $device)); + + $response->assertSessionHas('error', 'device offline'); + } + + public function test_show_page_renders_shadow_and_telemetry(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $zone = Zone::forceCreate(['user_id' => $owner->id, 'name' => 'Гроубокс']); + $type = DeviceType::create([ + 'code' => 'sensor_temp_humidity', + 'category' => DeviceCategory::Sensor, + 'capabilities' => ['temperature'], + ]); + $device = Device::forceCreate([ + 'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id, + 'name' => 'Датчик', 'external_id' => 'sensor-1', 'protocol' => 'mqtt', + ]); + + $this->mock(DeviceShadow::class, function ($mock) { + $mock->shouldReceive('snapshot')->once()->with('sensor-1')->andReturn([ + 'status' => 'online', + 'last_seen' => null, + 'desired_state' => [], + 'reported_state' => [], + ]); + }); + $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)->get(route('devices.show', $device)); + + $response->assertOk(); + $response->assertSee('online'); + $response->assertSee('24.5'); + } +} diff --git a/services/device-control-service/README.md b/services/device-control-service/README.md index c6e6805..a88e806 100644 --- a/services/device-control-service/README.md +++ b/services/device-control-service/README.md @@ -5,8 +5,11 @@ Зона ответственности: - Предоставляет gRPC API (см. `proto/device_control/device_control.proto`: `TurnOn`, `TurnOff`, `SetLevel` → `CommandResult`) для отправки команд - устройствам — вызывается из Laravel (ручное управление) и из - rule-engine-service (действия по срабатыванию правил). + устройствам — вызывается из rule-engine-service (действия по срабатыванию + правил). +- Тот же функционал доступен и по HTTP/JSON (`internal/httpapi`, + `POST /devices/{device_id}/turn-on|turn-off|set-level`) — для Laravel + (ручное управление из UI), см. «Архитектурные решения» почему не gRPC. - Публикует команду в MQTT-топик устройства (`devices/{device_id}/commands`) и слушает подтверждения (`devices/{device_id}/ack`). - Поддерживает паттерн Device Shadow в Redis: `desired_state` (чего хочет @@ -37,6 +40,14 @@ телеметрии/ack, а не выгружается из таблицы `devices`. Это удерживает сервис в границах MQTT+Redis+RabbitMQ+gRPC, как и в диаграмме архитектуры корневого README. +- **HTTP рядом с gRPC, а не вместо.** ТЗ подразумевало gRPC-вызов и от + Laravel тоже, но `grpc/grpc` под PHP требует PECL-расширение, которое в + Alpine компилируется мучительно долго (C-код, 10-20+ минут) и утяжеляет + образ. `internal/httpapi` — тонкий транспорт поверх ТОГО ЖЕ + `server.Server` (никакой логики не дублируется, просто JSON вместо + Protobuf): диаграмма архитектуры в ТЗ и так допускала «HTTP/очередь» для + Laravel→device-control. gRPC-контракт между Go-сервисами (rule-engine) + не тронут. ## Запуск diff --git a/services/device-control-service/cmd/device-control-service/main.go b/services/device-control-service/cmd/device-control-service/main.go index 70e99d1..c0090c6 100644 --- a/services/device-control-service/cmd/device-control-service/main.go +++ b/services/device-control-service/cmd/device-control-service/main.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "net" + "net/http" "os" "os/signal" "strings" @@ -20,6 +21,7 @@ import ( devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control" "git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/config" "git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/healthcheck" + "git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/httpapi" "git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/mqttclient" "git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/rabbitmq" "git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/server" @@ -81,20 +83,36 @@ func run(logger *slog.Logger) error { checker.Start() defer checker.Stop() + srv := server.New(shadowStore, mqttClient, logger) + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.GRPCPort)) if err != nil { return fmt.Errorf("listen on grpc port %d: %w", cfg.GRPCPort, err) } grpcServer := grpc.NewServer() - devicecontrol.RegisterDeviceControlServer(grpcServer, server.New(shadowStore, mqttClient, logger)) + devicecontrol.RegisterDeviceControlServer(grpcServer, srv) + + // Same server instance, second transport — for callers where a full + // gRPC client is impractical (Laravel/PHP; see README). + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.HTTPPort), + Handler: httpapi.NewRouter(srv, logger), + } go func() { - logger.Info("device-control-service started", "grpc_port", cfg.GRPCPort) + logger.Info("grpc server started", "grpc_port", cfg.GRPCPort) if err := grpcServer.Serve(lis); err != nil { logger.Error("grpc server stopped", "error", err) } }() + go func() { + logger.Info("http server started", "http_port", cfg.HTTPPort) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("http server stopped", "error", err) + } + }() + stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop @@ -102,6 +120,12 @@ func run(logger *slog.Logger) error { logger.Info("shutting down") grpcServer.GracefulStop() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + logger.Error("http server shutdown failed", "error", err) + } + return nil } diff --git a/services/device-control-service/internal/config/config.go b/services/device-control-service/internal/config/config.go index 8826e00..12263f1 100644 --- a/services/device-control-service/internal/config/config.go +++ b/services/device-control-service/internal/config/config.go @@ -11,6 +11,7 @@ import ( type Config struct { GRPCPort int + HTTPPort int MQTTBrokerURL string MQTTClientID string @@ -45,6 +46,9 @@ func Load() (Config, error) { if cfg.GRPCPort, err = getEnvInt("DEVICE_CONTROL_GRPC_PORT", 50051); err != nil { return Config{}, err } + if cfg.HTTPPort, err = getEnvInt("DEVICE_CONTROL_HTTP_PORT", 8090); err != nil { + return Config{}, err + } if cfg.RedisDB, err = getEnvInt("REDIS_DB", 0); err != nil { return Config{}, err } diff --git a/services/device-control-service/internal/httpapi/router.go b/services/device-control-service/internal/httpapi/router.go new file mode 100644 index 0000000..dee5946 --- /dev/null +++ b/services/device-control-service/internal/httpapi/router.go @@ -0,0 +1,80 @@ +// Package httpapi exposes the same device commands as the gRPC server over +// plain HTTP/JSON, for callers where a full gRPC client is impractical +// (Laravel/PHP, in this platform's case — see the service README for why). +// It's a thin transport: all the actual logic (Device Shadow patch + MQTT +// publish) lives in internal/server and is reused as-is. +package httpapi + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control" +) + +// Dispatcher is the subset of server.Server this package needs. +type Dispatcher interface { + TurnOn(ctx context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) + TurnOff(ctx context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) + SetLevel(ctx context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) +} + +type commandResult struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func NewRouter(dispatcher Dispatcher, logger *slog.Logger) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("POST /devices/{device_id}/turn-on", func(w http.ResponseWriter, r *http.Request) { + deviceID := r.PathValue("device_id") + res, err := dispatcher.TurnOn(r.Context(), &devicecontrol.TurnOnRequest{DeviceId: deviceID}) + writeResult(w, logger, res, err) + }) + + mux.HandleFunc("POST /devices/{device_id}/turn-off", func(w http.ResponseWriter, r *http.Request) { + deviceID := r.PathValue("device_id") + res, err := dispatcher.TurnOff(r.Context(), &devicecontrol.TurnOffRequest{DeviceId: deviceID}) + writeResult(w, logger, res, err) + }) + + mux.HandleFunc("POST /devices/{device_id}/set-level", func(w http.ResponseWriter, r *http.Request) { + deviceID := r.PathValue("device_id") + + var body struct { + Level float64 `json:"level"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(commandResult{Success: false, Error: "invalid JSON body: expected {\"level\": number}"}) + return + } + + res, err := dispatcher.SetLevel(r.Context(), &devicecontrol.SetLevelRequest{DeviceId: deviceID, Level: body.Level}) + writeResult(w, logger, res, err) + }) + + return mux +} + +// writeResult always answers 200 with the command's success/error in the +// body — the wire contract mirrors devicecontrol.CommandResult exactly, the +// same as the gRPC transport. A non-nil err here is a transport-layer bug in +// the dispatcher (it isn't supposed to return one), logged and surfaced as a +// generic failure rather than leaking internals to the caller. +func writeResult(w http.ResponseWriter, logger *slog.Logger, res *devicecontrol.CommandResult, err error) { + w.Header().Set("Content-Type", "application/json") + + if err != nil { + logger.Error("dispatcher returned unexpected error", "error", err) + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(commandResult{Success: false, Error: "internal error"}) + return + } + + _ = json.NewEncoder(w).Encode(commandResult{Success: res.GetSuccess(), Error: res.GetError()}) +} diff --git a/services/device-control-service/internal/httpapi/router_test.go b/services/device-control-service/internal/httpapi/router_test.go new file mode 100644 index 0000000..5d8f714 --- /dev/null +++ b/services/device-control-service/internal/httpapi/router_test.go @@ -0,0 +1,161 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control" +) + +type fakeDispatcher struct { + turnOnReq *devicecontrol.TurnOnRequest + turnOffReq *devicecontrol.TurnOffRequest + setLevelReq *devicecontrol.SetLevelRequest + result *devicecontrol.CommandResult + err error +} + +func (f *fakeDispatcher) TurnOn(_ context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) { + f.turnOnReq = req + return f.result, f.err +} + +func (f *fakeDispatcher) TurnOff(_ context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) { + f.turnOffReq = req + return f.result, f.err +} + +func (f *fakeDispatcher) SetLevel(_ context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) { + f.setLevelReq = req + return f.result, f.err +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func decodeResult(t *testing.T, rec *httptest.ResponseRecorder) commandResult { + t.Helper() + var res commandResult + if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { + t.Fatalf("decode response body %q: %v", rec.Body.String(), err) + } + return res +} + +func TestTurnOn_Success(t *testing.T) { + fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: true}} + router := NewRouter(fake, discardLogger()) + + req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-on", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want 200", rec.Code) + } + if fake.turnOnReq == nil || fake.turnOnReq.DeviceId != "fan-1" { + t.Fatalf("got TurnOn request %+v, want device_id=fan-1", fake.turnOnReq) + } + if res := decodeResult(t, rec); !res.Success { + t.Fatalf("got %+v, want success", res) + } +} + +func TestTurnOff_Success(t *testing.T) { + fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: true}} + router := NewRouter(fake, discardLogger()) + + req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-off", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want 200", rec.Code) + } + if fake.turnOffReq == nil || fake.turnOffReq.DeviceId != "fan-1" { + t.Fatalf("got TurnOff request %+v, want device_id=fan-1", fake.turnOffReq) + } +} + +func TestSetLevel_Success(t *testing.T) { + fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: true}} + router := NewRouter(fake, discardLogger()) + + body := bytes.NewBufferString(`{"level": 42.5}`) + req := httptest.NewRequest(http.MethodPost, "/devices/light-1/set-level", body) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want 200", rec.Code) + } + if fake.setLevelReq == nil || fake.setLevelReq.Level != 42.5 { + t.Fatalf("got SetLevel request %+v, want level=42.5", fake.setLevelReq) + } +} + +func TestSetLevel_InvalidBody(t *testing.T) { + fake := &fakeDispatcher{} + router := NewRouter(fake, discardLogger()) + + req := httptest.NewRequest(http.MethodPost, "/devices/light-1/set-level", bytes.NewBufferString(`not json`)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("got status %d, want 400", rec.Code) + } + if fake.setLevelReq != nil { + t.Fatal("dispatcher should not have been called with an invalid body") + } +} + +func TestBusinessFailure_PassesThroughAs200(t *testing.T) { + fake := &fakeDispatcher{result: &devicecontrol.CommandResult{Success: false, Error: "device offline"}} + router := NewRouter(fake, discardLogger()) + + req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-on", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want 200 (business failures aren't transport errors)", rec.Code) + } + res := decodeResult(t, rec) + if res.Success || res.Error != "device offline" { + t.Fatalf("got %+v, want business failure passed through", res) + } +} + +func TestDispatcherError_Returns500(t *testing.T) { + fake := &fakeDispatcher{err: errors.New("boom")} + router := NewRouter(fake, discardLogger()) + + req := httptest.NewRequest(http.MethodPost, "/devices/fan-1/turn-on", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("got status %d, want 500", rec.Code) + } +} + +func TestUnknownRoute_404(t *testing.T) { + router := NewRouter(&fakeDispatcher{}, discardLogger()) + + req := httptest.NewRequest(http.MethodGet, "/devices/fan-1/turn-on", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { + t.Fatalf("got status %d for GET on a POST-only route, want 404 or 405", rec.Code) + } +}