This commit is contained in:
Mateusz Gruszczyński
2026-09-07 10:01:52 +02:00
parent 11da46c4d6
commit 7772e1e339
18 changed files with 347 additions and 170 deletions
+39 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.13.6.
"""Developer smoke/integration tests for GREE Controller API 0.13.7.
Default mode is read-only and safe to run against a real controller.
Use --settings-write to additionally round-trip all split settings resources and
@@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
DEFAULT_EXPECTED_VERSION = "0.13.6"
DEFAULT_EXPECTED_VERSION = "0.13.7"
SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"},
@@ -131,6 +131,10 @@ EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | {
"/api/integrations/home-assistant/snapshot",
}
BOOTSTRAP_SETTINGS_SECTIONS = {
"application", "gree", "history", "influxdb", "notifications", "night", "home_assistant", "debug"
}
class TestFailure(AssertionError):
pass
@@ -343,6 +347,36 @@ def test_openapi(client: ApiClient, expected_version: str) -> str:
return f"paths={len(paths)}, version={version}"
def test_bootstrap_contract(client: ApiClient) -> str:
resp = client.get("/api/bootstrap")
assert_status(resp, 200)
data = assert_json_object(resp)
required = {
"devices", "zones", "groups", "schedules", "automations", "flows",
"access_tokens", "settings", "house", "outdoor_temperature",
"control_plan", "control_plan_revision", "system",
}
missing = sorted(required - set(data))
if missing:
raise TestFailure(f"bootstrap missing fields: {missing}")
settings = data.get("settings")
if not isinstance(settings, dict):
raise TestFailure("bootstrap.settings is not an object")
missing_sections = sorted(BOOTSTRAP_SETTINGS_SECTIONS - set(settings))
if missing_sections:
raise TestFailure(f"bootstrap.settings missing sections: {missing_sections}")
forbidden = {
"influxdb": {"password", "token"},
"home_assistant": {"token"},
"notifications": {"pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"},
}
for section, fields in forbidden.items():
leaked = sorted(fields & set(settings.get(section, {})))
if leaked:
raise TestFailure(f"bootstrap.settings.{section} leaks secret fields: {leaked}")
return f"settings_sections={len(BOOTSTRAP_SETTINGS_SECTIONS)}, control_plan_revision={data.get('control_plan_revision')}"
def test_protected_auth(client: ApiClient) -> str:
if not client.token:
raise SkipTest("no token supplied; controller may be in trusted-LAN mode")
@@ -519,7 +553,8 @@ def run_suite(args: argparse.Namespace) -> int:
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
runner.run("public health", lambda: test_health(client, args.expected_version))
runner.run("OpenAPI 0.13.6 contract", lambda: test_openapi(client, args.expected_version))
runner.run("OpenAPI 0.13.7 contract", lambda: test_openapi(client, args.expected_version))
runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client))
runner.run("protected API requires auth", lambda: test_protected_auth(client))
for path in SAFE_GET_PATHS:
@@ -587,7 +622,7 @@ def run_suite(args: argparse.Namespace) -> int:
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Developer smoke/integration tests for GREE Controller API 0.13.6.",
description="Developer smoke/integration tests for GREE Controller API 0.13.7.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/api_dev_test.py
+18 -7
View File
@@ -27,7 +27,7 @@ class FakeWebSocket {
}
const app = {
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [],
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [],
settings: { house_mode: 'cool', debug: { overlay_enabled: false }, home_assistant: {} },
system: {}, outdoorTemperature: null, controlPlan: null, controlPlanRevision: null,
controlPlanPushReady: false, controlPlanTimer: null, currentView: 'dashboard',
@@ -54,7 +54,6 @@ const context = {
withBase: value => value,
$: () => null,
isFormDirty: () => false,
loadSettingsSections: async () => {},
applySettingsSection: (name, data) => { app.settings[name] = data; },
loadDebugBacklog: hit('loadDebugBacklog'),
loadBootstrap: async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; },
@@ -78,6 +77,7 @@ for (const name of renderNames) context[name] = hit(name);
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' });
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.js' });
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/realtime.js'), 'utf8'), context, { filename: 'realtime.js' });
// Source files define DOM-heavy renderers. Replace those implementations for this state-machine test.
for (const name of renderNames) context[name] = hit(name);
@@ -85,7 +85,6 @@ context.updateDevice = device => {
const index = app.devices.findIndex(item => item.id === device.id);
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
};
context.loadSettingsSections = async () => {};
context.applySettingsSection = (name, data) => { app.settings[name] = data; };
context.loadDebugBacklog = hit('loadDebugBacklog');
context.loadBootstrap = async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; };
@@ -112,10 +111,8 @@ async function testBootstrapReloadQueue() {
assert.equal(requestPath, '/api/bootstrap');
bootstrapCalls += 1;
if (bootstrapCalls === 1) return firstResponse;
return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 };
return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 };
},
fetchSettingsSections: async () => ({ application: {}, gree: {}, history: {}, influxdb: {}, notifications: {}, night: {}, home_assistant: {}, debug: {} }),
aggregateSettingsSections: () => ({ home_assistant: {}, debug: { overlay_enabled: false } }),
renderAll: () => {}, scheduleControlPlanLoad: () => {}, loadDebugBacklog: () => {}, toast: () => {}, tr: key => key,
$: () => ({ open: false }), connectWebSocket: () => {},
setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; },
@@ -126,8 +123,9 @@ async function testBootstrapReloadQueue() {
assert.equal(c.app.loading, true);
await c.loadBootstrap();
assert.equal(c.app.bootstrapReloadPending, true, 'a bootstrap request during loading must be queued');
firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 });
firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 });
await first;
assert.equal(c.app.outdoorTemperature, null, 'null/missing outdoor temperature must stay unknown, not become 0 C');
assert.equal(c.app.bootstrapReloadPending, false);
assert.equal(queuedTimers.length, 1);
assert.equal(queuedTimers[0].delay, 0);
@@ -138,6 +136,7 @@ async function testBootstrapReloadQueue() {
async function main() {
await testBootstrapReloadQueue();
const apiCallsBeforeBootstrap = apiCalls.length;
await send('bootstrap', {
devices: [{ id: 'd1', power: false }],
zones: [{ id: 'z1', demand: false }],
@@ -145,9 +144,21 @@ async function main() {
schedules: [{ id: 's1', name: 'S1' }],
automations: [{ id: 'a1', name: 'A1' }],
flows: [{ id: 'f1', name: 'F1' }],
access_tokens: [{ id: 't1', name: 'HA', token_prefix: 'abc', created_at: '2026-09-07T08:00:00Z' }],
settings: {
application: { simulator_enabled: true },
gree: { controller_id: 'test-controller' },
history: { retention_days: 30 }, influxdb: {}, notifications: {}, night: {},
home_assistant: { sensor_aliases: { 'sensor.bootstrap': 'Bootstrap' }, flow_inputs: [], outdoor_assist_enabled: true },
debug: { overlay_enabled: false },
},
house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10,
control_plan: { marker: 'p1' }, control_plan_revision: 1,
});
assert.equal(apiCalls.length, apiCallsBeforeBootstrap, 'WebSocket bootstrap must not fetch split settings endpoints');
assert.equal(app.accessTokens[0].id, 't1', 'WebSocket bootstrap must refresh access-token metadata');
assert.equal(app.settings.controller_id, 'test-controller');
assert.equal(app.sensorAliases['sensor.bootstrap'], 'Bootstrap');
assert.equal(app.controlPlan.marker, 'p1');
assert.equal(app.controlPlanRevision, 1);
assert.equal(app.controlPlanPushReady, true);
+29
View File
@@ -48,6 +48,35 @@ grep -qi 'swagger-ui' "$TMP/swagger.html"
curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json"
grep -q 'sim-salon' "$TMP/bootstrap.json"
python3 - "$TMP/bootstrap.json" "$PORT" <<'PYBOOTSTRAP'
import json, sys, urllib.request
bootstrap = json.load(open(sys.argv[1], encoding="utf-8"))
port = sys.argv[2]
settings = bootstrap.get("settings")
assert isinstance(settings, dict), "bootstrap.settings missing"
paths = {
"application": "/api/settings/application",
"gree": "/api/settings/gree",
"history": "/api/settings/history",
"influxdb": "/api/settings/influxdb",
"notifications": "/api/settings/notifications",
"night": "/api/settings/night",
"home_assistant": "/api/settings/home-assistant",
"debug": "/api/settings/debug",
}
assert set(paths) <= set(settings), f"bootstrap settings sections missing: {set(paths) - set(settings)}"
for section, path in paths.items():
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}") as response:
standalone = json.load(response)
assert settings[section] == standalone, f"bootstrap settings mismatch for {section}"
assert "password" not in settings["influxdb"]
assert "token" not in settings["influxdb"]
assert "token" not in settings["home_assistant"]
for secret in ("pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"):
assert secret not in settings["notifications"], f"secret field leaked: {secret}"
PYBOOTSTRAP
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"power":true,"mode":"cool","target_temperature":22}' \