Private
Public Access
Adds a 5th "Custom" option to the theme swatch grid alongside the existing 4 presets, opening a picker for all ~12 CSS custom properties (backgrounds, borders, text, three accent tiers, highlight, danger) plus a light/dark toggle for native control rendering. Persisted as a new users.custom_theme_colors JSONB column, validated server-side against exactly what a native <input type="color"> can ever produce. Colors survive switching to a preset and back, since there's no reason picking a preset for a moment should force redoing every color pick. Applied at runtime as inline custom properties on :root (presets stay static CSS) via a shared applyTheme() helper, which is also responsible for clearing those inline overrides when switching away -- otherwise they'd silently keep winning over whatever preset's own stylesheet values should apply next. Live preview on every color change; persists only on explicit "Save colors" (not per keystroke, since a color input fires continuously while dragging), and closing the modal without saving reverts the preview back to whatever's actually persisted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
270 lines
9.6 KiB
Python
270 lines
9.6 KiB
Python
import io
|
|
import uuid
|
|
|
|
from PIL import Image
|
|
|
|
from app.storage import UPLOADS_DIR
|
|
from tests.conftest import register_and_login
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def _png_bytes(size: tuple[int, int] = (10, 10)) -> bytes:
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", size, color=(255, 0, 0)).save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
async def test_update_display_name_persists(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch("/api/auth/me", json={"display_name": "Alice A."})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["display_name"] == "Alice A."
|
|
|
|
me = await client.get("/api/auth/me")
|
|
assert me.json()["display_name"] == "Alice A."
|
|
|
|
|
|
async def test_empty_display_name_clears_it(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.patch("/api/auth/me", json={"display_name": "Alice A."})
|
|
|
|
resp = await client.patch("/api/auth/me", json={"display_name": ""})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["display_name"] is None
|
|
|
|
|
|
async def test_whitespace_display_name_clears_it(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.patch("/api/auth/me", json={"display_name": "Alice A."})
|
|
|
|
resp = await client.patch("/api/auth/me", json={"display_name": " "})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["display_name"] is None
|
|
|
|
|
|
async def test_display_name_too_long_rejected(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch("/api/auth/me", json={"display_name": "x" * 51})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_update_theme_persists(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch("/api/auth/me", json={"theme": "midnight"})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["theme"] == "midnight"
|
|
|
|
me = await client.get("/api/auth/me")
|
|
assert me.json()["theme"] == "midnight"
|
|
|
|
|
|
async def test_invalid_theme_rejected(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch("/api/auth/me", json={"theme": "not-a-real-theme"})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_updating_theme_does_not_clobber_display_name(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.patch("/api/auth/me", json={"display_name": "Alice A."})
|
|
|
|
resp = await client.patch("/api/auth/me", json={"theme": "light"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["display_name"] == "Alice A."
|
|
assert resp.json()["theme"] == "light"
|
|
|
|
|
|
async def test_updating_display_name_does_not_clobber_theme(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.patch("/api/auth/me", json={"theme": "sunset"})
|
|
|
|
resp = await client.patch("/api/auth/me", json={"display_name": "Alice A."})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["theme"] == "sunset"
|
|
assert resp.json()["display_name"] == "Alice A."
|
|
|
|
|
|
def _sample_custom_colors(**overrides) -> dict:
|
|
colors = {
|
|
"void": "#07080f",
|
|
"void_2": "#0b0c1a",
|
|
"surface": "#101030",
|
|
"surface_2": "#181848",
|
|
"border": "#242478",
|
|
"text": "#fce4fc",
|
|
"muted": "#c0ccd8",
|
|
"accent": "#60d8fc",
|
|
"accent_2": "#6c60fc",
|
|
"accent_3": "#7848fc",
|
|
"highlight": "#f060fc",
|
|
"danger": "#fc6060",
|
|
"color_scheme": "dark",
|
|
}
|
|
colors.update(overrides)
|
|
return colors
|
|
|
|
|
|
async def test_custom_theme_colors_persist(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch(
|
|
"/api/auth/me", json={"theme": "custom", "custom_theme_colors": _sample_custom_colors()}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["theme"] == "custom"
|
|
assert resp.json()["custom_theme_colors"] == _sample_custom_colors()
|
|
|
|
me = await client.get("/api/auth/me")
|
|
assert me.json()["custom_theme_colors"] == _sample_custom_colors()
|
|
|
|
|
|
async def test_custom_theme_colors_rejects_bad_hex(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch(
|
|
"/api/auth/me",
|
|
json={
|
|
"theme": "custom",
|
|
"custom_theme_colors": _sample_custom_colors(accent="not-a-color"),
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_custom_theme_colors_rejects_missing_field(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
colors = _sample_custom_colors()
|
|
del colors["danger"]
|
|
resp = await client.patch(
|
|
"/api/auth/me", json={"theme": "custom", "custom_theme_colors": colors}
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_custom_theme_colors_rejects_invalid_color_scheme(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.patch(
|
|
"/api/auth/me",
|
|
json={
|
|
"theme": "custom",
|
|
"custom_theme_colors": _sample_custom_colors(color_scheme="sepia"),
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_switching_away_from_custom_preserves_saved_colors(client, db_session):
|
|
# Switching to a preset and back must not lose previously-saved custom
|
|
# colors -- there's no reason picking "Dark" for a moment should force
|
|
# you to redo all 12 color picks if you switch back to Custom later.
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.patch(
|
|
"/api/auth/me", json={"theme": "custom", "custom_theme_colors": _sample_custom_colors()}
|
|
)
|
|
|
|
resp = await client.patch("/api/auth/me", json={"theme": "dark"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["theme"] == "dark"
|
|
assert resp.json()["custom_theme_colors"] == _sample_custom_colors()
|
|
|
|
|
|
async def test_avatar_upload_succeeds_and_persists(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
filename = resp.json()["avatar_filename"]
|
|
assert filename
|
|
|
|
me = await client.get("/api/auth/me")
|
|
assert me.json()["avatar_filename"] == filename
|
|
|
|
|
|
async def test_avatar_reupload_deletes_old_file(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
first = await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("first.png", _png_bytes(), "image/png")}
|
|
)
|
|
first_filename = first.json()["avatar_filename"]
|
|
assert (UPLOADS_DIR / first_filename).exists()
|
|
|
|
second = await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("second.png", _png_bytes((20, 20)), "image/png")}
|
|
)
|
|
second_filename = second.json()["avatar_filename"]
|
|
assert second_filename != first_filename
|
|
assert not (UPLOADS_DIR / first_filename).exists()
|
|
assert (UPLOADS_DIR / second_filename).exists()
|
|
|
|
|
|
async def test_avatar_oversized_rejected(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
oversized = b"0" * (9 * 1024 * 1024)
|
|
resp = await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("huge.png", oversized, "image/png")}
|
|
)
|
|
assert resp.status_code == 413
|
|
|
|
|
|
async def test_avatar_non_image_rejected(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
|
|
resp = await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("fake.png", b"not an image", "image/png")}
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
async def test_avatar_visible_without_shared_room(client, db_session):
|
|
alice = await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
|
|
)
|
|
|
|
# bob shares no room with alice at all -- unlike message images (room-
|
|
# gated), avatar visibility matches username visibility: any
|
|
# authenticated user can see it.
|
|
await register_and_login(client, db_session, username=_unique("bob"))
|
|
resp = await client.get(f"/api/users/{alice['id']}/avatar")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "image/png"
|
|
|
|
|
|
async def test_remove_avatar_clears_and_404s(client, db_session):
|
|
alice = await register_and_login(client, db_session, username=_unique("alice"))
|
|
await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
|
|
)
|
|
|
|
resp = await client.delete("/api/auth/me/avatar")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["avatar_filename"] is None
|
|
|
|
avatar_resp = await client.get(f"/api/users/{alice['id']}/avatar")
|
|
assert avatar_resp.status_code == 404
|
|
|
|
|
|
async def test_room_members_include_avatar_filename(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
upload = await client.post(
|
|
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
|
|
)
|
|
avatar_filename = upload.json()["avatar_filename"]
|
|
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
members = (await client.get(f"/api/rooms/{room['id']}/members")).json()
|
|
assert members[0]["avatar_filename"] == avatar_filename
|