forked from retoor/devplacepy
Add personal notes, DeepSearch history, backup offload, and gateway auth throttling
Also streamline the top navigation: drop the Tools dropdown, make Quizzes and Battles icon-only entries, and remove the Workspace, Containers and Editor entry points from the project detail page.
This commit is contained in:
@@ -452,6 +452,29 @@ def test_profiles_api_doc_delete_is_member_safe(app_server):
|
||||
assert "purge" not in lowered
|
||||
|
||||
|
||||
def test_docs_content_response_fields_carry_correct_nullable_flags(app_server):
|
||||
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/content.html").text)
|
||||
posts_detail = {p["name"]: p for p in by_id["posts-detail"]["params"] if p["location"] == "response"}
|
||||
assert posts_detail["post.title"]["nullable"] is True
|
||||
assert posts_detail["post.image"]["nullable"] is True
|
||||
assert posts_detail["post.content"]["nullable"] is False
|
||||
assert posts_detail["post.slug"]["nullable"] is False
|
||||
feed_list = {p["name"]: p for p in by_id["feed-list"]["params"] if p["location"] == "response"}
|
||||
assert feed_list["items[].poll"]["nullable"] is True
|
||||
assert feed_list["items[].war"]["nullable"] is True
|
||||
assert feed_list["items[].my_vote"]["nullable"] is False
|
||||
assert feed_list["items[].comment_count"]["nullable"] is False
|
||||
|
||||
|
||||
def test_docs_profiles_response_fields_carry_correct_nullable_flags(app_server):
|
||||
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/profiles.html").text)
|
||||
profile_detail = {p["name"]: p for p in by_id["profile-detail"]["params"] if p["location"] == "response"}
|
||||
assert profile_detail["api_key"]["nullable"] is True
|
||||
assert profile_detail["rank"]["nullable"] is True
|
||||
assert profile_detail["is_following"]["nullable"] is False
|
||||
assert profile_detail["profile_user.username"]["nullable"] is False
|
||||
|
||||
|
||||
def test_admin_api_doc_has_moderation(seeded_db):
|
||||
admin = get_table("users").find_one(username="alice_test")
|
||||
text = requests.get(
|
||||
|
||||
@@ -437,6 +437,44 @@ def test_strict_transport_security_header(app_server):
|
||||
assert "includeSubDomains" in r.headers.get("Strict-Transport-Security", "")
|
||||
|
||||
|
||||
def test_feed_card_truncation_does_not_break_a_straddling_image_url(app_server):
|
||||
s, _ = _session_polls()
|
||||
prefix = "word " * 58
|
||||
url = "https://cdn.example.com/gallery/a-descriptive-name-for-the-photo.jpg"
|
||||
content = prefix + url + " trailing words that must not leak into the truncated preview"
|
||||
r = s.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"content": content,
|
||||
"title": f"trunc-{int(time.time() * 1000)}",
|
||||
"topic": "devlog",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
html = s.get(f"{BASE_URL}/feed").text
|
||||
assert "cdn.example.com" not in html
|
||||
assert "trailing words" not in html
|
||||
|
||||
|
||||
def test_feed_card_renders_full_image_embed_when_url_fits_before_truncation(app_server):
|
||||
s, _ = _session_polls()
|
||||
url = "https://cdn.example.com/photo.jpg"
|
||||
content = url + " " + ("caption word " * 5)
|
||||
r = s.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"content": content,
|
||||
"title": f"embed-{int(time.time() * 1000)}",
|
||||
"topic": "devlog",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
html = s.get(f"{BASE_URL}/feed").text
|
||||
assert f'<img src="{url}"' in html
|
||||
|
||||
|
||||
def test_referrer_policy_header(app_server):
|
||||
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
|
||||
assert r.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
|
||||
@@ -37,6 +37,39 @@ def _upload_uploads(s, name="a.png"):
|
||||
return r.json()["uid"]
|
||||
|
||||
|
||||
JSON_search = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def test_search_recipients_includes_avatar_seed_for_custom_seed(app_server):
|
||||
alice = _session_uploads()
|
||||
bob, bob_name = _user_uploads("bob")
|
||||
|
||||
r = bob.post(
|
||||
f"{BASE_URL}/profile/{bob_name}/regenerate-avatar",
|
||||
headers=JSON_search,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
seed = r.json()["data"]["avatar_seed"]
|
||||
assert seed
|
||||
|
||||
found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()[
|
||||
"results"
|
||||
]
|
||||
match = next(x for x in found if x["username"] == bob_name)
|
||||
assert match["avatar_seed"] == seed
|
||||
|
||||
|
||||
def test_search_recipients_avatar_seed_null_without_custom_seed(app_server):
|
||||
alice = _session_uploads()
|
||||
bob, bob_name = _user_uploads("bob")
|
||||
|
||||
found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()[
|
||||
"results"
|
||||
]
|
||||
match = next(x for x in found if x["username"] == bob_name)
|
||||
assert match.get("avatar_seed") is None
|
||||
|
||||
|
||||
def test_message_links_multiple_attachments(app_server):
|
||||
alice = _session_uploads()
|
||||
bob, bob_name = _user_uploads("bob")
|
||||
|
||||
@@ -207,6 +207,56 @@ def test_ws_sync_replays_messages_since(app_server):
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_ws_active_marker_suppresses_message_notification(app_server):
|
||||
sender_session, sender_name = _member()
|
||||
receiver_session, receiver_name = _member()
|
||||
sender_uid = _db_user(sender_name)["uid"]
|
||||
receiver_uid = _db_user(receiver_name)["uid"]
|
||||
|
||||
async def run():
|
||||
async with websockets.connect(
|
||||
WS_URL, additional_headers=_cookie_header(receiver_session)
|
||||
) as receiver_ws:
|
||||
await _recv_until(receiver_ws, "ready")
|
||||
await receiver_ws.send(
|
||||
json.dumps({"type": "active", "with_uid": sender_uid})
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
resp = sender_session.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
headers={"Accept": "application/json"},
|
||||
data={"content": "are you watching this", "receiver_uid": receiver_uid},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
refresh_snapshot()
|
||||
notifications = list(
|
||||
get_table("notifications").find(user_uid=receiver_uid, type="message")
|
||||
)
|
||||
assert notifications == []
|
||||
|
||||
|
||||
def test_ws_without_active_marker_still_notifies(app_server):
|
||||
sender_session, _ = _member()
|
||||
receiver_session, receiver_name = _member()
|
||||
receiver_uid = _db_user(receiver_name)["uid"]
|
||||
|
||||
resp = sender_session.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
headers={"Accept": "application/json"},
|
||||
data={"content": "hello without watching", "receiver_uid": receiver_uid},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
refresh_snapshot()
|
||||
notifications = list(
|
||||
get_table("notifications").find(user_uid=receiver_uid, type="message")
|
||||
)
|
||||
assert len(notifications) == 1
|
||||
|
||||
|
||||
def test_ws_ping_and_second_send_keep_the_socket_open(app_server):
|
||||
sender_session, _ = _member()
|
||||
receiver_session, receiver_name = _member()
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
_counter_notes = [0]
|
||||
AJAX_notes = {"X-Requested-With": "fetch"}
|
||||
|
||||
|
||||
def _session_notes():
|
||||
_counter_notes[0] += 1
|
||||
name = f"nte{int(time.time() * 1000)}{_counter_notes[0]}"
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
|
||||
|
||||
def _uid_notes(username):
|
||||
return get_table("users").find_one(username=username)["uid"]
|
||||
|
||||
|
||||
def _make_post_notes(owner_uid, title):
|
||||
uid = generate_uid()
|
||||
get_table("posts").insert(
|
||||
{
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
"uid": uid,
|
||||
"user_uid": owner_uid,
|
||||
"slug": f"{uid[-8:]}-note-post",
|
||||
"title": title,
|
||||
"content": "note target content",
|
||||
"topic": "random",
|
||||
"project_uid": None,
|
||||
"image": None,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def test_note_set_creates_row(app_server):
|
||||
s, name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(name), "Note target")
|
||||
r = s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Remember to check this later."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content"] == "Remember to check this later."
|
||||
row = get_table("notes").find_one(
|
||||
user_uid=_uid_notes(name), target_uid=post_uid, deleted_at=None
|
||||
)
|
||||
assert row is not None
|
||||
assert row["content"] == "Remember to check this later."
|
||||
|
||||
|
||||
def test_note_set_replaces_existing_content(app_server):
|
||||
s, name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(name), "Note replace target")
|
||||
s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "First draft."},
|
||||
)
|
||||
s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Second draft."},
|
||||
)
|
||||
rows = list(
|
||||
get_table("notes").find(
|
||||
user_uid=_uid_notes(name), target_uid=post_uid, deleted_at=None
|
||||
)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["content"] == "Second draft."
|
||||
|
||||
|
||||
def test_note_delete_soft_deletes(app_server):
|
||||
s, name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(name), "Note delete target")
|
||||
s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Delete me."},
|
||||
)
|
||||
r = s.post(f"{BASE_URL}/notes/post/{post_uid}/delete", headers=AJAX_notes)
|
||||
assert r.json() == {"deleted": True}
|
||||
assert (
|
||||
get_table("notes").count(
|
||||
user_uid=_uid_notes(name), target_uid=post_uid, deleted_at=None
|
||||
)
|
||||
== 0
|
||||
)
|
||||
row = get_table("notes").find_one(user_uid=_uid_notes(name), target_uid=post_uid)
|
||||
assert row is not None
|
||||
assert row["deleted_at"] is not None
|
||||
assert row["deleted_by"] == _uid_notes(name)
|
||||
|
||||
|
||||
def test_note_revives_after_delete(app_server):
|
||||
s, name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(name), "Note revive target")
|
||||
s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Before delete."},
|
||||
)
|
||||
s.post(f"{BASE_URL}/notes/post/{post_uid}/delete", headers=AJAX_notes)
|
||||
s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "After revive."},
|
||||
)
|
||||
rows = list(get_table("notes").find(user_uid=_uid_notes(name), target_uid=post_uid))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["deleted_at"] is None
|
||||
assert rows[0]["content"] == "After revive."
|
||||
|
||||
|
||||
def test_note_requires_login(app_server):
|
||||
owner_s, owner_name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(owner_name), "Needs login")
|
||||
anon = requests.Session()
|
||||
r = anon.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "sneaky"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert get_table("notes").count(target_uid=post_uid) == 0
|
||||
|
||||
|
||||
def test_note_invalid_target_type_returns_400(app_server):
|
||||
s, name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(name), "Bad target")
|
||||
r = s.post(
|
||||
f"{BASE_URL}/notes/widget/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "does not matter"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_note_is_private_per_user(app_server):
|
||||
alice, alice_name = _session_notes()
|
||||
bob, bob_name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(alice_name), "Shared target")
|
||||
alice.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Alice's private note."},
|
||||
)
|
||||
bob.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Bob's private note."},
|
||||
)
|
||||
alice_row = get_table("notes").find_one(
|
||||
user_uid=_uid_notes(alice_name), target_uid=post_uid, deleted_at=None
|
||||
)
|
||||
bob_row = get_table("notes").find_one(
|
||||
user_uid=_uid_notes(bob_name), target_uid=post_uid, deleted_at=None
|
||||
)
|
||||
assert alice_row["content"] == "Alice's private note."
|
||||
assert bob_row["content"] == "Bob's private note."
|
||||
|
||||
bob_view = bob.get(
|
||||
f"{BASE_URL}/posts/{post_uid}", headers={"Accept": "application/json"}
|
||||
)
|
||||
assert bob_view.json()["note_content"] == "Bob's private note."
|
||||
|
||||
|
||||
def test_note_delete_only_affects_own_row(app_server):
|
||||
alice, alice_name = _session_notes()
|
||||
bob, bob_name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(alice_name), "Delete isolation target")
|
||||
alice.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Alice keeps this."},
|
||||
)
|
||||
bob.post(f"{BASE_URL}/notes/post/{post_uid}/delete", headers=AJAX_notes)
|
||||
alice_row = get_table("notes").find_one(
|
||||
user_uid=_uid_notes(alice_name), target_uid=post_uid, deleted_at=None
|
||||
)
|
||||
assert alice_row is not None
|
||||
assert alice_row["content"] == "Alice keeps this."
|
||||
@@ -0,0 +1,89 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
_counter_notes = [0]
|
||||
AJAX_notes = {"X-Requested-With": "fetch"}
|
||||
|
||||
|
||||
def _session_notes():
|
||||
_counter_notes[0] += 1
|
||||
name = f"ntesv{int(time.time() * 1000)}{_counter_notes[0]}"
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
|
||||
|
||||
def _uid_notes(username):
|
||||
return get_table("users").find_one(username=username)["uid"]
|
||||
|
||||
|
||||
def _make_post_notes(owner_uid, title):
|
||||
uid = generate_uid()
|
||||
get_table("posts").insert(
|
||||
{
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
"uid": uid,
|
||||
"user_uid": owner_uid,
|
||||
"slug": f"{uid[:8]}-note-saved-post",
|
||||
"title": title,
|
||||
"content": "note saved target content",
|
||||
"topic": "random",
|
||||
"project_uid": None,
|
||||
"image": None,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def test_notes_saved_page_lists_note_content(app_server):
|
||||
s, name = _session_notes()
|
||||
owner_uid = _uid_notes(name)
|
||||
post_title = f"Note saved post {int(time.time() * 1000)}"
|
||||
post_uid = _make_post_notes(owner_uid, post_title)
|
||||
s.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "A note worth remembering."},
|
||||
)
|
||||
html = s.get(f"{BASE_URL}/notes/saved").text
|
||||
assert post_title in html
|
||||
assert "A note worth remembering." in html
|
||||
|
||||
|
||||
def test_notes_saved_page_requires_login(app_server):
|
||||
anon = requests.Session()
|
||||
r = anon.get(f"{BASE_URL}/notes/saved", allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
def test_notes_saved_page_only_shows_own_notes(app_server):
|
||||
alice, alice_name = _session_notes()
|
||||
bob, bob_name = _session_notes()
|
||||
post_uid = _make_post_notes(_uid_notes(alice_name), "Isolation saved post")
|
||||
alice.post(
|
||||
f"{BASE_URL}/notes/post/{post_uid}",
|
||||
headers=AJAX_notes,
|
||||
data={"content": "Alice only note."},
|
||||
)
|
||||
html = bob.get(f"{BASE_URL}/notes/saved").text
|
||||
assert "Alice only note." not in html
|
||||
@@ -1,7 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
@@ -128,3 +130,44 @@ def test_pagination_spans_pages(seeded_db):
|
||||
assert data["pagination"]["total_pages"] >= 2
|
||||
page2 = _audit(admin, event_key="security.authz.denied", page=2)
|
||||
assert len(page2["entries"]) > 0
|
||||
|
||||
|
||||
def _seed_notifications(user_uid, count=30):
|
||||
notifications = get_table("notifications")
|
||||
base = datetime.now(timezone.utc)
|
||||
for i in range(count):
|
||||
notifications.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"type": "test",
|
||||
"message": f"quota notification {i}",
|
||||
"related_uid": user_uid,
|
||||
"target_url": None,
|
||||
"read": False,
|
||||
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
||||
}
|
||||
)
|
||||
refresh_snapshot()
|
||||
|
||||
|
||||
def test_load_more_link_is_percent_encoded(seeded_db):
|
||||
session, name = _member()
|
||||
user = _db_user(name)
|
||||
_seed_notifications(user["uid"])
|
||||
|
||||
r = session.get(f"{BASE_URL}/notifications")
|
||||
assert r.status_code == 200
|
||||
match = re.search(r'href="(/notifications\?before=[^"]*)"', r.text)
|
||||
assert match, "expected a Load More link on the notifications page"
|
||||
href = match.group(1)
|
||||
|
||||
assert "%2B" in href
|
||||
assert "+" not in href
|
||||
|
||||
cursor = parse_qs(urlparse(href).query)["before"][0]
|
||||
assert "+" in cursor
|
||||
|
||||
r2 = session.get(f"{BASE_URL}{href}")
|
||||
assert r2.status_code == 200
|
||||
assert "quota notification 29" in r2.text
|
||||
|
||||
@@ -280,6 +280,29 @@ def test_profile_renders_heatmap_and_streak(app_server):
|
||||
assert "1 day streak" in html
|
||||
|
||||
|
||||
def test_search_users_includes_avatar_seed_for_custom_seed(app_server):
|
||||
s, name = _signup_media()
|
||||
r = s.post(
|
||||
f"{BASE_URL}/profile/{name}/regenerate-avatar",
|
||||
headers=JSON_media,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
seed = r.json()["data"]["avatar_seed"]
|
||||
assert seed
|
||||
|
||||
found = s.get(f"{BASE_URL}/profile/search", params={"q": name}).json()["results"]
|
||||
match = next(x for x in found if x["username"] == name)
|
||||
assert match["avatar_seed"] == seed
|
||||
|
||||
|
||||
def test_search_users_avatar_seed_null_without_custom_seed(app_server):
|
||||
s, name = _signup_media()
|
||||
|
||||
found = s.get(f"{BASE_URL}/profile/search", params={"q": name}).json()["results"]
|
||||
match = next(x for x in found if x["username"] == name)
|
||||
assert match.get("avatar_seed") is None
|
||||
|
||||
|
||||
def test_profile_badges_json_has_non_null_names(app_server):
|
||||
import time
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
@@ -121,6 +121,87 @@ def test_register_non_object_body_rejected(app_server):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_unregister_requires_auth(app_server):
|
||||
r = requests.delete(
|
||||
f"{BASE_URL}/push.json",
|
||||
json={"endpoint": "https://push.example.com/anon"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_unregister_invalid_json_rejected(app_server):
|
||||
s = _session_push()
|
||||
r = s.delete(
|
||||
f"{BASE_URL}/push.json",
|
||||
data="not-json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_unregister_non_object_body_rejected(app_server):
|
||||
s = _session_push()
|
||||
r = s.delete(f"{BASE_URL}/push.json", json=["nope"])
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_unregister_unknown_provider_rejected(app_server):
|
||||
s = _session_push()
|
||||
r = s.delete(
|
||||
f"{BASE_URL}/push.json",
|
||||
json={"provider": "carrier-pigeon", "endpoint": "https://push.example.com/x"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_unregister_missing_identity_rejected(app_server):
|
||||
s = _session_push()
|
||||
r = s.delete(f"{BASE_URL}/push.json", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_unregister_unknown_identity_is_idempotent(app_server):
|
||||
s = _session_push()
|
||||
r = s.delete(
|
||||
f"{BASE_URL}/push.json",
|
||||
json={"endpoint": "https://push.example.com/never-registered"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("unregistered") is False
|
||||
|
||||
|
||||
def test_unregister_removes_a_registered_subscription(app_server):
|
||||
s = _session_push()
|
||||
body = {
|
||||
"endpoint": "https://push.example.com/to-unregister",
|
||||
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
||||
}
|
||||
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
|
||||
|
||||
r = s.delete(f"{BASE_URL}/push.json", json={"endpoint": body["endpoint"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("unregistered") is True
|
||||
|
||||
r = s.delete(f"{BASE_URL}/push.json", json={"endpoint": body["endpoint"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("unregistered") is False
|
||||
|
||||
|
||||
def test_unregister_then_register_revives_the_row(app_server):
|
||||
s = _session_push()
|
||||
body = {
|
||||
"endpoint": "https://push.example.com/revive-after-unregister",
|
||||
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
||||
}
|
||||
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
|
||||
assert s.delete(f"{BASE_URL}/push.json", json={"endpoint": body["endpoint"]}).status_code == 200
|
||||
|
||||
r = s.post(f"{BASE_URL}/push.json", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("registered") is True
|
||||
|
||||
|
||||
def test_register_ignores_client_id_on_webpush(app_server):
|
||||
s = _session_push()
|
||||
r = s.post(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import (
|
||||
create_deepsearch_session,
|
||||
get_table,
|
||||
refresh_snapshot,
|
||||
set_setting,
|
||||
update_deepsearch_session,
|
||||
)
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
_counter_dsh = [0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _settings(app_server):
|
||||
set_setting("rate_limit_per_minute", "1000000")
|
||||
set_setting("registration_open", "1")
|
||||
yield
|
||||
|
||||
|
||||
def _user():
|
||||
_counter_dsh[0] += 1
|
||||
name = f"dsh{int(time.time() * 1000)}{_counter_dsh[0]}"
|
||||
requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
refresh_snapshot()
|
||||
user = get_table("users").find_one(username=name)
|
||||
return user["uid"], user["api_key"]
|
||||
|
||||
|
||||
def _seed_session(owner_id, status="done", keep_job=True, query="a research question"):
|
||||
uid = queue.enqueue(
|
||||
"deepsearch",
|
||||
{"query": query, "depth": 1, "max_pages": 5},
|
||||
"user",
|
||||
owner_id,
|
||||
f"DeepSearch: {query}",
|
||||
)
|
||||
create_deepsearch_session(
|
||||
uid, "user", owner_id, query, 1, 5, f"ds_{uid.replace('-', '')}"
|
||||
)
|
||||
update_deepsearch_session(
|
||||
uid,
|
||||
{
|
||||
"status": status,
|
||||
"score": 80,
|
||||
"confidence": 0.7,
|
||||
"source_diversity": 0.6,
|
||||
"page_count": 5,
|
||||
"chunk_count": 40,
|
||||
"summary": "A short summary of findings.",
|
||||
},
|
||||
)
|
||||
if not keep_job:
|
||||
get_table("jobs").delete(uid=uid)
|
||||
refresh_snapshot()
|
||||
return uid
|
||||
|
||||
|
||||
def test_history_lists_own_sessions_newest_first(app_server):
|
||||
owner_uid, key = _user()
|
||||
first = _seed_session(owner_uid, query="first question")
|
||||
time.sleep(0.01)
|
||||
second = _seed_session(owner_uid, query="second question")
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-API-KEY": key, **JSON},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
uids = [s["uid"] for s in body["sessions"]]
|
||||
assert uids.index(second) < uids.index(first)
|
||||
|
||||
|
||||
def test_history_item_shape_for_done_session(app_server):
|
||||
owner_uid, key = _user()
|
||||
uid = _seed_session(owner_uid)
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-API-KEY": key, **JSON},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
items = {s["uid"]: s for s in r.json()["sessions"]}
|
||||
item = items[uid]
|
||||
assert item["query"] == "a research question"
|
||||
assert item["status"] == "done"
|
||||
assert item["score"] == 80
|
||||
assert item["reopen_url"] == f"/tools/deepsearch/{uid}/session"
|
||||
assert item["chat_available"] is True
|
||||
assert item["available"] is True
|
||||
|
||||
|
||||
def test_history_marks_expired_session_unavailable(app_server):
|
||||
owner_uid, key = _user()
|
||||
uid = _seed_session(owner_uid, keep_job=False)
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-API-KEY": key, **JSON},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
items = {s["uid"]: s for s in r.json()["sessions"]}
|
||||
item = items[uid]
|
||||
assert item["available"] is False
|
||||
assert item["chat_available"] is False
|
||||
|
||||
|
||||
def test_history_is_scoped_to_the_owner(app_server):
|
||||
owner_uid, key = _user()
|
||||
other_uid, other_key = _user()
|
||||
mine = _seed_session(owner_uid, query="mine only")
|
||||
_seed_session(other_uid, query="not mine")
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-API-KEY": key, **JSON},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
uids = [s["uid"] for s in r.json()["sessions"]]
|
||||
assert mine in uids
|
||||
r_other = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-API-KEY": other_key, **JSON},
|
||||
)
|
||||
other_uids = [s["uid"] for s in r_other.json()["sessions"]]
|
||||
assert mine not in other_uids
|
||||
|
||||
|
||||
def test_history_scopes_guests_by_ip(app_server):
|
||||
uid = queue.enqueue(
|
||||
"deepsearch",
|
||||
{"query": "guest question", "depth": 1, "max_pages": 5},
|
||||
"guest",
|
||||
"203.0.113.55",
|
||||
"DeepSearch: guest question",
|
||||
)
|
||||
create_deepsearch_session(
|
||||
uid, "guest", "203.0.113.55", "guest question", 1, 5, f"ds_{uid.replace('-', '')}"
|
||||
)
|
||||
refresh_snapshot()
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-Real-IP": "203.0.113.55", **JSON},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
uids = [s["uid"] for s in r.json()["sessions"]]
|
||||
assert uid in uids
|
||||
r_other_ip = requests.get(
|
||||
f"{BASE_URL}/tools/deepsearch/history",
|
||||
headers={"X-Real-IP": "203.0.113.99", **JSON},
|
||||
)
|
||||
other_uids = [s["uid"] for s in r_other_ip.json()["sessions"]]
|
||||
assert uid not in other_uids
|
||||
|
||||
|
||||
def test_history_renders_html(app_server):
|
||||
r = requests.get(f"{BASE_URL}/tools/deepsearch/history")
|
||||
assert r.status_code == 200
|
||||
assert "DeepSearch history" in r.text
|
||||
@@ -127,6 +127,35 @@ def test_self_vote_does_not_notify(app_server):
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_vote_zero_retracts_existing_vote(app_server):
|
||||
s_a, a_name = _session_votes()
|
||||
s_b, _ = _session_votes()
|
||||
post_uid = _make_post_votes(_uid_votes(a_name))
|
||||
s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "1"}, headers=AJAX_votes)
|
||||
r = s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "0"}, headers=AJAX_votes)
|
||||
assert r.json() == {"net": 0, "up": 0, "down": 0, "value": 0}
|
||||
|
||||
|
||||
def test_vote_zero_with_no_prior_vote_is_a_noop(app_server):
|
||||
s_a, a_name = _session_votes()
|
||||
s_b, _ = _session_votes()
|
||||
post_uid = _make_post_votes(_uid_votes(a_name))
|
||||
r = s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "0"}, headers=AJAX_votes)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"net": 0, "up": 0, "down": 0, "value": 0}
|
||||
votes = get_table("votes")
|
||||
assert votes.count(target_uid=post_uid, target_type="post") == 0
|
||||
|
||||
|
||||
def test_vote_zero_after_downvote_retracts(app_server):
|
||||
s_a, a_name = _session_votes()
|
||||
s_b, _ = _session_votes()
|
||||
post_uid = _make_post_votes(_uid_votes(a_name))
|
||||
s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "-1"}, headers=AJAX_votes)
|
||||
r = s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "0"}, headers=AJAX_votes)
|
||||
assert r.json() == {"net": 0, "up": 0, "down": 0, "value": 0}
|
||||
|
||||
|
||||
def test_non_ajax_vote_redirects_to_referer(app_server):
|
||||
s_a, a_name = _session_votes()
|
||||
s_b, _ = _session_votes()
|
||||
|
||||
Reference in New Issue
Block a user