39842c72d7
- 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>
245 lines
12 KiB
Python
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
|