SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
This commit is contained in:
torben committed 2026-09-20 20:52:05 +02:00
1 parent 3c1d4cb028
commit e07d1ca42a
17 files changed
+1112 -239

No files matched your search

+344 -46
View File
@@ -1,21 +1,23 @@
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124).
"""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 on 2026-09-19: a
flat top-level object with `project`/`task`/`tag` as `@ngrx/entity`
`{"ids": [...], "entities": {...}}` maps (see the module docstring for the
exact source files). No test here starts a real Super Productivity instance,
touches the network beyond a loopback socket, or reads a path under the
developer's home - everything is built inside `tmp_path`
(`instructions/dev/testing-conventions.md`).
`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
@@ -55,7 +57,7 @@ def _snapshot() -> dict:
"projectId": "p1",
"isDone": False,
"tagIds": ["tag-wait"],
"remindAt": _ms(2026, 3, 1),
"dueWithTime": _ms(2026, 3, 1),
},
"t2": {
"id": "t2",
@@ -94,73 +96,115 @@ 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:
db_path = tmp_path / "db.json"
_write_snapshot(db_path)
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_120000.json")
return sp.SuperProductivityConfig(
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
# --- from_dict ---------------------------------------------------------------
# --- from_dict -----------------------------------------------------------------
def test_from_dict_requires_backups_dir_or_db_path():
with pytest.raises(ValidationError):
sp.SuperProductivityConfig.from_dict({"api_base_url": "http://127.0.0.1:3876"})
def test_from_dict_requires_access():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
def test_from_dict_defaults_the_api_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"})
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_expands_user_in_paths(monkeypatch):
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({"backups_dir": "~/backups"})
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": "~/backups"})
assert cfg_.backups_dir == Path("/home/fixture/backups")
# --- latest_snapshot_path / schema drift -------------------------------------
def test_db_path_must_exist(tmp_path):
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "missing.json",
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
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.latest_snapshot_path(cfg_)
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(
backups_dir=tmp_path / "nope", db_path=None,
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_json_files_is_an_error(tmp_path):
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(
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
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_file(tmp_path):
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-01T000000Z.json", older)
_write_snapshot(backups / "2026-02-01T000000Z.json", newer)
_write_snapshot(backups / "2026-01-01_000000.json", older)
_write_snapshot(backups / "2026-02-01_000000.json", newer)
cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
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()}
@@ -168,10 +212,31 @@ def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
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(cfg.db_path, data)
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="task"):
reader.projects()
@@ -180,13 +245,13 @@ def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
def test_missing_top_level_key_fails_loud(cfg):
data = _snapshot()
del data["project"]
_write_snapshot(cfg.db_path, data)
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="project"):
reader.projects()
# --- read path ----------------------------------------------------------------
# --- read path (snapshot) -------------------------------------------------------
def test_projects_lists_name_and_created(cfg):
reader = sp.SuperProductivityReader(cfg)
@@ -195,13 +260,23 @@ def test_projects_lists_name_and_created(cfg):
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_not_due_date(cfg):
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
@@ -230,7 +305,49 @@ def test_someday_items_come_from_backlog_task_ids_only(cfg):
assert items[0].modified == date(2026, 1, 15)
# --- write path: HumanInterventionRequired -----------------------------------
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)
@@ -257,17 +374,17 @@ def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
"taskIds": [], "backlogTaskIds": [],
}
_write_snapshot(cfg.db_path, data)
_write_snapshot(sp.latest_snapshot_path(cfg), data)
assert exc.verify() is True
# --- health -------------------------------------------------------------------
# --- health ------------------------------------------------------------------------
def test_health_is_false_when_nothing_listens(tmp_path):
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "db.json",
api_base_url="http://127.0.0.1:1", api_token=None,
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
@@ -289,10 +406,191 @@ def test_health_is_true_when_the_endpoint_answers(tmp_path):
try:
port = server.server_address[1]
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "db.json",
api_base_url=f"http://127.0.0.1:{port}", api_token=None,
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].follow_up_at == date(2026, 3, 15)
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.title for i in items] == ["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
# --- 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()]