"""PATCH settings merges a diff onto fresh state, so a renderer save can't clobber a field it didn't send. This is the structural close on the last renderer-vs-agent race: the lost update is now unrepresentable, you can't overwrite a field you never put in the body. """ from __future__ import annotations import asyncio import httpx import pytest from fastapi.testclient import TestClient from backend.main import app def p_auth_headers(): import backend.auth as auth_mod if not auth_mod.TOKEN: import secrets auth_mod.TOKEN = secrets.token_urlsafe(32) return {"Authorization": f"Bearer {auth_mod.TOKEN}"} @pytest.fixture def client(): return TestClient(app, headers=p_auth_headers()) @pytest.fixture def reset_settings(): from backend.apps.settings.settings import load_settings, save_settings original = load_settings().model_copy(deep=True) yield save_settings(original) def test_patch_changes_only_sent_fields(client, reset_settings): from backend.apps.settings.settings import load_settings, save_settings s = load_settings() s.theme = "dark" s.default_mode = "chat" # as if something else had set this save_settings(s) r = client.patch("/api/settings", json={"theme": "light"}) assert r.status_code == 200, r.text final = load_settings() assert final.theme == "light" # the field we sent changed assert final.default_mode == "chat" # the field we DIDN'T send is untouched def test_patch_ignores_unknown_fields(client, reset_settings): r = client.patch("/api/settings", json={"theme": "light", "not_a_field": 123}) assert r.status_code == 200 # Unknown key is dropped, not stored; the real field still applied. from backend.apps.settings.settings import load_settings assert load_settings().theme == "light" assert "not_a_field" not in load_settings().model_dump() @pytest.mark.asyncio async def test_concurrent_renderer_patch_and_agent_write_both_survive(reset_settings): """The renderer PATCHes one field while an autonomous agent writes another, at the same time. Both must land: the renderer never sends the agent's field, so it can't clobber it, and both reads happen fresh under the shared lock.""" from backend.apps.settings.settings import load_settings, save_settings base = load_settings() base.theme = "dark" base.default_mode = "agent" save_settings(base) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test", headers=p_auth_headers()) as client: r1, r2 = await asyncio.gather( client.patch("/api/settings", json={"theme": "light"}), client.post("/api/settings-meta/write", json={"changes": {"default_mode": "chat"}}), ) assert r1.status_code == 200 and r2.status_code == 200 final = load_settings() assert final.theme == "light", "renderer's change lost" assert final.default_mode == "chat", "agent's change clobbered by the renderer PATCH"