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()