water_device_dashboard_test.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. <?php
  2. /** Device dashboard integration checks. Fixtures are transactional; --http only reads live data. */
  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\DeviceReport;
  8. use plugin\saiadmin\app\controller\water\WaterController;
  9. use plugin\saiadmin\app\model\water\{WaterDevice, WaterDeviceUsage, WaterSku, WaterAccount};
  10. use plugin\saiadmin\app\service\{WaterBusiness, WaterDeviceDashboard};
  11. use plugin\saiadmin\exception\ApiException;
  12. $checks = 0;
  13. function check(bool $condition, string $message): void
  14. {
  15. global $checks;
  16. $checks++;
  17. if (!$condition) {
  18. throw new RuntimeException($message);
  19. }
  20. }
  21. function rejects(callable $callback, string $message): void
  22. {
  23. try {
  24. $callback();
  25. } catch (ApiException $error) {
  26. check(true, $message);
  27. return;
  28. }
  29. throw new RuntimeException($message);
  30. }
  31. function workbook(string $content): array
  32. {
  33. $path = tempnam(sys_get_temp_dir(), 'device-test-');
  34. $reader = new \OpenSpout\Reader\XLSX\Reader();
  35. try {
  36. file_put_contents($path, $content);
  37. $reader->open($path);
  38. $rows = [];
  39. foreach ($reader->getSheetIterator() as $sheet) {
  40. foreach ($sheet->getRowIterator() as $row) {
  41. $rows[] = $row->toArray();
  42. }
  43. }
  44. return $rows;
  45. } finally {
  46. $reader->close();
  47. unlink($path);
  48. }
  49. }
  50. $db = (new WaterDevice())->getConnection();
  51. $tag = 'DEVICE-UI-' . bin2hex(random_bytes(5));
  52. $now = new DateTimeImmutable();
  53. $service = new WaterDeviceDashboard($now);
  54. $business = new WaterBusiness();
  55. $counts = [WaterDevice::query()->count(), WaterDeviceUsage::query()->count()];
  56. $db->beginTransaction();
  57. try {
  58. $baseline = $service->dashboard([]);
  59. $enterprise = $business->save('enterprise', ['name' => $tag . '-enterprise', 'contact' => 'test', 'phone' => 'TEST', 'address' => 'TEST', 'region' => $tag], 1)['id'];
  60. $sku = $business->save('sku', ['name' => $tag, 'liters' => 18.9], 1)['id'];
  61. $ids = [];
  62. for ($i = 0; $i < 10; $i++) {
  63. $ids[] = $business->save('device', [
  64. 'name' => ($i === 0 ? '=' : '') . $tag . '-' . $i,
  65. 'sn' => $tag . '-' . $i,
  66. 'enterprise_id' => $enterprise,
  67. 'sku_id' => $sku,
  68. 'firmware' => $i % 2 ? 'v-test-2' : 'v-test-1',
  69. ], 1)['id'];
  70. }
  71. WaterDevice::query()->whereIn('id', $ids)->update(['created_at' => $now->format('Y-m-d H:i:s')]);
  72. WaterDevice::query()->whereIn('id', array_slice($ids, 0, 4))->update(['last_seen' => $now->format('Y-m-d H:i:s')]);
  73. WaterDevice::query()->where('id', $ids[4])->update(['last_seen' => $now->modify('-15 minutes')->format('Y-m-d H:i:s')]);
  74. WaterDevice::query()->where('id', $ids[5])->update(['last_seen' => $now->modify('-901 seconds')->format('Y-m-d H:i:s')]);
  75. WaterDevice::query()->where('id', $ids[6])->update(['last_seen' => $now->modify('-25 hours')->format('Y-m-d H:i:s')]);
  76. WaterDevice::query()->where('id', $ids[7])->update(['fault' => '流量上报异常', 'last_seen' => $now->format('Y-m-d H:i:s')]);
  77. WaterDevice::query()->where('id', $ids[8])->update(['fault' => '流量上报异常', 'last_seen' => $now->modify('-26 hours')->format('Y-m-d H:i:s')]);
  78. WaterDevice::query()->where('id', $ids[0])->update(['today_liters' => 0, 'telemetry_date' => $now->format('Y-m-d')]);
  79. WaterDevice::query()->where('id', $ids[1])->update(['today_liters' => 8, 'telemetry_date' => $now->modify('-1 day')->format('Y-m-d')]);
  80. $first = $service->dashboard(['keyword' => ' ' . $tag . ' ', 'limit' => 8]);
  81. $second = $service->dashboard(['keyword' => $tag, 'page' => 2, 'limit' => 8]);
  82. check($first['list']['total'] === 10 && count($first['list']['data']) === 8 && count($second['list']['data']) === 2, 'eight-item pagination');
  83. $rows = array_merge($first['list']['data'], $second['list']['data']);
  84. check(array_column($rows, 'id') === array_reverse($ids), 'stable pagination without duplicates');
  85. check($first['summary'] === $second['summary'], 'global cards independent of pagination');
  86. check($first['summary'] === $service->dashboard(['keyword' => $tag . '-missing'])['summary'], 'global cards independent of search');
  87. foreach (['total' => 10, 'online' => 5, 'offline' => 2, 'fault' => 2, 'unconnected' => 1, 'offline_long' => 1, 'new_month' => 10] as $key => $delta) {
  88. check($first['summary'][$key] - $baseline['summary'][$key] === $delta, 'summary delta ' . $key);
  89. }
  90. check(array_sum(array_intersect_key($first['summary'], array_flip(['online', 'offline', 'fault', 'unconnected']))) === $first['summary']['total'], 'exclusive state counters reconcile');
  91. $byId = array_column($rows, null, 'id');
  92. check($byId[$ids[4]]['connection_status'] === 'online' && $byId[$ids[5]]['connection_status'] === 'offline', '15-minute boundary');
  93. check($byId[$ids[7]]['connection_status'] === 'fault' && $byId[$ids[8]]['connection_status'] === 'fault', 'fault takes precedence over connectivity');
  94. check($byId[$ids[9]]['last_seen_seconds'] === null && $byId[$ids[9]]['connection_status'] === 'unconnected', 'no heartbeat is unconnected');
  95. check($byId[$ids[0]]['today_liters'] === 0.0 && $byId[$ids[1]]['today_liters'] === null, 'zero usage differs from missing current-day data');
  96. check($service->dashboard(['keyword' => $tag . '-enterprise', 'status' => 'offline'])['list']['total'] === 2, 'enterprise and status filter');
  97. check($service->dashboard(['keyword' => $tag, 'region' => $tag, 'firmware' => 'v-test-2'])['list']['total'] === 5, 'combined region and version filters');
  98. check($service->dashboard(['keyword' => $tag, 'region' => 'nonexistent'])['list']['total'] === 0, 'empty filter result');
  99. check(in_array($tag, $first['options']['regions'], true), 'region options from existing devices');
  100. check(array_column($first['options']['firmwares'], 'total', 'version')['v-test-1'] === 5, 'firmware distribution');
  101. $faultRows = array_column($first['anomalies'], null, 'label');
  102. check(($faultRows['流量上报异常']['total'] ?? 0) >= 2, 'anomalies use recorded descriptions');
  103. check(count($first['trend']) === 30 && $first['trend'][0]['day'] === $now->modify('-29 days')->format('Y-m-d'), '30 calendar days');
  104. foreach ([-30 => 999, -29 => 2, -1 => 9] as $offset => $liters) {
  105. WaterDeviceUsage::query()->insert(['device_id' => $ids[0], 'day' => $now->modify($offset . ' days')->format('Y-m-d'), 'liters' => $liters]);
  106. }
  107. $reporter = new DeviceReport();
  108. $usage = ['sn' => $tag . '-0', 'kind' => 'usage', 'reported_at' => date('Y-m-d H:i:s', time() - 10), 'today_liters' => 12.5];
  109. $reporter->accept($usage);
  110. $reporter->accept($usage);
  111. $reporter->accept(array_replace($usage, ['today_liters' => 8]));
  112. $daily = WaterDeviceUsage::query()->where('device_id', $ids[0])->where('day', $now->format('Y-m-d'));
  113. check($daily->count() === 1 && (float) $daily->value('liters') === 12.5, 'duplicate and lower snapshots do not add usage');
  114. $reporter->accept(array_replace($usage, ['today_liters' => 15]));
  115. check((float) $daily->value('liters') === 15.0, 'higher snapshot advances daily total');
  116. $trend = $service->dashboard([])['trend'];
  117. check($trend[0]['liters'] - ($baseline['trend'][0]['liters'] ?? 0) === 2.0, 'first day included, preceding day excluded');
  118. check($trend[28]['liters'] - ($baseline['trend'][28]['liters'] ?? 0) === 9.0, 'historical day retained');
  119. check($trend[29]['liters'] - ($baseline['trend'][29]['liters'] ?? 0) === 15.0, 'today sums cumulative reports');
  120. check($trend[1] === $baseline['trend'][1], 'unreported date remains unchanged');
  121. check((int) WaterSku::query()->find($sku)->available === 0 && WaterAccount::query()->where('enterprise_id', $enterprise)->count() === 0, 'telemetry does not mutate stock or rights');
  122. $book = workbook($service->export(['keyword' => $tag, 'limit' => 1, 'page' => 2]));
  123. check(count($book) === 11 && count($book[0]) === 10, 'complete export ignores pagination');
  124. check($book[10][0] === "'=" . $tag . '-0', 'export formula prefix escaped');
  125. check(count(workbook($service->export(['keyword' => $tag, 'status' => 'offline']))) === 3, 'export reuses status filter');
  126. check(count(workbook($service->export(['keyword' => $tag . '-missing']))) === 1, 'empty export still has headers');
  127. rejects(fn () => $service->dashboard(['keyword' => str_repeat('字', 121)]), 'keyword length validated');
  128. rejects(fn () => $service->dashboard(['status' => 'invented']), 'unknown state rejected');
  129. rejects(fn () => $service->export(['region' => []]), 'malformed filter rejected');
  130. $reflection = new ReflectionClass(WaterController::class);
  131. $controller = $reflection->newInstanceWithoutConstructor();
  132. $reflection->getProperty('adminId')->setValue($controller, 99999999);
  133. $request = new \support\Request("GET /core/water/devices/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n");
  134. rejects(fn () => $controller->deviceDashboard($request), 'dashboard read authorization');
  135. rejects(fn () => $controller->deviceExport($request), 'export read authorization');
  136. $reflection->getProperty('adminId')->setValue($controller, 1);
  137. $body = json_encode(['keyword' => $tag]);
  138. $request = new \support\Request("POST /core/water/devices/export HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body);
  139. check(count(workbook($controller->deviceExport($request)->rawBody())) === 11, 'authorized JSON export controller');
  140. } finally {
  141. $db->rollBack();
  142. }
  143. check([WaterDevice::query()->count(), WaterDeviceUsage::query()->count()] === $counts, 'all device and history fixtures rolled back');
  144. if (in_array('--http', $argv, true)) {
  145. $token = \Tinywan\Jwt\JwtToken::generateToken(['id' => 1, 'username' => 'admin', 'plat' => 'saiadmin', 'type' => 'web', 'client' => $tag, 'access_exp' => 120, 'refresh_exp' => 120])['access_token'];
  146. foreach (['devices/dashboard?limit=1', 'devices/export'] as $path) {
  147. $ch = curl_init('http://127.0.0.1:8787/core/water/' . $path);
  148. $options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token]];
  149. if ($path === 'devices/export') {
  150. $options += [CURLOPT_POST => true, CURLOPT_POSTFIELDS => '{}'];
  151. }
  152. curl_setopt_array($ch, $options);
  153. $raw = curl_exec($ch);
  154. $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  155. curl_close($ch);
  156. check($status === 200, 'HTTP status ' . $path);
  157. if ($path === 'devices/export') {
  158. check(substr($raw, 0, 2) === 'PK' && count(workbook($raw)) >= 1, 'HTTP device workbook');
  159. } else {
  160. $json = json_decode($raw, true);
  161. check(($json['code'] ?? 0) === 200 && count($json['data']['trend'] ?? []) === 30 && count($json['data']['list']['data'] ?? []) <= 1, 'HTTP dashboard contract');
  162. }
  163. }
  164. unset($token);
  165. }
  166. echo "PASS: $checks device dashboard checks; all fixtures rolled back.\n";