c5b049edba
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>
123 lines
5.1 KiB
Python
123 lines
5.1 KiB
Python
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
|