Add Stork family name board MVP with invite auth and votes.
Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user