feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
This commit is contained in:
@@ -133,6 +133,13 @@ def app_server(test_db_path):
|
||||
f"Server did not start after 30s. "
|
||||
f"Log:\n{server_log()}"
|
||||
)
|
||||
|
||||
from devplacepy.database import get_table as _get_table
|
||||
ops_settings = _get_table("site_settings")
|
||||
for key, value in (("rate_limit_per_minute", "1000000"), ("rate_limit_window_seconds", "60")):
|
||||
if not ops_settings.find_one(key=key):
|
||||
ops_settings.insert({"uid": f"test_{key}", "key": key, "value": value})
|
||||
|
||||
yield proc
|
||||
try:
|
||||
proc.terminate()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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 = [0]
|
||||
AJAX = {"X-Requested-With": "fetch"}
|
||||
|
||||
|
||||
def _session():
|
||||
_counter[0] += 1
|
||||
name = f"bkm{int(time.time() * 1000)}{_counter[0]}"
|
||||
s = requests.Session()
|
||||
s.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": name, "email": f"{name}@t.dev",
|
||||
"password": "secret123", "confirm_password": "secret123",
|
||||
}, allow_redirects=True)
|
||||
return s, name
|
||||
|
||||
|
||||
def _uid(username):
|
||||
return get_table("users").find_one(username=username)["uid"]
|
||||
|
||||
|
||||
def _make_post(owner_uid, title):
|
||||
uid = generate_uid()
|
||||
get_table("posts").insert({
|
||||
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-bookmark-post",
|
||||
"title": title, "content": "bookmark target content", "topic": "random",
|
||||
"project_uid": None, "image": None, "stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def _make_gist(owner_uid, title):
|
||||
uid = generate_uid()
|
||||
get_table("gists").insert({
|
||||
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-bookmark-gist",
|
||||
"title": title, "description": None, "source_code": "print('x')",
|
||||
"language": "python", "stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def test_bookmark_toggle_on(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name), "Bookmark toggle on")
|
||||
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
|
||||
assert r.json() == {"saved": True}
|
||||
assert get_table("bookmarks").count(user_uid=_uid(name), target_uid=post_uid) == 1
|
||||
|
||||
|
||||
def test_bookmark_toggle_off(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name), "Bookmark toggle off")
|
||||
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
|
||||
r = s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
|
||||
assert r.json() == {"saved": False}
|
||||
assert get_table("bookmarks").count(user_uid=_uid(name), target_uid=post_uid) == 0
|
||||
|
||||
|
||||
def test_saved_page_lists_post_and_gist(app_server):
|
||||
s, name = _session()
|
||||
owner_uid = _uid(name)
|
||||
post_title = f"Saved post {int(time.time() * 1000)}"
|
||||
gist_title = f"Saved gist {int(time.time() * 1000)}"
|
||||
post_uid = _make_post(owner_uid, post_title)
|
||||
gist_uid = _make_gist(owner_uid, gist_title)
|
||||
s.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX)
|
||||
s.post(f"{BASE_URL}/bookmarks/gist/{gist_uid}", headers=AJAX)
|
||||
html = s.get(f"{BASE_URL}/bookmarks/saved").text
|
||||
assert post_title in html
|
||||
assert gist_title in html
|
||||
|
||||
|
||||
def test_invalid_target_type_returns_400(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name), "Bad target")
|
||||
r = s.post(f"{BASE_URL}/bookmarks/widget/{post_uid}", headers=AJAX)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_bookmark_requires_login(app_server):
|
||||
owner_s, owner_name = _session()
|
||||
post_uid = _make_post(_uid(owner_name), "Needs login")
|
||||
anon = requests.Session()
|
||||
r = anon.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX, allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert get_table("bookmarks").count(target_uid=post_uid) == 0
|
||||
@@ -0,0 +1,76 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def _open_composer(page):
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
page.locator(".feed-fab").first.click()
|
||||
|
||||
|
||||
def _create_plain_post(page, content):
|
||||
_open_composer(page)
|
||||
page.fill("#post-content", content)
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def test_poll_builder_hidden_until_add_poll(alice):
|
||||
page, _ = alice
|
||||
_open_composer(page)
|
||||
expect(page.locator("[data-poll-builder]")).to_be_hidden()
|
||||
page.locator("[data-poll-toggle]").click()
|
||||
expect(page.locator("[data-poll-builder]")).to_be_visible()
|
||||
|
||||
|
||||
def test_create_poll_and_vote(alice):
|
||||
page, _ = alice
|
||||
_open_composer(page)
|
||||
page.fill("#post-content", "Post that carries a poll for the UI test.")
|
||||
page.locator("[data-poll-toggle]").click()
|
||||
page.fill("#create-post-modal input[name='poll_question']", "Tabs or spaces?")
|
||||
options = page.locator("#create-post-modal input[name='poll_options']")
|
||||
options.nth(0).fill("Tabs")
|
||||
options.nth(1).fill("Spaces")
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
|
||||
expect(page.locator(".poll-question")).to_have_text("Tabs or spaces?")
|
||||
expect(page.locator(".poll-option")).to_have_count(2)
|
||||
|
||||
page.locator(".poll-option").first.click()
|
||||
expect(page.locator(".poll-option").first).to_have_class(re.compile(r"\bchosen\b"))
|
||||
expect(page.locator(".poll-option").first.locator(".poll-option-pct")).to_be_visible()
|
||||
|
||||
|
||||
def test_remove_poll_does_not_create_poll(alice):
|
||||
page, _ = alice
|
||||
_open_composer(page)
|
||||
page.fill("#post-content", "Post where the poll is added then removed before posting.")
|
||||
page.locator("[data-poll-toggle]").click()
|
||||
page.fill("#create-post-modal input[name='poll_question']", "Discarded question?")
|
||||
options = page.locator("#create-post-modal input[name='poll_options']")
|
||||
options.nth(0).fill("One")
|
||||
options.nth(1).fill("Two")
|
||||
page.locator("[data-poll-toggle]").click()
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
expect(page.locator(".poll")).to_have_count(0)
|
||||
|
||||
|
||||
def test_reaction_palette_toggle_and_react(alice):
|
||||
page, _ = alice
|
||||
_create_plain_post(page, "Post to react to in the reaction UI test.")
|
||||
|
||||
expect(page.locator(".reaction-chip:visible")).to_have_count(0)
|
||||
expect(page.locator(".reaction-palette").first).to_be_hidden()
|
||||
|
||||
page.locator(".reaction-add-btn").first.click()
|
||||
expect(page.locator(".reaction-palette").first).to_be_visible()
|
||||
|
||||
page.locator(".reaction-palette-btn").first.click()
|
||||
expect(page.locator(".reaction-chip.reacted").first).to_be_visible()
|
||||
expect(page.locator(".reaction-chip.reacted").first.locator(".reaction-count")).to_have_text("1")
|
||||
@@ -0,0 +1,140 @@
|
||||
import time
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
|
||||
DEFAULT_MAINTENANCE_MESSAGE = "DevPlace is undergoing scheduled maintenance. Please check back shortly."
|
||||
|
||||
OPERATIONAL_FIELDS = (
|
||||
"rate_limit_per_minute",
|
||||
"rate_limit_window_seconds",
|
||||
"news_service_interval",
|
||||
"session_max_age_days",
|
||||
"session_remember_days",
|
||||
"registration_open",
|
||||
"maintenance_mode",
|
||||
"maintenance_message",
|
||||
)
|
||||
|
||||
|
||||
def _save_settings(page, **fields):
|
||||
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
|
||||
for name, value in fields.items():
|
||||
locator = page.locator(f"#{name}")
|
||||
tag = locator.evaluate("el => el.tagName.toLowerCase()")
|
||||
if tag == "select":
|
||||
page.select_option(f"#{name}", value)
|
||||
else:
|
||||
locator.fill(value)
|
||||
page.click("button:has-text('Save Settings')")
|
||||
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def test_operational_fields_render(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=Operational")
|
||||
for field in OPERATIONAL_FIELDS:
|
||||
assert page.is_visible(f"#{field}"), field
|
||||
|
||||
|
||||
def test_operational_settings_persist(alice):
|
||||
page, _ = alice
|
||||
try:
|
||||
_save_settings(
|
||||
page,
|
||||
news_service_interval="1800",
|
||||
session_remember_days="14",
|
||||
maintenance_message="Custom maintenance text",
|
||||
)
|
||||
assert page.locator("#news_service_interval").input_value() == "1800"
|
||||
assert page.locator("#session_remember_days").input_value() == "14"
|
||||
assert page.locator("#maintenance_message").input_value() == "Custom maintenance text"
|
||||
finally:
|
||||
_save_settings(
|
||||
page,
|
||||
news_service_interval="3600",
|
||||
session_remember_days="30",
|
||||
maintenance_message=DEFAULT_MAINTENANCE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
def test_maintenance_mode_blocks_guests(alice):
|
||||
page, _ = alice
|
||||
try:
|
||||
_save_settings(page, maintenance_mode="1", maintenance_message="Down for tests")
|
||||
response = requests.get(f"{BASE_URL}/feed")
|
||||
assert response.status_code == 503
|
||||
assert "Down for tests" in response.text
|
||||
finally:
|
||||
_save_settings(page, maintenance_mode="0", maintenance_message=DEFAULT_MAINTENANCE_MESSAGE)
|
||||
|
||||
|
||||
def test_maintenance_mode_admin_retains_access(alice):
|
||||
page, _ = alice
|
||||
try:
|
||||
_save_settings(page, maintenance_mode="1")
|
||||
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
|
||||
assert page.is_visible("#maintenance_mode")
|
||||
login = requests.get(f"{BASE_URL}/auth/login")
|
||||
assert login.status_code == 200
|
||||
finally:
|
||||
_save_settings(page, maintenance_mode="0")
|
||||
|
||||
|
||||
def test_registration_closed_blocks_signup(alice):
|
||||
page, _ = alice
|
||||
try:
|
||||
_save_settings(page, registration_open="0")
|
||||
|
||||
signup_page = requests.get(f"{BASE_URL}/auth/signup")
|
||||
assert signup_page.status_code == 200
|
||||
assert "Registration is currently closed" in signup_page.text
|
||||
|
||||
attempt = requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": "closed_signup",
|
||||
"email": "closed_signup@test.devplace",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
)
|
||||
assert "Registration is currently closed" in attempt.text
|
||||
assert get_table("users").find_one(username="closed_signup") is None
|
||||
finally:
|
||||
_save_settings(page, registration_open="1")
|
||||
|
||||
|
||||
def test_registration_open_allows_signup(alice):
|
||||
page, _ = alice
|
||||
_save_settings(page, registration_open="1")
|
||||
signup_page = requests.get(f"{BASE_URL}/auth/signup")
|
||||
assert "Registration is currently closed" not in signup_page.text
|
||||
assert 'name="username"' in signup_page.text
|
||||
|
||||
|
||||
def test_session_length_setting_applies(alice, browser):
|
||||
page, _ = alice
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
guest = context.new_page()
|
||||
guest.set_default_timeout(15000)
|
||||
try:
|
||||
_save_settings(page, session_max_age_days="1")
|
||||
|
||||
username = f"sesslen_{int(time.time() * 1000)}"
|
||||
guest.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
|
||||
guest.fill("#username", username)
|
||||
guest.fill("#email", f"{username}@test.devplace")
|
||||
guest.fill("#password", "secret123")
|
||||
guest.fill("#confirm_password", "secret123")
|
||||
guest.click("button:has-text('Create account')")
|
||||
guest.wait_for_url("**/feed", wait_until="domcontentloaded")
|
||||
|
||||
session_cookie = next(c for c in context.cookies() if c["name"] == "session")
|
||||
remaining = session_cookie["expires"] - time.time()
|
||||
assert 0 < remaining < 2 * 86400, remaining
|
||||
finally:
|
||||
guest.close()
|
||||
context.close()
|
||||
_save_settings(page, session_max_age_days="7")
|
||||
@@ -0,0 +1,128 @@
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
|
||||
_counter = [0]
|
||||
AJAX = {"X-Requested-With": "fetch"}
|
||||
|
||||
|
||||
def _session():
|
||||
_counter[0] += 1
|
||||
name = f"pol{int(time.time() * 1000)}{_counter[0]}"
|
||||
s = requests.Session()
|
||||
s.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": name, "email": f"{name}@t.dev",
|
||||
"password": "secret123", "confirm_password": "secret123",
|
||||
}, allow_redirects=True)
|
||||
return s, name
|
||||
|
||||
|
||||
def _create_post(session, title, question, options):
|
||||
fields = [
|
||||
("content", "Poll host post content for tests."),
|
||||
("title", title),
|
||||
("topic", "question"),
|
||||
("poll_question", question),
|
||||
]
|
||||
fields.extend(("poll_options", option) for option in options)
|
||||
r = session.post(f"{BASE_URL}/posts/create", data=fields, allow_redirects=False)
|
||||
slug = r.headers["location"].split("/posts/")[-1]
|
||||
return get_table("posts").find_one(slug=slug)["uid"]
|
||||
|
||||
|
||||
def _poll_for(post_uid):
|
||||
poll = get_table("polls").find_one(post_uid=post_uid)
|
||||
options = list(get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"]))
|
||||
return poll, options
|
||||
|
||||
|
||||
def test_valid_poll_persists(app_server):
|
||||
s, _ = _session()
|
||||
title = f"valid-poll-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Tabs or spaces?", ["Tabs", "Spaces", "Both"])
|
||||
poll, options = _poll_for(post_uid)
|
||||
assert poll is not None
|
||||
assert len(options) == 3
|
||||
|
||||
|
||||
def test_poll_dropped_with_one_option(app_server):
|
||||
s, _ = _session()
|
||||
title = f"one-option-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Only one?", ["Solo"])
|
||||
assert get_table("polls").find_one(post_uid=post_uid) is None
|
||||
|
||||
|
||||
def test_poll_dropped_without_question(app_server):
|
||||
s, _ = _session()
|
||||
title = f"no-question-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "", ["Tabs", "Spaces"])
|
||||
assert get_table("polls").find_one(post_uid=post_uid) is None
|
||||
|
||||
|
||||
def test_vote_records_choice(app_server):
|
||||
s, _ = _session()
|
||||
title = f"vote-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
|
||||
poll, options = _poll_for(post_uid)
|
||||
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
|
||||
payload = r.json()
|
||||
assert payload["total"] == 1
|
||||
assert payload["my_choice"] == options[0]["uid"]
|
||||
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 1
|
||||
|
||||
|
||||
def test_switch_vote_moves_choice(app_server):
|
||||
s, _ = _session()
|
||||
title = f"switch-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
|
||||
poll, options = _poll_for(post_uid)
|
||||
s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
|
||||
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[1]["uid"]}, headers=AJAX)
|
||||
payload = r.json()
|
||||
assert payload["my_choice"] == options[1]["uid"]
|
||||
assert payload["total"] == 1
|
||||
|
||||
|
||||
def test_repeat_same_option_retracts(app_server):
|
||||
s, _ = _session()
|
||||
title = f"retract-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
|
||||
poll, options = _poll_for(post_uid)
|
||||
s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
|
||||
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
|
||||
payload = r.json()
|
||||
assert payload["my_choice"] is None
|
||||
assert payload["total"] == 0
|
||||
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 0
|
||||
|
||||
|
||||
def test_vote_invalid_option_returns_400(app_server):
|
||||
s, _ = _session()
|
||||
title = f"badopt-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
|
||||
poll, _ = _poll_for(post_uid)
|
||||
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": "not-a-real-option"}, headers=AJAX)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_vote_requires_login(app_server):
|
||||
s, _ = _session()
|
||||
title = f"authvote-{int(time.time() * 1000)}"
|
||||
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
|
||||
poll, options = _poll_for(post_uid)
|
||||
anon = requests.Session()
|
||||
r = anon.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX, allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 0
|
||||
|
||||
|
||||
def test_feed_shows_poll_results_without_voting(app_server):
|
||||
s, _ = _session()
|
||||
title = f"feedpoll-{int(time.time() * 1000)}"
|
||||
_create_post(s, title, "Visible results question?", ["Yes", "No"])
|
||||
html = s.get(f"{BASE_URL}/feed").text
|
||||
assert "Visible results question?" in html
|
||||
assert "poll-option-bar" in html
|
||||
+19
-6
@@ -2,9 +2,15 @@ import devplacepy.main as m
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_rate_limit_blocks_excess(monkeypatch):
|
||||
monkeypatch.setattr(m, "RATE_LIMIT", 3)
|
||||
def _patch_limits(monkeypatch, limit, window=60):
|
||||
def fake_get_int_setting(key, default):
|
||||
return {"rate_limit_per_minute": limit, "rate_limit_window_seconds": window}.get(key, default)
|
||||
monkeypatch.setattr(m, "get_int_setting", fake_get_int_setting)
|
||||
m._rate_limit_store.clear()
|
||||
|
||||
|
||||
def test_rate_limit_blocks_excess(monkeypatch):
|
||||
_patch_limits(monkeypatch, 3)
|
||||
client = TestClient(m.app)
|
||||
codes = [client.post("/", headers={"X-Real-IP": "9.9.9.9"}).status_code for _ in range(6)]
|
||||
assert 429 in codes, codes
|
||||
@@ -12,8 +18,7 @@ def test_rate_limit_blocks_excess(monkeypatch):
|
||||
|
||||
|
||||
def test_rate_limit_is_per_ip(monkeypatch):
|
||||
monkeypatch.setattr(m, "RATE_LIMIT", 2)
|
||||
m._rate_limit_store.clear()
|
||||
_patch_limits(monkeypatch, 2)
|
||||
client = TestClient(m.app)
|
||||
for _ in range(2):
|
||||
client.post("/", headers={"X-Real-IP": "1.1.1.1"})
|
||||
@@ -24,8 +29,16 @@ def test_rate_limit_is_per_ip(monkeypatch):
|
||||
|
||||
|
||||
def test_get_requests_not_rate_limited(monkeypatch):
|
||||
monkeypatch.setattr(m, "RATE_LIMIT", 2)
|
||||
m._rate_limit_store.clear()
|
||||
_patch_limits(monkeypatch, 2)
|
||||
client = TestClient(m.app)
|
||||
codes = [client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code for _ in range(5)]
|
||||
assert all(c == 200 for c in codes), codes
|
||||
|
||||
|
||||
def test_rate_limit_reads_configured_setting(monkeypatch):
|
||||
_patch_limits(monkeypatch, 1)
|
||||
client = TestClient(m.app)
|
||||
first = client.post("/", headers={"X-Real-IP": "7.7.7.7"}).status_code
|
||||
second = client.post("/", headers={"X-Real-IP": "7.7.7.7"}).status_code
|
||||
assert first != 429
|
||||
assert second == 429
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.config import DATABASE_URL
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.constants import REACTION_EMOJI
|
||||
|
||||
_counter = [0]
|
||||
AJAX = {"X-Requested-With": "fetch"}
|
||||
|
||||
ROCKET = REACTION_EMOJI[2]
|
||||
HEART = REACTION_EMOJI[1]
|
||||
|
||||
|
||||
def _session():
|
||||
_counter[0] += 1
|
||||
name = f"rxn{int(time.time() * 1000)}{_counter[0]}"
|
||||
s = requests.Session()
|
||||
s.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": name, "email": f"{name}@t.dev",
|
||||
"password": "secret123", "confirm_password": "secret123",
|
||||
}, allow_redirects=True)
|
||||
return s, name
|
||||
|
||||
|
||||
def _uid(username):
|
||||
return get_table("users").find_one(username=username)["uid"]
|
||||
|
||||
|
||||
def _live_query(sql, params):
|
||||
conn = sqlite3.connect(DATABASE_URL.replace("sqlite:///", "", 1))
|
||||
try:
|
||||
return conn.execute(sql, params).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _live_reaction_count(target_type, target_uid):
|
||||
return _live_query(
|
||||
"SELECT COUNT(*) FROM reactions WHERE target_type=? AND target_uid=?",
|
||||
(target_type, target_uid),
|
||||
)[0]
|
||||
|
||||
|
||||
def _live_post_uid(slug):
|
||||
row = _live_query("SELECT uid FROM posts WHERE slug=?", (slug,))
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _make_post(owner_uid):
|
||||
uid = generate_uid()
|
||||
get_table("posts").insert({
|
||||
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-reaction-post",
|
||||
"title": None, "content": "reaction target content", "topic": "random",
|
||||
"project_uid": None, "image": None, "stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def _make_comment(post_uid, owner_uid):
|
||||
uid = generate_uid()
|
||||
get_table("comments").insert({
|
||||
"uid": uid, "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
|
||||
"user_uid": owner_uid, "content": "reaction target comment", "parent_uid": None,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def test_add_reaction_returns_counts(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name))
|
||||
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
|
||||
payload = r.json()
|
||||
assert payload["counts"][ROCKET] == 1
|
||||
assert ROCKET in payload["mine"]
|
||||
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 1
|
||||
|
||||
|
||||
def test_same_emoji_toggles_off(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name))
|
||||
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
|
||||
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
|
||||
payload = r.json()
|
||||
assert payload["counts"].get(ROCKET, 0) == 0
|
||||
assert payload["mine"] == []
|
||||
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
|
||||
|
||||
|
||||
def test_two_distinct_emoji_both_counted(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name))
|
||||
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
|
||||
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": HEART}, headers=AJAX)
|
||||
payload = r.json()
|
||||
assert payload["counts"][ROCKET] == 1
|
||||
assert payload["counts"][HEART] == 1
|
||||
assert set(payload["mine"]) == {ROCKET, HEART}
|
||||
|
||||
|
||||
def test_emoji_outside_palette_rejected(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name))
|
||||
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": "notanemoji"}, headers=AJAX, allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
|
||||
|
||||
|
||||
def test_invalid_target_type_returns_400(app_server):
|
||||
s, name = _session()
|
||||
post_uid = _make_post(_uid(name))
|
||||
r = s.post(f"{BASE_URL}/reactions/news/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_reaction_requires_login(app_server):
|
||||
owner_s, owner_name = _session()
|
||||
post_uid = _make_post(_uid(owner_name))
|
||||
anon = requests.Session()
|
||||
r = anon.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX, allow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
|
||||
|
||||
|
||||
def test_react_on_comment_target(app_server):
|
||||
s, name = _session()
|
||||
owner_uid = _uid(name)
|
||||
post_uid = _make_post(owner_uid)
|
||||
comment_uid = _make_comment(post_uid, owner_uid)
|
||||
r = s.post(f"{BASE_URL}/reactions/comment/{comment_uid}", data={"emoji": HEART}, headers=AJAX)
|
||||
assert r.json()["counts"][HEART] == 1
|
||||
assert get_table("reactions").count(target_type="comment", target_uid=comment_uid) == 1
|
||||
|
||||
|
||||
def test_delete_post_cascades_reactions(app_server):
|
||||
s, name = _session()
|
||||
title = f"cascade-{int(time.time() * 1000)}"
|
||||
r = s.post(f"{BASE_URL}/posts/create", data={
|
||||
"content": "Post created via the server for the reaction cascade test.",
|
||||
"title": title, "topic": "random",
|
||||
}, allow_redirects=False)
|
||||
slug = r.headers["location"].split("/posts/")[-1]
|
||||
post_uid = _live_post_uid(slug)
|
||||
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
|
||||
assert _live_reaction_count("post", post_uid) == 1
|
||||
s.post(f"{BASE_URL}/posts/delete/{slug}", allow_redirects=False)
|
||||
assert s.get(f"{BASE_URL}/posts/{slug}").status_code == 404
|
||||
assert _live_reaction_count("post", post_uid) == 0
|
||||
@@ -0,0 +1,111 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, get_activity_calendar, get_streaks, get_activity_heatmap
|
||||
from devplacepy.utils import generate_uid, check_milestone_badges
|
||||
|
||||
_counter = [0]
|
||||
|
||||
|
||||
def _make_user():
|
||||
_counter[0] += 1
|
||||
uid = generate_uid()
|
||||
name = f"stk{int(time.time() * 1000)}{_counter[0]}"
|
||||
get_table("users").insert({
|
||||
"uid": uid, "username": name, "email": f"{name}@t.dev",
|
||||
"role": "Member", "is_active": True, "xp": 0, "level": 1,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def _insert_post(user_uid, dt):
|
||||
uid = generate_uid()
|
||||
get_table("posts").insert({
|
||||
"uid": uid, "user_uid": user_uid, "slug": f"{uid[:8]}-streak",
|
||||
"title": None, "content": "streak activity", "topic": "random",
|
||||
"project_uid": None, "image": None, "stars": 0,
|
||||
"created_at": dt.isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def _insert_comment(user_uid, post_uid, dt):
|
||||
uid = generate_uid()
|
||||
get_table("comments").insert({
|
||||
"uid": uid, "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
|
||||
"user_uid": user_uid, "content": "streak comment", "parent_uid": None,
|
||||
"created_at": dt.isoformat(),
|
||||
})
|
||||
return uid
|
||||
|
||||
|
||||
def test_consecutive_days_build_streak(app_server):
|
||||
uid = _make_user()
|
||||
today = datetime.now(timezone.utc)
|
||||
for offset in (0, 1, 2):
|
||||
_insert_post(uid, today - timedelta(days=offset))
|
||||
streaks = get_streaks(uid)
|
||||
assert streaks["current"] == 3
|
||||
assert streaks["longest"] >= 3
|
||||
|
||||
|
||||
def test_gap_resets_current_streak(app_server):
|
||||
uid = _make_user()
|
||||
today = datetime.now(timezone.utc)
|
||||
_insert_post(uid, today)
|
||||
_insert_post(uid, today - timedelta(days=3))
|
||||
streaks = get_streaks(uid)
|
||||
assert streaks["current"] == 1
|
||||
|
||||
|
||||
def test_yesterday_only_counts_as_current(app_server):
|
||||
uid = _make_user()
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
_insert_post(uid, yesterday)
|
||||
assert get_streaks(uid)["current"] == 1
|
||||
|
||||
|
||||
def test_calendar_sums_across_sources(app_server):
|
||||
uid = _make_user()
|
||||
today = datetime.now(timezone.utc)
|
||||
post_uid = _insert_post(uid, today)
|
||||
_insert_comment(uid, post_uid, today)
|
||||
calendar = get_activity_calendar(uid)
|
||||
assert calendar.get(today.date().isoformat()) == 2
|
||||
|
||||
|
||||
def test_heatmap_shape(app_server):
|
||||
uid = _make_user()
|
||||
weeks = get_activity_heatmap(uid)
|
||||
assert 52 <= len(weeks) <= 54
|
||||
assert all(len(week) <= 7 for week in weeks)
|
||||
|
||||
|
||||
def test_seven_day_streak_awards_on_fire_badge(app_server):
|
||||
uid = _make_user()
|
||||
today = datetime.now(timezone.utc)
|
||||
for offset in range(7):
|
||||
_insert_post(uid, today - timedelta(days=offset))
|
||||
check_milestone_badges(uid)
|
||||
assert get_table("badges").count(user_uid=uid, badge_name="On Fire") == 1
|
||||
|
||||
|
||||
def test_profile_renders_heatmap_and_streak(app_server):
|
||||
_counter[0] += 1
|
||||
name = f"stkp{int(time.time() * 1000)}{_counter[0]}"
|
||||
s = requests.Session()
|
||||
s.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": name, "email": f"{name}@t.dev",
|
||||
"password": "secret123", "confirm_password": "secret123",
|
||||
}, allow_redirects=True)
|
||||
s.post(f"{BASE_URL}/posts/create", data={
|
||||
"content": "A post created today for the streak heatmap.",
|
||||
"title": "Streak heatmap post", "topic": "devlog",
|
||||
}, allow_redirects=True)
|
||||
html = s.get(f"{BASE_URL}/profile/{name}").text
|
||||
assert "heatmap-grid" in html
|
||||
assert "1 day streak" in html
|
||||
Reference in New Issue
Block a user