SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
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:
1 parent
3c1d4cb028
commit
e07d1ca42a
17 files changed
+1112
-239
No files matched your search
@@ -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()]
|
||||
Reference in new issue
Block a user