Initial WhatToDo app

Self-hosted personal task list: FastAPI + SQLite in Docker, mobile-friendly
PWA frontend, deadlines, priorities, categories, tags, notes, checklists,
recurring tasks, single-password login, and JSON export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-10-07 11:43:56 +02:00
commit c5b049edba
23 changed files with 2045 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import os
import tempfile
os.environ["DB_PATH"] = os.path.join(tempfile.mkdtemp(), "test.db")
os.environ["APP_PASSWORD"] = "test-password"
os.environ["SECRET_KEY"] = "test-secret"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app import main # noqa: E402
H = {"X-WhatToDo": "1"}
@pytest.fixture(scope="module")
def client():
with TestClient(main.app) as c:
assert c.post("/api/login", json={"password": "test-password"}, headers=H).status_code == 200
yield c
def test_requires_login():
with TestClient(main.app) as anon:
assert anon.get("/api/tasks").status_code == 401
assert anon.get("/api/health").status_code == 200
assert anon.get("/").status_code == 200 # static shell is public
def test_wrong_password():
with TestClient(main.app) as anon:
assert anon.post("/api/login", json={"password": "nope"}, headers=H).status_code == 401
def test_write_requires_header(client):
assert client.post("/api/tasks", json={"title": "x"}).status_code == 403
def test_default_categories(client):
names = [c["name"] for c in client.get("/api/categories").json()]
assert names[:4] == ["Work", "Life", "Personal", "YouTube"]
def test_task_crud(client):
cat = client.get("/api/categories").json()[0]
r = client.post("/api/tasks", headers=H, json={
"title": " Write script ", "category_id": cat["id"], "priority": 3,
"due_date": "2026-10-10", "tags": ["#Video", "video", " edit "],
"subtasks": [{"title": "outline"}, {"title": "draft"}],
})
assert r.status_code == 201, r.text
t = r.json()
assert t["title"] == "Write script"
assert t["tags"] == ["video", "edit"]
assert [s["title"] for s in t["subtasks"]] == ["outline", "draft"]
r = client.patch(f"/api/tasks/{t['id']}", headers=H, json={
"due_date": None, "subtasks": [{"title": "outline", "done": True}],
})
t2 = r.json()
assert t2["due_date"] is None and t2["priority"] == 3
assert t2["subtasks"] == [{"id": t2["subtasks"][0]["id"], "title": "outline", "done": True}]
assert client.post("/api/tasks", headers=H, json={"title": " "}).status_code == 422
assert client.post("/api/tasks", headers=H, json={"title": "x", "category_id": 9999}).status_code == 400
assert client.delete(f"/api/tasks/{t['id']}", headers=H).status_code == 204
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"title": "x"}).status_code == 404
def test_recurring_completion(client):
t = client.post("/api/tasks", headers=H, json={
"title": "Upload video", "due_date": "2026-10-02", "recurrence": "weekly",
"subtasks": [{"title": "thumbnail", "done": True}],
}).json()
r = client.post(f"/api/tasks/{t['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()
assert r["task"]["done"] is True
# Oct 2 + 1 week = Oct 9, which is after "today" (Oct 7).
assert r["next"]["due_date"] == "2026-10-09"
assert r["next"]["done"] is False
assert r["next"]["subtasks"][0]["done"] is False
reopened = client.post(f"/api/tasks/{t['id']}/reopen", headers=H).json()
assert reopened["done"] is False
def test_next_due_skips_past_and_clamps_month():
from datetime import date
assert main.next_due(date(2026, 9, 1), "daily", 1, date(2026, 10, 7)) == date(2026, 10, 8)
assert main.next_due(date(2026, 1, 31), "monthly", 1, date(2026, 1, 31)) == date(2026, 2, 28)
assert main.next_due(None, "yearly", 1, date(2026, 10, 7)) == date(2027, 10, 7)
def test_category_crud(client):
c = client.post("/api/categories", headers=H, json={"name": "Gym", "color": "#123456"}).json()
assert client.post("/api/categories", headers=H, json={"name": "gym", "color": "#123456"}).status_code == 409
t = client.post("/api/tasks", headers=H, json={"title": "Leg day", "category_id": c["id"]}).json()
assert client.patch(f"/api/categories/{c['id']}", headers=H, json={"name": "Fitness"}).json()["name"] == "Fitness"
client.delete(f"/api/categories/{c['id']}", headers=H)
tasks = {x["id"]: x for x in client.get("/api/tasks").json()}
assert tasks[t["id"]]["category_id"] is None
def test_export_import_roundtrip(client):
client.post("/api/tasks", headers=H, json={"title": "Export me", "category_id": 1, "tags": ["a"]})
exported = client.get("/api/export")
assert "attachment" in exported.headers["content-disposition"]
data = exported.json()
n = len(data["tasks"])
r = client.post("/api/import?mode=merge", headers=H, json=data).json()
assert r["tasks"] == n
assert len(client.get("/api/tasks").json()) == 2 * n
assert len(client.get("/api/categories").json()) == len(data["categories"]) # matched by name
client.post("/api/import?mode=replace", headers=H, json=data)
after = client.get("/api/tasks").json()
assert len(after) == n
cats = {c["id"]: c["name"] for c in client.get("/api/categories").json()}
exported_task = next(t for t in after if t["title"] == "Export me")
assert cats[exported_task["category_id"]] == "Work"
assert client.post("/api/import", headers=H, json={"app": "other", "version": 1}).status_code == 400