Files
goyban 39842c72d7 Add plan dates, postponing, backlog and projects
- Tasks get an optional plan date separate from the deadline. Unfinished
  plans roll over in Today; only missed deadlines count as overdue.
- Postpone sheet on every task (tomorrow, weekend, next week, ...) that
  can move either the plan date or the deadline, with undo.
- Backlog view for undated tasks, with suggestions in Today.
- Projects with deadline, notes, progress, next step and "falling
  behind" warnings, shown in Today and in their own view.
- Times and the calendar now follow the plan date (else the deadline).
- Schema v3 migration (backup taken first); export format v3 includes
  projects and still imports older files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:26:32 +02:00

245 lines
12 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] == str(db.SCHEMA_VERSION)
conn.close()
assert (tmp_path / f"old.db.before-v{db.SCHEMA_VERSION}.bak").exists()
cols = {r[1] for r in sqlite3.connect(path).execute("PRAGMA table_info(tasks)")}
assert {"start_time", "planned_date", "project_id"} <= cols
def test_planned_date(client):
t = client.post("/api/tasks", headers=H, json={
"title": "Clean garage", "planned_date": "2026-10-12", "start_time": "09:00",
})
assert t.status_code == 201, t.text
t = t.json()
assert (t["planned_date"], t["due_date"]) == ("2026-10-12", None)
# Removing the plan date while a time is set is rejected (time needs a date).
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"planned_date": None}).status_code == 422
r = client.patch(f"/api/tasks/{t['id']}", headers=H, json={"planned_date": "2026-10-19"}).json()
assert r["planned_date"] == "2026-10-19"
def test_recurring_with_plan_dates(client):
# No deadline: repeats on plan dates.
gym = client.post("/api/tasks", headers=H, json={
"title": "Gym", "planned_date": "2026-10-05", "recurrence": "daily",
}).json()
nxt = client.post(f"/api/tasks/{gym['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()["next"]
assert (nxt["planned_date"], nxt["due_date"]) == ("2026-10-08", None)
# Deadline + plan date: the plan keeps its distance to the deadline.
rpt = client.post("/api/tasks", headers=H, json={
"title": "Report", "due_date": "2026-10-09", "planned_date": "2026-10-07", "recurrence": "weekly",
}).json()
nxt = client.post(f"/api/tasks/{rpt['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()["next"]
assert (nxt["due_date"], nxt["planned_date"]) == ("2026-10-16", "2026-10-14")
def test_projects(client):
p = client.post("/api/projects", headers=H, json={
"name": "Launch course", "due_date": "2026-12-31", "category_id": 1, "notes": "big one",
})
assert p.status_code == 201, p.text
p = p.json()
assert p["done"] is False and p["due_date"] == "2026-12-31"
t = client.post("/api/tasks", headers=H, json={"title": "Outline", "project_id": p["id"]}).json()
assert t["project_id"] == p["id"]
assert client.post("/api/tasks", headers=H, json={"title": "x", "project_id": 9999}).status_code == 400
assert client.post("/api/projects", headers=H, json={"name": " "}).status_code == 422
done = client.patch(f"/api/projects/{p['id']}", headers=H, json={"done": True}).json()
assert done["done"] is True and done["done_at"]
reopened = client.patch(f"/api/projects/{p['id']}", headers=H, json={"done": False}).json()
assert reopened["done_at"] is None
# Export/import keeps the project link.
data = client.get("/api/export").json()
client.post("/api/import?mode=replace", headers=H, json=data)
projects = client.get("/api/projects").json()
assert [x["name"] for x in projects] == ["Launch course"]
imported = next(x for x in client.get("/api/tasks").json() if x["title"] == "Outline")
assert imported["project_id"] == projects[0]["id"]
# Deleting a project keeps its tasks.
assert client.delete(f"/api/projects/{projects[0]['id']}", headers=H).status_code == 204
kept = next(x for x in client.get("/api/tasks").json() if x["title"] == "Outline")
assert kept["project_id"] is None