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:
2026-06-06 14:31:42 +00:00
parent fd2ab34adc
commit add1b7c56b
79 changed files with 2431 additions and 185 deletions
+7
View File
@@ -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()
+95
View File
@@ -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
+76
View File
@@ -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")
+140
View File
@@ -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")
+128
View File
@@ -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
View File
@@ -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
+155
View File
@@ -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
+111
View File
@@ -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