509b1a1320
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>
183 lines
8.3 KiB
Python
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()
|