Files
whattodo/tests/test_api.py
T
goyban 509b1a1320 Add optional time ranges and a calendar day view
Tasks can now have a start/end time on their deadline date. The new
Calendar view shows a day as a timeline (with a week strip and projected
repeats of recurring tasks), and the task form lists what is already
planned that day and flags overlaps. Existing databases are migrated to
schema v2 automatically, with a backup copy taken first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:08:09 +02:00

183 lines
8.3 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
def test_times(client):
t = client.post("/api/tasks", headers=H, json={
"title": "Meeting", "due_date": "2026-10-08", "start_time": "10:00", "end_time": "12:00",
"recurrence": "weekly",
})
assert t.status_code == 201, t.text
t = t.json()
assert (t["start_time"], t["end_time"]) == ("10:00", "12:00")
bad = [
{"title": "x", "start_time": "10:00"}, # no date
{"title": "x", "due_date": "2026-10-08", "end_time": "10:00"}, # end without start
{"title": "x", "due_date": "2026-10-08", "start_time": "10:00", "end_time": "09:00"},
]
for body in bad:
assert client.post("/api/tasks", headers=H, json=body).status_code == 422, body
# Clearing the date while a time is set is rejected; clearing all three works.
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"due_date": None}).status_code == 422
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"end_time": "09:30"}).status_code == 422
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"end_time": None}).json()["end_time"] is None
# The next occurrence of a recurring task keeps its time.
nxt = client.post(f"/api/tasks/{t['id']}/complete", headers=H, json={"today": "2026-10-08"}).json()["next"]
assert (nxt["due_date"], nxt["start_time"]) == ("2026-10-15", "10:00")
cleared = client.patch(f"/api/tasks/{nxt['id']}", headers=H,
json={"due_date": None, "start_time": None, "end_time": None}).json()
assert cleared["due_date"] is None and cleared["start_time"] is None
def test_migration_from_v1(tmp_path, monkeypatch):
import sqlite3
from app import db
path = tmp_path / "old.db"
conn = sqlite3.connect(path)
conn.executescript("""
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO meta VALUES ('schema_version', '1');
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT NOT NULL DEFAULT '#6b7280', position INTEGER NOT NULL DEFAULT 0);
CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '',
category_id INTEGER, priority INTEGER NOT NULL DEFAULT 0, due_date TEXT,
tags TEXT NOT NULL DEFAULT '[]', recurrence TEXT NOT NULL DEFAULT 'none',
recur_interval INTEGER NOT NULL DEFAULT 1, done INTEGER NOT NULL DEFAULT 0, done_at TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE subtasks (id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL, title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0);
INSERT INTO tasks (title, created_at, updated_at) VALUES ('old task', 'x', 'x');
""")
conn.close()
monkeypatch.setattr(db, "DB_PATH", str(path))
db.init()
conn = sqlite3.connect(path)
assert conn.execute("SELECT title, start_time FROM tasks").fetchall() == [("old task", None)]
assert conn.execute("SELECT value FROM meta").fetchone()[0] == "2"
conn.close()
assert (tmp_path / "old.db.before-v2.bak").exists()