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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A lightweight HTTP wrapper around [Things 3](https://culturedcode.com/things/) f
- Read all Things collections: inbox, today, upcoming, anytime, someday, logbook, projects, areas, tags
- Create and update tasks and projects
- Complete or cancel tasks
- Move tasks between collections (inbox, today, upcoming, someday, anytime, 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 @@ -76,10 +77,11 @@ Writes go through the Things [URL scheme](https://culturedcode.com/things/suppor
|---|---|---|
| POST | `/tasks` | Create a task |
| PATCH | `/tasks/{uuid}` | Update a task |
| POST | `/tasks/{uuid}/complete` | Complete a task |
| POST | `/tasks/{uuid}/cancel` | Cancel a task |
| POST | `/projects` | Create a project |
| PATCH | `/projects/{uuid}` | Update a project |
|| POST | `/tasks/{uuid}/complete` | Complete a task |
|| POST | `/tasks/{uuid}/cancel` | Cancel a task |
|| POST | `/tasks/{uuid}/move` | Move task to collection |
|| POST | `/projects` | Create a project |
|| PATCH | `/projects/{uuid}` | Update a project |

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

Expand All @@ -98,6 +100,11 @@ 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

# Move a task to today
curl -u things:secret -X POST http://localhost:8000/tasks/SOME-UUID/move \
-H 'Content-Type: application/json' \
-d '{"collection": "today"}'
```

## Running as a system service
Expand Down
13 changes: 13 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ class ProjectUpdate(BaseModel):
area_id: Optional[str] = None


class TaskMove(BaseModel):
"""Move a task to a different collection (inbox, today, upcoming, someday, anytime, or a project)."""
collection: str
"""Target collection: one of 'inbox', 'today', 'upcoming', 'someday', 'anytime', or a project ID."""


# --- Write endpoints ---

@app.post("/tasks", status_code=202)
Expand Down Expand Up @@ -181,6 +187,13 @@ def cancel_task(uuid: str, auth=Depends(verify)):
return {"status": "accepted"}


@app.post("/tasks/{uuid}/move", status_code=202)
def move_task(uuid: str, body: TaskMove, auth=Depends(verify)):
"""Move a task to a different collection (inbox, today, upcoming, someday, anytime, or a project)."""
_open(things.url(uuid=uuid, command="move-to-collection", collection=body.collection))
return {"status": "accepted"}


@app.post("/projects", status_code=202)
def create_project(body: ProjectCreate, auth=Depends(verify)):
params = {k: v for k, v in body.model_dump().items() if v is not None}
Expand Down
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.
266 changes: 266 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
"""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="dGhpbmdzOnRlc3Qtc2VjcmV0" # base64("things:test-secret")


def _auth():
return {"Authorization": f"Basic {AUTH}"}


@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.upcoming")
def test_upcoming(upcoming_fn):
upcoming_fn.return_value = []
resp = client.get("/upcoming", headers=_auth())
assert resp.status_code == 200


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


@patch("main.things.someday")
def test_someday(someday_fn):
someday_fn.return_value = []
resp = client.get("/someday", 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


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


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


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


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


@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 ---


@patch("main.things.url")
@patch("main._open")
def test_create_task(open_fn, url_fn):
url_fn.return_value = "things://test"
open_fn.return_value = None
resp = client.post(
"/tasks",
headers=_auth(),
json={"title": "New task", "when": "today"},
)
assert resp.status_code == 202
url_fn.assert_called_once()
# Check that the URL was built with add command
call_kwargs = url_fn.call_args[1]
assert call_kwargs.get("command") == "add"


@patch("main.things.url")
@patch("main._open")
def test_update_task(open_fn, url_fn):
url_fn.return_value = "things://test"
open_fn.return_value = None
resp = client.patch(
"/tasks/abc",
headers=_auth(),
json={"title": "Updated title"},
)
assert resp.status_code == 202
url_fn.assert_called_once()
call_kwargs = url_fn.call_args[1]
assert call_kwargs.get("command") == "update"
assert call_kwargs.get("uuid") == "abc"


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


@patch("main.things.url")
@patch("main._open")
def test_cancel_task(open_fn, url_fn):
url_fn.return_value = "things://test"
open_fn.return_value = None
resp = client.post(
"/tasks/abc/cancel",
headers=_auth(),
)
assert resp.status_code == 202
url_fn.assert_called_once()
call_kwargs = url_fn.call_args[1]
assert call_kwargs.get("command") == "update"
assert call_kwargs.get("uuid") == "abc"
assert call_kwargs.get("canceled") == "true"


# --- Move endpoint tests ---


@patch("main.things.url")
@patch("main._open")
def test_move_task_to_today(open_fn, url_fn):
"""Move a task to the 'today' collection."""
url_fn.return_value = "things://test"
open_fn.return_value = None
resp = client.post(
"/tasks/abc/move",
headers=_auth(),
json={"collection": "today"},
)
assert resp.status_code == 202
url_fn.assert_called_once()
call_kwargs = url_fn.call_args[1]
assert call_kwargs.get("command") == "move-to-collection"
assert call_kwargs.get("uuid") == "abc"
assert call_kwargs.get("collection") == "today"


@patch("main.things.url")
@patch("main._open")
def test_move_task_to_inbox(open_fn, url_fn):
"""Move a task to the inbox."""
url_fn.return_value = "things://test"
open_fn.return_value = None
resp = client.post(
"/tasks/abc/move",
headers=_auth(),
json={"collection": "inbox"},
)
assert resp.status_code == 202
call_kwargs = url_fn.call_args[1]
assert call_kwargs.get("command") == "move-to-collection"
assert call_kwargs.get("collection") == "inbox"


@patch("main.things.url")
@patch("main._open")
def test_move_task_to_project(open_fn, url_fn):
"""Move a task to a specific project."""
url_fn.return_value = "things://test"
open_fn.return_value = None
resp = client.post(
"/tasks/abc/move",
headers=_auth(),
json={"collection": "project-uuid-123"},
)
assert resp.status_code == 202
call_kwargs = url_fn.call_args[1]
assert call_kwargs.get("command") == "move-to-collection"
assert call_kwargs.get("collection") == "project-uuid-123"


@patch("main._open")
def test_move_task_unauthorized(open_fn):
"""Move endpoint should require auth."""
resp = client.post(
"/tasks/abc/move",
json={"collection": "today"},
)
assert resp.status_code == 401