unsloth/studio/backend/tests/test_personalization_settings.py
Daniel Han 53e8de601d
studio: persist personalization (profile, avatar, theme) server-side (#6516)
* studio: persist personalization (profile + theme) server-side

Profile name/nickname/avatar and appearance (theme) were stored only in the
browser's localStorage, so every browser or device that connected to the same
Studio started from defaults and forgot the user's personalization.

Persist them server-side (single-account, stored as one JSON blob in
app_settings) so they follow the account:
- utils/personalization_settings.py + GET/PUT /api/settings/personalization,
  with validation (theme/shape enums, avatar must be an image data URL capped at
  512 KB) and a 'saved' flag.
- Frontend usePersonalizationSync (mounted in the root layout when signed in)
  hydrates the profile + theme stores from the server when a blob exists, and
  otherwise migrates the existing local settings up once so nothing is lost;
  later changes are written through, debounced. Writers keep using the local
  stores unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix/adjust personalization sync for PR #6516

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix Studio personalization sync edge cases

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-22 04:09:48 -07:00

146 lines
4.9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
_BACKEND = Path(__file__).resolve().parents[1]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
import utils.personalization_settings as pers # noqa: E402
from auth.authentication import get_current_subject # noqa: E402
from routes import settings as settings_routes # noqa: E402
from routes.settings import PersonalizationPayload # noqa: E402
def test_defaults_fill_missing_fields():
p = PersonalizationPayload.model_validate({})
assert p.version == pers.PERSONALIZATION_VERSION
assert p.appearance.theme == "system"
assert p.profile.avatarShape == "circle"
assert p.profile.displayName == ""
def test_unknown_keys_are_ignored():
p = PersonalizationPayload.model_validate(
{"profile": {"displayName": "Mike", "bogus": 1}, "extra": True}
)
assert p.profile.displayName == "Mike"
def test_invalid_theme_rejected():
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate({"appearance": {"theme": "neon"}})
def test_avatar_must_be_image_data_url():
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
{"profile": {"avatarDataUrl": "http://example.com/a.png"}}
)
def test_avatar_size_is_capped():
big = "data:image/png;base64," + "A" * (pers.MAX_AVATAR_DATA_URL_BYTES + 1)
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate({"profile": {"avatarDataUrl": big}})
def test_valid_avatar_and_shape():
p = PersonalizationPayload.model_validate(
{
"profile": {
"avatarDataUrl": "data:image/png;base64,AAAA",
"avatarShape": "rounded",
}
}
)
assert p.profile.avatarShape == "rounded"
def test_bundled_avatar_url_allowed():
p = PersonalizationPayload.model_validate(
{"profile": {"avatarDataUrl": "/Sloth%20emojis/large%20sloth%20yay.png"}}
)
assert p.profile.avatarDataUrl == "/Sloth%20emojis/large%20sloth%20yay.png"
def test_bundled_avatar_subpath_allowed():
p = PersonalizationPayload.model_validate(
{"profile": {"avatarDataUrl": "/studio/Sloth%20emojis/large%20sloth%20yay.png"}}
)
assert "Sloth%20emojis" in p.profile.avatarDataUrl
def test_bundled_avatar_traversal_rejected():
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
{"profile": {"avatarDataUrl": "/Sloth%20emojis/../secret.png"}}
)
def test_get_read_errors_propagate(monkeypatch):
def fail(*args, **kwargs):
raise RuntimeError("read failed")
monkeypatch.setattr("storage.studio_db.get_app_setting", fail)
with pytest.raises(RuntimeError, match = "read failed"):
pers.get_personalization()
def test_get_set_roundtrip(monkeypatch):
store: dict = {}
monkeypatch.setattr("storage.studio_db.get_app_setting", lambda k, d = None: store.get(k, d))
monkeypatch.setattr("storage.studio_db.upsert_app_settings", lambda d: store.update(d))
assert pers.get_personalization() == {}
data = {
"version": 1,
"profile": {"displayName": "Mike"},
"appearance": {"theme": "dark"},
}
pers.set_personalization(data)
assert store[pers.PERSONALIZATION_SETTING_KEY]["profile"]["displayName"] == "Mike"
assert pers.get_personalization()["appearance"]["theme"] == "dark"
def test_personalization_route_roundtrip_real_shape(monkeypatch):
store: dict = {}
monkeypatch.setattr("storage.studio_db.get_app_setting", lambda k, d = None: store.get(k, d))
monkeypatch.setattr("storage.studio_db.upsert_app_settings", lambda d: store.update(d))
app = FastAPI()
app.dependency_overrides[get_current_subject] = lambda: "unsloth"
app.include_router(settings_routes.router, prefix = "/api/settings")
client = TestClient(app)
initial = client.get("/api/settings/personalization")
assert initial.status_code == 200
assert initial.json()["saved"] is False
payload = {
"version": 1,
"profile": {
"displayName": "Mike",
"nickname": "M",
"avatarDataUrl": "/Sloth%20emojis/large%20sloth%20yay.png",
"avatarShape": "rounded",
},
"appearance": {"theme": "dark", "language": "en"},
}
put = client.put("/api/settings/personalization", json = payload)
assert put.status_code == 200
saved = client.get("/api/settings/personalization")
assert saved.status_code == 200
body = saved.json()
assert body["saved"] is True
assert body["profile"] == payload["profile"]
assert body["appearance"] == payload["appearance"]