water_device_ingress_test.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. <?php
  2. /** 预留设备入口测试:真实数据库事务回滚,不启用网关、不连接 Broker。 */
  3. require dirname(__DIR__).'/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. date_default_timezone_set('Asia/Shanghai');
  7. use app\service\water\{DeviceIngress,DeviceReport,DeviceProtocolAdapter};
  8. use plugin\saiadmin\app\service\WaterBusiness;
  9. use plugin\saiadmin\exception\ApiException;
  10. use support\Db;
  11. use plugin\saiadmin\app\model\water\WaterModel;
  12. class TestWaterProtocol implements DeviceProtocolAdapter
  13. {
  14. public function fromHttp(string $body,array $headers): ?array { return json_decode($body,true,512,JSON_THROW_ON_ERROR); }
  15. public function fromMqtt(string $topic,string $payload,array $properties): ?array { return $topic === 'test/water' ? $this->fromHttp($payload,[]) : null; }
  16. }
  17. $checks = 0;
  18. function verify(bool $ok,string $message): void { global $checks; ++$checks; if (!$ok) throw new RuntimeException($message); }
  19. function rejected(callable $fn,int $status): void { try { $fn(); } catch (ApiException $e) { verify($e->getCode()===$status,'Expected status '.$status); return; } throw new RuntimeException('Expected rejection'); }
  20. $disabled = new DeviceIngress([]);
  21. rejected(fn()=>$disabled->http('{}','',[]),503);
  22. $disabled->mqtt(['topic'=>'test/water','payload'=>'invalid']);
  23. verify(!config('water_device.http_enabled') && !config('water_device.mqtt_enabled') && config('water_device.adapter') === null,'Local device integrations stay disabled');
  24. $response = (new \app\controller\WaterDeviceController())->report(new \support\Request("POST /iot/water/report HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{}"));
  25. verify($response->getStatusCode()===503,'Reserved HTTP controller returns real 503');
  26. \Webman\Event\BootStrap::start(null);
  27. verify(count(\Webman\Event\Event::getListeners('mqtt.message')) > 0,'MQTT event extension is registered');
  28. $token = str_repeat('test-only-',4);
  29. $settings = ['http_enabled'=>true,'mqtt_enabled'=>true,'http_token'=>$token,'adapter'=>TestWaterProtocol::class,'max_payload_bytes'=>1024];
  30. $ingress = new DeviceIngress($settings);
  31. rejected(fn()=>$ingress->http('{}','Bearer wrong',[]),401);
  32. rejected(fn()=>(new DeviceIngress(array_replace($settings,['http_token'=>''])))->http('{}','Bearer ',[]),503);
  33. rejected(fn()=>(new DeviceIngress(array_replace($settings,['adapter'=>null])))->http('{}','Bearer '.$token,[]),503);
  34. rejected(fn()=>$ingress->http(str_repeat('x',1025),'Bearer '.$token,[]),413);
  35. $before = \plugin\saiadmin\app\model\water\WaterDevice::query()->count();
  36. $service = new WaterBusiness();
  37. $reporter = new DeviceReport();
  38. $tag = 'INGRESS-TEST-'.bin2hex(random_bytes(5));
  39. Db::beginTransaction();
  40. try {
  41. $enterprise = $service->save('enterprise',['name'=>$tag,'contact'=>'测试','phone'=>'TEST','address'=>'回滚测试地址'],1)['id'];
  42. $sku = $service->save('sku',['name'=>$tag,'liters'=>18.9],1)['id'];
  43. $device = $service->save('device',['name'=>$tag,'sn'=>$tag,'enterprise_id'=>$enterprise,'sku_id'=>$sku],1)['id'];
  44. $get = fn()=>\plugin\saiadmin\app\model\water\WaterDevice::query()->where('id',$device)->first()?->toArray();
  45. $at = date('Y-m-d H:i:s',time()-10);
  46. $heartbeat = ['kind'=>'heartbeat','sn'=>$tag,'reported_at'=>$at];
  47. $result = $ingress->http(json_encode($heartbeat),'Bearer '.$token,[]);
  48. verify($result['updated'] && $get()['last_seen']===$at,'Authenticated normalized heartbeat updates last_seen');
  49. verify(!$reporter->accept($heartbeat)['updated'],'Repeated heartbeat does not refresh last_seen');
  50. verify(!$reporter->accept(array_replace($heartbeat,['reported_at'=>date('Y-m-d H:i:s',time()-20)]))['updated'],'Out-of-order heartbeat cannot move last_seen backwards');
  51. verify($service->listing('devices',['keyword'=>$tag])['data'][0]['connection_status']==='online','Heartbeat drives existing online display');
  52. foreach ([date('Y-m-d H:i:s',time()-600),date('Y-m-d H:i:s',time()+600),'2026-02-30 00:00:00'] as $invalid) rejected(fn()=>$reporter->accept(array_replace($heartbeat,['reported_at'=>$invalid])),422);
  53. rejected(fn()=>$reporter->accept(array_replace($heartbeat,['sn'=>$tag.'-missing'])),404);
  54. rejected(fn()=>$reporter->accept(array_replace($heartbeat,['kind'=>'exchange'])),422);
  55. $usage = array_replace($heartbeat,['kind'=>'usage','today_liters'=>12.5]);
  56. $ingress->mqtt(['topic'=>'test/water','payload'=>json_encode($usage)]);
  57. verify((float)$get()['today_liters']===12.5 && $get()['telemetry_date']===date('Y-m-d'),'MQTT adapter feeds same cumulative usage handler');
  58. verify(!$reporter->accept($usage)['updated'],'Duplicate snapshot is not summed');
  59. verify(!$reporter->accept(array_replace($usage,['today_liters'=>8]))['updated'],'Lower out-of-order snapshot is ignored');
  60. $reporter->accept(array_replace($usage,['today_liters'=>15]));
  61. verify((float)$get()['today_liters']===15.0,'Larger snapshot replaces current total');
  62. foreach ([-1,100000000,'bad',null] as $invalid) rejected(fn()=>$reporter->accept(array_replace($usage,['today_liters'=>$invalid])),422);
  63. \plugin\saiadmin\app\model\water\WaterDevice::query()->where('id',$device)->update(['telemetry_date'=>date('Y-m-d',strtotime('-1 day')),'today_liters'=>99]);
  64. $reporter->accept(array_replace($usage,['today_liters'=>0]));
  65. verify((float)$get()['today_liters']===0.0,'New day begins from new cumulative snapshot');
  66. $snapshot = $get();
  67. $ingress->mqtt(['topic'=>'unrelated/topic','payload'=>json_encode($usage)]);
  68. verify($get()===$snapshot,'Unrelated MQTT topic ignored');
  69. verify(!\plugin\saiadmin\app\model\water\WaterAccount::query()->where('enterprise_id',$enterprise)->count() && (int)\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->value('available')===0,'Telemetry never changes stock or entitlement');
  70. \plugin\saiadmin\app\model\water\WaterEnterprise::query()->where('id',$enterprise)->update(['status'=>2]);
  71. rejected(fn()=>$reporter->accept($heartbeat),422);
  72. } finally { Db::rollback(); }
  73. verify(\plugin\saiadmin\app\model\water\WaterDevice::query()->count()===$before,'Test devices rolled back');
  74. echo "PASS: $checks device ingress checks; all database fixtures rolled back.\n";