test: include realtime in production smoke
This commit is contained in:
@@ -142,7 +142,7 @@ curl -sS 'http://115.29.187.205:20210/api/stats/daily-metrics?dateFrom=2026-07-0
|
|||||||
python3 tools/go_native_prod_smoke.py --date 2026-07-02 --timeout 8
|
python3 tools/go_native_prod_smoke.py --date 2026-07-02 --timeout 8
|
||||||
```
|
```
|
||||||
|
|
||||||
该脚本通过 `20210` HTTP API 验证 GB32960、JT808、宇通 MQTT 的 RAW 查询,JT808 的位置/里程点查询,以及 GB32960/JT808 的 `daily_mileage_km`、`daily_total_mileage_km` 统计。任一检查不达标时退出码为非 0。
|
该脚本通过 `20210` HTTP API 验证 GB32960、JT808、宇通 MQTT 的 RAW 查询,JT808 的位置/里程点查询,GB32960/JT808 的 `daily_mileage_km`、`daily_total_mileage_km` 统计,以及三类协议的 Redis realtime snapshot、online、protocol 查询。任一检查不达标时退出码为非 0。
|
||||||
|
|
||||||
## 部署命令
|
## 部署命令
|
||||||
|
|
||||||
@@ -188,3 +188,4 @@ journalctl -u lingniu-go-realtime-api --since '5 minutes ago' --no-pager
|
|||||||
- `realtime-api` 正在监听 `20210`。
|
- `realtime-api` 正在监听 `20210`。
|
||||||
- GB32960、JT808、YUTONG_MQTT 的东八区 RAW 查询均可命中生产数据。
|
- GB32960、JT808、YUTONG_MQTT 的东八区 RAW 查询均可命中生产数据。
|
||||||
- `vehicle_daily_metric` 可查到 `daily_mileage_km` 和 `daily_total_mileage_km`。
|
- `vehicle_daily_metric` 可查到 `daily_mileage_km` 和 `daily_total_mileage_km`。
|
||||||
|
- Redis realtime 可查到 GB32960、JT808、YUTONG_MQTT 的在线状态和协议快照。
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class CheckSpec:
|
|||||||
path: str
|
path: str
|
||||||
params: dict[str, Any]
|
params: dict[str, Any]
|
||||||
minimum: int
|
minimum: int
|
||||||
|
kind: str = "total"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -43,7 +44,10 @@ def shanghai_day_window(now: dt.datetime | None = None) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def api_url(base_url: str, path: str, params: dict[str, Any]) -> str:
|
def api_url(base_url: str, path: str, params: dict[str, Any]) -> str:
|
||||||
return base_url.rstrip("/") + path + "?" + urllib.parse.urlencode(params)
|
url = base_url.rstrip("/") + path
|
||||||
|
if not params:
|
||||||
|
return url
|
||||||
|
return url + "?" + urllib.parse.urlencode(params)
|
||||||
|
|
||||||
|
|
||||||
def query_json(base_url: str, path: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
def query_json(base_url: str, path: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
@@ -63,6 +67,31 @@ def check_total(name: str, payload: dict[str, Any], minimum: int) -> Check:
|
|||||||
return Check(name, "fail", count, minimum, f"count={count}; expected >= {minimum}; sample={sample}")
|
return Check(name, "fail", count, minimum, f"count={count}; expected >= {minimum}; sample={sample}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_realtime(name: str, payload: dict[str, Any]) -> Check:
|
||||||
|
if payload.get("online") is False:
|
||||||
|
return Check(name, "fail", 0, 1, "online=false")
|
||||||
|
identifier = payload.get("vin") or payload.get("vehicle_key") or ""
|
||||||
|
protocols = payload.get("protocols") or []
|
||||||
|
protocol = payload.get("protocol") or ""
|
||||||
|
fields = payload.get("fields") or {}
|
||||||
|
message = json.dumps(
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in {
|
||||||
|
"identifier": identifier,
|
||||||
|
"protocol": protocol,
|
||||||
|
"protocols": protocols,
|
||||||
|
"field_count": len(fields) if isinstance(fields, dict) else 0,
|
||||||
|
"online": payload.get("online"),
|
||||||
|
}.items()
|
||||||
|
if value not in (None, "", [])
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
return Check(name, "pass", 1, 1, message)
|
||||||
|
|
||||||
|
|
||||||
def sample_summary(items: list[Any]) -> str:
|
def sample_summary(items: list[Any]) -> str:
|
||||||
if not items:
|
if not items:
|
||||||
return "{}"
|
return "{}"
|
||||||
@@ -161,17 +190,65 @@ def build_check_specs(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def vehicle_identifier(item: dict[str, Any]) -> str:
|
||||||
|
for key in ("vin", "vehicle_key"):
|
||||||
|
value = str(item.get(key) or "").strip()
|
||||||
|
if value and value.lower() != "unknown":
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_realtime_specs(payloads: dict[str, dict[str, Any]]) -> list[CheckSpec]:
|
||||||
|
configs = [
|
||||||
|
("gb32960", "gb32960.raw", "GB32960"),
|
||||||
|
("jt808", "jt808.raw", "JT808"),
|
||||||
|
("yutong_mqtt", "yutong_mqtt.raw", "YUTONG_MQTT"),
|
||||||
|
]
|
||||||
|
specs: list[CheckSpec] = []
|
||||||
|
for prefix, source_name, protocol in configs:
|
||||||
|
items = payloads.get(source_name, {}).get("items") or []
|
||||||
|
if not items or not isinstance(items[0], dict):
|
||||||
|
continue
|
||||||
|
identifier = vehicle_identifier(items[0])
|
||||||
|
if not identifier:
|
||||||
|
continue
|
||||||
|
encoded = urllib.parse.quote(identifier, safe="")
|
||||||
|
base_path = "/api/realtime/vehicles/" + encoded
|
||||||
|
specs.extend([
|
||||||
|
CheckSpec(prefix + ".realtime", base_path, {}, 1, "realtime"),
|
||||||
|
CheckSpec(prefix + ".realtime_online", base_path + "/online", {}, 1, "realtime"),
|
||||||
|
CheckSpec(prefix + ".realtime_protocol", base_path + "/protocols/" + protocol, {}, 1, "realtime"),
|
||||||
|
])
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
def run_checks(base_url: str, specs: list[CheckSpec], timeout: float) -> list[Check]:
|
def run_checks(base_url: str, specs: list[CheckSpec], timeout: float) -> list[Check]:
|
||||||
checks: list[Check] = []
|
checks: list[Check] = []
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
try:
|
try:
|
||||||
payload = query_json(base_url, spec.path, spec.params, timeout)
|
payload = query_json(base_url, spec.path, spec.params, timeout)
|
||||||
|
if spec.kind == "realtime":
|
||||||
|
checks.append(check_realtime(spec.name, payload))
|
||||||
|
else:
|
||||||
checks.append(check_total(spec.name, payload, spec.minimum))
|
checks.append(check_total(spec.name, payload, spec.minimum))
|
||||||
except Exception as exc: # pragma: no cover - exercised by live failures.
|
except Exception as exc: # pragma: no cover - exercised by live failures.
|
||||||
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
||||||
return checks
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def query_payloads(base_url: str, specs: list[CheckSpec], timeout: float) -> tuple[list[Check], dict[str, dict[str, Any]]]:
|
||||||
|
checks: list[Check] = []
|
||||||
|
payloads: dict[str, dict[str, Any]] = {}
|
||||||
|
for spec in specs:
|
||||||
|
try:
|
||||||
|
payload = query_json(base_url, spec.path, spec.params, timeout)
|
||||||
|
payloads[spec.name] = payload
|
||||||
|
checks.append(check_total(spec.name, payload, spec.minimum))
|
||||||
|
except Exception as exc:
|
||||||
|
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
||||||
|
return checks, payloads
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||||
@@ -201,7 +278,8 @@ def main(argv: list[str]) -> int:
|
|||||||
min_history=args.min_history,
|
min_history=args.min_history,
|
||||||
min_stat=args.min_stat,
|
min_stat=args.min_stat,
|
||||||
)
|
)
|
||||||
checks = run_checks(args.base_url, specs, args.timeout)
|
checks, payloads = query_payloads(args.base_url, specs, args.timeout)
|
||||||
|
checks.extend(run_checks(args.base_url, build_realtime_specs(payloads), args.timeout))
|
||||||
status = overall_status(checks)
|
status = overall_status(checks)
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
@@ -78,6 +78,64 @@ class GoNativeProdSmokeTest(unittest.TestCase):
|
|||||||
self.assertIn("gb32960.daily_mileage", names)
|
self.assertIn("gb32960.daily_mileage", names)
|
||||||
self.assertIn("jt808.daily_total_mileage", names)
|
self.assertIn("jt808.daily_total_mileage", names)
|
||||||
|
|
||||||
|
def test_vehicle_identifier_prefers_vin_over_vehicle_key(self):
|
||||||
|
identifier = smoke.vehicle_identifier({
|
||||||
|
"vin": "LB9A32A20R0LS1343",
|
||||||
|
"vehicle_key": "GB32960:ignored",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(identifier, "LB9A32A20R0LS1343")
|
||||||
|
|
||||||
|
def test_vehicle_identifier_falls_back_to_vehicle_key(self):
|
||||||
|
identifier = smoke.vehicle_identifier({
|
||||||
|
"vin": "",
|
||||||
|
"vehicle_key": "JT808:013307811170",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(identifier, "JT808:013307811170")
|
||||||
|
|
||||||
|
def test_realtime_specs_are_built_from_raw_samples_with_vin(self):
|
||||||
|
payloads = {
|
||||||
|
"gb32960.raw": {"items": [{"vin": "LB9A32A20R0LS1343", "vehicle_key": "LB9A32A20R0LS1343"}]},
|
||||||
|
"jt808.raw": {"items": [{"vin": "", "vehicle_key": "JT808:013307811170"}]},
|
||||||
|
"yutong_mqtt.raw": {"items": [{"vin": "LMRKH9AC6R1004108"}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
specs = smoke.build_realtime_specs(payloads)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[(spec.name, spec.path) for spec in specs],
|
||||||
|
[
|
||||||
|
("gb32960.realtime", "/api/realtime/vehicles/LB9A32A20R0LS1343"),
|
||||||
|
("gb32960.realtime_online", "/api/realtime/vehicles/LB9A32A20R0LS1343/online"),
|
||||||
|
("gb32960.realtime_protocol", "/api/realtime/vehicles/LB9A32A20R0LS1343/protocols/GB32960"),
|
||||||
|
("jt808.realtime", "/api/realtime/vehicles/JT808%3A013307811170"),
|
||||||
|
("jt808.realtime_online", "/api/realtime/vehicles/JT808%3A013307811170/online"),
|
||||||
|
("jt808.realtime_protocol", "/api/realtime/vehicles/JT808%3A013307811170/protocols/JT808"),
|
||||||
|
("yutong_mqtt.realtime", "/api/realtime/vehicles/LMRKH9AC6R1004108"),
|
||||||
|
("yutong_mqtt.realtime_online", "/api/realtime/vehicles/LMRKH9AC6R1004108/online"),
|
||||||
|
("yutong_mqtt.realtime_protocol", "/api/realtime/vehicles/LMRKH9AC6R1004108/protocols/YUTONG_MQTT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_realtime_check_requires_online_true_when_field_exists(self):
|
||||||
|
check = smoke.check_realtime(
|
||||||
|
"gb32960.realtime_online",
|
||||||
|
{"vin": "LB9A32A20R0LS1343", "online": True, "protocols": ["GB32960"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(check.status, "pass")
|
||||||
|
self.assertEqual(check.count, 1)
|
||||||
|
|
||||||
|
def test_realtime_check_fails_when_online_false(self):
|
||||||
|
check = smoke.check_realtime(
|
||||||
|
"gb32960.realtime_online",
|
||||||
|
{"vin": "LB9A32A20R0LS1343", "online": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(check.status, "fail")
|
||||||
|
self.assertIn("online=false", check.message)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user