fix: Super Productivity API path unwraps the {ok, data} envelope, drops the inbox project, health waits for the renderer (#162)
Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/doctor.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_task_cmd.py
This commit is contained in:
1 parent
8be5e6e5f3
commit
529793b255
8 files changed
+267
-58
No files matched your search
+27
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8.0.0-beta.1 - 2026-09-30 - Page titles must form valid, unique file names on Windows and macOS
|
## 8.0.0-beta.2 - 2026-09-30 - Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- dist export no longer cuts the dist export record out of the shipped tools/CONTRACT.md
|
- dist export no longer cuts the dist export record out of the shipped tools/CONTRACT.md
|
||||||
- fail() prints the command's ON FAILURE lines on stderr
|
- fail() prints the command's ON FAILURE lines on stderr
|
||||||
- Budget gate and loop-breaker refusals exit without a traceback
|
- Budget gate and loop-breaker refusals exit without a traceback
|
||||||
|
- Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
|
||||||
|
|
||||||
**Low impact**
|
**Low impact**
|
||||||
- version bump no longer points at version release in its output
|
- version bump no longer points at version release in its output
|
||||||
@@ -108,6 +109,31 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- new_page/type_resolver comments no longer claim only entities declare a layout:
|
- new_page/type_resolver comments no longer claim only entities declare a layout:
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (Gitea #162)
|
||||||
|
|
||||||
|
Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the
|
||||||
|
first time, and four assumptions about its local REST API did not hold. The test fakes had been
|
||||||
|
written from the same assumptions, so the suite stayed green while `access: "api"` could not read
|
||||||
|
anything.
|
||||||
|
|
||||||
|
- **The envelope.** The app answers `{"ok": true, "data": ...}` or `{"ok": false, "error": {"code",
|
||||||
|
"message"}}`; the adapter expected a bare list. Every read - `review`, `task list`, `task new`,
|
||||||
|
`task close` - failed with "did not return a list of objects". `_ApiClient` now unwraps `data`,
|
||||||
|
raises with the API's own code and message on `ok: false`, and refuses a body without the
|
||||||
|
envelope instead of guessing.
|
||||||
|
- **The inbox.** `GET /projects` and the backup snapshot both list `INBOX_PROJECT`, so `review`
|
||||||
|
would have reported "Inbox" as a tracker project without a `kb/` page. Both readers and the
|
||||||
|
writer's name-to-id lookup now drop it.
|
||||||
|
- **`health()`** requires `data.rendererReady`; an app whose renderer is still loading no longer
|
||||||
|
counts as healthy, and `doctor` says so.
|
||||||
|
- **Closing a task** sets `doneOn` and `modified`; the module docstring claimed it does not. The
|
||||||
|
behaviour is harmless - the store fills them exactly as for a tick in the UI - the reasoning is
|
||||||
|
corrected.
|
||||||
|
|
||||||
|
The fakes in `test_superproductivity.py`, `test_task_cmd.py` and `test_new_page.py` now answer in the real envelope,
|
||||||
|
and a test pins that a bare list is rejected. Drop-in in both directions; no `.wikitool-tasks.json`
|
||||||
|
change.
|
||||||
|
|
||||||
### Page titles must form valid, unique file names on Windows and macOS (Gitea #155)
|
### Page titles must form valid, unique file names on Windows and macOS (Gitea #155)
|
||||||
|
|
||||||
A title is the wiki's only identifier for a page and becomes the file name one to one, but nothing
|
A title is the wiki's only identifier for a page and becomes the file name one to one, but nothing
|
||||||
|
|||||||
+1
-1
@@ -3206,7 +3206,7 @@ Check that this instance is correctly configured.
|
|||||||
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
||||||
- MCP `submit` tool: whether `.wikitool-upload.json` is present, absent or malformed, its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means the write path does not exist at all; malformed is a `FAIL`.
|
- MCP `submit` tool: whether `.wikitool-upload.json` is present, absent or malformed, its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means the write path does not exist at all; malformed is a `FAIL`.
|
||||||
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`.
|
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`.
|
||||||
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
|
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
|
||||||
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
|
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
|
||||||
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
|
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
|
||||||
- Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session's count and byte total against both caps; never a `FAIL`.
|
- Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session's count and byte total against both caps; never a `FAIL`.
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ def run_doctor() -> list[Check]:
|
|||||||
"(no tracker configured), malformed is a `FAIL`.",
|
"(no tracker configured), malformed is a `FAIL`.",
|
||||||
"For a configured `superproductivity` provider, the configured `access` path's own "
|
"For a configured `superproductivity` provider, the configured `access` path's own "
|
||||||
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
|
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
|
||||||
"right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
|
"with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
|
||||||
"path is never attempted, and neither state is ever a `FAIL`.",
|
"path is never attempted, and neither state is ever a `FAIL`.",
|
||||||
"For a configured `caldav` provider, whether the server is reachable and Basic auth "
|
"For a configured `caldav` provider, whether the server is reachable and Basic auth "
|
||||||
"succeeds - never a `FAIL`; only a broken config block is.",
|
"succeeds - never a `FAIL`; only a broken config block is.",
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ CRUD do not exist. Every endpoint but `GET /health` requires
|
|||||||
request are not the same failure - `GET /health` answers `503 APP_NOT_READY`
|
request are not the same failure - `GET /health` answers `503 APP_NOT_READY`
|
||||||
when the backend is up but the renderer is not yet, which this adapter
|
when the backend is up but the renderer is not yet, which this adapter
|
||||||
surfaces as its own message rather than folding into "unreachable".
|
surfaces as its own message rather than folding into "unreachable".
|
||||||
`GET /projects` runs through `selectUnarchivedProjects` and excludes
|
Every response is wrapped in an envelope: `{"ok": true, "data": ...}` on
|
||||||
`isArchived` projects server-side; the snapshot path below does the same
|
success, `{"ok": false, "error": {"code": ..., "message": ...}}` on failure
|
||||||
filtering itself, so the two access paths agree on that without either one
|
(verified live against v19.1.0, Gitea #162). `_ApiClient` unwraps it and fails
|
||||||
needing to know how the other got there (verified against `master`,
|
loudly on a body that does not carry one. `GET /health` answers
|
||||||
2026-09-20).
|
`{"ok": true, "data": {"server": "up", "rendererReady": <bool>}}`; the app only
|
||||||
|
counts as healthy once `rendererReady` is true.
|
||||||
|
`GET /projects` excludes `isArchived` projects server-side, but **does** list
|
||||||
|
the inbox project (`id: "INBOX_PROJECT"`, v19.1.0, Gitea #162) - and the
|
||||||
|
snapshot path's `project` entity state carries it too. Both paths therefore
|
||||||
|
drop archived projects and the inbox themselves, so they agree without either
|
||||||
|
one needing to know how the other got there.
|
||||||
|
|
||||||
## The two GTD conventions this adapter encodes (#119 D9/D30, corrected #135)
|
## The two GTD conventions this adapter encodes (#119 D9/D30, corrected #135)
|
||||||
|
|
||||||
@@ -94,11 +100,11 @@ path `TaskService.setDone(id)` itself takes
|
|||||||
either the service or the task reducer. Concretely:
|
either the service or the task reducer. Concretely:
|
||||||
|
|
||||||
- `isDone` is in `ALLOWED_TASK_FIELDS`, so the route accepts it.
|
- `isDone` is in `ALLOWED_TASK_FIELDS`, so the route accepts it.
|
||||||
- Marking a task done through this API is **bit-identical** to the UI's own
|
- The request carries `isDone: true` and nothing else. The app answers with
|
||||||
checkbox: neither sets `doneOn` or any other field - `TaskCopy.doneOn`
|
`isDone`, `doneOn` and `modified` set (observed live against v19.1.0, Gitea
|
||||||
exists on the model but nothing in `setDone`'s own call path writes it, so
|
#162): the store fills the timestamps itself, so a task closed here carries
|
||||||
a task closed here looks exactly like one a human clicked done on, not a
|
the same fields as one a human ticked in the UI, not a half-written state.
|
||||||
half-written state with a missing timestamp the UI would have set.
|
This adapter never sends `doneOn` or `modified`.
|
||||||
- An unknown task id makes the same handler return `404 TASK_NOT_FOUND`
|
- An unknown task id makes the same handler return `404 TASK_NOT_FOUND`
|
||||||
before any write happens, which this module's `_ApiClient` already turns
|
before any write happens, which this module's `_ApiClient` already turns
|
||||||
into an ordinary `ValidationError` - no separate existence preflight is
|
into an ordinary `ValidationError` - no separate existence preflight is
|
||||||
@@ -135,17 +141,13 @@ from chemenu.tasks.protocol import (
|
|||||||
# - this instance's own convention, not something Super Productivity defines.
|
# - this instance's own convention, not something Super Productivity defines.
|
||||||
WAITING_TAG_TITLE = "waiting"
|
WAITING_TAG_TITLE = "waiting"
|
||||||
|
|
||||||
# Super Productivity's own inbox project id, verified against
|
# Super Productivity's own inbox project id. It is a real project entity: both
|
||||||
# `project.const.ts`/`project.selectors.ts` on `master` (Gitea #132, 2026-09-20):
|
# `GET /projects` and the backup snapshot list it (verified live against
|
||||||
# a real project entity the store adds to itself if missing
|
# v19.1.0, Gitea #162 - an earlier reading of `selectUnarchivedProjects` said
|
||||||
# (`_addInboxProjectIfNecessary`), but `selectUnarchivedProjects` filters it out
|
# otherwise). Both readers therefore drop it explicitly (`_is_tracker_project`),
|
||||||
# unconditionally by this exact id - so it never appears in `GET /projects`
|
# so "Inbox" never appears as a project name to join against in any
|
||||||
# (nor in the snapshot path's own `project` entity state, which mirrors that
|
# `chemenu.review` check. `create_item`'s `--inbox` route is the only place
|
||||||
# filtering, module docstring). `create_item`'s `--inbox` route is the only
|
# this module ever writes it.
|
||||||
# place this module ever writes it; because of the same filter, an item filed
|
|
||||||
# there is invisible to every `chemenu.review` check that walks
|
|
||||||
# `TaskReader.projects()` - "Inbox" never appears as a project name to join
|
|
||||||
# against, not merely one this instance chooses to exclude.
|
|
||||||
INBOX_PROJECT_ID = "INBOX_PROJECT"
|
INBOX_PROJECT_ID = "INBOX_PROJECT"
|
||||||
|
|
||||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
||||||
@@ -321,6 +323,15 @@ def _follow_up_at(task: dict) -> Optional[date]:
|
|||||||
return _iso_day_to_date(task.get("dueDay"))
|
return _iso_day_to_date(task.get("dueDay"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tracker_project(record: dict, record_id: Any = None) -> bool:
|
||||||
|
"""Whether a project record is one the tracker join sees: not archived
|
||||||
|
(Gitea #133) and not the inbox (Gitea #162). Both access paths apply this
|
||||||
|
themselves rather than trusting the other end to have filtered."""
|
||||||
|
if record.get("isArchived"):
|
||||||
|
return False
|
||||||
|
return (record.get("id") or record_id) != INBOX_PROJECT_ID
|
||||||
|
|
||||||
|
|
||||||
def _is_waiting(task: dict, tags_by_id: dict[str, dict]) -> bool:
|
def _is_waiting(task: dict, tags_by_id: dict[str, dict]) -> bool:
|
||||||
for tag_id in task.get("tagIds") or []:
|
for tag_id in task.get("tagIds") or []:
|
||||||
tag = tags_by_id.get(tag_id)
|
tag = tags_by_id.get(tag_id)
|
||||||
@@ -343,11 +354,7 @@ class SuperProductivityReader:
|
|||||||
projects = _entity_state(snapshot, "project", path)
|
projects = _entity_state(snapshot, "project", path)
|
||||||
tasks = _entity_state(snapshot, "task", path)
|
tasks = _entity_state(snapshot, "task", path)
|
||||||
tags = _entity_state(snapshot, "tag", path)
|
tags = _entity_state(snapshot, "tag", path)
|
||||||
# Both access paths exclude archived projects (Gitea #133) - the API
|
projects = {pid: p for pid, p in projects.items() if _is_tracker_project(p, pid)}
|
||||||
# does it server-side (`selectUnarchivedProjects`), this path mirrors
|
|
||||||
# it explicitly so the two agree without either knowing about the
|
|
||||||
# other.
|
|
||||||
projects = {pid: p for pid, p in projects.items() if not p.get("isArchived")}
|
|
||||||
return path, projects, tasks, tags
|
return path, projects, tasks, tags
|
||||||
|
|
||||||
def projects(self) -> list[ProjectSummary]:
|
def projects(self) -> list[ProjectSummary]:
|
||||||
@@ -417,16 +424,20 @@ class SuperProductivityReader:
|
|||||||
|
|
||||||
|
|
||||||
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
|
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
|
||||||
"""Whether the local REST API answers `GET /health` right now - the one
|
"""Whether the local REST API answers `GET /health` with a ready renderer
|
||||||
unauthenticated endpoint (module docstring). Never raises: an unreachable
|
right now - the one unauthenticated endpoint (module docstring). Never
|
||||||
app is an ordinary, expected state (`doctor` reports it, it does not
|
raises: an unreachable or still-loading app is an ordinary, expected state
|
||||||
FAIL), not a defect in this adapter."""
|
(`doctor` reports it, it does not FAIL), not a defect in this adapter."""
|
||||||
url = cfg.api_base_url.rstrip("/") + "/health"
|
url = cfg.api_base_url.rstrip("/") + "/health"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - localhost only
|
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - localhost only
|
||||||
return 200 <= response.status < 300
|
if not 200 <= response.status < 300:
|
||||||
|
return False
|
||||||
|
payload = json.loads(response.read())
|
||||||
except (urllib.error.URLError, OSError, ValueError):
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
data = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
return isinstance(data, dict) and data.get("rendererReady") is True
|
||||||
|
|
||||||
|
|
||||||
def _expect_list(value: Any, what: str) -> list[dict]:
|
def _expect_list(value: Any, what: str) -> list[dict]:
|
||||||
@@ -438,6 +449,45 @@ def _expect_list(value: Any, what: str) -> list[dict]:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope_error(payload: Any) -> Optional[dict]:
|
||||||
|
if isinstance(payload, dict) and payload.get("ok") is False:
|
||||||
|
error = payload.get("error")
|
||||||
|
return error if isinstance(error, dict) else {}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
||||||
|
"""` (CODE: message)` from an error envelope, else nothing - an error
|
||||||
|
answer that is not an envelope must not hide the HTTP status."""
|
||||||
|
try:
|
||||||
|
error = _envelope_error(json.loads(exc.read()))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return ""
|
||||||
|
if not error:
|
||||||
|
return ""
|
||||||
|
return f" ({error.get('code', '?')}: {error.get('message', '')})"
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_envelope(payload: Any, method: str, path: str) -> Any:
|
||||||
|
"""`data` of an `{"ok": true, "data": ...}` answer. An `ok: false` answer
|
||||||
|
raises with the API's own code and message; a body without the envelope
|
||||||
|
raises too - guessing would turn a drifted API into silent wrong data
|
||||||
|
(Gitea #162)."""
|
||||||
|
error = _envelope_error(payload)
|
||||||
|
if error is not None:
|
||||||
|
raise ValidationError(
|
||||||
|
f"superproductivity: API refused {method} {path}: "
|
||||||
|
f"{error.get('code', '?')} - {error.get('message', '')}"
|
||||||
|
)
|
||||||
|
if not isinstance(payload, dict) or payload.get("ok") is not True or "data" not in payload:
|
||||||
|
raise ValidationError(
|
||||||
|
f"superproductivity: API answer for {method} {path} is not a "
|
||||||
|
'{"ok": true, "data": ...} envelope - the response shape does not match what '
|
||||||
|
"this adapter expects."
|
||||||
|
)
|
||||||
|
return payload["data"]
|
||||||
|
|
||||||
|
|
||||||
class _ApiClient:
|
class _ApiClient:
|
||||||
"""The one HTTP transport `SuperProductivityApiReader`/`SuperProductivityWriter`
|
"""The one HTTP transport `SuperProductivityApiReader`/`SuperProductivityWriter`
|
||||||
use - a thin, loudly-failing wrapper, not a general REST client. `get` and
|
use - a thin, loudly-failing wrapper, not a general REST client. `get` and
|
||||||
@@ -477,7 +527,8 @@ class _ApiClient:
|
|||||||
"yet. Wait a moment and retry."
|
"yet. Wait a moment and retry."
|
||||||
) from exc
|
) from exc
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"superproductivity: API returned HTTP {exc.code} for {method} {path}."
|
f"superproductivity: API returned HTTP {exc.code} for {method} {path}"
|
||||||
|
f"{_error_detail(exc)}."
|
||||||
) from exc
|
) from exc
|
||||||
except (urllib.error.URLError, OSError) as exc:
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -485,17 +536,18 @@ class _ApiClient:
|
|||||||
"Is Super Productivity running?"
|
"Is Super Productivity running?"
|
||||||
) from exc
|
) from exc
|
||||||
try:
|
try:
|
||||||
return json.loads(response_body)
|
payload = json.loads(response_body)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"superproductivity: API returned unparseable JSON for {method} {path}."
|
f"superproductivity: API returned unparseable JSON for {method} {path}."
|
||||||
) from exc
|
) from exc
|
||||||
|
return _unwrap_envelope(payload, method, path)
|
||||||
|
|
||||||
|
|
||||||
class SuperProductivityApiReader:
|
class SuperProductivityApiReader:
|
||||||
"""`TaskReader` over the local REST API (`access: "api"`) - the current
|
"""`TaskReader` over the local REST API (`access: "api"`) - the current
|
||||||
live state, re-fetched on every call. `GET /projects` already excludes
|
live state, re-fetched on every call. Archived projects and the inbox are
|
||||||
archived projects server-side; open items are counted against
|
dropped here (`_is_tracker_project`); open items are counted against
|
||||||
`project.taskIds` rather than `GET /tasks?projectId=`, because a subtask
|
`project.taskIds` rather than `GET /tasks?projectId=`, because a subtask
|
||||||
inherits its parent's `projectId` and would otherwise be double-counted
|
inherits its parent's `projectId` and would otherwise be double-counted
|
||||||
against that filter (Gitea #133) - the same source of truth
|
against that filter (Gitea #133) - the same source of truth
|
||||||
@@ -509,11 +561,7 @@ class SuperProductivityApiReader:
|
|||||||
projects = _expect_list(self._client.get("/projects"), "/projects")
|
projects = _expect_list(self._client.get("/projects"), "/projects")
|
||||||
tasks = _expect_list(self._client.get("/tasks"), "/tasks")
|
tasks = _expect_list(self._client.get("/tasks"), "/tasks")
|
||||||
tags = _expect_list(self._client.get("/tags"), "/tags")
|
tags = _expect_list(self._client.get("/tags"), "/tags")
|
||||||
# `GET /projects` already runs through `selectUnarchivedProjects`
|
projects = [p for p in projects if _is_tracker_project(p)]
|
||||||
# server-side - filtered again here so both access paths hold the
|
|
||||||
# same guarantee (Gitea #133) rather than one of them trusting the
|
|
||||||
# other end to have done it.
|
|
||||||
projects = [p for p in projects if not p.get("isArchived")]
|
|
||||||
tasks_by_id = {t["id"]: t for t in tasks if isinstance(t.get("id"), str)}
|
tasks_by_id = {t["id"]: t for t in tasks if isinstance(t.get("id"), str)}
|
||||||
tags_by_id = {t["id"]: t for t in tags if isinstance(t.get("id"), str)}
|
tags_by_id = {t["id"]: t for t in tags if isinstance(t.get("id"), str)}
|
||||||
return projects, tasks_by_id, tags_by_id
|
return projects, tasks_by_id, tags_by_id
|
||||||
@@ -671,6 +719,8 @@ class SuperProductivityWriter:
|
|||||||
read path growing an SP-specific field for this one caller."""
|
read path growing an SP-specific field for this one caller."""
|
||||||
target = normalize_project_name(project_name)
|
target = normalize_project_name(project_name)
|
||||||
for record in _expect_list(self._client.get("/projects"), "/projects"):
|
for record in _expect_list(self._client.get("/projects"), "/projects"):
|
||||||
|
if not _is_tracker_project(record):
|
||||||
|
continue
|
||||||
if normalize_project_name(str(record.get("title", ""))) == target:
|
if normalize_project_name(str(record.get("title", ""))) == target:
|
||||||
project_id = record.get("id")
|
project_id = record.get("id")
|
||||||
if isinstance(project_id, str) and project_id:
|
if isinstance(project_id, str) and project_id:
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _make_api_handler(state: dict):
|
|||||||
|
|
||||||
def do_GET(self): # noqa: N802 - stdlib method name
|
def do_GET(self): # noqa: N802 - stdlib method name
|
||||||
if self.path == "/health":
|
if self.path == "/health":
|
||||||
self._reply(200, {"ok": True})
|
self._reply(200, {"server": "up", "rendererReady": True})
|
||||||
return
|
return
|
||||||
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
|
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
|
||||||
self._reply(401, {"error": "unauthorized"})
|
self._reply(401, {"error": "unauthorized"})
|
||||||
@@ -100,7 +100,12 @@ def _make_api_handler(state: dict):
|
|||||||
self._reply(404, {"error": "no such endpoint (fixture)"})
|
self._reply(404, {"error": "no such endpoint (fixture)"})
|
||||||
|
|
||||||
def _reply(self, code: int, payload) -> None:
|
def _reply(self, code: int, payload) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
# The app's real envelope (v19.1.0, Gitea #162).
|
||||||
|
if code < 400:
|
||||||
|
envelope = {"ok": True, "data": payload}
|
||||||
|
else:
|
||||||
|
envelope = {"ok": False, "error": {"code": "ERROR", "message": str(payload)}}
|
||||||
|
body = json.dumps(envelope).encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ The fixture snapshot below mirrors the real shape verified against
|
|||||||
`super-productivity/super-productivity`'s `master` branch: a flat top-level
|
`super-productivity/super-productivity`'s `master` branch: a flat top-level
|
||||||
object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
|
object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
|
||||||
"entities": {...}}` maps for the snapshot path, and the same records as a
|
"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
|
flat list for the API path (see the module docstring for the exact source
|
||||||
docstring for the exact source files). No test here starts a real Super
|
files). The API fakes answer in the app's real envelope - `{"ok": true,
|
||||||
|
"data": ...}` / `{"ok": false, "error": {...}}` (v19.1.0, Gitea #162); a fake
|
||||||
|
that answers without it is how that defect went unnoticed. No test here starts a real Super
|
||||||
Productivity instance or touches anything beyond a loopback socket and
|
Productivity instance or touches anything beyond a loopback socket and
|
||||||
`tmp_path` (`instructions/dev/testing-conventions.md`).
|
`tmp_path` (`instructions/dev/testing-conventions.md`).
|
||||||
"""
|
"""
|
||||||
@@ -25,6 +27,17 @@ from chemenu.errors import HumanInterventionRequired, ValidationError
|
|||||||
from chemenu.tasks import superproductivity as sp
|
from chemenu.tasks import superproductivity as sp
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope(code: int, payload: Any) -> dict:
|
||||||
|
"""The local REST API's real answer shape for a fake's `payload`."""
|
||||||
|
if code < 400:
|
||||||
|
return {"ok": True, "data": payload}
|
||||||
|
detail = payload if isinstance(payload, dict) else {}
|
||||||
|
return {"ok": False, "error": {
|
||||||
|
"code": detail.get("code", "ERROR"),
|
||||||
|
"message": detail.get("message", str(detail.get("error", ""))),
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
def _ms(year: int, month: int, day: int) -> int:
|
def _ms(year: int, month: int, day: int) -> int:
|
||||||
return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000)
|
return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
@@ -408,12 +421,49 @@ class _HealthHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
def do_GET(self): # noqa: N802 - stdlib method name
|
def do_GET(self): # noqa: N802 - stdlib method name
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(b'{"ok": true}')
|
self.wfile.write(json.dumps(_envelope(200, {"server": "up", "rendererReady": True})).encode())
|
||||||
|
|
||||||
def log_message(self, *args): # silence stderr noise during the test run
|
def log_message(self, *args): # silence stderr noise during the test run
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _RendererLoadingHandler(_HealthHandler):
|
||||||
|
def do_GET(self): # noqa: N802 - stdlib method name
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(_envelope(200, {"server": "up", "rendererReady": False})).encode())
|
||||||
|
|
||||||
|
|
||||||
|
class _BareOkHandler(_HealthHandler):
|
||||||
|
def do_GET(self): # noqa: N802 - stdlib method name
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"ok": true}')
|
||||||
|
|
||||||
|
|
||||||
|
def _health_with(handler_cls) -> bool:
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
|
access=sp.ACCESS_API, backups_dir=None,
|
||||||
|
api_base_url=f"http://127.0.0.1:{server.server_address[1]}", api_token="t",
|
||||||
|
)
|
||||||
|
return sp.health(cfg_, timeout=2.0)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_false_while_the_renderer_is_still_loading():
|
||||||
|
assert _health_with(_RendererLoadingHandler) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_false_without_the_renderer_ready_flag():
|
||||||
|
assert _health_with(_BareOkHandler) is False
|
||||||
|
|
||||||
|
|
||||||
def test_health_is_true_when_the_endpoint_answers(tmp_path):
|
def test_health_is_true_when_the_endpoint_answers(tmp_path):
|
||||||
server = http.server.HTTPServer(("127.0.0.1", 0), _HealthHandler)
|
server = http.server.HTTPServer(("127.0.0.1", 0), _HealthHandler)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
@@ -432,13 +482,18 @@ def test_health_is_true_when_the_endpoint_answers(tmp_path):
|
|||||||
|
|
||||||
# --- the API read path (Gitea #133) -------------------------------------------------
|
# --- the API read path (Gitea #133) -------------------------------------------------
|
||||||
|
|
||||||
def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None):
|
def _make_api_handler(
|
||||||
|
routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None,
|
||||||
|
raw_body: Any = None,
|
||||||
|
):
|
||||||
|
"""`raw_body`, when given, is sent verbatim instead of the envelope - the
|
||||||
|
one way a test makes the fake answer like an API that has no envelope."""
|
||||||
status_override = status_override or {}
|
status_override = status_override or {}
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def do_GET(self): # noqa: N802
|
def do_GET(self): # noqa: N802
|
||||||
if self.path == "/health":
|
if self.path == "/health":
|
||||||
self._reply(200, {"ok": True})
|
self._reply(200, {"server": "up", "rendererReady": True})
|
||||||
return
|
return
|
||||||
override = status_override.get(self.path)
|
override = status_override.get(self.path)
|
||||||
if override is not None:
|
if override is not None:
|
||||||
@@ -453,7 +508,7 @@ def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: di
|
|||||||
self._reply(404, {"error": "not found"})
|
self._reply(404, {"error": "not found"})
|
||||||
|
|
||||||
def _reply(self, code: int, payload: Any) -> None:
|
def _reply(self, code: int, payload: Any) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(raw_body if raw_body is not None else _envelope(code, payload)).encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -466,8 +521,8 @@ def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: di
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None):
|
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None, raw_body=None):
|
||||||
handler_cls = _make_api_handler(routes, token, status_override=status_override)
|
handler_cls = _make_api_handler(routes, token, status_override=status_override, raw_body=raw_body)
|
||||||
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -569,6 +624,71 @@ def test_api_reader_non_list_response_fails_loud():
|
|||||||
reader.projects()
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_rejects_a_bare_list_without_the_envelope():
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
with _api_server({"/projects": projects}, raw_body=projects) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
with pytest.raises(ValidationError, match="envelope"):
|
||||||
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_ok_false_surfaces_the_error_code_and_message():
|
||||||
|
body = {"ok": False, "error": {"code": "APP_NOT_READY", "message": "renderer not ready"}}
|
||||||
|
with _api_server({"/projects": []}, raw_body=body) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
with pytest.raises(ValidationError, match="APP_NOT_READY - renderer not ready"):
|
||||||
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_http_error_names_the_error_envelope_code():
|
||||||
|
with _api_server({}, status_override={"/projects": 400}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
with pytest.raises(ValidationError, match=r"HTTP 400 .*ERROR"):
|
||||||
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_excludes_the_inbox_project():
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
inbox = {"id": sp.INBOX_PROJECT_ID, "title": "Inbox", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": ["t2"], "backlogTaskIds": ["t3"]}
|
||||||
|
with _api_server({"/projects": [inbox, *projects], "/tasks": tasks, "/tags": tags}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
assert "Inbox" not in {p.name for p in reader.projects()}
|
||||||
|
assert reader.open_items("Inbox").count == 0
|
||||||
|
assert [i.id for i in reader.someday_items()] == ["t3"] # only p1's, not the inbox's copy
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_reader_excludes_the_inbox_project(tmp_path):
|
||||||
|
snapshot = _snapshot()
|
||||||
|
snapshot["project"]["entities"][sp.INBOX_PROJECT_ID] = {
|
||||||
|
"id": sp.INBOX_PROJECT_ID, "title": "Inbox", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": ["t2"], "backlogTaskIds": ["t3"],
|
||||||
|
}
|
||||||
|
snapshot["project"]["ids"].append(sp.INBOX_PROJECT_ID)
|
||||||
|
backups_dir = tmp_path / "backups"
|
||||||
|
backups_dir.mkdir()
|
||||||
|
_write_snapshot(backups_dir / "2026-03-01_120000.json", 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,
|
||||||
|
))
|
||||||
|
assert "Inbox" not in {p.name for p in reader.projects()}
|
||||||
|
assert reader.open_items("Inbox").count == 0
|
||||||
|
assert [i.id for i in reader.someday_items()] == ["t3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_id_never_resolves_to_the_inbox_even_under_a_user_project_name():
|
||||||
|
"""A user project titled like the inbox must not be answered with the
|
||||||
|
inbox's own id when the writer resolves a name."""
|
||||||
|
state = {"projects": [
|
||||||
|
{"id": sp.INBOX_PROJECT_ID, "title": "Inbox", "taskIds": [], "backlogTaskIds": []},
|
||||||
|
{"id": "p9", "title": "Inbox", "taskIds": [], "backlogTaskIds": []},
|
||||||
|
]}
|
||||||
|
with _write_api_server(state) as server:
|
||||||
|
_writer_for(server).create_item("x", project_name="Inbox")
|
||||||
|
assert state["posted"][0]["projectId"] == "p9"
|
||||||
|
|
||||||
|
|
||||||
def test_api_reader_source_is_live_and_needs_no_network():
|
def test_api_reader_source_is_live_and_needs_no_network():
|
||||||
"""`source()` on the API path must not itself perform a request - a
|
"""`source()` on the API path must not itself perform a request - a
|
||||||
static description is correct regardless of reachability (Gitea #133)."""
|
static description is correct regardless of reachability (Gitea #133)."""
|
||||||
@@ -591,7 +711,7 @@ def _make_write_handler(state: dict, *, token: str = "test-token"):
|
|||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def do_GET(self): # noqa: N802
|
def do_GET(self): # noqa: N802
|
||||||
if self.path == "/health":
|
if self.path == "/health":
|
||||||
self._reply(200, {"ok": True})
|
self._reply(200, {"server": "up", "rendererReady": True})
|
||||||
return
|
return
|
||||||
if self.headers.get("Authorization") != f"Bearer {token}":
|
if self.headers.get("Authorization") != f"Bearer {token}":
|
||||||
self._reply(401, {"error": "unauthorized"})
|
self._reply(401, {"error": "unauthorized"})
|
||||||
@@ -641,7 +761,7 @@ def _make_write_handler(state: dict, *, token: str = "test-token"):
|
|||||||
self._reply(404, {"error": "not found"})
|
self._reply(404, {"error": "not found"})
|
||||||
|
|
||||||
def _reply(self, code: int, payload) -> None:
|
def _reply(self, code: int, payload) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(_envelope(code, payload)).encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def _make_handler(state: dict, *, token: str = _API_TOKEN):
|
|||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def do_GET(self): # noqa: N802
|
def do_GET(self): # noqa: N802
|
||||||
if self.path == "/health":
|
if self.path == "/health":
|
||||||
self._reply(200, {"ok": True})
|
self._reply(200, {"server": "up", "rendererReady": True})
|
||||||
return
|
return
|
||||||
if self.headers.get("Authorization") != f"Bearer {token}":
|
if self.headers.get("Authorization") != f"Bearer {token}":
|
||||||
self._reply(401, {"error": "unauthorized"})
|
self._reply(401, {"error": "unauthorized"})
|
||||||
@@ -91,7 +91,15 @@ def _make_handler(state: dict, *, token: str = _API_TOKEN):
|
|||||||
self._reply(404, {"error": "not found"})
|
self._reply(404, {"error": "not found"})
|
||||||
|
|
||||||
def _reply(self, code: int, payload: Any) -> None:
|
def _reply(self, code: int, payload: Any) -> None:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
# The app's real envelope (v19.1.0, Gitea #162).
|
||||||
|
if code < 400:
|
||||||
|
envelope = {"ok": True, "data": payload}
|
||||||
|
else:
|
||||||
|
envelope = {"ok": False, "error": {
|
||||||
|
"code": payload.get("code", "ERROR"),
|
||||||
|
"message": payload.get("message", str(payload.get("error", ""))),
|
||||||
|
}}
|
||||||
|
body = json.dumps(envelope).encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
Reference in new issue
Block a user