Files
chemenu/tools/chemenu/tests/test_superproductivity.py
T
torben 1875449b31
CI / verify (push) Successful in 48s
Release / release (push) Successful in 35s
stack: Provider-Schicht für Aufgaben-Tracker mit Super-Productivity-Adapter (#124)
Files changed:
- .gitignore
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/config.py
- tools/chemenu/errors.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_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_tasks_protocol.py
2026-09-19 21:46:56 +02:00

299 lines
9.8 KiB
Python

"""Tests for `chemenu.tasks.superproductivity` (Gitea #124).
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`).
"""
from __future__ import annotations
import http.server
import json
import threading
from datetime import date, datetime, timezone
from pathlib import Path
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"],
"remindAt": _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")
@pytest.fixture
def cfg(tmp_path) -> sp.SuperProductivityConfig:
db_path = tmp_path / "db.json"
_write_snapshot(db_path)
return sp.SuperProductivityConfig(
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
)
# --- 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_defaults_the_api_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"})
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
def test_from_dict_expands_user_in_paths(monkeypatch):
monkeypatch.setenv("HOME", "/home/fixture")
cfg_ = sp.SuperProductivityConfig.from_dict({"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,
)
with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_)
def test_backups_dir_must_exist(tmp_path):
cfg_ = sp.SuperProductivityConfig(
backups_dir=tmp_path / "nope", db_path=None,
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):
backups = tmp_path / "backups"
backups.mkdir()
cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, 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):
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)
cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, 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_schema_drift_on_task_key_fails_loud_not_silent(cfg):
data = _snapshot()
data["task"] = ["not", "an", "entity", "state"]
_write_snapshot(cfg.db_path, 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(cfg.db_path, data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="project"):
reader.projects()
# --- read path ----------------------------------------------------------------
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_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):
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("Ship Chemenu 7.0")
assert len(result.waiting) == 1
waiting = result.waiting[0]
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 == ()
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].title == "Irgendwann Keller aufraeumen"
assert items[0].modified == date(2026, 1, 15)
# --- 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(cfg.db_path, data)
assert exc.verify() is True
# --- 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,
)
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(
backups_dir=None, db_path=tmp_path / "db.json",
api_base_url=f"http://127.0.0.1:{port}", api_token=None,
)
assert sp.health(cfg_, timeout=2.0) is True
finally:
server.shutdown()
thread.join(timeout=2)