diff --git a/routes/prefs_routes.py b/routes/prefs_routes.py
index eb8cb9c35..9ca9acd15 100644
--- a/routes/prefs_routes.py
+++ b/routes/prefs_routes.py
@@ -118,8 +118,12 @@ def setup_prefs_routes():
async def set_pref(request: Request, key: str, body: dict):
user = get_current_user(request)
prefs = _load_for_user(user)
- prefs[key] = body.get("value")
+ val = body.get("value")
+ if val is None:
+ prefs.pop(key, None)
+ else:
+ prefs[key] = val
_save_for_user(user, prefs)
- return {"key": key, "value": prefs[key]}
+ return {"key": key, "value": prefs.get(key)}
return router
diff --git a/src/ai_interaction.py b/src/ai_interaction.py
index c79c0a8ae..bfae3f6a7 100644
--- a/src/ai_interaction.py
+++ b/src/ai_interaction.py
@@ -1181,9 +1181,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
request on this so it never falls back to deterministic approximations.
"""
try:
- from src.settings import load_settings
+ from src.settings import get_user_setting, load_settings
- if not load_settings().get("image_gen_enabled", True):
+ if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
return False
except Exception:
pass
@@ -1283,8 +1283,12 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
def get_user_setting(key, owner="", default=None):
return default
- if not get_user_setting("image_gen_enabled", owner=owner, default=True):
+ # The administrator's global image-generation disable switch is authoritative.
+ # Non-admin preferences cannot re-enable generation when disabled globally.
+ if not _settings.get("image_gen_enabled", True):
return {"error": "Image generation is disabled by the administrator."}
+ if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
+ return {"error": "Image generation is disabled by user preference."}
# Use user- or admin-configured model/quality if not specified by the tool call
if not model_spec:
diff --git a/src/settings.py b/src/settings.py
index 53c4c523a..11f7dc462 100644
--- a/src/settings.py
+++ b/src/settings.py
@@ -314,6 +314,20 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
Falls back gracefully if the prefs module can't be imported (cycle/early
boot) — admin-global settings keep working.
"""
+ if key == "image_gen_enabled":
+ global_val = get_setting("image_gen_enabled", default if default is not None else False)
+ if not global_val:
+ return False
+ if owner:
+ try:
+ from routes.prefs_routes import _load_for_user
+ prefs = _load_for_user(owner) or {}
+ if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
+ return bool(prefs["image_gen_enabled"])
+ except Exception:
+ pass
+ return bool(global_val)
+
if owner and key in _PER_USER_KEYS:
try:
from routes.prefs_routes import _load_for_user
diff --git a/static/js/settings/imageModels.js b/static/js/settings/imageModels.js
index 551293b53..37fedf666 100644
--- a/static/js/settings/imageModels.js
+++ b/static/js/settings/imageModels.js
@@ -4,7 +4,7 @@
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
-import { saveImagePreferences as _postSettings } from './imagePreferences.js';
+import { saveImagePreferences as _postSettings, readImagePreferences } from './imagePreferences.js';
import { modelCaps } from '../editor/ai-models.js';
import { sortModelIds } from '../modelSort.js';
let preferencesListener = null;
@@ -46,8 +46,7 @@ export async function initImageSettings() {
}
} catch (e) { console.warn('Failed to load models for image settings', e); }
try {
- const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
- const settings = await settingsRes.json();
+ const settings = await readImagePreferences();
if (settings.image_model) modelSel.value = settings.image_model;
if (editModelSel) editModelSel.value = settings.image_edit_model || '';
if (settings.image_quality) qualSel.value = settings.image_quality;
diff --git a/static/js/settings/imagePreferences.js b/static/js/settings/imagePreferences.js
index cbd40ea16..ffa097809 100644
--- a/static/js/settings/imagePreferences.js
+++ b/static/js/settings/imagePreferences.js
@@ -1,5 +1,7 @@
import { invalidateSettings } from '../appConfig.js';
+const USER_PREFERENCE_KEYS = ['image_model', 'image_edit_model', 'image_quality'];
+
export async function readImagePreferences() {
const [prefsRes, settingsRes] = await Promise.all([
fetch('/api/prefs', { credentials: 'same-origin' }).catch(() => null),
@@ -12,6 +14,7 @@ export async function readImagePreferences() {
...settings,
image_model: prefs?.image_model || settings?.image_model || '',
image_edit_model: prefs?.image_edit_model || settings?.image_edit_model || '',
+ image_quality: prefs?.image_quality || settings?.image_quality || 'medium',
};
}
@@ -24,17 +27,31 @@ export function saveImagePreferences(patch) {
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
});
if (response.status === 403) {
- for (const [key, value] of Object.entries(patch)) {
+ const userEntries = Object.entries(patch).filter(([k]) => USER_PREFERENCE_KEYS.includes(k));
+ for (const [key, value] of userEntries) {
const prefRes = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (!prefRes.ok) {
- throw new Error('Could not save image preferences');
+ const err = new Error(await prefRes.text().catch(() => 'Could not save image preferences'));
+ err.status = prefRes.status;
+ throw err;
}
}
- } else if (!response.ok) {
+ response = typeof Response !== 'undefined'
+ ? new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } })
+ : { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => '{"ok":true}' };
+ } else if (response.ok) {
+ await Promise.all(USER_PREFERENCE_KEYS.map(key =>
+ fetch(`/api/prefs/${encodeURIComponent(key)}`, {
+ method: 'PUT', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ value: null }),
+ }).catch(() => null)
+ ));
+ } else {
throw new Error('Could not save image settings');
}
invalidateSettings();
diff --git a/tests/test_editor_generation_model_routing.py b/tests/test_editor_generation_model_routing.py
index 77058e2fa..b3acb0e47 100644
--- a/tests/test_editor_generation_model_routing.py
+++ b/tests/test_editor_generation_model_routing.py
@@ -293,3 +293,208 @@ def test_is_background_removal_intent_grammar_and_dos_safety():
assert not _is_background_removal_intent(evil_invalid)
elapsed_invalid = time.perf_counter() - start
assert elapsed_invalid < 0.1, f"Invalid intent parsing took too long: {elapsed_invalid:.4f}s"
+
+
+def test_image_generation_global_disabled_user_enabled_rejected(tmp_path, monkeypatch):
+ import asyncio
+ from fastapi import HTTPException
+ from routes.gallery import gallery_routes as gallery
+ from routes import editor_agent_routes, prefs_routes
+ from src import ai_interaction, settings
+
+ prefs_file = str(tmp_path / "user_prefs.json")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
+
+ # Admin globally disables image generation
+ monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False, "image_model": "gpt-image-1"})
+
+ # User 'alice' has image_gen_enabled = True in her preferences
+ prefs_routes._save_for_user("alice", {"image_gen_enabled": True, "image_model": "alice-model"})
+
+ # 1. get_user_setting must be False despite user preference
+ assert settings.get_user_setting("image_gen_enabled", owner="alice", default=True) is False
+
+ # 2. image_editing_provider_available must be False
+ assert ai_interaction.image_editing_provider_available(owner="alice") is False
+
+ # 3. Direct generation must be rejected with admin disabled message
+ direct_res = asyncio.run(ai_interaction.do_generate_image(
+ json.dumps({"prompt": "a dog"}), owner="alice"
+ ))
+ assert "error" in direct_res
+ assert "disabled by the administrator" in direct_res["error"]
+
+ # 4. Gallery route must reject with 503
+ app = FastAPI()
+ app.include_router(gallery.setup_gallery_routes())
+ monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "alice")
+ with TestClient(app) as client:
+ gal_res = client.post("/api/image/generate", json={"prompt": "a dog"})
+ assert gal_res.status_code == 503
+ assert "switched off" in gal_res.json()["detail"].lower()
+
+ # 5. Editor agent route must be blocked
+ app_editor = FastAPI()
+ app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
+ monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "alice")
+ routes.set_editor_planner(lambda messages, owner: json.dumps({
+ "reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a dog"}}]
+ }))
+ try:
+ with TestClient(app_editor) as client:
+ ed_res = client.post("/api/editor/agent", json={"prompt": "create a dog"})
+ assert ed_res.status_code == 200
+ data = ed_res.json()
+ assert data.get("blocked") is True
+ assert data.get("needs_provider") is True
+ finally:
+ routes.set_editor_planner(None)
+
+
+def test_image_generation_global_enabled_user_disabled_rejected(tmp_path, monkeypatch):
+ import asyncio
+ from routes.gallery import gallery_routes as gallery
+ from routes import editor_agent_routes, prefs_routes
+ from src import ai_interaction, settings
+
+ prefs_file = str(tmp_path / "user_prefs.json")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
+
+ # Admin globally enables image generation
+ monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
+
+ # User 'bob' has image_gen_enabled = False in his preferences
+ prefs_routes._save_for_user("bob", {"image_gen_enabled": False, "image_model": "bob-model"})
+
+ # 1. get_user_setting must be False per user preference
+ assert settings.get_user_setting("image_gen_enabled", owner="bob", default=True) is False
+
+ # 2. image_editing_provider_available must be False for bob
+ assert ai_interaction.image_editing_provider_available(owner="bob") is False
+
+ # 3. Direct generation must be rejected with user preference message
+ direct_res = asyncio.run(ai_interaction.do_generate_image(
+ json.dumps({"prompt": "a cat"}), owner="bob"
+ ))
+ assert "error" in direct_res
+ assert "disabled by user preference" in direct_res["error"]
+
+ # 4. Gallery route must reject with 503
+ app = FastAPI()
+ app.include_router(gallery.setup_gallery_routes())
+ monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "bob")
+ with TestClient(app) as client:
+ gal_res = client.post("/api/image/generate", json={"prompt": "a cat"})
+ assert gal_res.status_code == 503
+
+ # 5. Editor agent route must be blocked for bob
+ app_editor = FastAPI()
+ app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
+ monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "bob")
+ routes.set_editor_planner(lambda messages, owner: json.dumps({
+ "reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a cat"}}]
+ }))
+ try:
+ with TestClient(app_editor) as client:
+ ed_res = client.post("/api/editor/agent", json={"prompt": "create a cat"})
+ assert ed_res.status_code == 200
+ data = ed_res.json()
+ assert data.get("blocked") is True
+ assert data.get("needs_provider") is True
+ finally:
+ routes.set_editor_planner(None)
+
+
+def test_image_generation_authorized_generation_and_privilege_checks(tmp_path, monkeypatch):
+ import asyncio
+ from fastapi import HTTPException
+ from routes.gallery import gallery_routes as gallery
+ from routes import editor_agent_routes, prefs_routes
+ from src import ai_interaction, settings
+
+ prefs_file = str(tmp_path / "user_prefs.json")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
+
+ # Admin globally enables image generation
+ monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
+
+ # User 'charlie' has image generation enabled (no disabling pref)
+ prefs_routes._save_for_user("charlie", {"image_model": "charlie-gen"})
+
+ assert settings.get_user_setting("image_gen_enabled", owner="charlie", default=True) is True
+
+ # Check privilege enforcement: user lacking can_generate_images is forbidden
+ def require_privilege_mock(request, privilege):
+ if privilege == "can_generate_images":
+ raise HTTPException(403, "Permission denied")
+ return "charlie"
+
+ monkeypatch.setattr("src.auth_helpers.require_privilege", require_privilege_mock)
+ monkeypatch.setattr(gallery, "require_privilege", require_privilege_mock)
+
+ # Gallery checks privilege and returns 403
+ app = FastAPI()
+ app.include_router(gallery.setup_gallery_routes())
+ with TestClient(app) as client:
+ gal_res = client.post("/api/image/generate", json={"prompt": "a bird"})
+ assert gal_res.status_code == 403
+ assert "Permission denied" in gal_res.text
+
+ # Editor checks privilege on AI content and raises 403
+ app_editor = FastAPI()
+ app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
+ monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "charlie")
+ monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
+ routes.set_editor_planner(lambda messages, owner: json.dumps({
+ "reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a bird"}}]
+ }))
+ try:
+ with TestClient(app_editor) as client:
+ ed_res = client.post("/api/editor/agent", json={"prompt": "create a bird"})
+ assert ed_res.status_code == 403
+ finally:
+ routes.set_editor_planner(None)
+
+ # Now grant privilege: generation proceeds
+ monkeypatch.setattr("src.auth_helpers.require_privilege", lambda request, priv: "charlie")
+ monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "charlie")
+ seen = []
+ async def fake_do_generate(content, **kwargs):
+ seen.append(kwargs)
+ return {"image_url": "data:image/png;base64,dGVzdA=="}
+ monkeypatch.setattr(ai_interaction, "do_generate_image", fake_do_generate)
+
+ with TestClient(app) as client:
+ gal_ok = client.post("/api/image/generate", json={"prompt": "a bird"})
+ assert gal_ok.status_code == 200
+ assert seen[0]["owner"] == "charlie"
+
+
+def test_put_pref_clears_key_when_value_is_null(tmp_path, monkeypatch):
+ from routes import prefs_routes
+ from src import settings
+
+ prefs_file = str(tmp_path / "user_prefs.json")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
+
+ # Set initial preference
+ prefs_routes._save_for_user("admin_user", {"image_model": "old-admin-model"})
+ assert prefs_routes._load_for_user("admin_user").get("image_model") == "old-admin-model"
+
+ app = FastAPI()
+ app.include_router(prefs_routes.setup_prefs_routes())
+ monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: "admin_user")
+ monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: "admin_user")
+
+ with TestClient(app) as client:
+ res = client.put("/api/prefs/image_model", json={"value": None})
+ assert res.status_code == 200
+ assert res.json() == {"key": "image_model", "value": None}
+
+ # Verify key is deleted from user's record
+ user_prefs = prefs_routes._load_for_user("admin_user")
+ assert "image_model" not in user_prefs
+
+ # Verify get_user_setting now falls back to global setting
+ monkeypatch.setattr(settings, "load_settings", lambda: {"image_model": "new-global-model"})
+ assert settings.get_user_setting("image_model", owner="admin_user") == "new-global-model"
diff --git a/tests/test_editor_layer_scoped_actions_browser.py b/tests/test_editor_layer_scoped_actions_browser.py
index 1818b0147..92348cb50 100644
--- a/tests/test_editor_layer_scoped_actions_browser.py
+++ b/tests/test_editor_layer_scoped_actions_browser.py
@@ -454,6 +454,100 @@ def test_image_model_settings_sync_both_directions_and_survive_reopening(page):
assert len(writes) == 2
+def test_non_admin_image_model_preferences_persist_and_reload(page):
+ global_settings = {
+ 'image_model': 'global-default-gen',
+ 'image_edit_model': '',
+ 'image_quality': 'medium',
+ 'image_gen_enabled': True,
+ }
+ user_prefs = {}
+ auth_posts = []
+ pref_puts = []
+ is_admin = False
+
+ def handle_auth_settings(route):
+ if route.request.method == 'POST':
+ auth_posts.append(route.request.post_data_json)
+ if not is_admin:
+ route.fulfill(status=403, json={'error': 'Admin access is required to change these settings.'})
+ return
+ global_settings.update(route.request.post_data_json)
+ route.fulfill(status=200, json=dict(global_settings))
+ return
+ route.fulfill(status=200, json=dict(global_settings))
+
+ def handle_prefs(route):
+ url = route.request.url
+ if route.request.method == 'PUT':
+ key = url.split('/api/prefs/')[-1]
+ data = route.request.post_data_json or {}
+ val = data.get('value')
+ pref_puts.append((key, val))
+ if val is None:
+ user_prefs.pop(key, None)
+ else:
+ user_prefs[key] = val
+ route.fulfill(status=200, json={'key': key, 'value': val})
+ return
+ route.fulfill(status=200, json=dict(user_prefs))
+
+ page.route('**/api/auth/settings', handle_auth_settings)
+ page.route('**/api/prefs**', handle_prefs)
+ page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
+ 'is_enabled': True, 'online': True, 'model_type': 'llm',
+ 'models': ['global-default-gen', 'openai/gpt-5-image', 'openai/gpt-image-2'],
+ }]))
+
+ # Step 1: Non-admin loads image settings, selects a preference
+ page.evaluate("""async () => {
+ document.body.innerHTML = ''
+ + ''
+ + ''
+ + '';
+ const {initImageSettings} = await import('/static/js/settings/imageModels.js');
+ await initImageSettings();
+ }""")
+
+ # Non-admin changes generation model
+ page.select_option('#set-imgModelSelect', 'openai/gpt-image-2')
+
+ # Wait for status message to show 'Saved'
+ page.wait_for_function("document.getElementById('set-imgSettingsMsg').textContent === 'Saved'")
+ msg_color = page.evaluate("document.getElementById('set-imgSettingsMsg').style.color")
+ assert msg_color == 'var(--fg)', f"Expected var(--fg), got {msg_color}"
+
+ # Verify per-user preference was saved and global toggle was NOT propagated
+ assert user_prefs.get('image_model') == 'openai/gpt-image-2'
+ assert 'image_gen_enabled' not in user_prefs, "Global policy toggle must not be saved to user prefs"
+
+ # Step 2: Simulate page reload / re-initialization for non-admin
+ page.evaluate("""async () => {
+ document.getElementById('set-imgModelSelect').value = '';
+ const {initImageSettings} = await import('/static/js/settings/imageModels.js');
+ await initImageSettings();
+ }""")
+ page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
+ assert page.evaluate("document.getElementById('set-imgModelSelect').value") == 'openai/gpt-image-2'
+
+ # Step 3: Admin saves settings - pre-existing per-user preferences must be cleared
+ is_admin = True
+ page.evaluate("""async () => {
+ const {saveImagePreferences} = await import('/static/js/settings/imagePreferences.js');
+ await saveImagePreferences({ image_model: 'admin-chosen-model', image_edit_model: '', image_quality: 'high', image_gen_enabled: true });
+ }""")
+ # Verify per-user preference for image_model was cleared (PUT with value: null)
+ assert 'image_model' not in user_prefs, "Admin save must clear pre-existing per-user preference"
+ assert global_settings['image_model'] == 'admin-chosen-model'
+
+ # Admin reloads settings - receives newly saved global setting, not stale pref
+ page.evaluate("""async () => {
+ const {initImageSettings} = await import('/static/js/settings/imageModels.js');
+ await initImageSettings();
+ }""")
+ assert global_settings['image_model'] == 'admin-chosen-model'
+
+
def test_generation_places_result_without_stretching_or_refresh(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
diff --git a/tests/test_settings_save_response_ok_js.py b/tests/test_settings_save_response_ok_js.py
index 5bc5eff05..13a96a1af 100644
--- a/tests/test_settings_save_response_ok_js.py
+++ b/tests/test_settings_save_response_ok_js.py
@@ -118,3 +118,122 @@ def test_failed_save_shows_failure_in_simulated_dom():
assert data["serverErrResult"] == {"text": "Failed to save", "color": "var(--red)"}
assert data["okResult"] == {"text": "Saved", "color": "var(--fg)"}
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_image_preferences_js_policy_and_non_admin_fallback():
+ """Verify imagePreferences.js non-admin fallback, policy toggle isolation, and admin clearing."""
+ js = """
+ globalThis.window = { dispatchEvent: () => {} };
+ globalThis.CustomEvent = class CustomEvent {};
+
+ const { saveImagePreferences, readImagePreferences } = await import('./static/js/settings/imagePreferences.js');
+
+ const results = {};
+
+ // Scenario 1: Non-admin save (POST /api/auth/settings returns 403)
+ const prefCalls = [];
+ globalThis.fetch = async (url, opts = {}) => {
+ if (url === '/api/auth/settings') {
+ return { status: 403, ok: false, text: async () => 'Forbidden' };
+ }
+ if (url.startsWith('/api/prefs/')) {
+ prefCalls.push({ url, body: JSON.parse(opts.body) });
+ return { status: 200, ok: true, json: async () => ({ ok: true }) };
+ }
+ throw new Error('Unexpected URL: ' + url);
+ };
+
+ const patch = {
+ image_gen_enabled: true,
+ image_model: 'user-gen-model',
+ image_edit_model: 'user-edit-model',
+ image_quality: 'high',
+ };
+
+ const res = await saveImagePreferences(patch);
+ results.fallbackResOk = res.ok;
+ results.fallbackResStatus = res.status;
+ results.prefCalls = prefCalls;
+
+ // Scenario 2: Non-admin save fails on prefs PUT (keeps actual failure visible)
+ globalThis.fetch = async (url) => {
+ if (url === '/api/auth/settings') return { status: 403, ok: false };
+ if (url.startsWith('/api/prefs/')) return { status: 500, ok: false, text: async () => 'Disk error' };
+ throw new Error('Unexpected URL: ' + url);
+ };
+ try {
+ await saveImagePreferences({ image_model: 'fail-model' });
+ results.prefFailureThrew = false;
+ } catch (err) {
+ results.prefFailureThrew = true;
+ results.prefFailureStatus = err.status;
+ }
+
+ // Scenario 3: Admin save clears pre-existing per-user preferences
+ const adminPrefClears = [];
+ globalThis.fetch = async (url, opts = {}) => {
+ if (url === '/api/auth/settings') {
+ return { status: 200, ok: true, json: async () => ({ ok: true }) };
+ }
+ if (url.startsWith('/api/prefs/')) {
+ adminPrefClears.push({ url, body: JSON.parse(opts.body) });
+ return { status: 200, ok: true, json: async () => ({ ok: true }) };
+ }
+ throw new Error('Unexpected URL: ' + url);
+ };
+ const adminRes = await saveImagePreferences({ image_model: 'admin-model', image_gen_enabled: true });
+ results.adminResOk = adminRes.ok;
+ results.adminPrefClears = adminPrefClears;
+
+ // Scenario 4: readImagePreferences loads image_quality along with models
+ globalThis.fetch = async (url) => {
+ if (url === '/api/prefs') {
+ return {
+ ok: true,
+ json: async () => ({ image_model: 'pref-gen', image_quality: 'low' }),
+ };
+ }
+ if (url === '/api/auth/settings') {
+ return {
+ ok: true,
+ json: async () => ({ image_model: 'global-gen', image_edit_model: 'global-edit', image_quality: 'medium' }),
+ };
+ }
+ throw new Error('Unexpected URL: ' + url);
+ };
+ const loaded = await readImagePreferences();
+ results.loaded = loaded;
+
+ console.log(JSON.stringify(results));
+ """
+ proc = subprocess.run(["node", "--input-type=module", "-e", js], capture_output=True, text=True, check=True)
+ import json
+ data = json.loads(proc.stdout)
+
+ # 1. Non-admin fallback returns ok=True, status=200
+ assert data["fallbackResOk"] is True
+ assert data["fallbackResStatus"] == 200
+
+ # 2. image_gen_enabled was filtered out; only image_model, image_edit_model, image_quality written
+ written_keys = [c["url"].split("/api/prefs/")[1] for c in data["prefCalls"]]
+ assert "image_gen_enabled" not in written_keys
+ assert "image_model" in written_keys
+ assert "image_edit_model" in written_keys
+ assert "image_quality" in written_keys
+
+ # 3. Preference failure threw error and preserved status
+ assert data["prefFailureThrew"] is True
+ assert data["prefFailureStatus"] == 500
+
+ # 4. Admin save cleared per-user preferences with value: null
+ assert data["adminResOk"] is True
+ cleared_keys = [c["url"].split("/api/prefs/")[1] for c in data["adminPrefClears"]]
+ assert "image_model" in cleared_keys
+ for call in data["adminPrefClears"]:
+ assert call["body"] == {"value": None}
+
+ # 5. readImagePreferences includes image_quality from prefs
+ assert data["loaded"]["image_model"] == "pref-gen"
+ assert data["loaded"]["image_edit_model"] == "global-edit"
+ assert data["loaded"]["image_quality"] == "low"
+