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
803 lines
31 KiB
Python
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()]
|