feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
|
|
|
|
from devplacepy.models import GameLegacyForm
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_game_legacy_form_accepts_valid_key():
|
|
|
|
|
assert GameLegacyForm(key="multiplier").key == "multiplier"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_game_legacy_form_rejects_empty_key():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
GameLegacyForm(key="")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_game_legacy_form_rejects_overlong_key():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
GameLegacyForm(key="x" * 41)
|
2026-07-06 05:57:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_isslop_run_form_accepts_git_and_http_sources():
|
|
|
|
|
from devplacepy.models import IsslopRunForm
|
|
|
|
|
|
|
|
|
|
assert IsslopRunForm(url="https://github.com/owner/repo").url == "https://github.com/owner/repo"
|
|
|
|
|
assert IsslopRunForm(url="git@github.com:owner/repo.git").url == "git@github.com:owner/repo.git"
|
|
|
|
|
assert IsslopRunForm(url=" https://example.com ").url == "https://example.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_isslop_run_form_rejects_other_schemes():
|
|
|
|
|
from devplacepy.models import IsslopRunForm
|
|
|
|
|
|
|
|
|
|
for bad in ("ftp://example.com/file", "javascript:alert(1)//aaa", "file:///etc/passwd"):
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
IsslopRunForm(url=bad)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_isslop_run_form_normalizes_typos_and_bare_domains():
|
|
|
|
|
from devplacepy.models import IsslopRunForm
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
IsslopRunForm(url="https:/retoor.molodetz.nl/retoor/devplacepy").url
|
|
|
|
|
== "https://retoor.molodetz.nl/retoor/devplacepy"
|
|
|
|
|
)
|
|
|
|
|
assert IsslopRunForm(url="example.com/owner/repo").url == "https://example.com/owner/repo"
|
|
|
|
|
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
|
2026-07-26 14:57:18 +02:00
|
|
|
|
|
|
|
|
|
Dedicate the project page to the project: hero, tabs, screenshots, sidebar
The project detail page becomes a full project showcase on the site's
content measure. The hero card opens with a cover banner from the
project's first image attachment (brand-gradient band as fallback),
then title + status chip, type/platform chips, dates and forked-from
meta, the author row with an owner-set Visit Website CTA, and the
unchanged action row. A sticky anchor tab bar (Overview, Devlog,
Screenshots when images exist, Comments, Files) navigates the page
with plain server-rendered anchors so crawlers index one complete
document. The two-column body keeps About (description + non-image
attachments), the Devlog timeline and the comment thread in the main
column, adds a Screenshots gallery built from image attachments
(lightbox-wired thumbnails), and a sidebar with Links (website, files,
fork source), the Stats card with a last-update line, and the Author
card.
New optional projects.website_url rides the whole stack: normalized
and validated in models (scheme-less input gets https://, non-http(s)
rejected), settable in the create and edit modals, on ProjectOut, in
the Devii create/edit actions and the API docs, rendered as the hero
CTA and Links entry with rel noopener nofollow, and emitted as
schema.org sameAs. The app schema also gains screenshot urls from the
image attachments. The e2e project comment/files tests scope their
locators (.comment-form textarea, .project-detail-actions a) per the
documented dual-control idiom - the composer modal made the bare
selectors ambiguous - and new unit/api tests cover URL normalization,
sameAs/screenshot schema output, the cover, and the gallery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:56:50 +02:00
|
|
|
def test_project_form_website_url_normalizes_and_validates():
|
|
|
|
|
from devplacepy.models import ProjectForm, normalize_website_url
|
|
|
|
|
|
|
|
|
|
base = {"title": "T", "description": "D"}
|
|
|
|
|
assert ProjectForm(**base).website_url == ""
|
|
|
|
|
assert ProjectForm(**base, website_url="myproject.dev").website_url == "https://myproject.dev"
|
|
|
|
|
assert (
|
|
|
|
|
ProjectForm(**base, website_url="http://x.dev/a?b=1").website_url
|
|
|
|
|
== "http://x.dev/a?b=1"
|
|
|
|
|
)
|
|
|
|
|
assert normalize_website_url(" ") == ""
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
ProjectForm(**base, website_url="javascript:alert(1)")
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
ProjectForm(**base, website_url="not a url")
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 14:57:18 +02:00
|
|
|
def test_reaction_form_accepts_any_single_emoji():
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
for value in (
|
|
|
|
|
"\U0001F44D",
|
|
|
|
|
"\U0001F984",
|
|
|
|
|
"\u2764\uFE0F",
|
|
|
|
|
"\U0001F44D\U0001F3FD",
|
|
|
|
|
"\U0001F9D1\u200d\U0001F4BB",
|
|
|
|
|
"\U0001F3F3\uFE0F\u200d\U0001F308",
|
|
|
|
|
"\U0001F1F3\U0001F1F1",
|
|
|
|
|
"1\uFE0F\u20E3",
|
|
|
|
|
):
|
|
|
|
|
assert ReactionForm(emoji=value).emoji == value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reaction_form_accepts_every_emoji_the_picker_offers():
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
data_path = (
|
|
|
|
|
Path(__file__).resolve().parents[2]
|
|
|
|
|
/ "devplacepy"
|
|
|
|
|
/ "static"
|
|
|
|
|
/ "vendor"
|
|
|
|
|
/ "emoji-picker-element"
|
|
|
|
|
/ "data.json"
|
|
|
|
|
)
|
|
|
|
|
entries = json.loads(data_path.read_text(encoding="utf-8"))
|
|
|
|
|
offered = [entry["emoji"] for entry in entries]
|
|
|
|
|
offered += [
|
|
|
|
|
skin["emoji"] for entry in entries for skin in (entry.get("skins") or [])
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
assert len(offered) > 3000
|
|
|
|
|
for value in offered:
|
|
|
|
|
assert ReactionForm(emoji=value).emoji == value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reaction_form_strips_surrounding_whitespace():
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
assert ReactionForm(emoji=" \U0001F680 ").emoji == "\U0001F680"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reaction_form_rejects_non_emoji_values():
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
for value in (
|
|
|
|
|
"notanemoji",
|
|
|
|
|
"a",
|
|
|
|
|
" ",
|
|
|
|
|
"",
|
|
|
|
|
"<script>",
|
|
|
|
|
"\u200b",
|
|
|
|
|
"\U0001F44D x",
|
|
|
|
|
"\U0001F44D\U0001F44D",
|
|
|
|
|
):
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
ReactionForm(emoji=value)
|