forked from retoor/devplacepy
chore: document project-wide soft delete pattern and add deleted_by column to all tables
This commit is contained in:
+567
-67
@@ -1,21 +1,34 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
|
||||
AJAX = {"X-Requested-With": "fetch"}
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _session():
|
||||
# --------------------------------------------------------------------------- #
|
||||
# helpers (HTTP integration, mirroring test_uploads.py)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _png_bytes(color=(200, 30, 30)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _signup(prefix="media"):
|
||||
s = requests.Session()
|
||||
name = f"med_{uuid.uuid4().hex[:10]}"
|
||||
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@test.dev",
|
||||
"email": f"{name}@test.devplace",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
@@ -24,86 +37,573 @@ def _session():
|
||||
return s, name
|
||||
|
||||
|
||||
def _png_bytes():
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload(s):
|
||||
r = s.post(
|
||||
f"{BASE_URL}/uploads/upload",
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
def _login(seeded_db, who):
|
||||
s = requests.Session()
|
||||
creds = seeded_db[who]
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={"email": creds["email"], "password": creds["password"]},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def _upload(s, name="pic.png", content=None, mime="image/png"):
|
||||
files = {"file": (name, content if content is not None else _png_bytes(), mime)}
|
||||
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["uid"]
|
||||
|
||||
|
||||
def test_delete_own_media(app_server):
|
||||
s, name = _session()
|
||||
def _create_post(s, attachment_uids, title="media post"):
|
||||
r = s.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"content": "A post that carries media for the gallery tests.",
|
||||
"title": title,
|
||||
"topic": "random",
|
||||
"attachment_uids": attachment_uids,
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert "/posts/" in r.url, r.url
|
||||
return r.url
|
||||
|
||||
|
||||
def _create_project(s, attachment_uids, title="Media Project"):
|
||||
r = s.post(
|
||||
f"{BASE_URL}/projects/create",
|
||||
data={
|
||||
"title": title,
|
||||
"description": "Project with media for gallery tests.",
|
||||
"project_type": "software",
|
||||
"platforms": "linux",
|
||||
"status": "In Development",
|
||||
"attachment_uids": attachment_uids,
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert "/projects/" in r.url, r.url
|
||||
return r.url
|
||||
|
||||
|
||||
def _media_json(session, username):
|
||||
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
return r.json()
|
||||
|
||||
|
||||
def _media_uids(session, username):
|
||||
return [m["uid"] for m in _media_json(session, username)["media"]]
|
||||
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# unit: schema visibility + agent gating (no server needed)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_member_media_schema_hides_soft_delete():
|
||||
from devplacepy.schemas import MediaItemOut, AdminMediaItemOut
|
||||
|
||||
assert "deleted_at" not in MediaItemOut.model_fields
|
||||
# the admin-only model is the only one that exposes it
|
||||
assert "deleted_at" in AdminMediaItemOut.model_fields
|
||||
assert "uploader" in AdminMediaItemOut.model_fields
|
||||
|
||||
|
||||
def test_devii_delete_media_is_confirm_gated():
|
||||
from devplacepy.services.devii.actions.catalog import ACTIONS
|
||||
from devplacepy.services.devii.actions.dispatcher import (
|
||||
CONFIRM_REQUIRED,
|
||||
confirmation_error,
|
||||
)
|
||||
|
||||
names = {a.name for a in ACTIONS}
|
||||
assert "delete_media" in names
|
||||
assert "list_media" in names
|
||||
assert "delete_media" in CONFIRM_REQUIRED
|
||||
# without confirm the dispatcher refuses; with confirm it proceeds
|
||||
assert confirmation_error("delete_media", {}) is not None
|
||||
assert confirmation_error("delete_media", {"confirm": "true"}) is None
|
||||
# the member-facing description must not reveal the soft-delete mechanics
|
||||
delete = next(a for a in ACTIONS if a.name == "delete_media")
|
||||
blob = f"{delete.summary} {delete.description or ''}".lower()
|
||||
for leak in ("soft", "restore", "recoverable", "trash"):
|
||||
assert leak not in blob, leak
|
||||
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# gallery listing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_media_tab_lists_linked_attachment(app_server):
|
||||
s, name = _signup()
|
||||
uid = _upload(s)
|
||||
r = s.post(f"{BASE_URL}/media/{uid}/delete", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
attachment = get_table("attachments").find_one(uid=uid)
|
||||
assert attachment is not None
|
||||
assert attachment.get("deleted_at") is not None
|
||||
_create_post(s, uid)
|
||||
data = _media_json(s, name)
|
||||
uids = [m["uid"] for m in data["media"]]
|
||||
assert uid in uids
|
||||
item = next(m for m in data["media"] if m["uid"] == uid)
|
||||
assert item["is_image"] is True
|
||||
assert item["target_type"] == "post"
|
||||
assert item["target_url"].startswith("/posts/")
|
||||
|
||||
|
||||
def test_delete_others_media_denied(app_server):
|
||||
s_alice, _ = _session()
|
||||
uid = _upload(s_alice)
|
||||
s_bob, _ = _session()
|
||||
r = s_bob.post(f"{BASE_URL}/media/{uid}/delete", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
attachment = get_table("attachments").find_one(uid=uid)
|
||||
assert attachment.get("deleted_at") is None
|
||||
def test_media_tab_html_renders_grid(app_server):
|
||||
s, name = _signup()
|
||||
uid = _upload(s)
|
||||
_create_post(s, uid)
|
||||
r = s.get(f"{BASE_URL}/profile/{name}?tab=media")
|
||||
assert r.status_code == 200
|
||||
assert "media-grid" in r.text
|
||||
assert "data-lightbox" in r.text
|
||||
assert uid in r.text
|
||||
|
||||
|
||||
def test_media_tab_empty_state_for_new_user(app_server):
|
||||
s, name = _signup()
|
||||
r = s.get(f"{BASE_URL}/profile/{name}?tab=media")
|
||||
assert r.status_code == 200
|
||||
assert "No media yet." in r.text
|
||||
assert _media_json(s, name)["media"] == []
|
||||
|
||||
|
||||
def test_media_tab_is_public(app_server):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
# an anonymous visitor can browse the gallery
|
||||
anon = requests.Session()
|
||||
assert uid in _media_uids(anon, name)
|
||||
|
||||
|
||||
def test_orphan_upload_not_listed(app_server):
|
||||
s, name = _signup()
|
||||
uid = _upload(s) # uploaded but never linked to any content
|
||||
assert uid not in _media_uids(s, name)
|
||||
|
||||
|
||||
def test_media_newest_first(app_server):
|
||||
s, name = _signup()
|
||||
first = _upload(s)
|
||||
_create_post(s, first, title="older")
|
||||
second = _upload(s)
|
||||
_create_post(s, second, title="newer")
|
||||
uids = _media_uids(s, name)
|
||||
assert uids.index(second) < uids.index(first)
|
||||
|
||||
|
||||
def test_media_covers_multiple_target_types(app_server):
|
||||
s, name = _signup()
|
||||
post_att = _upload(s)
|
||||
_create_post(s, post_att)
|
||||
project_att = _upload(s)
|
||||
_create_project(s, project_att)
|
||||
items = {m["uid"]: m for m in _media_json(s, name)["media"]}
|
||||
assert items[post_att]["target_type"] == "post"
|
||||
assert items[project_att]["target_type"] == "project"
|
||||
|
||||
|
||||
def test_media_json_omits_deleted_at(app_server):
|
||||
# Members must never be shown the soft-delete column.
|
||||
s, name = _signup()
|
||||
uid = _upload(s)
|
||||
_create_post(s, uid)
|
||||
data = _media_json(s, name)
|
||||
assert data["media"], "expected at least one media item"
|
||||
for item in data["media"]:
|
||||
assert "deleted_at" not in item
|
||||
|
||||
|
||||
def test_media_pagination(app_server):
|
||||
s, name = _signup()
|
||||
# per_page is 24; create 26 linked attachments
|
||||
for i in range(26):
|
||||
uid = _upload(s)
|
||||
_create_post(s, uid, title=f"p{i}")
|
||||
data = _media_json(s, name)
|
||||
pag = data["media_pagination"]
|
||||
assert pag["total"] >= 26
|
||||
assert pag["total_pages"] >= 2
|
||||
assert len(data["media"]) == pag["per_page"] == 24
|
||||
r2 = s.get(f"{BASE_URL}/profile/{name}?tab=media&page=2", headers=JSON)
|
||||
assert r2.status_code == 200
|
||||
assert len(r2.json()["media"]) >= 2
|
||||
# the HTML pager keeps the tab so links do not fall back to posts
|
||||
# (the ampersand is entity-escaped in the rendered href)
|
||||
html = s.get(f"{BASE_URL}/profile/{name}?tab=media").text
|
||||
assert "tab=media&page=2" in html or "tab=media&page=2" in html
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# delete (soft) - authorization
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_delete_requires_login(app_server):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
anon = requests.Session()
|
||||
r = anon.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_delete_nonexistent_media(app_server):
|
||||
s, _ = _session()
|
||||
r = s.post(f"{BASE_URL}/media/nonexistent-uid/delete", allow_redirects=False)
|
||||
s, _ = _signup()
|
||||
r = s.post(f"{BASE_URL}/media/nonexistent-uid/delete", headers=JSON)
|
||||
assert r.status_code in (302, 303, 404)
|
||||
|
||||
|
||||
def test_admin_can_delete_any_media(app_server, seeded_db):
|
||||
s_user, _ = _session()
|
||||
uid = _upload(s_user)
|
||||
s_admin = requests.Session()
|
||||
s_admin.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={
|
||||
"email": seeded_db["alice"]["email"],
|
||||
"password": seeded_db["alice"]["password"],
|
||||
},
|
||||
)
|
||||
r = s_admin.post(f"{BASE_URL}/media/{uid}/delete", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
attachment = get_table("attachments").find_one(uid=uid)
|
||||
assert attachment.get("deleted_at") is not None
|
||||
def test_member_cannot_delete_other_members_media(app_server):
|
||||
owner, owner_name = _signup("owner")
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
attacker, _ = _signup("attacker")
|
||||
r = attacker.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
assert r.status_code == 403
|
||||
# still visible to the owner
|
||||
assert uid in _media_uids(owner, owner_name)
|
||||
|
||||
|
||||
def test_restore_media_requires_admin(app_server, seeded_db):
|
||||
s_user, _ = _session()
|
||||
uid = _upload(s_user)
|
||||
s_admin = requests.Session()
|
||||
s_admin.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={
|
||||
"email": seeded_db["alice"]["email"],
|
||||
"password": seeded_db["alice"]["password"],
|
||||
},
|
||||
)
|
||||
s_admin.post(f"{BASE_URL}/media/{uid}/delete", allow_redirects=False)
|
||||
attachment = get_table("attachments").find_one(uid=uid)
|
||||
assert attachment.get("deleted_at") is not None
|
||||
r = s_admin.post(f"{BASE_URL}/media/{uid}/restore", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
attachment = get_table("attachments").find_one(uid=uid)
|
||||
assert attachment.get("deleted_at") is None
|
||||
def test_owner_soft_deletes_own_media(app_server):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
assert uid in _media_uids(owner, name)
|
||||
|
||||
r = owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
# gone from the gallery (parent-object hiding is asserted in
|
||||
# test_soft_delete_hides_attachment_from_feed)
|
||||
assert uid not in _media_uids(owner, name)
|
||||
|
||||
|
||||
def test_restore_media_denied_for_non_admin(app_server):
|
||||
s, _ = _session()
|
||||
def test_soft_delete_hides_attachment_from_feed(app_server):
|
||||
# The parent-object invariant: a soft-deleted attachment disappears from the place it
|
||||
# was attached. The feed reads attachments via the raw-query batch helper, so this is
|
||||
# a reliable end-to-end check that the relation is hidden (not unlinked).
|
||||
s, name = _signup()
|
||||
uid = _upload(s)
|
||||
r = s.post(f"{BASE_URL}/media/{uid}/restore", allow_redirects=False)
|
||||
_create_post(s, uid, title="feed invariant post")
|
||||
|
||||
def attached_on_feed():
|
||||
posts = s.get(f"{BASE_URL}/feed", headers=JSON).json().get("posts", [])
|
||||
for item in posts:
|
||||
uids = [a.get("uid") for a in (item.get("attachments") or [])]
|
||||
if uid in uids:
|
||||
return True
|
||||
return False
|
||||
|
||||
assert attached_on_feed()
|
||||
r = s.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
assert r.status_code == 200
|
||||
assert not attached_on_feed()
|
||||
|
||||
|
||||
def test_admin_can_delete_any_media(seeded_db):
|
||||
owner, owner_name = _signup("victim")
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
admin = _login(seeded_db, "alice")
|
||||
r = admin.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
assert r.status_code == 200
|
||||
assert uid not in _media_uids(owner, owner_name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# admin trash: restore + purge
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_restore_requires_admin(seeded_db):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
# a plain member may not restore
|
||||
member = _login(seeded_db, "bob")
|
||||
r = member.post(f"{BASE_URL}/media/{uid}/restore", headers=JSON)
|
||||
assert r.status_code in (302, 303, 403)
|
||||
assert uid not in _media_uids(owner, name)
|
||||
|
||||
|
||||
def test_admin_restore_brings_media_back(seeded_db):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
post_url = _create_post(owner, uid)
|
||||
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
assert uid not in _media_uids(owner, name)
|
||||
|
||||
admin = _login(seeded_db, "alice")
|
||||
r = admin.post(f"{BASE_URL}/media/{uid}/restore", headers=JSON)
|
||||
assert r.status_code == 200
|
||||
# reappears in the gallery, and its relation to the parent post is intact
|
||||
# (delete never cleared target_type/target_uid), so it is back on the post too
|
||||
restored = {m["uid"]: m for m in _media_json(owner, name)["media"]}
|
||||
assert uid in restored
|
||||
assert restored[uid]["target_type"] == "post"
|
||||
assert post_url.endswith(restored[uid]["target_url"])
|
||||
|
||||
|
||||
def test_admin_trash_lists_deleted_media(seeded_db):
|
||||
owner, owner_name = _signup("trashed")
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
|
||||
admin = _login(seeded_db, "alice")
|
||||
data = admin.get(f"{BASE_URL}/admin/media", headers=JSON).json()
|
||||
item = next((m for m in data["media"] if m["uid"] == uid), None)
|
||||
assert item is not None
|
||||
assert item["uploader"] == owner_name
|
||||
assert item["deleted_at"]
|
||||
|
||||
|
||||
def test_admin_trash_requires_admin(seeded_db):
|
||||
member = _login(seeded_db, "bob")
|
||||
r = member.get(f"{BASE_URL}/admin/media", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
anon = requests.Session()
|
||||
assert anon.get(f"{BASE_URL}/admin/media", allow_redirects=False).status_code in (
|
||||
302,
|
||||
303,
|
||||
)
|
||||
|
||||
|
||||
def test_purge_requires_admin(seeded_db):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
member = _login(seeded_db, "bob")
|
||||
r = member.post(f"{BASE_URL}/admin/media/{uid}/purge", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
|
||||
def test_admin_purge_hard_deletes(seeded_db):
|
||||
owner, name = _signup()
|
||||
uid = _upload(owner)
|
||||
_create_post(owner, uid)
|
||||
file_url = next(m["url"] for m in _media_json(owner, name)["media"] if m["uid"] == uid)
|
||||
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
|
||||
admin = _login(seeded_db, "alice")
|
||||
assert owner.get(f"{BASE_URL}{file_url}").status_code == 200
|
||||
r = admin.post(f"{BASE_URL}/admin/media/{uid}/purge", headers=JSON)
|
||||
assert r.status_code == 200
|
||||
# row gone (not in trash, cannot restore) and file removed from disk
|
||||
trash = admin.get(f"{BASE_URL}/admin/media", headers=JSON).json()
|
||||
assert all(m["uid"] != uid for m in trash["media"])
|
||||
assert owner.get(f"{BASE_URL}{file_url}").status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# documentation visibility (members never learn about soft delete)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_media_gallery_doc_is_public_and_member_safe(app_server):
|
||||
r = requests.get(f"{BASE_URL}/docs/media-gallery.html")
|
||||
assert r.status_code == 200
|
||||
assert "Media gallery" in r.text
|
||||
lowered = r.text.lower()
|
||||
for leak in ("soft delete", "soft-delete", "trash", "purge", "restore"):
|
||||
assert leak not in lowered, leak
|
||||
|
||||
|
||||
def test_media_moderation_doc_is_admin_only(seeded_db):
|
||||
# hidden from guests and members
|
||||
assert requests.get(f"{BASE_URL}/docs/media-moderation.html").status_code == 404
|
||||
bob = get_table("users").find_one(username="bob_test")
|
||||
member = requests.get(
|
||||
f"{BASE_URL}/docs/media-moderation.html", headers={"X-API-KEY": bob["api_key"]}
|
||||
)
|
||||
assert member.status_code == 404
|
||||
# visible to admins, and this is where soft-delete is explained
|
||||
admin = get_table("users").find_one(username="alice_test")
|
||||
page = requests.get(
|
||||
f"{BASE_URL}/docs/media-moderation.html",
|
||||
headers={"X-API-KEY": admin["api_key"]},
|
||||
)
|
||||
assert page.status_code == 200
|
||||
assert "soft delete" in page.text.lower()
|
||||
|
||||
|
||||
def test_media_moderation_hidden_from_member_search(seeded_db):
|
||||
public = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "soft delete media"})
|
||||
assert "/docs/media-moderation.html" not in public.text
|
||||
admin = get_table("users").find_one(username="alice_test")
|
||||
found = requests.get(
|
||||
f"{BASE_URL}/docs/search.html",
|
||||
params={"q": "soft delete media"},
|
||||
headers={"X-API-KEY": admin["api_key"]},
|
||||
)
|
||||
assert "/docs/media-moderation.html" in found.text
|
||||
|
||||
|
||||
def test_profiles_api_doc_delete_is_member_safe(app_server):
|
||||
text = requests.get(f"{BASE_URL}/docs/profiles.html").text
|
||||
assert "/media/" in text # the member delete endpoint is documented
|
||||
lowered = text.lower()
|
||||
assert "soft delete" not in lowered
|
||||
assert "purge" not in lowered
|
||||
|
||||
|
||||
def test_admin_api_doc_has_moderation(seeded_db):
|
||||
admin = get_table("users").find_one(username="alice_test")
|
||||
text = requests.get(
|
||||
f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": admin["api_key"]}
|
||||
).text
|
||||
assert "/admin/media" in text
|
||||
lowered = text.lower()
|
||||
assert "restore" in lowered
|
||||
assert "purge" in lowered
|
||||
assert "soft" in lowered
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Playwright UI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _seed_media_via_browser(page, title="ui media"):
|
||||
up = page.request.post(
|
||||
f"{BASE_URL}/uploads/upload",
|
||||
multipart={
|
||||
"file": {
|
||||
"name": "pic.png",
|
||||
"mimeType": "image/png",
|
||||
"buffer": _png_bytes(),
|
||||
}
|
||||
},
|
||||
)
|
||||
assert up.ok, up.text()
|
||||
uid = up.json()["uid"]
|
||||
post = page.request.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
form={
|
||||
"content": "media for the ui gallery test",
|
||||
"title": title,
|
||||
"topic": "random",
|
||||
"attachment_uids": uid,
|
||||
},
|
||||
)
|
||||
assert post.ok, post.text()
|
||||
return uid
|
||||
|
||||
|
||||
def test_ui_media_tab_link_visible(alice):
|
||||
page, user = alice
|
||||
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
||||
assert page.locator(".profile-tab:has-text('Media')").is_visible()
|
||||
|
||||
|
||||
def test_ui_media_tab_shows_thumbnail(alice):
|
||||
page, user = alice
|
||||
_seed_media_via_browser(page)
|
||||
page.goto(
|
||||
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
assert page.locator(".profile-tab.active:has-text('Media')").is_visible()
|
||||
thumb = page.locator(".media-grid img.gallery-thumb[data-lightbox]").first
|
||||
thumb.wait_for(state="visible")
|
||||
assert thumb.get_attribute("data-full")
|
||||
|
||||
|
||||
def test_ui_media_lightbox_opens(alice):
|
||||
page, user = alice
|
||||
_seed_media_via_browser(page)
|
||||
page.goto(
|
||||
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
thumb = page.locator(".media-grid img.gallery-thumb[data-lightbox]").first
|
||||
thumb.wait_for(state="visible")
|
||||
thumb.click()
|
||||
overlay = page.locator(".lightbox-overlay.visible")
|
||||
overlay.wait_for(state="visible")
|
||||
assert page.locator(".lightbox-overlay.visible .lightbox-image").is_visible()
|
||||
page.locator(".lightbox-overlay.visible .lightbox-close").click()
|
||||
overlay.wait_for(state="hidden")
|
||||
|
||||
|
||||
def test_ui_owner_delete_removes_tile(alice):
|
||||
page, user = alice
|
||||
uid = _seed_media_via_browser(page, title="to delete")
|
||||
page.goto(
|
||||
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
tile = page.locator(f".media-tile[data-media-tile='{uid}']")
|
||||
tile.wait_for(state="attached")
|
||||
page.locator(f".media-tile[data-media-tile='{uid}'] .media-delete-btn").click()
|
||||
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
||||
tile.wait_for(state="detached")
|
||||
assert page.locator(f".media-tile[data-media-tile='{uid}']").count() == 0
|
||||
|
||||
|
||||
def test_ui_member_sees_no_delete_button_on_other_profile(bob, alice):
|
||||
# alice (admin) seeds media on her own profile
|
||||
page_a, user_a = alice
|
||||
_seed_media_via_browser(page_a)
|
||||
# bob (member) views alice's gallery: no delete controls
|
||||
page_b, _ = bob
|
||||
page_b.goto(
|
||||
f"{BASE_URL}/profile/{user_a['username']}?tab=media",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page_b.locator(".media-grid img.gallery-thumb").first.wait_for(state="visible")
|
||||
assert page_b.locator(".media-delete-btn").count() == 0
|
||||
|
||||
|
||||
def test_ui_admin_media_sidebar_and_trash(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
link = page.locator(".sidebar-link[href='/admin/media']")
|
||||
assert link.is_visible()
|
||||
link.click()
|
||||
page.wait_for_url("**/admin/media", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=Media trash")
|
||||
|
||||
|
||||
def test_ui_admin_restore_from_trash(alice):
|
||||
page, user = alice
|
||||
uid = _seed_media_via_browser(page, title="restore me")
|
||||
# soft delete via API (carries the admin's cookies)
|
||||
page.request.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON)
|
||||
page.goto(f"{BASE_URL}/admin/media", wait_until="domcontentloaded")
|
||||
tile = page.locator(f".media-tile[data-media-tile='{uid}']")
|
||||
tile.wait_for(state="attached")
|
||||
page.locator(
|
||||
f".media-tile[data-media-tile='{uid}'] form[action='/media/{uid}/restore'] button"
|
||||
).click()
|
||||
page.wait_for_url("**/admin/media", wait_until="domcontentloaded")
|
||||
assert uid in _media_uids_via_page(page, user["username"])
|
||||
|
||||
|
||||
def _media_uids_via_page(page, username):
|
||||
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON)
|
||||
return [m["uid"] for m in resp.json()["media"]]
|
||||
|
||||
|
||||
def test_ui_mobile_media_grid(mobile_page):
|
||||
page, user = mobile_page
|
||||
_seed_media_via_browser(page)
|
||||
page.goto(
|
||||
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
grid = page.locator(".media-grid")
|
||||
grid.wait_for(state="visible")
|
||||
assert page.locator(".media-grid .media-tile").count() >= 1
|
||||
|
||||
Reference in New Issue
Block a user