Files
chemenu/tools/chemenu/tests/test_superproductivity.py
T
torben 62d1c5e636
CI / verify (push) Successful in 54s
Release / release (push) Successful in 36s
task: Weekly review proposes task new/task close; tracker gains a closing write path
Files changed:
- CHANGES.md
- INSTALL.md
- README.md
- VERSION
- docs/knowledge-and-commitment.md
- instructions/gtd-weekly-review/SKILL.md
- instructions/ingest-large-tree.md
- instructions/wiki-ingest/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
2026-09-22 21:46:09 +02:00

803 lines
31 KiB
Python

"""Tests for `chemenu.tasks.superproductivity` (Gitea #124, #133, #135).
The fixture snapshot below mirrors the real shape verified against
`super-productivity/super-productivity`'s `master` branch: a flat top-level
object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
"entities": {...}}` maps for the snapshot path, and the same records as a
flat list (the local REST API's own shape) for the API path (see the module
docstring for the exact source files). No test here starts a real Super
Productivity instance or touches anything beyond a loopback socket and
`tmp_path` (`instructions/dev/testing-conventions.md`).
"""
from __future__ import annotations
import contextlib
import http.server
import json
import threading
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks import superproductivity as sp
def _ms(year: int, month: int, day: int) -> int:
return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000)
def _entity_state(records: dict[str, dict]) -> dict:
return {"ids": list(records.keys()), "entities": records}
def _snapshot() -> dict:
projects = {
"p1": {
"id": "p1",
"title": "Ship Chemenu 7.0",
"created": _ms(2026, 1, 1),
"taskIds": ["t1", "t2"],
"backlogTaskIds": ["t3"],
},
"p2": {
"id": "p2",
"title": "Kueche renovieren",
"created": _ms(2026, 2, 1),
"taskIds": ["t4"],
"backlogTaskIds": [],
},
}
tasks = {
"t1": {
"id": "t1",
"title": "Warte auf Angebot vom Elektriker - Tobias",
"projectId": "p1",
"isDone": False,
"tagIds": ["tag-wait"],
"dueWithTime": _ms(2026, 3, 1),
},
"t2": {
"id": "t2",
"title": "Kickoff-Meeting vorbereiten",
"projectId": "p1",
"isDone": False,
"tagIds": [],
},
"t3": {
"id": "t3",
"title": "Irgendwann Keller aufraeumen",
"isDone": False,
"tagIds": [],
"updated": _ms(2026, 1, 15),
},
"t4": {
"id": "t4",
"title": "Angebot einholen",
"projectId": "p2",
"isDone": True,
"tagIds": [],
},
}
tags = {
"tag-wait": {"id": "tag-wait", "title": "Waiting"},
"tag-urgent": {"id": "tag-urgent", "title": "Urgent"},
}
return {
"project": _entity_state(projects),
"task": _entity_state(tasks),
"tag": _entity_state(tags),
}
def _write_snapshot(path: Path, data: dict | None = None) -> None:
path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8")
def _minimal_snapshot(task_extra: dict) -> dict:
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False,
"tagIds": ["tag-wait"], **task_extra}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
return {"project": _entity_state(projects), "task": _entity_state(tasks), "tag": _entity_state(tags)}
@pytest.fixture
def cfg(tmp_path) -> sp.SuperProductivityConfig:
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_120000.json")
return sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
# --- from_dict -----------------------------------------------------------------
def test_from_dict_requires_access():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
def test_from_dict_rejects_an_unknown_access_value():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"access": "auto", "backups_dir": "/x"})
def test_from_dict_api_requires_a_token():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict({"access": "api"})
def test_from_dict_api_defaults_the_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "api", "api_token": "t"})
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
assert cfg_.access == sp.ACCESS_API
def test_from_dict_api_rejects_a_snapshot_field():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_token": "t", "backups_dir": "/x"}
)
def test_from_dict_snapshot_requires_backups_dir():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict({"access": "snapshot"})
def test_from_dict_snapshot_rejects_an_api_field():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict(
{"access": "snapshot", "backups_dir": "/x", "api_token": "t"}
)
def test_from_dict_snapshot_expands_user_in_backups_dir(monkeypatch):
monkeypatch.setenv("HOME", "/home/fixture")
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": "~/backups"})
assert cfg_.backups_dir == Path("/home/fixture/backups")
def test_from_dict_rejects_db_path_entirely():
"""`db_path` was #133's own casualty - it must not silently work as an
alias for `backups_dir` under either access mode."""
with pytest.raises(ValidationError):
sp.SuperProductivityConfig.from_dict({"access": "snapshot", "db_path": "/x/db.json"})
# --- latest_snapshot_path / schema drift ---------------------------------------
def test_backups_dir_must_exist(tmp_path):
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=tmp_path / "nope",
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_)
def test_backups_dir_with_no_timestamped_file_is_an_error(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
(backups / "sp-backup_2026-01-01.json").write_text("{}", encoding="utf-8")
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_)
def test_backups_dir_picks_the_lexically_latest_timestamped_file(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
older = _snapshot()
older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project"
newer = _snapshot()
_write_snapshot(backups / "2026-01-01_000000.json", older)
_write_snapshot(backups / "2026-02-01_000000.json", newer)
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
reader = sp.SuperProductivityReader(cfg_)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "Old Snapshot Project" not in names
def test_a_manual_export_never_wins_even_placed_next_to_an_older_timestamp(tmp_path):
"""`sp-backup_*.json` sorts lexically *after* every timestamp - the
hardened glob (Gitea #133) must never pick it, whatever its own name or
mtime looks like next to the real backups."""
backups = tmp_path / "backups"
backups.mkdir()
timestamped = _snapshot()
manual_export = _snapshot()
manual_export["project"]["entities"]["p1"]["title"] = "From Manual Export"
_write_snapshot(backups / "2026-01-01_000000.json", timestamped)
_write_snapshot(backups / "sp-backup_2026-06-01.json", manual_export)
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
reader = sp.SuperProductivityReader(cfg_)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "From Manual Export" not in names
def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
data = _snapshot()
data["task"] = ["not", "an", "entity", "state"]
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="task"):
reader.projects()
def test_missing_top_level_key_fails_loud(cfg):
data = _snapshot()
del data["project"]
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="project"):
reader.projects()
# --- read path (snapshot) -------------------------------------------------------
def test_projects_lists_name_and_created(cfg):
reader = sp.SuperProductivityReader(cfg)
by_name = {p.name: p for p in reader.projects()}
assert by_name["Ship Chemenu 7.0"].created == date(2026, 1, 1)
assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
def test_projects_excludes_archived(cfg):
data = _snapshot()
data["project"]["entities"]["p1"]["isArchived"] = True
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" not in names
assert "Kueche renovieren" in names
def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg):
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("ship CHEMENU 7.0")
assert result.count == 2
def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("Ship Chemenu 7.0")
assert len(result.waiting) == 1
waiting = result.waiting[0]
assert waiting.id == "t1"
assert waiting.title == "Warte auf Angebot vom Elektriker - Tobias"
assert waiting.follow_up_at == date(2026, 3, 1)
def test_open_items_excludes_done_tasks(cfg):
reader = sp.SuperProductivityReader(cfg)
assert reader.open_items("Kueche renovieren").count == 0
def test_open_items_unknown_project_is_empty_not_an_error(cfg):
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("No Such Project")
assert result.count == 0
assert result.waiting == ()
assert result.items == ()
def test_open_items_items_carries_id_title_and_waiting_for_every_open_item(cfg):
"""Gitea #138 - `task list` reads this field, and it must agree with
`waiting`: every waiting item also appears here, marked `waiting=True`."""
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("Ship Chemenu 7.0")
by_id = {item.id: item for item in result.items}
assert len(result.items) == result.count
assert by_id["t1"].title == "Warte auf Angebot vom Elektriker - Tobias"
assert by_id["t1"].waiting is True
assert by_id["t2"].waiting is False
def test_someday_items_come_from_backlog_task_ids_only(cfg):
reader = sp.SuperProductivityReader(cfg)
items = reader.someday_items()
assert len(items) == 1
assert items[0].id == "t3"
assert items[0].title == "Irgendwann Keller aufraeumen"
assert items[0].modified == date(2026, 1, 15)
def test_source_names_the_snapshot_file(cfg):
source = sp.SuperProductivityReader(cfg).source()
assert source.kind == sp.ACCESS_SNAPSHOT
assert "2026-03-01_120000.json" in source.detail
# --- follow_up_at mapping (Gitea #135) -------------------------------------------
def test_follow_up_at_reads_due_with_time(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueWithTime": _ms(2026, 3, 1)}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 1)
def test_follow_up_at_falls_back_to_due_day(cfg):
"""The gap #135 closed: an all-day, notification-free tickler has no
`dueWithTime` and no (now-removed) `remindAt` at all, and must still be
read as a follow-up date."""
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueDay": "2026-03-15"}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 15)
def test_follow_up_at_prefers_due_with_time_over_due_day(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
"dueWithTime": _ms(2026, 3, 1), "dueDay": "2026-04-01",
}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 1)
def test_follow_up_at_never_reads_remind_at_or_deadline_fields(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
"remindAt": _ms(2026, 3, 1),
"deadlineDay": "2026-03-01",
"deadlineWithTime": _ms(2026, 3, 1),
"deadlineRemindAt": _ms(2026, 3, 1),
}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at is None
# --- write path: HumanInterventionRequired ---------------------------------------
def test_create_project_refuses_a_name_collision(cfg):
reader = sp.SuperProductivityReader(cfg)
writer = sp.SuperProductivityWriter(cfg, reader)
with pytest.raises(ValidationError):
writer.create_project("ship chemenu 7.0")
def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
reader = sp.SuperProductivityReader(cfg)
writer = sp.SuperProductivityWriter(cfg, reader)
with pytest.raises(HumanInterventionRequired) as excinfo:
writer.create_project("Kueche renovieren, Phase 2")
exc = excinfo.value
assert "POST" not in str(exc) # instructions are for a human, not an HTTP client
assert "Kueche renovieren, Phase 2" in str(exc)
assert exc.verify() is False
data = _snapshot()
data["project"]["ids"].append("p3")
data["project"]["entities"]["p3"] = {
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
"taskIds": [], "backlogTaskIds": [],
}
_write_snapshot(sp.latest_snapshot_path(cfg), data)
assert exc.verify() is True
# --- health ------------------------------------------------------------------------
def test_health_is_false_when_nothing_listens(tmp_path):
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
assert sp.health(cfg_, timeout=0.5) is False
class _HealthHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - stdlib method name
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"ok": true}')
def log_message(self, *args): # silence stderr noise during the test run
pass
def test_health_is_true_when_the_endpoint_answers(tmp_path):
server = http.server.HTTPServer(("127.0.0.1", 0), _HealthHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
port = server.server_address[1]
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url=f"http://127.0.0.1:{port}", api_token="t",
)
assert sp.health(cfg_, timeout=2.0) is True
finally:
server.shutdown()
thread.join(timeout=2)
# --- the API read path (Gitea #133) -------------------------------------------------
def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None):
status_override = status_override or {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/health":
self._reply(200, {"ok": True})
return
override = status_override.get(self.path)
if override is not None:
self._reply(override, {"error": "fixture"})
return
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
if self.path in routes:
self._reply(200, routes[self.path])
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload: Any) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # noqa: D401 - silence stderr noise
pass
return Handler
@contextlib.contextmanager
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None):
handler_cls = _make_api_handler(routes, token, status_override=status_override)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=2)
def _api_cfg(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityConfig:
port = server.server_address[1]
return sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url=f"http://127.0.0.1:{port}", api_token=token,
)
def _fixture_records() -> tuple[list[dict], list[dict], list[dict]]:
projects = [
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": ["t1", "t2"], "backlogTaskIds": ["t3"]},
{"id": "p2", "title": "Archived Project", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": [], "isArchived": True},
]
tasks = [
{"id": "t1", "title": "Warte auf Angebot vom Elektriker - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueDay": "2026-03-15"},
# A subtask: inherits projectId from its parent, is NOT itself in
# project.taskIds (only t1/t2 are) - counting via project.taskIds
# must not pick it up, matching the snapshot path (Gitea #133).
{"id": "t2b", "title": "Unterschritt von t1", "isDone": False, "tagIds": [],
"projectId": "p1", "parentId": "t1"},
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
{"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, "tagIds": [],
"updated": _ms(2026, 1, 15)},
]
tags = [{"id": "tag-wait", "title": "Waiting"}]
return projects, tasks, tags
def test_api_reader_projects_excludes_archived():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "Archived Project" not in names
def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_filter():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
result = reader.open_items("Ship Chemenu 7.0")
assert result.count == 2 # t1, t2 - not the subtask t2b
assert len(result.waiting) == 1
assert result.waiting[0].id == "t1"
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
assert {item.id for item in result.items} == {"t1", "t2"}
def test_api_reader_someday_items():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
items = reader.someday_items()
assert [(i.id, i.title) for i in items] == [("t3", "Irgendwann Keller aufraeumen")]
def test_api_reader_401_without_the_right_token_fails_loud():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}, token="right-token") as server:
cfg_ = _api_cfg(server, token="wrong-token")
with pytest.raises(ValidationError):
sp.SuperProductivityApiReader(cfg_).projects()
def test_api_reader_503_app_not_ready_is_its_own_message():
with _api_server({"/projects": []}, status_override={"/projects": 503}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
with pytest.raises(ValidationError, match="APP_NOT_READY"):
reader.projects()
def test_api_reader_unreachable_fails_loud_not_silent(tmp_path):
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
with pytest.raises(ValidationError):
sp.SuperProductivityApiReader(cfg_).projects()
def test_api_reader_non_list_response_fails_loud():
with _api_server({"/projects": {"not": "a list"}}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
with pytest.raises(ValidationError):
reader.projects()
def test_api_reader_source_is_live_and_needs_no_network():
"""`source()` on the API path must not itself perform a request - a
static description is correct regardless of reachability (Gitea #133)."""
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
source = sp.SuperProductivityApiReader(cfg_).source()
assert source.kind == sp.ACCESS_API
# --- write path: create_item (Gitea #132) --------------------------------------
def _make_write_handler(state: dict, *, token: str = "test-token"):
"""A stub API supporting `GET /projects`, `GET /tags` and `POST /tasks`
only - the three routes `create_item` ever touches. `state["posted"]`
collects every request body `POST /tasks` received, so a test can assert
on the exact fields sent (or that nothing was sent at all)."""
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/health":
self._reply(200, {"ok": True})
return
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
if self.path == "/projects":
self._reply(200, state.get("projects", []))
return
if self.path == "/tags":
self._reply(200, state.get("tags", []))
return
if self.path == "/tasks":
# `find_project`'s preflight goes through the full
# `SuperProductivityApiReader`, which always reads all three
# routes (module docstring) - `create_item` itself never
# reads this one.
self._reply(200, state.get("tasks", []))
return
self._reply(404, {"error": "not found"})
def do_POST(self): # noqa: N802
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length)) if length else {}
if self.path == "/tasks":
state.setdefault("posted", []).append(payload)
self._reply(201, {"id": "new-task", **payload})
return
self._reply(404, {"error": "not found"})
def do_PATCH(self): # noqa: N802
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length)) if length else {}
if self.path.startswith("/tasks/"):
task_id = self.path[len("/tasks/"):]
known_ids = {t["id"] for t in state.get("tasks", [])}
if task_id not in known_ids:
self._reply(404, {"code": "TASK_NOT_FOUND", "message": "Task not found"})
return
state.setdefault("patched", []).append((task_id, payload))
self._reply(200, {"id": task_id, **payload})
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence stderr noise during the test run
pass
return Handler
@contextlib.contextmanager
def _write_api_server(state: dict, *, token: str = "test-token"):
handler_cls = _make_write_handler(state, token=token)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=2)
def _writer_for(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityWriter:
cfg_ = _api_cfg(server, token=token)
reader = sp.SuperProductivityApiReader(cfg_)
return sp.SuperProductivityWriter(cfg_, reader)
def test_create_item_posts_project_id_resolved_from_the_read_path():
state = {"projects": [
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []},
]}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item("Rueckruf beim Kunden", project_name="ship CHEMENU 7.0")
assert len(state["posted"]) == 1
assert state["posted"][0] == {"title": "Rueckruf beim Kunden", "projectId": "p1"}
def test_create_item_refuses_an_unknown_project_and_posts_nothing():
state = {"projects": []}
with _write_api_server(state) as server:
writer = _writer_for(server)
with pytest.raises(ValidationError, match="No project named"):
writer.create_item("x", project_name="No Such Project")
assert "posted" not in state
def test_create_item_inbox_route_uses_the_fixed_inbox_project_id():
state = {"projects": []}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item("Beleg ablegen", project_name=None)
assert state["posted"][0]["projectId"] == sp.INBOX_PROJECT_ID
def test_create_item_sets_the_waiting_tag_and_due_day():
state = {
"projects": [{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}],
"tags": [{"id": "tag-wait", "title": "Waiting"}],
}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item(
"Nachfassen beim Elektriker", project_name="Ship Chemenu 7.0",
waiting=True, follow_up_at=date(2026, 4, 1),
)
posted = state["posted"][0]
assert posted["tagIds"] == ["tag-wait"]
assert posted["dueDay"] == "2026-04-01"
def test_create_item_carries_the_freetext_backref_in_notes():
state = {"projects": [{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}]}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.create_item(
"Rueckruf", project_name="Ship Chemenu 7.0", notes="Source - Kundenmail 2026-09-20",
)
assert state["posted"][0]["notes"] == "Source - Kundenmail 2026-09-20"
def test_create_item_waiting_without_the_tag_refuses_and_posts_nothing():
"""Gitea #132's own acceptance criterion: a missing `waiting` tag must
fail loud, never create an item without the status it was asked for."""
state = {
"projects": [{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}],
"tags": [],
}
with _write_api_server(state) as server:
writer = _writer_for(server)
with pytest.raises(ValidationError, match="waiting"):
writer.create_item("x", project_name="Ship Chemenu 7.0", waiting=True)
assert "posted" not in state
# --- write path: close_item (Gitea #138) ---------------------------------------
def test_close_item_patches_is_done_true_and_nothing_else():
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
with _write_api_server(state) as server:
writer = _writer_for(server)
writer.close_item("t1")
assert state["patched"] == [("t1", {"isDone": True})]
def test_close_item_unknown_id_refuses_and_writes_nothing():
state = {"tasks": [{"id": "t1", "title": "x", "isDone": False}]}
with _write_api_server(state) as server:
writer = _writer_for(server)
with pytest.raises(ValidationError):
writer.close_item("no-such-id")
assert "patched" not in state
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
projects, tasks, tags = _fixture_records()
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_000000.json", {
"project": _entity_state({p["id"]: p for p in projects}),
"task": _entity_state({t["id"]: t for t in tasks}),
"tag": _entity_state({t["id"]: t for t in tags}),
})
snapshot_reader = sp.SuperProductivityReader(sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
))
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
api_reader = sp.SuperProductivityApiReader(_api_cfg(server))
assert {p.name: p.created for p in snapshot_reader.projects()} == \
{p.name: p.created for p in api_reader.projects()}
snap_items = snapshot_reader.open_items("Ship Chemenu 7.0")
api_items = api_reader.open_items("Ship Chemenu 7.0")
assert snap_items.count == api_items.count
assert [(w.title, w.follow_up_at) for w in snap_items.waiting] == \
[(w.title, w.follow_up_at) for w in api_items.waiting]
assert [(s.title, s.modified) for s in snapshot_reader.someday_items()] == \
[(s.title, s.modified) for s in api_reader.someday_items()]