Files
stork/tests/test_api.py
T
ilia 669568aada
CI / secret-scan (pull_request) Successful in 39s
CI / python-ci (pull_request) Successful in 57s
Add generator, nicknames, ideas mail; drop logo gallery
Ship name corpus lookup/spin, nickname suggestions, significance UX,
site-ideas SMTP, and remove the Logo concepts page and assets.
2026-08-11 20:07:58 -04:00

492 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""API tests for Stork MVP."""
from __future__ import annotations
import importlib
from fastapi.testclient import TestClient
# Minimal EBML/WebM header so upload sniffing accepts the fixture.
WEBM_BYTES = b"\x1a\x45\xdf\xa3" + b"\x00" * 32
def test_health(client: TestClient) -> None:
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ok"] is True
def test_logos_gallery_gone(client: TestClient) -> None:
assert client.get("/logos").status_code == 404
assert client.get("/static/logos.html").status_code == 404
def test_home_is_paper_board(client: TestClient) -> None:
res = client.get("/")
assert res.status_code == 200
assert b"Start a private board" in res.content
def test_v2_redirects_home(client: TestClient) -> None:
res = client.get("/v2", follow_redirects=False)
assert res.status_code == 302
assert res.headers["location"] == "/"
def test_v1_redirects_home(client: TestClient) -> None:
res = client.get("/v1", follow_redirects=False)
assert res.status_code == 302
assert res.headers["location"] == "/"
def test_public_create_board_and_share_url(client: TestClient) -> None:
created = client.post("/api/boards", json={"title": "Baby Levkin"})
assert created.status_code == 200
board_id = created.json()["id"]
assert board_id.startswith("b_")
assert created.json()["url"].endswith(f"/b/{board_id}")
assert created.json()["readonly"] is False
page = client.get(f"/b/{board_id}")
assert page.status_code == 200
assert b"Start a private board" in page.content or b"Your name" in page.content
def test_join_board_with_display_name(client: TestClient) -> None:
created = client.post("/api/boards", json={"title": "Friends"}).json()
session = client.post(
"/api/session",
json={"board_id": created["id"], "display_name": "Dan"},
)
assert session.status_code == 200
assert session.json()["board"]["id"] == created["id"]
assert client.post("/api/names", json={"kind": "c1", "spelling": "Noa"}).status_code == 200
def test_boards_are_isolated(client: TestClient) -> None:
a = client.post("/api/boards", json={"title": "A"}).json()
b = client.post("/api/boards", json={"title": "B"}).json()
client.post("/api/session", json={"board_id": a["id"], "display_name": "Ilia"})
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
a_names = client.get("/api/names?kind=c1").json()["items"]
client.post("/api/session", json={"board_id": b["id"], "display_name": "Dan"})
assert client.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).status_code == 200
b_names = client.get("/api/names?kind=c1").json()["items"]
assert len(a_names) == 1 and len(b_names) == 1
assert a_names[0]["id"] != b_names[0]["id"]
def test_default_one_column(authed: TestClient) -> None:
cols = authed.get("/api/columns").json()["items"]
assert len(cols) == 1
assert cols[0]["id"] == "c1"
assert cols[0]["label"] == "First"
def test_add_rename_columns(authed: TestClient) -> None:
c2 = authed.post("/api/columns")
assert c2.status_code == 200
assert c2.json()["id"] == "c2"
renamed = authed.patch("/api/columns/c2", json={"label": "Hebrew"})
assert renamed.status_code == 200
assert renamed.json()["label"] == "Hebrew"
assert authed.post("/api/names", json={"kind": "c2", "spelling": "שי"}).status_code == 200
def test_column_delete_by_member(authed: TestClient) -> None:
assert authed.post("/api/columns").status_code == 200
assert authed.delete("/api/columns/c2").status_code == 200
assert len(authed.get("/api/columns").json()["items"]) == 1
def test_cannot_delete_first_column(authed: TestClient) -> None:
assert authed.post("/api/columns").status_code == 200
assert authed.delete("/api/columns/c1").status_code == 400
assert len(authed.get("/api/columns").json()["items"]) == 2
def test_max_four_columns(authed: TestClient) -> None:
for _ in range(3):
assert authed.post("/api/columns").status_code == 200
assert authed.post("/api/columns").status_code == 400
def test_cannot_delete_last_column(authed: TestClient) -> None:
assert authed.delete("/api/columns/c1").status_code == 400
def test_recording_upload_and_play(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Roze"}).json()
name_id = created["id"]
files = {"file": ("voice.webm", WEBM_BYTES, "audio/webm")}
up = authed.post(f"/api/names/{name_id}/recording/en", files=files)
assert up.status_code == 200
assert up.json()["recordings"]["en"] is True
audio = authed.get(f"/api/names/{name_id}/recording/en")
assert audio.status_code == 200
assert audio.content == WEBM_BYTES
def test_recording_rejects_bad_magic(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "BadAudio"}).json()
files = {"file": ("voice.webm", b"not-audio", "audio/webm")}
up = authed.post(f"/api/names/{created['id']}/recording/en", files=files)
assert up.status_code == 400
def test_recording_rejects_oversized(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
monkeypatch.setenv("STORK_MAX_UPLOAD_BYTES", "40")
import stork.app as app_mod
import stork.rate_limit as rl
importlib.reload(rl)
importlib.reload(app_mod)
app_mod.limiter.reset()
with TestClient(app_mod.app) as client:
board = client.get("/api/resolve", params={"invite": "test-invite-token"}).json()
client.post("/api/session", json={"board_id": board["id"], "display_name": "Ilia"})
name = client.post("/api/names", json={"kind": "c1", "spelling": "Big"}).json()
huge = b"\x1a\x45\xdf\xa3" + b"\x00" * 64
up = client.post(
f"/api/names/{name['id']}/recording/en",
files={"file": ("voice.webm", huge, "audio/webm")},
)
assert up.status_code == 413
app_mod.store.close()
def test_orphan_empty_boards_gc(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
monkeypatch.setenv("STORK_ORPHAN_BOARD_HOURS", "1")
import time
import stork.app as app_mod
import stork.rate_limit as rl
importlib.reload(rl)
importlib.reload(app_mod)
app_mod.limiter.reset()
with TestClient(app_mod.app) as client:
family = client.get("/api/resolve", params={"invite": "test-invite-token"}).json()
orphan = client.post("/api/boards", json={"title": "Abandoned"}).json()
orphan_id = orphan["id"]
# Backdate orphan so it is past the 1h cutoff.
with app_mod.store._lock:
app_mod.store._conn.execute(
"UPDATE boards SET created_at = ? WHERE id = ?",
(time.time() - 7200, orphan_id),
)
app_mod.store._conn.commit()
kept = client.post("/api/boards", json={"title": "Fresh"}).json()
assert client.get(f"/api/boards/{orphan_id}").status_code == 404
assert client.get(f"/api/boards/{kept['id']}").status_code == 200
assert client.get(f"/api/boards/{family['id']}").status_code == 200
app_mod.store.close()
def test_classic_index_removed(client: TestClient) -> None:
assert client.get("/static/index.html").status_code == 404
def test_session_rejects_missing_board(client: TestClient) -> None:
res = client.post("/api/session", json={"display_name": "Ilia", "board_id": "b_missing"})
assert res.status_code == 404
def test_names_require_session(client: TestClient) -> None:
res = client.get("/api/names")
assert res.status_code in (401, 404)
def test_header_name_alone_rejected(client: TestClient, family_board: dict) -> None:
headers = {
"X-Stork-Board": family_board["id"],
"X-Stork-Display-Name": "Seed Bot",
}
res = client.post(
"/api/names",
headers=headers,
json={"kind": "c1", "spelling": "HeaderOnly"},
)
assert res.status_code == 401
def test_header_auth_with_opaque_voter(client: TestClient, family_board: dict) -> None:
headers = {
"X-Stork-Board": family_board["id"],
"X-Stork-Display-Name": "Seed Bot",
"X-Stork-Voter": "opaque-voter-token-1",
}
res = client.post(
"/api/names",
headers=headers,
json={"kind": "c1", "spelling": "HeaderOnly"},
)
assert res.status_code == 200
listed = client.get("/api/names?kind=c1", headers=headers)
assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])
def test_clear_session(client: TestClient, family_board: dict) -> None:
client.post(
"/api/session",
json={"board_id": family_board["id"], "display_name": "Ilia"},
)
assert client.get("/api/session").json()["authenticated"] is True
assert client.delete("/api/session").status_code == 200
assert client.get("/api/session").json()["authenticated"] is False
def test_add_vote_and_rank(authed: TestClient) -> None:
a = authed.post(
"/api/names",
json={
"kind": "c1",
"spelling": "Noa",
"locales": {
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion"},
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
},
},
)
assert a.status_code == 200
noa_id = a.json()["id"]
b = authed.post("/api/names", json={"kind": "c1", "spelling": "Levi"})
levi_id = b.json()["id"]
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
listed = authed.get("/api/names?kind=c1").json()["items"]
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
def test_duplicate_name_rejected(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "c1", "spelling": "Maya"}).status_code == 200
assert authed.post("/api/names", json={"kind": "c1", "spelling": "maya"}).status_code == 400
def test_names_lookup_and_autofill(authed: TestClient) -> None:
look = authed.get("/api/names/lookup", params={"spelling": "Rivka"})
assert look.status_code == 200
body = look.json()
assert body["source"] == "curated"
assert body["locales"]["he"]["pronunciation"] == "רבקה"
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Rivka"}).json()
assert created["locales"]["en"]["origin"] == "Hebrew"
assert created["locales"]["ru"]["origin"] == "иврит"
assert "bind" in created["locales"]["en"]["meaning"].casefold() or "join" in created["locales"]["en"]["meaning"].casefold()
assert created["nicknames"] # nicknames autofill still runs
def test_patch_locales(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Eden"}).json()
patched = authed.patch(
f"/api/names/{created['id']}",
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
)
assert patched.status_code == 200
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
assert patched.json()["locales"]["en"]["pronunciation"] == "Eden"
def test_significance_on_add_and_patch(authed: TestClient) -> None:
created = authed.post(
"/api/names",
json={
"kind": "c1",
"spelling": "Levi",
"significance": "Grandfathers Hebrew name",
},
).json()
assert created["significance"] == "Grandfathers Hebrew name"
patched = authed.patch(
f"/api/names/{created['id']}",
json={"significance": "Named after Ilias grandfather"},
)
assert patched.status_code == 200
assert patched.json()["significance"] == "Named after Ilias grandfather"
listed = authed.get("/api/names?kind=c1").json()["items"]
levi = next(n for n in listed if n["spelling"] == "Levi")
assert levi["significance"] == "Named after Ilias grandfather"
def test_nicknames_auto_suggest_and_patch(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Suzy"}).json()
assert "Su" in created["nicknames"]
suggest = authed.get("/api/nicknames/suggest", params={"spelling": "Roza"})
assert suggest.status_code == 200
assert "Ro" in suggest.json()["text"]
patched = authed.patch(
f"/api/names/{created['id']}",
json={"nicknames": "Zu / Zuzu"},
)
assert patched.status_code == 200
assert patched.json()["nicknames"] == "Zu, Zuzu"
def test_submit_idea_mailto_fallback(authed: TestClient, monkeypatch) -> None:
monkeypatch.delenv("STORK_SMTP_HOST", raising=False)
monkeypatch.delenv("STORK_SMTP_USER", raising=False)
monkeypatch.delenv("STORK_SMTP_PASSWORD", raising=False)
monkeypatch.setenv("STORK_IDEAS_TO", "idobkin@gmail.com")
res = authed.post("/api/ideas", json={"idea": "Make columns sticky on scroll", "from_name": "Ira"})
assert res.status_code == 200
body = res.json()
assert body["ok"] is True
assert body["sent"] is False
assert body["via"] == "mailto"
assert "mailto:idobkin@gmail.com" in body["mailto"]
def test_move_name_between_columns(authed: TestClient) -> None:
assert authed.post("/api/columns").status_code == 200
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Noa"}).json()
name_id = created["id"]
moved = authed.post(f"/api/names/{name_id}/move", json={"kind": "c2"})
assert moved.status_code == 200
assert moved.json()["kind"] == "c2"
assert authed.get("/api/names?kind=c1").json()["items"] == []
assert [n["spelling"] for n in authed.get("/api/names?kind=c2").json()["items"]] == ["Noa"]
back = authed.post(f"/api/names/{name_id}/move", json={"kind": "c1"})
assert back.status_code == 200
assert back.json()["kind"] == "c1"
def test_move_name_duplicate_conflict(authed: TestClient) -> None:
assert authed.post("/api/columns").status_code == 200
a = authed.post("/api/names", json={"kind": "c1", "spelling": "Maya"}).json()
assert authed.post("/api/names", json={"kind": "c2", "spelling": "Maya"}).status_code == 200
conflict = authed.post(f"/api/names/{a['id']}/move", json={"kind": "c2"})
assert conflict.status_code == 400
assert "already exists" in conflict.json()["detail"].lower()
def test_generator_meta_and_spin(authed: TestClient) -> None:
meta = authed.get("/api/generator/meta")
assert meta.status_code == 200
assert meta.json()["total"] > 100
spun = authed.post(
"/api/generator/spin",
json={"mode": "draw", "regions": ["ru"], "length": 4, "contains": "z"},
)
assert spun.status_code == 200
body = spun.json()
assert len([c for c in body["spelling"] if c.isalpha()]) == 4
assert "z" in body["spelling"].casefold()
remix = authed.post("/api/generator/spin", json={"mode": "remix", "regions": ["en"], "min_length": 3})
assert remix.status_code == 200
assert remix.json()["spelling"]
def test_admin_delete_name(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json()
assert authed.delete(f"/api/names/{created['id']}").status_code == 403
assert (
authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}).status_code == 200
)
def test_legacy_invite_resolve(client: TestClient, family_board: dict) -> None:
res = client.get("/api/resolve", params={"invite": "test-invite-token"})
assert res.status_code == 200
assert res.json()["id"] == family_board["id"]
assert res.json()["readonly"] is False
def test_readonly_board_blocks_mutations(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
monkeypatch.setenv("STORK_READONLY_BOARD_IDS", "")
import stork.app as app_mod
importlib.reload(app_mod)
with TestClient(app_mod.app) as client:
board = client.post("/api/boards", json={"title": "Demo"}).json()
board_id = board["id"]
# Reload with this board marked readonly
monkeypatch.setenv("STORK_READONLY_BOARD_IDS", board_id)
importlib.reload(app_mod)
with TestClient(app_mod.app) as client2:
info = client2.get(f"/api/boards/{board_id}")
assert info.status_code == 200
assert info.json()["readonly"] is True
client2.post(
"/api/session",
json={"board_id": board_id, "display_name": "Visitor"},
)
assert client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code == 403
assert client2.get("/api/names?kind=c1").status_code == 200
app_mod.store.close()
# close first store if still open
try:
app_mod.store.close()
except Exception:
pass
def test_rate_limit_create_board(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
monkeypatch.setenv("STORK_RL_BOARDS_PER_HOUR", "2")
import stork.app as app_mod
import stork.rate_limit as rl
importlib.reload(rl)
importlib.reload(app_mod)
app_mod.limiter.reset()
app_mod.limiter.configure("create_board", 2, 3600)
with TestClient(app_mod.app) as client:
assert client.post("/api/boards", json={"title": "One"}).status_code == 200
assert client.post("/api/boards", json={"title": "Two"}).status_code == 200
third = client.post("/api/boards", json={"title": "Three"})
assert third.status_code == 429
assert "Retry-After" in third.headers
app_mod.store.close()
def test_openapi_disabled(client: TestClient) -> None:
assert client.get("/openapi.json").status_code == 404
assert client.get("/docs").status_code == 404
def test_rate_limit_vote_and_generator(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
import stork.app as app_mod
import stork.rate_limit as rl
importlib.reload(rl)
importlib.reload(app_mod)
app_mod.limiter.reset()
app_mod.limiter.configure("vote", 2, 3600)
app_mod.limiter.configure("generator", 1, 3600)
with TestClient(app_mod.app) as client:
board = client.get("/api/resolve", params={"invite": "test-invite-token"}).json()
client.post("/api/session", json={"board_id": board["id"], "display_name": "Lim"})
name = client.post("/api/names", json={"kind": "c1", "spelling": "RateLim"}).json()
assert client.post(f"/api/names/{name['id']}/vote", json={"value": 1}).status_code == 200
assert client.post(f"/api/names/{name['id']}/vote", json={"value": 0}).status_code == 200
blocked = client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
assert blocked.status_code == 429
spin1 = client.post("/api/generator/spin", json={"mode": "draw", "regions": ["en"]})
assert spin1.status_code in (200, 400) # 400 if corpus empty in test env
spin2 = client.post("/api/generator/spin", json={"mode": "draw", "regions": ["en"]})
assert spin2.status_code == 429
app_mod.store.close()