From b88791a0b77323f0835523163c668ba9ef815891 Mon Sep 17 00:00:00 2001 From: ilia Date: Thu, 6 Aug 2026 16:43:06 -0400 Subject: [PATCH] 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. --- .env.example | 5 + .gitea/workflows/ci.yml | 48 ++++++ .gitignore | 55 ++++++ .gitleaks.toml | 36 ++++ AGENTS.md | 16 ++ Dockerfile | 12 ++ LICENSE | 21 +++ Makefile | 28 +++ README.md | 42 +++++ bootstrap.sh | 52 ++++++ data/seed-names.example.json | 109 ++++++++++++ docker-compose.yml | 18 ++ docs/writing-docs.md | 138 +++++++++++++++ pyproject.toml | 3 + requirements-dev.txt | 4 + requirements.txt | 3 + ruff.toml | 7 + scripts/seed_names.py | 48 ++++++ static/index.html | 323 +++++++++++++++++++++++++++++++++++ stork/__init__.py | 3 + stork/app.py | 206 ++++++++++++++++++++++ stork/db.py | 265 ++++++++++++++++++++++++++++ tests/conftest.py | 34 ++++ tests/test_api.py | 105 ++++++++++++ tests/test_store.py | 20 +++ 25 files changed, 1601 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .gitleaks.toml create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100755 bootstrap.sh create mode 100644 data/seed-names.example.json create mode 100644 docker-compose.yml create mode 100644 docs/writing-docs.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 ruff.toml create mode 100644 scripts/seed_names.py create mode 100644 static/index.html create mode 100644 stork/__init__.py create mode 100644 stork/app.py create mode 100644 stork/db.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_store.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e72db10 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env on the deploy host (never commit real tokens). +STORK_INVITE_TOKEN=change-me-long-random +STORK_ADMIN_TOKEN=change-me-admin-random +STORK_COOKIE_SECURE=false +STORK_DATA=./data diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d1ee41c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,48 @@ +--- +# Homelab CI — Python lane + secret scan. +# POLICY: lint and tests are HARD gates. Never add `|| true` to them. +name: CI + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + python-ci: + runs-on: [homelab, self-hosted, linux, python] + container: + image: node:20-bookworm + steps: + - uses: actions/checkout@v4 + + - name: Install Python tooling + run: | + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv + python3 -m pip install --upgrade pip --break-system-packages + if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi + pip install pytest ruff --break-system-packages + + - name: Ruff lint (hard gate) + run: ruff check . + + - name: Pytest (hard gate) + run: pytest -q + + # Advisory-only scanners (allowed to be soft — they're noisy, not gates) + - name: Bandit (advisory) + run: pip install bandit --break-system-packages && bandit -r . -q || true + + secret-scan: + runs-on: [homelab, self-hosted, linux, heavy] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Gitleaks + run: | + docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \ + detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..737dafc --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# --- OS / editor --- +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp + +# --- Secrets & env (real values never in git) --- +.env +.env.* +!.env.example +*.pem +*.key +*password* +*credential* +!*example* + +# --- Real data (commit *.example.* fixtures instead) --- +data/ +!data/ +data/* +!data/*.example.* +*.sqlite3 +*.local.yml +*.local.json + +# --- Python --- +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# --- Node --- +node_modules/ +dist/ +coverage/ +npm-debug.log* + +# --- Outputs / scratch --- +out/ +tmp/ +.tmp/ +scratch/ +*.log + +# --- Agent tooling (local, not for the repo) --- +.cursor/ +.claude/ +.codex/ +.agents/ +.opencode/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..1d64149 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,36 @@ +# Repo gitleaks config — same rules as the global fallback +# (~/.config/git/gitleaks.toml) so local hook and CI agree. + +title = "repo gitleaks" + +[extend] +useDefault = true + +[[rules]] +id = "env-file-committed" +description = "Raw .env file (or backup/variant) staged for commit" +path = '''(^|/)\.env(\.(backup|bak|old|orig|save|local|prod|production|dev|development|staging))?$''' + +[[rules]] +id = "password-or-credential-file" +description = "File name indicates stored password/credential material" +path = '''(?i)(^|/)[^/]*(password|passwd|credential)[^/]*\.(txt|json|ya?ml|env|cfg|ini)$''' + +[[rules]] +id = "private-key-file" +description = "Private key or keystore file staged for commit" +path = '''(^|/)(id_rsa|id_ed25519|id_ecdsa)[^/]*$|\.(pem|p12|pfx|key)$''' + +[[rules]] +id = "dotfile-secret-token" +description = "Hidden standalone secret/token file" +path = '''(^|/)\.[^/]*(secret|token)[^/]*$''' + +[allowlist] +description = "Legitimate non-secret files matching the patterns above" +paths = [ + '''(?i)\.(example|sample|template|dist)$''', + '''(^|/)\.env\.example[^/]*$''', + '''(^|/)[^/]*\.pub$''', + '''(^|/)package(-lock)?\.json$''', +] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd632de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md — stork + +Short orientation for Cursor agents. + +## Defaults + +- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`. +- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`. +- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens. +- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`). + +## Close-out + +1. `make test` and `make lint` before claiming done. +2. Do not put LAN IPs or real invites in this repo. +3. Merge only when the user asks. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d4dadb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY stork /app/stork +COPY static /app/static + +ENV STORK_DATA=/data +ENV STORK_COOKIE_SECURE=true +EXPOSE 8094 +CMD ["uvicorn", "stork.app:app", "--host", "0.0.0.0", "--port", "8094"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96c887d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ilia Dobkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c44ebf8 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: help install test lint run docker-build seed + +help: ## Show targets + @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-16s %s\n", $$1, $$2}' + +install: ## Install runtime + dev deps into .venv (prefers python3.12) + @PY=$$(command -v python3.12 || command -v python3); \ + $$PY -m venv .venv + .venv/bin/pip install -U pip + .venv/bin/pip install -r requirements-dev.txt + +lint: ## Ruff lint (CI hard gate) + ruff check . + +test: ## Pytest (CI hard gate) + pytest -q + +run: ## Local uvicorn on :8094 (needs .env) + @test -f .env || (echo "Copy .env.example to .env first" && exit 1) + set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094 + +seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...]) + @test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1) + STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \ + .venv/bin/python scripts/seed_names.py + +docker-build: ## Build local image + docker build -t homelab-stork:latest . diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ebf27c --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Stork + +Private family board for baby **first** and **middle** name ideas: suggest +names, vote, and keep origin / meaning / pronunciation in English, Russian, +and Hebrew. + +Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is +the app only — no LAN IPs or production secrets here. + +## Quick start (local) + +```bash +cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN) +make install +make test +make run +``` + +Open http://127.0.0.1:8094 — enter the invite code and your display name. +Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry. + +## API sketch + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/api/health` | Liveness | +| POST | `/api/session` | `{invite, display_name}` → cookies | +| GET | `/api/names?kind=first\|middle` | Ranked list | +| POST | `/api/names` | Add name + optional locales | +| PATCH | `/api/names/{id}` | Update locale notes | +| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` | +| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` | + +## Secrets + +- Local: gitignored `.env` +- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git + +## Production shape + +Same host pattern as Compare: Docker on automationlab LXC **225**, public +HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`. diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..2e9abea --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Create the Gitea repo for this project with gates preconfigured: +# private repo, delete-branch-after-merge, branch protection on main +# requiring the CI contexts, initial push. +# +# Usage: ./bootstrap.sh [--public] +set -euo pipefail + +NAME="${1:?usage: bootstrap.sh [--public]}" +LANE="${2:?usage: bootstrap.sh [--public]}" +PRIVATE=true +[[ "${3:-}" == "--public" ]] && PRIVATE=false + +GITEA="https://git.levkin.ca" +OWNER="ilia" +# shellcheck disable=SC1090 +source "$HOME/.config/hermes/gitea.env" # provides GITEA_TOKEN + +api() { + local method="$1" path="$2" body="${3:-}" + curl -sf -X "$method" -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" ${body:+-d "$body"} "${GITEA}/api/v1${path}" +} + +case "$LANE" in + python) CONTEXTS='["CI / python-ci (pull_request)","CI / secret-scan (pull_request)"]' ;; + node) CONTEXTS='["CI / node-ci (pull_request)","CI / secret-scan (pull_request)"]' ;; + *) echo "lane must be python or node" >&2; exit 1 ;; +esac + +if [[ ! -f ".gitea/workflows/ci.yml" ]]; then + echo "ERROR: .gitea/workflows/ci.yml missing — copy one from ci-templates/ first." >&2 + exit 1 +fi + +echo "Creating ${OWNER}/${NAME} (private=${PRIVATE})..." +api POST /user/repos "{\"name\":\"${NAME}\",\"private\":${PRIVATE},\"default_branch\":\"main\"}" >/dev/null + +echo "Enabling delete-branch-after-merge..." +api PATCH "/repos/${OWNER}/${NAME}" '{"default_delete_branch_after_merge": true}' >/dev/null + +echo "Pushing main..." +git remote add origin "${GITEA}/${OWNER}/${NAME}.git" 2>/dev/null || true +git push "https://${OWNER}:${GITEA_TOKEN}@git.levkin.ca/${OWNER}/${NAME}.git" main + +echo "Adding branch protection (required contexts: ${CONTEXTS})..." +api POST "/repos/${OWNER}/${NAME}/branch_protections" \ + "{\"branch_name\":\"main\",\"rule_name\":\"main\",\"enable_push\":false,\"enable_status_check\":true,\"status_check_contexts\":${CONTEXTS},\"required_approvals\":0}" >/dev/null + +echo "Done: ${GITEA}/${OWNER}/${NAME}" +echo "Work via PRs from now on; merge with:" +echo " GITEA_REPO=${NAME} bash ~/Documents/code/hermes/scripts/gitea-merge-when-green.sh " diff --git a/data/seed-names.example.json b/data/seed-names.example.json new file mode 100644 index 0000000..89eb9cf --- /dev/null +++ b/data/seed-names.example.json @@ -0,0 +1,109 @@ +{ + "names": [ + { + "kind": "first", + "spelling": "Shai", + "locales": { + "en": { + "pronunciation": "SHY (rhymes with sky)", + "origin": "Hebrew (שי)", + "meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai." + }, + "ru": { + "pronunciation": "Шай", + "origin": "иврит", + "meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai." + }, + "he": { + "pronunciation": "שַׁי — shai", + "origin": "עברית", + "meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay." + } + } + }, + { + "kind": "first", + "spelling": "Roze", + "locales": { + "en": { + "pronunciation": "ROHZ", + "origin": "Latin rosa / flower name (also Germanic Rose lineage)", + "meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)." + }, + "ru": { + "pronunciation": "Ро́уз / Ро́за", + "origin": "латинское rosa; славянская форма Роза", + "meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie." + }, + "he": { + "pronunciation": "רוֹז", + "origin": "לטינית / שם פרח", + "meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)." + } + } + }, + { + "kind": "first", + "spelling": "Odet", + "locales": { + "en": { + "pronunciation": "oh-DET", + "origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”", + "meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda." + }, + "ru": { + "pronunciation": "Оде́т", + "origin": "французское Odette ← германское «богатство»", + "meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta." + }, + "he": { + "pronunciation": "אוֹדֶט", + "origin": "צרפתית (Odette) משורש גרמאני", + "meaning": "עושר / שפע. צורות: Odette, Odetta, Odet." + } + } + }, + { + "kind": "first", + "spelling": "Riva", + "locales": { + "en": { + "pronunciation": "REE-vah", + "origin": "Hebrew / Yiddish short form of Rivka (Rebecca)", + "meaning": "Short for Rivka — “to bind / tie; captivating.” Related: Rivka, Rebecca, Rebekah, Rifka, Reba." + }, + "ru": { + "pronunciation": "Ри́ва", + "origin": "уменьшительное от Ривка / Ревекка", + "meaning": "краткое от Ривка («связывать», «пленить»). Варианты: Rivka, Rebecca, Rifka." + }, + "he": { + "pronunciation": "רִיבָה", + "origin": "קיצור של רבקה", + "meaning": "צורת חיבה של רבקה. קשור ל־Rivka / Rebecca." + } + } + }, + { + "kind": "first", + "spelling": "Rivka", + "locales": { + "en": { + "pronunciation": "riv-KAH / REEV-kah", + "origin": "Hebrew biblical (רִבְקָה) — wife of Isaac", + "meaning": "From root “to bind / tie / join”; often glossed captivating. English: Rebecca, Rebekah. Short forms: Riva, Rifka, Becky." + }, + "ru": { + "pronunciation": "Ри́вка", + "origin": "библейское еврейское имя (Ревекка)", + "meaning": "«связывать», «соединять»; библейская Ревекка. Варианты: Rebecca, Rebekah, Riva, Rifka." + }, + "he": { + "pronunciation": "רִבְקָה — rivká", + "origin": "תנ״ך — אשת יצחק", + "meaning": "שורש ר־ב־ק (לקשור / לחבר). באנגלית Rebecca. קיצורים: Riva, Rifka." + } + } + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..564da61 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +# Stork — family baby-name board (local / generic bind). +# Production bind + Caddy live in ansible deploy/stork/. + +services: + stork: + build: . + image: homelab-stork:latest + container_name: stork + restart: unless-stopped + ports: + - "8094:8094" + volumes: + - ./data:/data + env_file: + - .env + environment: + STORK_DATA: /data + STORK_COOKIE_SECURE: "${STORK_COOKIE_SECURE:-false}" diff --git a/docs/writing-docs.md b/docs/writing-docs.md new file mode 100644 index 0000000..efec301 --- /dev/null +++ b/docs/writing-docs.md @@ -0,0 +1,138 @@ +# Writing READMEs and guides + +House style for Markdown in Levkin repos. Goal: docs that read like a +competent human wrote them for another engineer, not like a product page +or a chatbot reply. + +Agents: also follow `~/.cursor/rules/docs-writing.mdc` and the humanizer +skill when rewriting prose. + +## What belongs where + +| Doc | Job | +|-----|-----| +| `README.md` | What it is, how to run it, where config and deeper docs live | +| `docs/guides/*.md` | How-to for one task (deploy, upgrade, recover) | +| `docs/reference/*.md` | Stable facts (URLs, ports, schema, CLI flags) | +| `AGENTS.md` / `.cursorrules` | Short orientation for agents, not a second README | +| `CHANGELOG.md` | What changed, by version | +| Scratch (`*_PLAN`, `*_SUMMARY`, `FIX_*`, `HANDOFF`, `PROGRESS_*`) | Temporary. Archive or delete when done; do not polish as product docs | + +Do not paste the same wall of text into README and three guides. Link once. + +## README checklist + +Every repo README should answer these, in roughly this order: + +1. **What** — one or two plain sentences. Name the audience if it is not obvious. +2. **Status** — active, beta, archived, research-only. Put archived status in a blockquote at the top. +3. **Run it** — install or `make` entrypoint and one command that works. +4. **Config** — `.env.example`, Infisical path (`/apps/`), or vault keys. Never real secrets. +5. **Pointers** — links to guides; do not duplicate them. +6. **License** — or say the repo is private / personal. + +Add when useful: short architecture sketch, known limits, deploy note +("ops runbooks live in the ansible repo"). + +Skip in READMEs: emoji decoration, marketing feature walls, welcome blurbs, +kanban ceremony, and change-narration ("this was added to replace…"). + +## Guide checklist + +A how-to guide should: + +1. State the goal in the first sentence ("Deploy X on LXC Y"). +2. List prerequisites (access, packages, secrets location). +3. Give ordered steps with copy-pasteable commands. +4. End with a verify step and what "done" looks like. +5. Link related runbooks instead of restating them. + +One job per guide. Split if you need more than one primary verb. + +## Voice and formatting + +### Do + +- Write in sentence case for headings: `## Getting started`, not `## Getting Started`. +- Prefer short paragraphs and concrete nouns (paths, hosts, commands). +- Use tables for env vars, ports, and host inventories. +- Use fenced code blocks with a language tag when it helps. +- Prefer `is` / `has` / `runs` over `serves as` / `boasts` / `features`. +- Keep bold for rare emphasis (a warning, a path). Not for every label. + +### Do not + +- Decorate headings or bullets with emoji (`🚀`, `✅`, `💡`, …). If a CLI + prints emoji, quote that output in a code block; do not restyle the doc around it. +- Use bold-colon list items as fake headings: + `- **Web-Based**: Modern React frontend…` → write a normal sentence or table. +- Stuff feature lists with adjectives: *state-of-the-art*, *seamless*, + *robust*, *powerful*, *comprehensive*, *cutting-edge*, *production-ready*, + *unlock*, *leverage*, *delve*, *landscape*, *tapestry*, *testament*. +- Open with chatbot filler: "Welcome to…", "Here's what you need to know", + "Let's dive in", "It is important to note that…". +- Overuse em dashes. Prefer a period, comma, colon, or parentheses. +- Title-Case Every Heading Like A Brochure. +- Invent facts, URLs, version numbers, or "experts say" attributions while editing. + +### Examples + +Bad: + +```markdown +# 🚀 PunimTag + +**Modern Photo Management and Facial Recognition System** + +A fast, simple, and modern web application using state-of-the-art DeepFace AI. + +## Key Features + +- **Web-Based**: Modern React frontend with FastAPI backend +- **Privacy-First**: All data stored locally, no cloud dependencies +``` + +Good: + +```markdown +# PunimTag + +Local photo library with face recognition (DeepFace / ArcFace). Admin UI +(React) and viewer (Next.js) share one monorepo and a PostgreSQL backend. + +## Features + +- Face detect and match with RetinaFace + ArcFace (or other listed models) +- Search by person, date, tag, or folder +- Runs on your machine; no cloud dependency for core data +``` + +## Public vs private repos + +Public READMEs and guides: + +- No LAN IPs, no `10.0.10.x`, no root SSH one-liners. +- No absolute home paths (`/Users/…`). +- No other people's PII. + +Private homelab docs (ansible, hermes, levkin) may name hosts and IPs. +Still skip emoji and marketing tone. + +Repos under `Gitilia/*` are push mirrors from Gitea. After merging a release or +public README/docs change, sync and verify from the ansible repo: +`make github-mirror-sync` then `make github-mirror-health` (see +`ansible/docs/guides/github-mirrors.md`). +## When rewriting existing docs + +1. Preserve every real fact (commands, paths, versions, URLs). +2. Cut fluff; keep the procedure. +3. Fix formatting to match this guide in the same pass. +4. Leave code blocks, frontmatter, and link targets intact unless the link is wrong. +5. Scratch/status files: delete or move under `docs/archive/` rather than + "humanizing" a dead plan. + +## Related + +- Project skeleton: this repo's root `README.md` +- Hard gates (CI, secrets, LICENSE): `~/.cursor/rules/new-project-standards.mdc` +- Tone scrub: Cursor skill `humanizer` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0e4c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..fd53f84 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.5 +httpx==0.28.1 +ruff==0.11.5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e4382f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +pydantic==2.11.3 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..9c671d0 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,7 @@ +# Lint gate config — enforced in CI (no || true). Start pragmatic; tighten +# per-project by extending `select`, never by muting the CI step. +target-version = "py311" +line-length = 120 + +[lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/scripts/seed_names.py b/scripts/seed_names.py new file mode 100644 index 0000000..ad35769 --- /dev/null +++ b/scripts/seed_names.py @@ -0,0 +1,48 @@ +"""Seed starter first names into a local or remote Stork instance. + +Usage: + # against local make run (cookies via invite): + STORK_URL=http://127.0.0.1:8094 STORK_INVITE=local-dev-invite \\ + .venv/bin/python scripts/seed_names.py + + # against prod after deploy: + STORK_URL=https://stork.levkin.ca STORK_INVITE="$(ssh root@10.0.10.45 grep STORK_INVITE_TOKEN /opt/stork/.env | cut -d= -f2-)" \\ + .venv/bin/python scripts/seed_names.py +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +SEED = ROOT / "data" / "seed-names.example.json" + + +def main() -> int: + base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/") + invite = os.environ.get("STORK_INVITE", "").strip() + display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia" + if not invite: + print("Set STORK_INVITE", file=sys.stderr) + return 1 + names = json.loads(SEED.read_text())["names"] + with httpx.Client(base_url=base, timeout=30.0) as client: + session = client.post("/api/session", json={"invite": invite, "display_name": display}) + session.raise_for_status() + for item in names: + res = client.post("/api/names", json=item) + if res.status_code == 400 and "already exists" in res.text: + print(f"skip {item['spelling']} (exists)") + continue + res.raise_for_status() + print(f"added {item['kind']}: {item['spelling']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..042801b --- /dev/null +++ b/static/index.html @@ -0,0 +1,323 @@ + + + + + + Stork — family name board + + + +
+

Stork

+

Family name ideas — vote, add notes in English, Russian, and Hebrew.

+ +
+ + + + + +
+ +
+
+ + +
+ + + diff --git a/stork/__init__.py b/stork/__init__.py new file mode 100644 index 0000000..2d259cb --- /dev/null +++ b/stork/__init__.py @@ -0,0 +1,3 @@ +"""Stork — family baby-name board with votes and multilingual notes.""" + +__version__ = "0.1.0" diff --git a/stork/app.py b/stork/app.py new file mode 100644 index 0000000..954d372 --- /dev/null +++ b/stork/app.py @@ -0,0 +1,206 @@ +"""Stork HTTP API + invite-gated family UI.""" + +from __future__ import annotations + +import os +import secrets +from pathlib import Path +from typing import Any + +from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from stork.db import KINDS, LANGS, Store + +DATA_DIR = Path(os.environ.get("STORK_DATA", "./data")) +INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip() +ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip() +COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} +STATIC = Path(__file__).resolve().parent.parent / "static" + +app = FastAPI(title="Stork", docs_url=None, redoc_url=None) +store = Store(DATA_DIR / "stork.sqlite3") + +if STATIC.is_dir(): + app.mount("/static", StaticFiles(directory=STATIC), name="static") + + +class LocaleIn(BaseModel): + pronunciation: str = "" + origin: str = "" + meaning: str = "" + + +class NameIn(BaseModel): + kind: str = Field(pattern="^(first|middle)$") + spelling: str = Field(min_length=1, max_length=80) + locales: dict[str, LocaleIn] = Field(default_factory=dict) + + +class LocalesPatch(BaseModel): + locales: dict[str, LocaleIn] + + +class VoteIn(BaseModel): + value: int = Field(ge=-1, le=1) + + +class SessionIn(BaseModel): + invite: str = Field(min_length=1, max_length=200) + display_name: str = Field(min_length=1, max_length=80) + + +def _require_invite_configured() -> None: + if not INVITE_TOKEN: + raise HTTPException(503, "STORK_INVITE_TOKEN not configured") + + +def _invite_ok(invite: str | None) -> bool: + _require_invite_configured() + return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN) + + +def _session( + stork_invite: str | None = Cookie(default=None), + stork_voter: str | None = Cookie(default=None), + stork_name: str | None = Cookie(default=None), + x_stork_invite: str | None = Header(default=None), +) -> dict[str, str]: + invite = x_stork_invite or stork_invite + if not _invite_ok(invite): + raise HTTPException(401, "Invite required") + voter = (stork_voter or "").strip() + name = (stork_name or "").strip() + if not voter or not name: + raise HTTPException(401, "Session required — open the invite link and enter your name") + return {"voter_key": voter, "display_name": name} + + +def _admin( + x_stork_admin: str | None = Header(default=None), +) -> None: + if not ADMIN_TOKEN: + raise HTTPException(503, "Admin not configured") + if not x_stork_admin or not secrets.compare_digest(x_stork_admin, ADMIN_TOKEN): + raise HTTPException(403, "Admin token required") + + +@app.get("/api/health") +def health() -> dict[str, Any]: + return {"ok": True, "service": "stork"} + + +@app.get("/api/meta") +def meta() -> dict[str, Any]: + return { + "langs": list(LANGS), + "kinds": list(KINDS), + "invite_required": True, + } + + +@app.post("/api/session") +def create_session(body: SessionIn, response: Response) -> dict[str, Any]: + if not _invite_ok(body.invite.strip()): + raise HTTPException(403, "Invalid invite") + voter = secrets.token_urlsafe(16) + display = body.display_name.strip() + cookie_kwargs: dict[str, Any] = { + "httponly": True, + "samesite": "lax", + "secure": COOKIE_SECURE, + "max_age": 60 * 60 * 24 * 400, + "path": "/", + } + response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs) + response.set_cookie("stork_voter", voter, **cookie_kwargs) + response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False}) + return {"ok": True, "display_name": display} + + +@app.get("/api/session") +def get_session( + stork_invite: str | None = Cookie(default=None), + stork_voter: str | None = Cookie(default=None), + stork_name: str | None = Cookie(default=None), + x_stork_invite: str | None = Header(default=None), +) -> dict[str, Any]: + invite = x_stork_invite or stork_invite + authed = _invite_ok(invite) if INVITE_TOKEN else False + return { + "authenticated": authed and bool(stork_voter) and bool(stork_name), + "display_name": stork_name or "", + "has_invite": authed, + } + + +@app.get("/api/names") +def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + if kind not in KINDS: + raise HTTPException(400, "kind must be first or middle") + return {"items": store.list_names(kind, voter_key=session["voter_key"])} + + +@app.post("/api/names") +def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[str, Any]: + locales = {k: v.model_dump() for k, v in body.locales.items()} + try: + return store.add_name( + kind=body.kind, + spelling=body.spelling, + created_by=session["display_name"], + locales=locales, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@app.patch("/api/names/{name_id}") +def patch_name( + name_id: int, + body: LocalesPatch, + session: dict[str, str] = Depends(_session), +) -> dict[str, Any]: + _ = session + locales = {k: v.model_dump() for k, v in body.locales.items()} + updated = store.update_locales(name_id, locales) + if not updated: + raise HTTPException(404, "Name not found") + return updated + + +@app.post("/api/names/{name_id}/vote") +def vote_name( + name_id: int, + body: VoteIn, + session: dict[str, str] = Depends(_session), +) -> dict[str, Any]: + try: + updated = store.vote( + name_id=name_id, + voter_key=session["voter_key"], + voter_name=session["display_name"], + value=body.value, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if not updated: + raise HTTPException(404, "Name not found") + return updated + + +@app.delete("/api/names/{name_id}") +def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]: + if not store.delete_name(name_id): + raise HTTPException(404, "Name not found") + return {"ok": True} + + +@app.get("/") +def home() -> FileResponse: + index = STATIC / "index.html" + if not index.is_file(): + raise HTTPException(404, "UI missing") + return FileResponse(index) diff --git a/stork/db.py b/stork/db.py new file mode 100644 index 0000000..b598b76 --- /dev/null +++ b/stork/db.py @@ -0,0 +1,265 @@ +"""SQLite persistence for names, locales, and votes.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any + +LANGS = ("en", "ru", "he") +KINDS = ("first", "middle") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')), + spelling TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + UNIQUE (kind, spelling COLLATE NOCASE) +); + +CREATE TABLE IF NOT EXISTS locales ( + name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE, + lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')), + pronunciation TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + meaning TEXT NOT NULL DEFAULT '', + PRIMARY KEY (name_id, lang) +); + +CREATE TABLE IF NOT EXISTS votes ( + name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE, + voter_key TEXT NOT NULL, + voter_name TEXT NOT NULL DEFAULT '', + value INTEGER NOT NULL CHECK (value IN (-1, 1)), + created_at REAL NOT NULL, + PRIMARY KEY (name_id, voter_key) +); + +CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind); +CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id); +""" + + +class Store: + def __init__(self, path: Path) -> None: + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._conn = sqlite3.connect(str(self.path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA foreign_keys = ON") + with self._lock: + self._conn.executescript(_SCHEMA) + self._conn.commit() + + def close(self) -> None: + with self._lock: + self._conn.close() + + def _empty_locales(self) -> dict[str, dict[str, str]]: + return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS} + + def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]: + out = self._empty_locales() + if not locales: + return out + for lang in LANGS: + raw = locales.get(lang) or {} + if not isinstance(raw, dict): + continue + out[lang] = { + "pronunciation": str(raw.get("pronunciation") or "").strip()[:200], + "origin": str(raw.get("origin") or "").strip()[:200], + "meaning": str(raw.get("meaning") or "").strip()[:500], + } + return out + + def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]: + locales = self._empty_locales() + cur = self._conn.execute( + "SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?", + (row["id"],), + ) + for loc in cur.fetchall(): + locales[loc["lang"]] = { + "pronunciation": loc["pronunciation"], + "origin": loc["origin"], + "meaning": loc["meaning"], + } + return { + "id": row["id"], + "kind": row["kind"], + "spelling": row["spelling"], + "created_by": row["created_by"], + "created_at": row["created_at"], + "locales": locales, + "score": score, + "vote_count": vote_count, + "my_vote": my_vote, + } + + def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]: + if kind not in KINDS: + raise ValueError("invalid kind") + with self._lock: + rows = self._conn.execute( + "SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE", + (kind,), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + score_row = self._conn.execute( + "SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?", + (row["id"],), + ).fetchone() + my_vote = None + if voter_key: + vote = self._conn.execute( + "SELECT value FROM votes WHERE name_id = ? AND voter_key = ?", + (row["id"], voter_key), + ).fetchone() + if vote: + my_vote = int(vote["value"]) + out.append( + self._row_to_name( + row, + int(score_row["score"]), + int(score_row["vote_count"]), + my_vote, + ) + ) + out.sort(key=lambda n: (-n["score"], n["spelling"].casefold())) + return out + + def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None: + with self._lock: + row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone() + if not row: + return None + score_row = self._conn.execute( + "SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?", + (name_id,), + ).fetchone() + my_vote = None + if voter_key: + vote = self._conn.execute( + "SELECT value FROM votes WHERE name_id = ? AND voter_key = ?", + (name_id, voter_key), + ).fetchone() + if vote: + my_vote = int(vote["value"]) + return self._row_to_name( + row, + int(score_row["score"]), + int(score_row["vote_count"]), + my_vote, + ) + + def add_name( + self, + kind: str, + spelling: str, + created_by: str, + locales: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if kind not in KINDS: + raise ValueError("invalid kind") + spelling = spelling.strip() + if not spelling or len(spelling) > 80: + raise ValueError("spelling required (1–80 chars)") + created_by = (created_by or "").strip()[:80] + locs = self._normalize_locales(locales) + now = time.time() + with self._lock: + try: + cur = self._conn.execute( + "INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)", + (kind, spelling, created_by, now), + ) + except sqlite3.IntegrityError as exc: + raise ValueError("name already exists for this kind") from exc + name_id = int(cur.lastrowid) + for lang, payload in locs.items(): + self._conn.execute( + "INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) VALUES (?, ?, ?, ?, ?)", + (name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]), + ) + self._conn.commit() + result = self.get_name(name_id) + assert result is not None + return result + + def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None: + locs = self._normalize_locales(locales) + with self._lock: + row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row: + return None + for lang, payload in locs.items(): + self._conn.execute( + """ + INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(name_id, lang) DO UPDATE SET + pronunciation = excluded.pronunciation, + origin = excluded.origin, + meaning = excluded.meaning + """, + (name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]), + ) + self._conn.commit() + return self.get_name(name_id) + + def vote( + self, + name_id: int, + voter_key: str, + voter_name: str, + value: int, + ) -> dict[str, Any] | None: + if value not in (-1, 0, 1): + raise ValueError("vote must be -1, 0, or 1") + voter_key = voter_key.strip() + if not voter_key or len(voter_key) > 64: + raise ValueError("invalid voter") + voter_name = (voter_name or "").strip()[:80] + with self._lock: + row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone() + if not row: + return None + if value == 0: + self._conn.execute( + "DELETE FROM votes WHERE name_id = ? AND voter_key = ?", + (name_id, voter_key), + ) + else: + self._conn.execute( + """ + INSERT INTO votes (name_id, voter_key, voter_name, value, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(name_id, voter_key) DO UPDATE SET + value = excluded.value, + voter_name = excluded.voter_name, + created_at = excluded.created_at + """, + (name_id, voter_key, voter_name, value, time.time()), + ) + self._conn.commit() + return self.get_name(name_id, voter_key=voter_key) + + def delete_name(self, name_id: int) -> bool: + with self._lock: + cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,)) + self._conn.commit() + return cur.rowcount > 0 + + def export_snapshot(self) -> str: + """Debug helper for tests.""" + with self._lock: + names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()] + return json.dumps(names) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2d06366 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..dbe6c79 --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..d189fc4 --- /dev/null +++ b/tests/test_store.py @@ -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()