Add Stork family name board MVP with invite auth and votes.
CI / python-ci (push) Successful in 1m39s
CI / secret-scan (push) Successful in 32s

Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout.
This commit is contained in:
2026-08-06 16:43:06 -04:00
commit b88791a0b7
25 changed files with 1601 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
"""Shared fixtures for Stork API tests."""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
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
importlib.reload(app_mod)
with TestClient(app_mod.app) as test_client:
yield test_client
app_mod.store.close()
@pytest.fixture()
def authed(client: TestClient) -> TestClient:
res = client.post(
"/api/session",
json={"invite": "test-invite-token", "display_name": "Aunt Mira"},
)
assert res.status_code == 200
return client
+105
View File
@@ -0,0 +1,105 @@
"""API tests for Stork MVP."""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_health(client: TestClient) -> None:
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ok"] is True
def test_session_rejects_bad_invite(client: TestClient) -> None:
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"})
assert res.status_code == 403
def test_names_require_session(client: TestClient) -> None:
res = client.get("/api/names")
assert res.status_code == 401
def test_add_vote_and_rank(authed: TestClient) -> None:
a = authed.post(
"/api/names",
json={
"kind": "first",
"spelling": "Noa",
"locales": {
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"},
"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": "first", "spelling": "Levi"})
assert b.status_code == 200
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=first").json()["items"]
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
assert listed[0]["score"] == 1
assert listed[0]["my_vote"] == 1
assert listed[1]["score"] == -1
# clear vote
cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0})
assert cleared.status_code == 200
assert cleared.json()["my_vote"] is None
assert cleared.json()["score"] == 0
def test_duplicate_name_rejected(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"})
assert dup.status_code == 400
def test_middle_names_separate(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200
assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200
first = authed.get("/api/names?kind=first").json()["items"]
middle = authed.get("/api/names?kind=middle").json()["items"]
assert len(first) == 1
assert len(middle) == 1
def test_patch_locales(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "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"] == "рай"
def test_admin_delete(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json()
denied = authed.delete(f"/api/names/{created['id']}")
assert denied.status_code == 403
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"})
assert ok.status_code == 200
assert authed.get("/api/names?kind=first").json()["items"] == []
def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None:
name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json()
authed.post(f"/api/names/{name['id']}/vote", json={"value": 1})
other = client.post(
"/api/session",
json={"invite": "test-invite-token", "display_name": "Uncle Dan"},
)
assert other.status_code == 200
client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
listed = client.get("/api/names?kind=first").json()["items"]
assert listed[0]["score"] == 2
assert listed[0]["vote_count"] == 2
+20
View File
@@ -0,0 +1,20 @@
"""Unit tests for Store ranking helpers."""
from __future__ import annotations
from pathlib import Path
from stork.db import Store
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
store = Store(tmp_path / "t.sqlite3")
a = store.add_name("first", "Zed", "x")
store.add_name("first", "Ann", "x")
c = store.add_name("first", "Bo", "x")
store.vote(a["id"], "v1", "A", 1)
store.vote(c["id"], "v1", "A", 1)
store.vote(c["id"], "v2", "B", 1)
ranked = store.list_names("first")
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
store.close()