mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): mask the Config Editor's secrets like GET /config/secrets The Config Editor tab (/partials/raw-json) filled its config_secrets.json editor with the file as it is on disk. GET /api/v3/config/secrets masks every value because the interface is reachable without a login by default, but this page handed the same credentials (GitHub token, Home Assistant token, plugin API keys) to anyone who loaded it. The masked-save path in save_raw_secrets_config was written for a masked editor and never got one. _load_raw_json_partial now masks the section with mask_all_secret_values after strip_auth_section, exactly as the GET does. Saving it back is safe: save_raw_secrets_config drops the masks (strip_masked_values) and merges the rest onto the stored file (deep_merge), so an untouched secret stays as it is and a replaced mask is the only value that changes. The config.json editor is left as it is. Its save (save_raw_main_config) writes the posted object verbatim, with no mask stripping or merge, so a masked main editor would write the bullets over any credential it holds. Masking it needs a merge-on-save of its own first. Tests: TestConfigEditorRoundTrip renders the partial over a real ConfigManager, checks no real value is in the editor, and posts the editor back unchanged (the file is identical) and with one mask replaced (only that value changes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions PluginOrderList draws one row per enabled plugin and, once drawn, rewrites its hidden inputs (plugin_rotation_order, vegas_plugin_order, vegas_excluded_plugins) from those rows. A disabled plugin has no row, so merely opening the Display or Rotation & Durations tab took it out of the inputs, and the next save of that form stored the lists without it. Exclude Clock from Vegas, disable it, change the brightness, re-enable it: Clock was scrolling in Vegas again and had moved to the end of the rotation. syncInputs now keeps the saved ids that have no row. In the order, each one keeps its saved slot and the rows fill the other slots in their current order, with rows not in the saved order last, as before. In the exclusions they follow the unchecked rows. Only string ids are carried over, once each: /config/main refuses a list holding anything else, which would block every later save of the tab. Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids) and is in run_all.js and the README. The durations DOM suite now reads only its own rows' ids from the input, since a rig's saved order can hold others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore reinstalls only the plugins that are missing POST /backup/restore with reinstall_plugins (the "Reinstall missing plugins" box) passed every plugin in the backup's plugins.json to install_plugin(). That replaces an installed copy with a fresh download, so a restore onto the same device re-downloaded every plugin inside the request. A plugin installed from its own URL is not in the registry, so its install returned False, plugins_failed set success to False, and the restore answered 500 "Restore incomplete ... plugins not reinstalled: <id>" (shown as "Restore failed") with the plugin still installed and the config restored. Each plugin is now looked up first with the store's _existing_install, the same lookup install_plugin makes to decide a copy exists: the id, or an id the registry proves is the same plugin (aliases, the plugin_path name), and never a bare ledmatrix-<id> folder (#686). One that is installed is recorded in result.skipped as "plugin:<id> (installed)", which the page lists under Skipped; a missing one is installed as before. The list_installed_plugins docstring said every listed plugin is reinstalled and now says otherwise. Tests: TestInstalledPluginsAreNotReinstalled, with a mocked store (installed skipped, missing installed; an installed plugin the store can't install is not a failure) and with a real PluginStoreManager (a registry alias and a third-party install are skipped, a missing plugin installed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): /config/main answers malformed JSON with a 400 save_main_config read a JSON body with request.get_json(), which raises Werkzeug's BadRequest for a body that does not parse (or an empty one sent as application/json). That happened inside the handler's try, so the catch-all answered 500 CONFIG_SAVE_FAILED with "Check file permissions on config directory" among its suggested fixes and logged a traceback at ERROR, for what was the caller's mistake. It now reads with get_json(silent=True), as save_raw_main_config does, and answers a sent-but-unparseable body with the same 400 {"status": "error", "message": "Invalid JSON in request body"}. An empty JSON body falls through to the existing 400 "No data provided". The change is limited to the lines that read the body. Tests: TestMalformedBody in test_api_v3_partial_main_save.py (the 400 and its shape, identical to /config/raw/main's, and nothing saved; the empty body). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore that brings back fonts clears the font catalog cache GET /api/v3/fonts/catalog caches its answer as fonts_catalog for five minutes. Font upload and delete clear that entry (fonts.py), but POST /backup/restore copies user fonts into assets/fonts without touching it, so restored fonts were missing from the Fonts tab and every font picker until the cache expired. backup_restore now clears fonts_catalog when the result lists restored fonts (restore_backup records them as "fonts (<count>)"). A restore that restored no fonts leaves the cache alone. Tests: TestFontsCatalogCache in test_api_v3_backup_restore.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): drop uninstalled plugins from the carried-over order and exclusions2b34f254made the plugin order list keep every saved id that has no row, so a disabled plugin keeps its rotation slot and Vegas exclusion. That also kept the ids of plugins that have since been uninstalled: they stayed in plugin_rotation_order and vegas_excluded_plugins for good, where before the next save of the tab dropped them. The widget already fetches /api/v3/plugins/installed, every installed plugin with its enabled flag, and draws only the enabled ones. It now keeps that response's full id set and carries over only saved ids that are installed but have no row (disabled). An id outside the set is dropped, as before. With no list, nothing is dropped: a failed request draws no rows and leaves the inputs as saved, and the carry-over keeps everything if the set was never filled. Tests: test/js/unit/test_plugin_order_list.js adds a disabled plugin kept while an uninstalled one is dropped (order and exclusions; fails on2b34f254), and a failed plugin list leaving both inputs as saved. The CHANGELOG bullet and the README row say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(js): register the order-list suite apart from other branches' suites Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
18 KiB
Python
357 lines
18 KiB
Python
"""POST /config/main: a partial JSON body changes only what it sends.
|
|
|
|
The settings forms post every field, and a browser leaves an unchecked box out,
|
|
so for a form a missing checkbox means False. JSON API clients send only what
|
|
they change. Treating their missing keys as unchecked meant:
|
|
|
|
- the MQTT bridge's Home Assistant brightness slider (``{"brightness": N}``)
|
|
turned off disable_hardware_pulsing, inverse_colors, show_refresh_rate and
|
|
use_short_date_format on every change;
|
|
- the documented timezone/location update turned off web_display_autostart and
|
|
weekly automatic updates.
|
|
|
|
The v3 forms post JSON as well (htmx json-enc), so they mark themselves with a
|
|
hidden ``__form_section`` input; these tests pin both halves of that contract.
|
|
|
|
Also here: the Vegas cycle-time fields no longer land in display_durations
|
|
(and a blank one no longer 400s the Display save), the Raw JSON editor starts
|
|
auto-update setup like the General form does, and both schedule POSTs accept
|
|
the per-day shape their GETs return.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
PARTIALS = REPO / 'web_interface' / 'templates' / 'v3' / 'partials'
|
|
|
|
STORED = {
|
|
'web_display_autostart': True,
|
|
'auto_update': {'enabled': True},
|
|
'timezone': 'America/Chicago',
|
|
'plugin_system': {'auto_discover': True, 'auto_load_enabled': True,
|
|
'development_mode': True},
|
|
'display': {
|
|
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
|
|
'disable_hardware_pulsing': True, 'inverse_colors': True,
|
|
'show_refresh_rate': True},
|
|
'runtime': {'gpio_slowdown': 4},
|
|
'use_short_date_format': True,
|
|
'double_sided': {'enabled': True, 'copies': 2, 'axis': 'horizontal'},
|
|
'vegas_scroll': {'enabled': True, 'auto_trim': True,
|
|
'dynamic_duration_enabled': True,
|
|
'continuous_scroll': True, 'smooth_scroll': True},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def saved(api_v3_module, monkeypatch):
|
|
captured = {}
|
|
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
|
lambda *a, **k: copy.deepcopy(STORED)
|
|
|
|
def fake_save(_manager, config, **_kwargs):
|
|
captured['config'] = config
|
|
return True, ''
|
|
|
|
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
|
from web_interface import auto_update
|
|
monkeypatch.setattr(auto_update, 'start_setup_if_needed', lambda *a, **k: None)
|
|
return captured
|
|
|
|
|
|
def _post_json(client, body):
|
|
return client.post('/api/v3/config/main', data=json.dumps(body),
|
|
content_type='application/json')
|
|
|
|
|
|
class TestJsonPartialSaves:
|
|
def test_mqtt_bridge_brightness_changes_only_brightness(self, api_v3_client, saved):
|
|
# integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py set_brightness
|
|
resp = _post_json(api_v3_client, {'brightness': 40})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
display = saved['config']['display']
|
|
assert display['hardware']['brightness'] == 40
|
|
assert display['hardware']['disable_hardware_pulsing'] is True
|
|
assert display['hardware']['inverse_colors'] is True
|
|
assert display['hardware']['show_refresh_rate'] is True
|
|
assert display['use_short_date_format'] is True
|
|
|
|
def test_timezone_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'timezone': 'UTC'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
config = saved['config']
|
|
assert config['timezone'] == 'UTC'
|
|
assert config['web_display_autostart'] is True
|
|
assert config['auto_update'] == {'enabled': True}
|
|
|
|
def test_location_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'city': 'Paris', 'country': 'FR'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
config = saved['config']
|
|
assert config['location'] == {'city': 'Paris', 'country': 'FR'}
|
|
assert config['web_display_autostart'] is True
|
|
assert config['auto_update'] == {'enabled': True}
|
|
|
|
@pytest.mark.parametrize('key', ['auto_discover', 'auto_load_enabled', 'development_mode'])
|
|
def test_a_legacy_plugin_system_toggle_is_not_a_general_save(self, api_v3_client, saved, key):
|
|
# These left the General form; a client still sending one must not
|
|
# have the general-settings checkboxes treated as unchecked.
|
|
resp = api_v3_client.post('/api/v3/config/main', data={key: 'on'},
|
|
content_type='application/x-www-form-urlencoded')
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['web_display_autostart'] is True
|
|
assert saved['config']['auto_update'] == {'enabled': True}
|
|
|
|
def test_vegas_speed_only_keeps_vegas_toggles(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'vegas_scroll_speed': 80})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
vegas = saved['config']['display']['vegas_scroll']
|
|
assert vegas['scroll_speed'] == 80
|
|
for key in ('enabled', 'auto_trim', 'dynamic_duration_enabled',
|
|
'continuous_scroll', 'smooth_scroll'):
|
|
assert vegas[key] is True, key
|
|
|
|
def test_vegas_speed_only_keeps_live_updates_as_stored(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'vegas_scroll_speed': 70})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert 'live_refresh' not in saved['config']['display']['vegas_scroll']
|
|
|
|
def test_live_updates_can_be_switched_off_and_on(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'vegas_live_refresh': False})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['vegas_scroll']['live_refresh'] is False
|
|
resp = _post_json(api_v3_client, {'__form_section': 'display',
|
|
'vegas_scroll_speed': '50',
|
|
'vegas_live_refresh': 'on'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
|
|
|
|
def test_live_games_can_be_kept_in_the_ticker_or_not(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'vegas_live_in_ticker': False})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
|
|
# An unticked box is absent from a submitted form: that is false too.
|
|
resp = _post_json(api_v3_client, {'__form_section': 'display',
|
|
'vegas_scroll_speed': '50',
|
|
'vegas_live_in_ticker': 'on'})
|
|
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is True
|
|
resp = _post_json(api_v3_client, {'__form_section': 'display',
|
|
'vegas_scroll_speed': '50'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
|
|
|
|
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['double_sided']['enabled'] is True
|
|
|
|
def test_json_can_still_turn_a_checkbox_off(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'inverse_colors': False, 'auto_update_enabled': False})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['hardware']['inverse_colors'] is False
|
|
assert saved['config']['display']['hardware']['disable_hardware_pulsing'] is True
|
|
assert saved['config']['auto_update'] == {'enabled': False}
|
|
|
|
def test_json_with_charset_is_still_json(self, api_v3_client, saved):
|
|
resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({'brightness': 41}),
|
|
content_type='application/json; charset=utf-8')
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['hardware']['brightness'] == 41
|
|
assert saved['config']['display']['hardware']['inverse_colors'] is True
|
|
|
|
def test_a_non_object_body_is_refused(self, api_v3_client, saved):
|
|
assert _post_json(api_v3_client, [1, 2]).status_code == 400
|
|
|
|
|
|
class TestFormSavesStillUncheck:
|
|
"""Unchecking a box in the UI must still save false."""
|
|
|
|
def test_marked_json_form_post_unchecks_missing_display_boxes(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
|
|
'vegas_scroll_speed': '50'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
display = saved['config']['display']
|
|
for key in ('disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate'):
|
|
assert display['hardware'][key] is False, key
|
|
assert display['use_short_date_format'] is False
|
|
assert display['vegas_scroll']['enabled'] is False
|
|
assert '__form_section' not in saved['config']
|
|
|
|
def test_marked_json_form_post_keeps_checked_boxes(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
|
|
'inverse_colors': 'on'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['hardware']['inverse_colors'] is True
|
|
assert saved['config']['display']['hardware']['show_refresh_rate'] is False
|
|
|
|
def test_marked_general_form_unchecks_autostart_and_auto_update(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'__form_section': 'general', 'timezone': 'UTC'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['web_display_autostart'] is False
|
|
assert saved['config']['auto_update'] == {'enabled': False}
|
|
|
|
def test_form_encoded_post_unchecks_missing_boxes(self, api_v3_client, saved):
|
|
resp = api_v3_client.post('/api/v3/config/main', data={'brightness': '40'},
|
|
content_type='application/x-www-form-urlencoded')
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['hardware']['inverse_colors'] is False
|
|
|
|
@pytest.mark.parametrize('partial', ['general.html', 'display.html', 'durations.html'])
|
|
def test_every_config_main_form_carries_the_marker(self, partial):
|
|
html = (PARTIALS / partial).read_text(encoding='utf-8')
|
|
form = re.search(r'<form hx-post="/api/v3/config/main".*?</form>', html, re.S)
|
|
assert form, f'{partial} no longer posts to /config/main'
|
|
assert re.search(r'<input type="hidden" name="__form_section" value="\w+">',
|
|
form.group(0)), f'{partial} form lost its __form_section marker'
|
|
|
|
|
|
class TestVegasCycleDurations:
|
|
def test_cycle_durations_are_not_display_durations(self, api_v3_client, saved):
|
|
resp = api_v3_client.post('/api/v3/config/main', data={
|
|
'vegas_scroll_enabled': 'on', 'vegas_min_cycle_duration': '90',
|
|
'vegas_max_cycle_duration': '300'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
display = saved['config']['display']
|
|
assert display['vegas_scroll']['min_cycle_duration'] == 90
|
|
assert display['vegas_scroll']['max_cycle_duration'] == 300
|
|
durations = display.get('display_durations', {})
|
|
assert 'vegas_min_cycle_duration' not in durations
|
|
assert 'vegas_max_cycle_duration' not in durations
|
|
assert 'vegas_min_cycle_duration' not in saved['config']
|
|
|
|
def test_blank_cycle_duration_does_not_reject_the_save(self, api_v3_client, saved):
|
|
resp = api_v3_client.post('/api/v3/config/main', data={
|
|
'vegas_scroll_enabled': 'on', 'vegas_scroll_speed': '60',
|
|
'vegas_min_cycle_duration': ''})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['vegas_scroll']['scroll_speed'] == 60
|
|
|
|
def test_real_display_durations_still_save(self, api_v3_client, saved):
|
|
resp = _post_json(api_v3_client, {'clock_duration': '45'})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['display_durations']['clock_duration'] == 45
|
|
|
|
def test_per_mode_duration_saves_and_blank_clears_it(self, api_v3_client, saved, api_v3_module):
|
|
# The Rotation page leaves a mode blank to mean "the plugin's own
|
|
# duration"; a saved value overrides the plugin, so blank must remove
|
|
# it rather than 400 or pin a number.
|
|
stored = copy.deepcopy(STORED)
|
|
stored['display']['display_durations'] = {'weather_current': 40, 'clock': 20}
|
|
api_v3_module.api_v3.config_manager.load_config.side_effect = lambda *a, **k: copy.deepcopy(stored)
|
|
resp = _post_json(api_v3_client, {'__form_section': 'durations',
|
|
'duration__clock': '45',
|
|
'duration__weather_current': ''})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert saved['config']['display']['display_durations'] == {'clock': 45}
|
|
|
|
|
|
class TestMalformedBody:
|
|
"""A JSON body that does not parse is the caller's mistake: a 400.
|
|
|
|
get_json() raised Werkzeug's BadRequest inside the handler's try, whose
|
|
catch-all answered 500 CONFIG_SAVE_FAILED with "check file permissions"
|
|
advice and logged a traceback at ERROR.
|
|
"""
|
|
|
|
def test_is_a_400_in_the_raw_routes_shape(self, api_v3_client, saved, api_v3_module):
|
|
api_v3_module.api_v3.config_manager.get_raw_file_content.return_value = {}
|
|
resp = api_v3_client.post('/api/v3/config/main', data='{not json',
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
assert resp.get_json() == {'status': 'error', 'message': 'Invalid JSON in request body'}
|
|
assert 'config' not in saved
|
|
raw = api_v3_client.post('/api/v3/config/raw/main', data='{not json',
|
|
content_type='application/json')
|
|
assert (raw.status_code, raw.get_json()) == (400, resp.get_json())
|
|
|
|
def test_an_empty_json_post_is_still_no_data(self, api_v3_client, saved):
|
|
resp = api_v3_client.post('/api/v3/config/main', data='',
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
assert resp.get_json()['message'] == 'No data provided'
|
|
|
|
|
|
class TestRawSaveStartsAutoUpdateSetup:
|
|
@pytest.fixture
|
|
def raw_env(self, api_v3_module, monkeypatch):
|
|
cm = api_v3_module.api_v3.config_manager
|
|
cm.get_raw_file_content.return_value = {'timezone': 'UTC', 'auto_update': {'enabled': False}}
|
|
calls = []
|
|
from web_interface import auto_update
|
|
monkeypatch.setattr(auto_update, 'start_setup_if_needed',
|
|
lambda was, config: calls.append((was, config)) or 'Setup note.')
|
|
return cm, calls
|
|
|
|
def test_enabling_from_raw_json_calls_setup(self, api_v3_client, raw_env):
|
|
cm, calls = raw_env
|
|
body = {'timezone': 'UTC', 'auto_update': {'enabled': True}}
|
|
resp = api_v3_client.post('/api/v3/config/raw/main', json=body)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
cm.save_raw_file_content.assert_called_once_with('main', body)
|
|
assert calls == [(False, body)]
|
|
assert 'Setup note.' in resp.get_json()['message']
|
|
|
|
def test_previously_enabled_is_passed_through(self, api_v3_client, raw_env):
|
|
cm, calls = raw_env
|
|
cm.get_raw_file_content.return_value = {'auto_update': {'enabled': True}}
|
|
body = {'auto_update': {'enabled': True}}
|
|
assert api_v3_client.post('/api/v3/config/raw/main', json=body).status_code == 200
|
|
assert calls == [(True, body)]
|
|
|
|
|
|
class TestSchedulesAcceptTheirGetShape:
|
|
PER_DAY = {
|
|
'enabled': True, 'mode': 'per-day', 'dim_brightness': 20,
|
|
'days': {
|
|
'monday': {'enabled': True, 'start_time': '21:15', 'end_time': '06:45'},
|
|
'tuesday': {'enabled': False},
|
|
'wednesday': {'enabled': True, 'start_time': '22:00', 'end_time': '05:30'},
|
|
'thursday': {'enabled': True, 'start_time': '20:00', 'end_time': '07:00'},
|
|
'friday': {'enabled': True, 'start_time': '23:00', 'end_time': '08:00'},
|
|
'saturday': {'enabled': True, 'start_time': '23:30', 'end_time': '09:00'},
|
|
'sunday': {'enabled': True, 'start_time': '21:00', 'end_time': '07:00'},
|
|
},
|
|
}
|
|
|
|
@pytest.fixture
|
|
def store(self, api_v3_module, monkeypatch):
|
|
state = {'config': {}}
|
|
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
|
lambda *a, **k: copy.deepcopy(state['config'])
|
|
|
|
def fake_save(_manager, config, **_kwargs):
|
|
state['config'] = copy.deepcopy(config)
|
|
return True, ''
|
|
|
|
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
|
return state
|
|
|
|
@pytest.mark.parametrize('route,section,extra', [
|
|
('/api/v3/config/dim-schedule', 'dim_schedule', {'dim_brightness': 20}),
|
|
('/api/v3/config/schedule', 'schedule', {}),
|
|
])
|
|
def test_get_output_posts_back_unchanged(self, api_v3_client, store, route, section, extra):
|
|
body = {k: v for k, v in self.PER_DAY.items() if k != 'dim_brightness'}
|
|
body.update(extra)
|
|
store['config'] = {section: copy.deepcopy(body)}
|
|
read = api_v3_client.get(route).get_json()['data']
|
|
resp = api_v3_client.post(route, json=read)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert store['config'][section]['days'] == body['days']
|
|
|
|
def test_flat_form_keys_still_work(self, api_v3_client, store):
|
|
body = {'enabled': True, 'mode': 'per-day', 'dim_brightness': 25,
|
|
'monday_enabled': 'on', 'monday_start': '19:00', 'monday_end': '06:00'}
|
|
resp = api_v3_client.post('/api/v3/config/dim-schedule', json=body)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert store['config']['dim_schedule']['days']['monday'] == {
|
|
'enabled': True, 'start_time': '19:00', 'end_time': '06:00'}
|