Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ A lightweight HTTP wrapper around [Things 3](https://culturedcode.com/things/) f
## Features

- Read all Things collections: inbox, today, upcoming, anytime, someday, logbook, projects, areas, tags
- Search tasks by title and notes across all collections
- View deadlines, trashed, and canceled tasks
- Create and update tasks and projects
- Complete or cancel tasks
- Complete or cancel tasks and projects
- HTTP Basic Auth on every endpoint
- Runs as a system service (available after boot, before login)
- Auto-docs at `/docs`
Expand Down Expand Up @@ -66,6 +68,10 @@ All endpoints require Basic Auth (`Authorization: Basic ...`).
| GET | `/projects` | All projects |
| GET | `/areas` | All areas |
| GET | `/tags` | All tags |
| GET | `/search?q=` | Search tasks by title/notes |
| GET | `/deadlines` | Tasks with deadlines |
| GET | `/trash` | Trashed/deleted items |
| GET | `/canceled` | Canceled tasks |
| GET | `/tasks/{uuid}` | Single task by UUID |

### Write
Expand All @@ -80,6 +86,8 @@ Writes go through the Things [URL scheme](https://culturedcode.com/things/suppor
| POST | `/tasks/{uuid}/cancel` | Cancel a task |
| POST | `/projects` | Create a project |
| PATCH | `/projects/{uuid}` | Update a project |
| POST | `/projects/{uuid}/complete` | Complete a project |
| POST | `/projects/{uuid}/cancel` | Cancel a project |

**Task fields:** `title`, `notes`, `when` (today/tomorrow/evening/someday/date), `deadline`, `tags`, `list_id`, `area_id`, `checklist_items`

Expand All @@ -98,6 +106,15 @@ curl -u things:secret -X POST http://localhost:8000/tasks \

# Complete a task
curl -u things:secret -X POST http://localhost:8000/tasks/SOME-UUID/complete

# Search tasks
curl -u things:secret "http://localhost:8000/search?q=meeting"

# Get deadlines
curl -u things:secret http://localhost:8000/deadlines

# Complete a project
curl -u things:secret -X POST http://localhost:8000/projects/SOME-UUID/complete
```

## Running as a system service
Expand Down
40 changes: 40 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@ def tags(auth=Depends(verify)):
return things.tags()


@app.get("/search")
def search(q: str = "", auth=Depends(verify)):
"""Search tasks across all collections by title and notes."""
if not q:
return []
return things.search(q)


@app.get("/deadlines")
def deadlines(auth=Depends(verify)):
"""Get all tasks with deadlines."""
return things.deadlines()


@app.get("/trash")
def trash(auth=Depends(verify)):
"""Get trashed/deleted items."""
return things.trash()


@app.get("/canceled")
def canceled(auth=Depends(verify)):
"""Get canceled tasks."""
return things.canceled()


@app.get("/tasks/{uuid}")
def get_task(uuid: str, auth=Depends(verify)):
item = things.get(uuid)
Expand Down Expand Up @@ -203,5 +229,19 @@ def update_project(uuid: str, body: ProjectUpdate, auth=Depends(verify)):
return {"status": "accepted"}


@app.post("/projects/{uuid}/complete", status_code=202)
def complete_project(uuid: str, auth=Depends(verify)):
"""Mark a project as completed."""
things.complete(uuid)
return {"status": "accepted"}


@app.post("/projects/{uuid}/cancel", status_code=202)
def cancel_project(uuid: str, auth=Depends(verify)):
"""Cancel a project."""
_open(things.url(uuid=uuid, command="update-project", canceled="true"))
return {"status": "accepted"}


if __name__ == "__main__":
uvicorn.run("main:app", host=HOST, port=PORT, reload=False)
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ dependencies = [
"fastapi",
"uvicorn",
]

[project.optional-dependencies]
test = [
"pytest",
"httpx",
]
Empty file added tests/__init__.py
Empty file.
265 changes: 265 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
"""Tests for things.http API endpoints."""

import os
import sys
from pathlib import Path

# Set required env before importing main
os.environ["THINGS_PASSWORD"] = "test-secret"

# Ensure main.py is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock

# Mock things.py before importing main
mock_things = MagicMock()
sys.modules["things"] = mock_things

# Now we can import main (it will use our mock)
import main # noqa: E402
from main import app # noqa: E402

client = TestClient(app)

AUTH = ("things", "test-secret")


def _auth():
return {"Authorization": "Basic dGhpbmdzOnRlc3Qtc2VjcmV0"}


@pytest.fixture(autouse=True)
def reset_mock():
mock_things.reset_mock()
yield
mock_things.reset_mock()


# --- Auth tests ---


def test_unauthorized():
"""All endpoints should return 401 without auth."""
resp = client.get("/today")
assert resp.status_code == 401


def test_wrong_password():
"""Wrong password should return 401."""
resp = client.get("/today", headers={"Authorization": "Basic dGhpbmdzOndyb25n"})
assert resp.status_code == 401


# --- Read endpoint tests ---


@patch("main.things.today")
def test_today(today_fn):
today_fn.return_value = [{"uuid": "abc", "title": "Task 1"}]
resp = client.get("/today", headers=_auth())
assert resp.status_code == 200
assert resp.json() == [{"uuid": "abc", "title": "Task 1"}]


@patch("main.things.inbox")
def test_inbox(inbox_fn):
inbox_fn.return_value = []
resp = client.get("/inbox", headers=_auth())
assert resp.status_code == 200
assert resp.json() == []


@patch("main.things.projects")
def test_projects(projects_fn):
projects_fn.return_value = [{"uuid": "p1", "title": "Project A"}]
resp = client.get("/projects", headers=_auth())
assert resp.status_code == 200


@patch("main.things.areas")
def test_areas(areas_fn):
areas_fn.return_value = [{"uuid": "a1", "title": "Health"}]
resp = client.get("/areas", headers=_auth())
assert resp.status_code == 200


@patch("main.things.tags")
def test_tags(tags_fn):
tags_fn.return_value = [{"uuid": "t1", "title": "work"}]
resp = client.get("/tags", headers=_auth())
assert resp.status_code == 200


@patch("main.things.logbook")
def test_logbook(logbook_fn):
logbook_fn.return_value = []
resp = client.get("/logbook", headers=_auth())
assert resp.status_code == 200


# --- New endpoints ---


@patch("main.things.search")
def test_search_no_query(search_fn):
"""Empty query returns empty list without calling search."""
resp = client.get("/search", headers=_auth())
assert resp.status_code == 200
assert resp.json() == []
search_fn.assert_not_called()


@patch("main.things.search")
def test_search_with_query(search_fn):
"""Search with a query calls things.search."""
search_fn.return_value = [
{"uuid": "s1", "title": "Meeting notes", "type": "to-do"}
]
resp = client.get("/search?q=meeting", headers=_auth())
assert resp.status_code == 200
assert resp.json() == [
{"uuid": "s1", "title": "Meeting notes", "type": "to-do"}
]
search_fn.assert_called_once_with("meeting")


@patch("main.things.search")
def test_search_special_chars(search_fn):
"""Search should handle special characters."""
search_fn.return_value = []
resp = client.get("/search?q=todo%20%23urgent", headers=_auth())
assert resp.status_code == 200
search_fn.assert_called_once_with("todo #urgent")


@patch("main.things.deadlines")
def test_deadlines(deadlines_fn):
"""Deadlines endpoint returns tasks with deadlines."""
deadlines_fn.return_value = [
{"uuid": "d1", "title": "Report", "deadline": "2026-06-01"},
]
resp = client.get("/deadlines", headers=_auth())
assert resp.status_code == 200
assert resp.json() == [
{"uuid": "d1", "title": "Report", "deadline": "2026-06-01"}
]
deadlines_fn.assert_called_once()


@patch("main.things.trash")
def test_trash(trash_fn):
"""Trash endpoint returns deleted items."""
trash_fn.return_value = [
{"uuid": "t1", "title": "Old task", "type": "to-do"},
]
resp = client.get("/trash", headers=_auth())
assert resp.status_code == 200
assert len(resp.json()) == 1


@patch("main.things.canceled")
def test_canceled(canceled_fn):
"""Canceled endpoint returns canceled tasks."""
canceled_fn.return_value = [
{"uuid": "c1", "title": "Cancelled item", "type": "to-do"},
]
resp = client.get("/canceled", headers=_auth())
assert resp.status_code == 200
assert len(resp.json()) == 1


# --- Single task endpoint ---


@patch("main.things.get")
def test_get_task_exists(get_fn):
get_fn.return_value = {"uuid": "abc", "title": "Task 1"}
resp = client.get("/tasks/abc", headers=_auth())
assert resp.status_code == 200


@patch("main.things.get")
def test_get_task_not_found(get_fn):
get_fn.return_value = None
resp = client.get("/tasks/nonexistent", headers=_auth())
assert resp.status_code == 404


# --- Write endpoint tests (sanity checks) ---


@patch("main.things.url")
@patch("main._open")
def test_create_task(mock_open, mock_url):
mock_url.return_value = "things:///add?title=test"
resp = client.post(
"/tasks",
headers=_auth(),
json={"title": "Test task", "when": "today"},
)
assert resp.status_code == 202
assert resp.json() == {"status": "accepted"}


@patch("main.things.complete")
def test_complete_task(mock_complete):
resp = client.post("/tasks/abc/complete", headers=_auth())
assert resp.status_code == 202


@patch("main.things.url")
@patch("main._open")
def test_cancel_task(mock_open, mock_url):
mock_url.return_value = "things:///update?uuid=abc&canceled=true"
resp = client.post("/tasks/abc/cancel", headers=_auth())
assert resp.status_code == 202


@patch("main.things.url")
@patch("main._open")
def test_update_task(mock_open, mock_url):
mock_url.return_value = "things:///update?uuid=abc&title=new"
resp = client.patch(
"/tasks/abc",
headers=_auth(),
json={"title": "new title"},
)
assert resp.status_code == 202


# --- Project write endpoint tests ---


@patch("main.things.complete")
def test_complete_project(mock_complete):
resp = client.post("/projects/p1/complete", headers=_auth())
assert resp.status_code == 202
assert resp.json() == {"status": "accepted"}
mock_complete.assert_called_once_with("p1")


@patch("main.things.url")
@patch("main._open")
def test_cancel_project(mock_open, mock_url):
mock_url.return_value = "things:///update-project?uuid=p1&canceled=true"
resp = client.post("/projects/p1/cancel", headers=_auth())
assert resp.status_code == 202
assert resp.json() == {"status": "accepted"}
mock_url.assert_called_once_with(uuid="p1", command="update-project", canceled="true")


@patch("main.things.url")
@patch("main._open")
def test_complete_project_requires_auth(mock_open, mock_url):
resp = client.post("/projects/p1/complete")
assert resp.status_code == 401


@patch("main.things.url")
@patch("main._open")
def test_cancel_project_requires_auth(mock_open, mock_url):
resp = client.post("/projects/p1/cancel")
assert resp.status_code == 401