From ce3ec99df2e6809a1087f9a7843a94a2ca0f1f3f Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:06:33 +0200 Subject: [PATCH 01/15] Hold each API wrapper to the endpoint its name promises The wrappers in LogseqAPI are one-liners around call(), so what they assert is which endpoint goes out, not what comes back. Nothing held them to it: pointing delete_page at logseq.Editor.renamePage left all 842 tests green, and a destructive command reaching a different endpoint is the worst form this defect takes. The set-vs-reality test in test_api_cache.py cannot catch it. It asserts a method name appears somewhere in api.py; after swapping two endpoints between wrappers both names are still there. A table mapping wrapper to endpoint would be no better. Copied out of api.py and checked against api.py, it pins down whatever is written there, mistake included. The wrapper's own name is the independent source: snake_case to camelCase gives the endpoint leaf, without exception across all 18. Also asserted: every endpoint sits in exactly one of the two method sets, and every mutating wrapper clears the cache when call() runs it. --- tests/test_api_endpoint_binding.py | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_api_endpoint_binding.py diff --git a/tests/test_api_endpoint_binding.py b/tests/test_api_endpoint_binding.py new file mode 100644 index 0000000..8b911ca --- /dev/null +++ b/tests/test_api_endpoint_binding.py @@ -0,0 +1,126 @@ +"""Each API wrapper must reach the endpoint its own name promises. + +The wrappers in :class:`LogseqAPI` are one-liners around ``call()``, so what +they assert is not a return value but *which* endpoint goes out with which +arguments. Nothing held them to that: a mutation pointing ``delete_page`` at +``logseq.Editor.renamePage`` left the whole suite green, and a destructive +command silently calling a different endpoint is the worst version of this +defect. + +The existing set-vs-reality test in ``test_api_cache.py`` cannot catch it. It +asserts a method name appears *somewhere* in ``api.py``; after swapping two +endpoints between wrappers both names are still there. + +These tests therefore derive the expectation instead of restating it. A table +mapping wrapper to endpoint would be copied out of ``api.py`` and checked +against ``api.py`` — it would pin down whatever is written there, including a +mistake. The wrapper's own name is the independent source: ``snake_case`` +turned to ``camelCase`` is the endpoint's leaf, without exception across all +18 wrappers. +""" + +import ast +import pathlib + +import pytest + +from logseq_cli.api import _CACHEABLE_METHODS, _MUTATING_METHODS + + +def _camel(snake: str) -> str: + head, *tail = snake.split("_") + return head + "".join(word.capitalize() for word in tail) + + +def _wrapper_endpoints(): + """{wrapper name: endpoint} read off the AST, not a list kept by hand.""" + source = pathlib.Path( + pathlib.Path(__file__).resolve().parent.parent / "logseq_cli" / "api.py" + ).read_text(encoding="utf-8") + tree = ast.parse(source) + cls = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "LogseqAPI" + ) + found = {} + for node in cls.body: + if not isinstance(node, ast.FunctionDef): + continue + endpoints = [ + sub.value for sub in ast.walk(node) + if isinstance(sub, ast.Constant) + and isinstance(sub.value, str) + and sub.value.startswith("logseq.") + ] + if len(endpoints) == 1: + found[node.name] = endpoints[0] + elif endpoints: + raise AssertionError( + f"{node.name} names more than one endpoint: {endpoints}. " + "The name-derived check below cannot decide which one is meant." + ) + return found + + +WRAPPERS = _wrapper_endpoints() + + +def test_every_wrapper_was_found(): + """Guards the reader itself: an empty result would make every test pass.""" + assert len(WRAPPERS) >= 18, WRAPPERS + + +@pytest.mark.parametrize("wrapper", sorted(WRAPPERS)) +def test_endpoint_leaf_matches_wrapper_name(wrapper): + """``get_page_blocks_tree`` must call ``…getPageBlocksTree``, not another read.""" + endpoint = WRAPPERS[wrapper] + leaf = endpoint.rsplit(".", 1)[1] + assert leaf == _camel(wrapper), ( + f"{wrapper}() calls {endpoint}; its name promises " + f"…{_camel(wrapper)}" + ) + + +@pytest.mark.parametrize("wrapper", sorted(WRAPPERS)) +def test_endpoint_is_classified_for_the_cache(wrapper): + """Every endpoint is either cacheable or mutating — never unclassified. + + An endpoint in neither set is read from the network every time *and* leaves + a stale cache behind it, which is the failure mode that is hardest to see: + both halves look like they work. + """ + endpoint = WRAPPERS[wrapper] + assert (endpoint in _CACHEABLE_METHODS) != (endpoint in _MUTATING_METHODS), ( + f"{endpoint} ({wrapper}) must be in exactly one of " + "_CACHEABLE_METHODS / _MUTATING_METHODS" + ) + + +def test_cache_invalidation_covers_every_mutating_wrapper(): + """Every wrapper classified as mutating must clear the cache when called. + + ``call()`` clears the cache in an ``elif`` on ``_MUTATING_METHODS``. A write + missing from that set would leave reads answering from a cache the write + just invalidated — success reported, stale data served. + """ + import json + from unittest.mock import MagicMock, patch + + from logseq_cli.api import LogseqAPI + + mutating = {w: e for w, e in WRAPPERS.items() if e in _MUTATING_METHODS} + assert mutating, "no mutating wrapper found — the reader is broken" + + for wrapper, endpoint in sorted(mutating.items()): + api = LogseqAPI(host="localhost", port="12315", token="t") + api._cache[("logseq.Editor.getPage", json.dumps(["Foo"]))] = ( + {"stale": True}, float("inf"), + ) + resp = MagicMock(status_code=200) + resp.json.return_value = {} + resp.raise_for_status = MagicMock() + with patch("requests.post", return_value=resp): + api.call(endpoint, []) + assert api._cache == {}, ( + f"{wrapper}() calls {endpoint} without clearing the cache" + ) From 6c253afdc27342b73e93ae1e74dc821f62e3dbe6 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:06:39 +0200 Subject: [PATCH 02/15] Derive the mutating-wrapper list instead of keeping it by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class around it walks the command registry precisely so a write command cannot be missed by a list nobody updated. The list of mutating wrappers it compares against stayed hand-written, and had already drifted: it carried replace_text, for which no wrapper exists. Harmless in that direction. A missing entry is not — it would excuse a write command from needing --dry-run, silently, which is the defect the registry walk was written to prevent one layer up. Now read off _MUTATING_METHODS and the api.py AST, so the two cannot disagree. --- tests/test_dry_run_coverage.py | 47 ++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/test_dry_run_coverage.py b/tests/test_dry_run_coverage.py index bc96637..9df4edd 100644 --- a/tests/test_dry_run_coverage.py +++ b/tests/test_dry_run_coverage.py @@ -541,13 +541,44 @@ class TestEveryWriteHasADryRun: apart the way a hand-kept inventory would. """ - # The wrappers in api.py that mutate the graph, by the name the CLI calls. - _MUTATING_CALLS = ( - "create_page", "delete_page", "rename_page", - "append_block_in_page", "insert_block", "insert_batch_block", - "update_block", "remove_block", "move_block", "replace_text", - "upsert_block_property", "remove_block_property", - ) + @staticmethod + @functools.lru_cache(maxsize=None) + def _mutating_calls(): + """The wrappers in api.py that mutate the graph, by the name the CLI calls. + + Derived from ``_MUTATING_METHODS`` rather than listed here. A list kept + by hand drifts, which is the very mistake this class was written to + stop one layer up -- and it had already happened: the previous tuple + carried ``replace_text``, for which no wrapper exists. Harmless in that + direction, but a missing entry would silently excuse a write command + from needing ``--dry-run``. + """ + import ast + import pathlib + + from logseq_cli.api import _MUTATING_METHODS + + source = pathlib.Path( + pathlib.Path(__file__).resolve().parent.parent + / "logseq_cli" / "api.py" + ).read_text(encoding="utf-8") + cls_node = next( + node for node in ast.parse(source).body + if isinstance(node, ast.ClassDef) and node.name == "LogseqAPI" + ) + names = [] + for node in cls_node.body: + if not isinstance(node, ast.FunctionDef): + continue + if any( + isinstance(sub, ast.Constant) + and isinstance(sub.value, str) + and sub.value in _MUTATING_METHODS + for sub in ast.walk(node) + ): + names.append(node.name) + assert names, "no mutating wrapper found in api.py -- reader is broken" + return tuple(names) @staticmethod @functools.lru_cache(maxsize=None) @@ -604,7 +635,7 @@ def _writing_commands(self): while hasattr(func, "__wrapped__"): func = func.__wrapped__ source = bodies.get(func.__name__, "") - if any(f"api.{call}(" in source for call in self._MUTATING_CALLS): + if any(f"api.{call}(" in source for call in self._mutating_calls()): writing[name] = command return writing From 18f4263325a5a9cf9d7f8a458b57df1d6e70278d Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:10:34 +0200 Subject: [PATCH 03/15] Hold the journal page name to the format Logseq reads it under format_journal_date turns a date into the title Logseq stores the entry under, and nine call sites in the journal, edit and analysis commands write through it. A wrong name does not look wrong -- it addresses a different page, so add-journal-entry creates one instead of appending to the entry already there. Nothing tested it. Returning "th" from get_day_suffix for every day left all 842 tests green, although it turns "mar 3rd, 2025" into "mar 3th, 2025" under the format Logseq ships with by default. Covered: the ordinal rule including the 11-13 exception that breaks the last-digit shortcut, the default format, the tokens a configured :journal/page-title-format can use, and that every day of a year yields one stable, unique name. --- tests/test_journal_page_names.py | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_journal_page_names.py diff --git a/tests/test_journal_page_names.py b/tests/test_journal_page_names.py new file mode 100644 index 0000000..a91ee1a --- /dev/null +++ b/tests/test_journal_page_names.py @@ -0,0 +1,109 @@ +"""The journal page name is an address, not a label. + +``format_journal_date`` turns a date into the page title Logseq stores it +under, and nine call sites across the journal, edit and analysis commands +write through it. A wrong name does not produce a wrong-looking output — it +addresses a different page, so ``add-journal-entry`` creates one instead of +appending to the entry that is already there. + +Nothing tested it. Making ``get_day_suffix`` return ``"th"`` for every day +left all 842 tests green, although it turns "mar 3rd, 2025" into +"mar 3th, 2025" under the default format Logseq ships with. + +The ordinal rule is the part worth spelling out: 11, 12 and 13 take "th" +although they end in 1, 2 and 3. That exception is why the suffix cannot be +read off the last digit alone. +""" + +import datetime + +import pytest + +from logseq_cli.helpers import format_journal_date, get_day_suffix + + +class TestOrdinalSuffix: + """st/nd/rd/th, including the teens that break the last-digit rule.""" + + @pytest.mark.parametrize("day,expected", [ + (1, "st"), (2, "nd"), (3, "rd"), (4, "th"), + (11, "th"), (12, "th"), (13, "th"), + (21, "st"), (22, "nd"), (23, "rd"), + (31, "st"), + ]) + def test_suffix_for_day(self, day, expected): + assert get_day_suffix(day) == expected + + def test_teens_differ_from_their_last_digit(self): + """The rule a naive implementation gets wrong, stated on its own.""" + for teen, ones in ((11, 1), (12, 2), (13, 3)): + assert get_day_suffix(teen) == "th" + assert get_day_suffix(ones) != "th" + + def test_every_day_of_a_month_gets_a_known_suffix(self): + for day in range(1, 32): + assert get_day_suffix(day) in {"st", "nd", "rd", "th"} + + +class TestDefaultFormatIsLogseqs: + """Without configuration the name must match what Logseq itself writes.""" + + @pytest.mark.parametrize("date,expected", [ + (datetime.date(2025, 3, 1), "mar 1st, 2025"), + (datetime.date(2025, 3, 2), "mar 2nd, 2025"), + (datetime.date(2025, 3, 3), "mar 3rd, 2025"), + (datetime.date(2025, 3, 4), "mar 4th, 2025"), + (datetime.date(2025, 3, 11), "mar 11th, 2025"), + (datetime.date(2025, 3, 12), "mar 12th, 2025"), + (datetime.date(2025, 3, 13), "mar 13th, 2025"), + (datetime.date(2025, 3, 21), "mar 21st, 2025"), + (datetime.date(2025, 3, 22), "mar 22nd, 2025"), + (datetime.date(2025, 3, 23), "mar 23rd, 2025"), + (datetime.date(2025, 12, 31), "dec 31st, 2025"), + ]) + def test_default_format(self, date, expected): + assert format_journal_date(date) == expected + + def test_none_means_the_logseq_default(self): + d = datetime.date(2025, 3, 3) + assert format_journal_date(d, None) == format_journal_date(d) + + +class TestConfiguredFormats: + """A graph may configure :journal/page-title-format; the tokens must hold.""" + + @pytest.mark.parametrize("fmt,expected", [ + ("yyyy-MM-dd", "2025-03-03"), + ("yyyy-MM-dd, EEEE", "2025-03-03, monday"), + ("dd.MM.yyyy", "03.03.2025"), + ("MMMM d, yyyy", "march 3, 2025"), + ("MMM do, yyyy", "mar 3rd, 2025"), + ("do MMMM yyyy", "3rd march 2025"), + ]) + def test_format_tokens(self, fmt, expected): + assert format_journal_date(datetime.date(2025, 3, 3), fmt) == expected + + def test_zero_padded_day_carries_no_ordinal(self): + """``dd`` and ``do`` are different tokens; mixing them changes the name.""" + d = datetime.date(2025, 3, 3) + assert format_journal_date(d, "dd") == "03" + assert format_journal_date(d, "do") == "3rd" + + +class TestNameIsStableAcrossAYear: + """Whatever the format, the same date must always give the same name. + + A page name that varies between two calls addresses two pages, which is + the failure this whole module exists to prevent. + """ + + def test_every_day_of_a_year_is_stable_and_unique(self): + seen = {} + d = datetime.date(2025, 1, 1) + while d.year == 2025: + name = format_journal_date(d) + assert name == format_journal_date(d), f"{d} not stable" + assert name not in seen, f"{d} and {seen.get(name)} share a name" + seen[name] = d + d += datetime.timedelta(days=1) + assert len(seen) == 365 From 484f6327b8da14ebcc345b6c4bc88e8e8bea3190 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:10:40 +0200 Subject: [PATCH 04/15] Check that create-page files a date page as a journal, and nothing else is_journal_date is what makes create-page pass journal?: true to Logseq. Wrong in one direction an ordinary page lands in the journal timeline; wrong in the other the journal for that day exists twice, once as a plain page. Returning True for every name left all 842 tests green. The recogniser and the formatter are two halves of one claim, so the central test feeds format_journal_date output back in rather than restating the four formats by hand -- a name the tool writes but does not recognise is exactly what creates the duplicate. Checked across a full year, where ordinal suffixes and zero padding vary. Also pinned: the patterns are anchored, so a date inside a longer name does not make the page a journal. --- tests/test_journal_flag_on_create.py | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/test_journal_flag_on_create.py diff --git a/tests/test_journal_flag_on_create.py b/tests/test_journal_flag_on_create.py new file mode 100644 index 0000000..46ce9f5 --- /dev/null +++ b/tests/test_journal_flag_on_create.py @@ -0,0 +1,102 @@ +"""``create-page`` decides by the name whether a page is a journal. + +``is_journal_date`` is what makes ``create-page`` pass ``journal?: true`` to +Logseq. Get it wrong in one direction and an ordinary page is filed as a +journal entry, which puts it in the journal timeline and changes how Logseq +treats it; get it wrong in the other and a date page is created as a plain +one, so the journal for that day exists twice. + +Nothing tested it: making the function return ``True`` for every name left +all 842 tests green. + +The recogniser and the formatter are two halves of one claim, so the central +test here does not restate the four formats by hand — it feeds +``format_journal_date`` output back in. A format the tool writes but does not +recognise is exactly the asymmetry that creates a duplicate journal page. +""" + +import datetime +import json +from unittest.mock import MagicMock, patch + +import pytest + +from logseq_cli.cli import cli +from logseq_cli.helpers import format_journal_date, is_journal_date +from tests.conftest import split_runner + + +@pytest.fixture +def api(): + mock = MagicMock() + mock.get_page.return_value = None + mock.create_page.return_value = {"id": 1, "name": "p"} + with patch("logseq_cli.group.LogseqAPI", return_value=mock): + yield mock + + +class TestRecogniserMatchesFormatter: + """Every name the tool writes must be read back as a journal name.""" + + @pytest.mark.parametrize("fmt", [ + None, # Logseq's default, 'MMM do, yyyy' + "MMM do, yyyy", + "yyyy-MM-dd", + "yyyy-MM-dd, EEEE", + "dd.MM.yyyy", + ]) + def test_formatter_output_is_recognised(self, fmt): + d = datetime.date(2025, 3, 3) + name = format_journal_date(d, fmt) if fmt else format_journal_date(d) + assert is_journal_date(name), f"{name!r} written but not recognised" + + def test_holds_for_every_day_of_a_year(self): + """Ordinal suffixes and zero padding vary across a year; all must match.""" + d = datetime.date(2025, 1, 1) + misses = [] + while d.year == 2025: + name = format_journal_date(d) + if not is_journal_date(name): + misses.append(name) + d += datetime.timedelta(days=1) + assert not misses, f"written but not recognised: {misses[:5]}" + + +class TestOrdinaryNamesAreNotJournals: + """The other direction: a plain page must not be filed as a journal.""" + + @pytest.mark.parametrize("name", [ + "Projekt-Plan", + "meeting notes", + "2025", # a year alone is not a date + "mar 2025", # no day + "14th", # no month, no year + # Anchored at both ends, so a date inside a longer name does not count. + # These pass with re.search as well -- the ^...$ in the patterns is what + # rejects them, not the match/search choice. + "notes from 2025-03-14", + "2025-03-14 review", + "", + ]) + def test_not_a_journal_name(self, name): + assert not is_journal_date(name) + + +class TestCreatePageSetsTheFlag: + """The user-visible half: what create-page hands to Logseq.""" + + def test_journal_name_gets_the_property(self, api): + result = split_runner().invoke(cli, ["create-page", "--name", "mar 3rd, 2025"]) + assert result.exit_code == 0, result.output + _, kwargs_or_args = api.create_page.call_args[0][0], api.create_page.call_args[0] + assert kwargs_or_args[1] == {"journal?": True} + + def test_ordinary_name_gets_no_property(self, api): + result = split_runner().invoke(cli, ["create-page", "--name", "Projekt-Plan"]) + assert result.exit_code == 0, result.output + assert api.create_page.call_args[0][1] is None + + def test_iso_date_name_gets_the_property(self, api): + result = split_runner().invoke(cli, ["create-page", "--name", "2025-03-14"]) + assert result.exit_code == 0, result.output + assert api.create_page.call_args[0][1] == {"journal?": True} From e7fbf1d0ad7bd123a03fa6300bf2562becee8a45 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:18:32 +0200 Subject: [PATCH 05/15] Match a page name literally when scanning for backlinks by hand find_backlinks is the fallback get-backlinks drops to when the native getPageLinkedReferences call fails. It builds a regex out of the page name, so a title carrying regex syntax -- C++, What is this?, Report (2025) -- compiled into a pattern that no longer matched the link it was built for. The command then reported no backlinks and exited 0. Output showing less than exists, with nothing to indicate it, is a defect this project has had before. Worse on this path: it only runs when something else has already broken. escape_regex is one line and dropping it left all 842 tests green. What is asserted here is not the call but the guarantee -- the name is matched literally, a metacharacter does not widen the match to a different page, and the fallback still finds the linking page when driven through the CLI. --- tests/test_backlinks_bruteforce_scan.py | 121 ++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/test_backlinks_bruteforce_scan.py diff --git a/tests/test_backlinks_bruteforce_scan.py b/tests/test_backlinks_bruteforce_scan.py new file mode 100644 index 0000000..08baded --- /dev/null +++ b/tests/test_backlinks_bruteforce_scan.py @@ -0,0 +1,121 @@ +"""The brute-force backlink scan, and the escaping it depends on. + +``find_backlinks`` is the fallback ``get-backlinks`` drops to when the native +``getPageLinkedReferences`` fails. It builds a regex out of the page name, so a +name carrying regex metacharacters — ``C++``, ``What is this?``, +``Report (2025)`` — compiles into a pattern that no longer matches the link it +was built for. The command then reports no backlinks and exits 0. + +That is the failure mode this project has had before: output that shows less +than exists, with nothing to indicate it. Worse here, because the path only +runs when something has already gone wrong. + +``escape_regex`` is one line, ``return re.escape(s)``, and dropping the escaping +left all 842 tests green. The assertion is not the line — it is that a page name +is matched literally, whatever characters it contains. +""" + +import re +from unittest.mock import MagicMock, patch + +import pytest + +from logseq_cli.cli import cli +from logseq_cli.helpers import escape_regex, find_backlinks +from tests.conftest import split_runner + + +# Names that are legal Logseq page titles and also regex syntax. Each one is a +# different way the unescaped pattern goes wrong: a quantifier with nothing to +# repeat, an optional character, a group, an alternation, a wildcard. +METACHARACTER_NAMES = [ + "C++", + "What is this?", + "Report (2025)", + "a|b", + "Notes.", + "Budget [2025]", + "50% capacity", + "foo*bar", +] + + +def _api(pages): + """pages: {page name: page text}. get_page_blocks_tree answers one block.""" + api = MagicMock() + api.get_all_pages.return_value = [{"originalName": n} for n in pages] + + def _tree(name): + text = pages.get(name) + return [{"content": text, "uuid": f"u-{name}", "children": []}] if text else [] + + api.get_page_blocks_tree.side_effect = _tree + return api + + +class TestEscaping: + """The one line, stated as what it guarantees rather than what it calls.""" + + @pytest.mark.parametrize("name", METACHARACTER_NAMES) + def test_escaped_name_matches_itself_literally(self, name): + pattern = re.compile(escape_regex(name)) + assert pattern.search(f"see [[{name}]] here"), name + + @pytest.mark.parametrize("name", METACHARACTER_NAMES) + def test_escaped_name_is_a_valid_pattern(self, name): + """An unescaped ``C++`` raises; the caller has no try/except for that.""" + re.compile(escape_regex(name)) + + def test_escaping_does_not_widen_the_match(self): + """``a|b`` must not match a page containing only ``b``.""" + pattern = re.compile(escape_regex("a|b")) + assert not pattern.search("see [[b]] here") + assert pattern.search("see [[a|b]] here") + + +class TestScanFindsNamesWithMetacharacters: + """The function, driven the way the command drives it.""" + + @pytest.mark.parametrize("name", METACHARACTER_NAMES) + def test_linking_page_is_found(self, name): + api = _api({name: "the target page", + "Linking Page": f"mentions [[{name}]] in passing", + "Unrelated": "no links here"}) + assert find_backlinks(api, name) == ["Linking Page"] + + def test_does_not_match_a_different_page(self): + """Without escaping, ``a|b`` would report a page linking only ``b``.""" + api = _api({"a|b": "target", + "False Friend": "links [[b]] only"}) + assert find_backlinks(api, "a|b") == [] + + def test_self_reference_is_excluded(self): + api = _api({"C++": "a page that mentions [[C++]] itself", + "Other": "also mentions [[C++]]"}) + assert find_backlinks(api, "C++") == ["Other"] + + def test_match_is_case_insensitive_and_tolerates_inner_spaces(self): + api = _api({"C++": "target", "L": "see [[ c++ ]] there"}) + assert find_backlinks(api, "C++") == ["L"] + + +class TestFallbackPathReachesTheScan: + """get-backlinks falls back to the scan when the native API fails. + + Driven through the CLI, because the escaping only matters on the path that + runs when something else already broke. + """ + + @pytest.mark.parametrize("name", ["C++", "Report (2025)"]) + def test_backlinks_reported_after_native_api_fails(self, name): + api = _api({name: "target", + "Linking Page": f"mentions [[{name}]]"}) + api.get_page_linked_references.side_effect = RuntimeError("unexpected format") + with patch("logseq_cli.group.LogseqAPI", return_value=api): + result = split_runner().invoke( + cli, ["get-backlinks", "--page", name, "--json"]) + assert result.exit_code == 0, result.output + import json + payload = json.loads(result.stdout) + entry = payload[0] if isinstance(payload, list) else payload + assert "Linking Page" in json.dumps(entry), result.stdout From 0594a089018118f6f075e369ed22360a84ead501 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:18:32 +0200 Subject: [PATCH 06/15] Drop the unused Counter import from helpers --- logseq_cli/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/logseq_cli/helpers.py b/logseq_cli/helpers.py index 8d1a1a3..59ac607 100644 --- a/logseq_cli/helpers.py +++ b/logseq_cli/helpers.py @@ -3,7 +3,6 @@ import calendar import json import datetime -from collections import Counter from pathlib import Path import click From 8392b045a5b34447233d5489f0698b1e9dab9d45 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:18:44 +0200 Subject: [PATCH 07/15] Use invented page names in the journal-flag tests "Projekt-Plan" was taken from a real graph, by way of the working notes this test was written from. It carried no personal data, but test fixtures should not borrow page names from anyone's graph at all -- the next borrowed name may not be harmless, and nothing would flag it. Replaced with an invented English name. No assertion changes. --- tests/test_journal_flag_on_create.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_journal_flag_on_create.py b/tests/test_journal_flag_on_create.py index 46ce9f5..6054200 100644 --- a/tests/test_journal_flag_on_create.py +++ b/tests/test_journal_flag_on_create.py @@ -66,7 +66,7 @@ class TestOrdinaryNamesAreNotJournals: """The other direction: a plain page must not be filed as a journal.""" @pytest.mark.parametrize("name", [ - "Projekt-Plan", + "Weekly Review", "meeting notes", "2025", # a year alone is not a date "mar 2025", # no day @@ -92,7 +92,7 @@ def test_journal_name_gets_the_property(self, api): assert kwargs_or_args[1] == {"journal?": True} def test_ordinary_name_gets_no_property(self, api): - result = split_runner().invoke(cli, ["create-page", "--name", "Projekt-Plan"]) + result = split_runner().invoke(cli, ["create-page", "--name", "Weekly Review"]) assert result.exit_code == 0, result.output assert api.create_page.call_args[0][1] is None From 2bb87ab7ff522fd0b072aa87f0e8f057d45e4956 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:23:48 +0200 Subject: [PATCH 08/15] Check that --no-cache reaches the client, not just that it parses The group callback turns the flag into api.cache_enabled = False. Replacing that line with pass left all 842 tests green: the one test on the subject sets the attribute itself, so it never runs the wiring the flag depends on. What the user loses is specific. --no-cache is what you reach for after changing something in Logseq, so a flag that does nothing answers with the state from before the change and gives no sign it did. Driven through the CLI and counted at the network boundary, since the claim is about requests going out rather than about an attribute being set. --- tests/test_no_cache_flag.py | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/test_no_cache_flag.py diff --git a/tests/test_no_cache_flag.py b/tests/test_no_cache_flag.py new file mode 100644 index 0000000..7226e80 --- /dev/null +++ b/tests/test_no_cache_flag.py @@ -0,0 +1,123 @@ +"""``--no-cache`` must reach the client, not just exist as a flag. + +The group callback translates the flag into ``api.cache_enabled = False``. +Replacing that line with ``pass`` left all 842 tests green: the one test on the +subject sets the attribute itself and never runs the wiring, so the flag could +stop working without a single failure. + +What the user loses is specific. ``--no-cache`` is what you reach for after +changing something in Logseq — the flag exists to get past a stale read. If it +does nothing, the tool answers from the cache and reports the state from before +the change, with no indication that it did. + +These tests drive the real CLI and count outgoing requests, because the claim +is about what goes to the network, not about an attribute. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from logseq_cli.cli import cli +from tests.conftest import split_runner + + +@pytest.fixture(autouse=True) +def _cache_on(monkeypatch): + """The cache is only meaningful with a TTL; make it explicit here.""" + monkeypatch.setenv("LOGSEQ_CLI_CACHE_TTL", "60") + + +def _response(payload): + resp = MagicMock(status_code=200) + resp.json.return_value = payload + resp.raise_for_status = MagicMock() + return resp + + +def _run(args): + """Invoke the CLI for real, counting the HTTP calls it makes.""" + blocks = [{"content": "some text", "uuid": "u1", "children": []}] + with patch("logseq_cli.api.requests.post", + return_value=_response(blocks)) as post: + result = split_runner().invoke(cli, ["--token", "t"] + args) + return result, post + + +class TestFlagReachesTheClient: + def test_flag_sets_cache_enabled_false(self): + """The wiring itself, read off the context the group builds.""" + seen = {} + + class _Spy: + def __init__(self, **kwargs): + self.cache_enabled = True + seen["api"] = self + + with patch("logseq_cli.group.LogseqAPI", _Spy): + split_runner().invoke(cli, ["--token", "t", "--no-cache", "get-page", + "--name", "Foo", "--no-backlinks"]) + assert seen["api"].cache_enabled is False + + def test_without_the_flag_the_cache_stays_on(self): + seen = {} + + class _Spy: + def __init__(self, **kwargs): + self.cache_enabled = True + seen["api"] = self + + with patch("logseq_cli.group.LogseqAPI", _Spy): + split_runner().invoke(cli, ["--token", "t", "get-page", + "--name", "Foo", "--no-backlinks"]) + assert seen["api"].cache_enabled is True + + +class TestRepeatedReadsGoOutAgain: + """The user-visible half: a second read must not be served from memory.""" + + def test_same_page_twice_without_flag_hits_the_cache(self): + """Baseline — without the flag the second read is served locally.""" + from logseq_cli.api import LogseqAPI + + api = LogseqAPI(token="t") + with patch("logseq_cli.api.requests.post", + return_value=_response([{"content": "x"}])) as post: + api.get_page("Foo") + api.get_page("Foo") + assert post.call_count == 1 + + def test_same_page_twice_with_flag_goes_out_twice(self): + """With --no-cache both reads reach Logseq, so a change is visible.""" + from logseq_cli.api import LogseqAPI + + seen = {} + + class _Spy(LogseqAPI): + def __init__(self, **kwargs): + super().__init__(**kwargs) + seen["api"] = self + + with patch("logseq_cli.group.LogseqAPI", _Spy): + with patch("logseq_cli.api.requests.post", + return_value=_response([{"content": "x"}])) as post: + split_runner().invoke(cli, ["--token", "t", "--no-cache", + "get-page", "--name", "Foo", + "--no-backlinks"]) + before = post.call_count + api = seen["api"] + api.get_page("Foo") + api.get_page("Foo") + assert post.call_count == before + 2, ( + "--no-cache left the cache on: the second read was served " + "from memory" + ) + + +class TestFlagIsDocumented: + """A flag that works but is not in --help cannot be reached on purpose.""" + + def test_no_cache_appears_in_help(self): + result = split_runner().invoke(cli, ["--help"]) + assert "--no-cache" in result.output From a039caaf3d27b55ab06414460b964c085be23eca Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:23:48 +0200 Subject: [PATCH 09/15] Pin the markdown a page renders to blocks_to_markdown is the default output of get-page and get-journal-range, and nothing tested it. Two independent mutations survived the whole suite: dropping the properties-block branch, and dropping indentation entirely. Worth spelling out, because is_properties_block itself is covered -- by tests that exercise get-backlinks --with-context, a different caller. Coverage of a function says nothing about coverage of the branch that calls it. The properties branch is why the renderer is not a one-liner: Logseq stores a page's key:: value header without a bullet, and rendering it as a list item produces a page that no longer round-trips into the graph. blocks_with_ids promises in its docstring that its indentation matches. That is asserted by rendering one tree through both renderers rather than by writing the expected tab runs out twice. --- tests/test_block_rendering.py | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/test_block_rendering.py diff --git a/tests/test_block_rendering.py b/tests/test_block_rendering.py new file mode 100644 index 0000000..841310a --- /dev/null +++ b/tests/test_block_rendering.py @@ -0,0 +1,111 @@ +"""The two text renderers behind ``get-page`` and ``get-journal-range``. + +``blocks_to_markdown`` produces the default output of ``get-page`` — the shape +a user reads and a script pipes on. Nothing tested it. Two independent +mutations survived the whole suite: dropping the properties-block branch, and +dropping indentation entirely. + +That is worth stating plainly, because ``is_properties_block`` *is* covered: +the tests for it exercise ``get-backlinks --with-context``, a different caller. +Coverage of a function says nothing about coverage of the branch that calls it. + +The properties branch is the reason the renderer is not a one-liner. Logseq +stores a page's ``key:: value`` header without a bullet; rendering it as a list +item produces a page that no longer round-trips into the graph. + +``blocks_with_ids`` promises in its own docstring that indentation matches +``blocks_to_markdown``. That is asserted here by rendering the same tree +through both, rather than by writing the expected tab runs out twice. +""" + +import pytest + +from logseq_cli.render import blocks_to_markdown, blocks_with_ids + + +def _block(content, uuid="u", children=()): + return {"content": content, "uuid": uuid, "children": list(children)} + + +PROPERTIES = "type:: note\nstatus:: open" + + +class TestPropertiesBlockKeepsItsShape: + """A top-level properties block is written without a bullet.""" + + def test_properties_block_has_no_bullet(self): + out = blocks_to_markdown([_block(PROPERTIES)]) + assert out == PROPERTIES + assert not out.startswith("- ") + + def test_ordinary_block_gets_a_bullet(self): + assert blocks_to_markdown([_block("plain text")]) == "- plain text" + + def test_single_property_line_counts_as_properties(self): + assert blocks_to_markdown([_block("type:: note")]) == "type:: note" + + def test_nested_properties_block_still_gets_a_bullet(self): + """Only the top level is a page header; deeper down it is content.""" + tree = [_block("parent", children=[_block(PROPERTIES)])] + out = blocks_to_markdown(tree) + assert "\t- type:: note" in out + + def test_mixed_block_is_not_a_properties_block(self): + """One prose line is enough to make it ordinary content.""" + content = "type:: note\nthis is prose" + assert blocks_to_markdown([_block(content)]) == f"- {content}" + + +class TestIndentation: + """Depth is carried by tabs; losing it flattens the tree.""" + + def test_child_is_indented_one_tab(self): + tree = [_block("parent", children=[_block("child")])] + assert blocks_to_markdown(tree) == "- parent\n\t- child" + + def test_depth_accumulates(self): + tree = [_block("a", children=[_block("b", children=[_block("c")])])] + assert blocks_to_markdown(tree) == "- a\n\t- b\n\t\t- c" + + def test_siblings_share_a_level(self): + tree = [_block("a"), _block("b")] + assert blocks_to_markdown(tree) == "- a\n- b" + + def test_empty_content_is_skipped_but_children_survive(self): + tree = [_block("", children=[_block("child")])] + assert blocks_to_markdown(tree) == "\t- child" + + +class TestBlocksWithIds: + """``\\t\\t``, for callers that avoid JSON.""" + + def test_line_carries_uuid_and_content(self): + assert blocks_with_ids([_block("text", uuid="abc")]) == "abc\t\ttext" + + def test_missing_uuid_leaves_the_field_empty(self): + block = {"content": "text", "children": []} + assert blocks_with_ids([block]) == "\t\ttext" + + def test_indentation_matches_blocks_to_markdown(self): + """The docstring's claim, asserted against the other renderer.""" + tree = [_block("a", children=[_block("b", children=[_block("c")])])] + md_depths = [ + len(line) - len(line.lstrip("\t")) + for line in blocks_to_markdown(tree).split("\n") + ] + id_depths = [ + len(line.split("\t", 1)[1]) - len(line.split("\t", 1)[1].lstrip("\t")) - 1 + for line in blocks_with_ids(tree).split("\n") + ] + assert md_depths == id_depths == [0, 1, 2] + + +class TestRoundTrip: + """What the renderer writes must read back as the same structure.""" + + def test_page_header_then_content_matches_logseq_file_layout(self): + tree = [_block(PROPERTIES), _block("first note", + children=[_block("detail")])] + assert blocks_to_markdown(tree) == ( + "type:: note\nstatus:: open\n- first note\n\t- detail" + ) From 82174b7ac83f2d535ec93f3e6215fd96768ea047 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:23:58 +0200 Subject: [PATCH 10/15] Run doctor's two probes instead of only its reaction to them test_doctor.py patches both probes out. That is right for what it tests -- how doctor reports a given answer -- but it means the probes themselves never ran: returning None from either left all 842 tests green. They decide the distinction their docstring calls the one that costs the most time by hand: "Logseq is not running" against "Logseq runs, but its HTTP API is off". Answered wrong, doctor sends the user to start an application that is already open, or into the settings of one that is closed. The port probe runs against a real socket bound on loopback and closed again in the fixture. The process probe runs against a stubbed pgrep, because a real one answers differently depending on whether the machine running the suite has Logseq open. Pinned along the way: unknown (None) stays distinguishable from not-running (False), and the lowercase pattern is still tried, which is the only one that matches on Linux. --- tests/test_doctor_probes.py | 109 ++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_doctor_probes.py diff --git a/tests/test_doctor_probes.py b/tests/test_doctor_probes.py new file mode 100644 index 0000000..e4def78 --- /dev/null +++ b/tests/test_doctor_probes.py @@ -0,0 +1,109 @@ +"""The two probes ``doctor`` uses to tell one outage from another. + +``test_doctor.py`` patches both of these out. That is right for what it tests — +how ``doctor`` reports a given answer — but it means the probes themselves were +never run: making either return ``None`` left all 842 tests green. + +What they decide is the distinction the docstring calls the one that costs the +most time by hand: "Logseq is not running" versus "Logseq runs, but its HTTP +API is off". Answer it wrong and ``doctor`` sends the user to start an +application that is already open, or into the settings of one that is closed. + +``_port_has_listener`` is tested against a real socket on localhost — bound and +closed within the test, never reaching the network. ``_logseq_process_running`` +is tested against a stubbed ``pgrep``, since a real one would answer differently +depending on whether the machine running the suite happens to have Logseq open. +""" + +import socket +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from logseq_cli.commands.meta import _logseq_process_running, _port_has_listener + + +@pytest.fixture +def bound_port(): + """A real listening socket on an ephemeral port, closed afterwards.""" + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + yield str(sock.getsockname()[1]) + sock.close() + + +@pytest.fixture +def free_port(): + """A port number that nothing is listening on.""" + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return str(port) + + +class TestPortProbe: + def test_true_when_something_listens(self, bound_port): + assert _port_has_listener("127.0.0.1", bound_port) is True + + def test_false_when_nothing_listens(self, free_port): + assert _port_has_listener("127.0.0.1", free_port, timeout=0.5) is False + + def test_answer_is_a_bool_not_none(self, free_port): + """``doctor`` branches on this; ``None`` would read as "no listener".""" + assert isinstance(_port_has_listener("127.0.0.1", free_port, timeout=0.5), bool) + + @pytest.mark.parametrize("port", ["not-a-port", "", "99999999"]) + def test_unusable_port_is_false_not_an_exception(self, port): + """doctor runs this before anything validates the configured port.""" + assert _port_has_listener("127.0.0.1", port, timeout=0.5) is False + + def test_unresolvable_host_is_false_not_an_exception(self): + assert _port_has_listener("no-such-host.invalid", "12315", timeout=0.5) is False + + +class TestProcessProbe: + """Three answers, and the third is not a failure: unknown is a real state.""" + + def _run(self, returncodes, has_pgrep=True): + calls = iter(returncodes) + + def fake_run(*args, **kwargs): + return MagicMock(returncode=next(calls)) + + with patch("shutil.which", return_value="/usr/bin/pgrep" if has_pgrep else None), \ + patch("subprocess.run", side_effect=fake_run): + return _logseq_process_running() + + def test_true_when_pgrep_finds_the_process(self): + assert self._run([0]) is True + + def test_lowercase_pattern_is_tried_too(self): + """Capitalised on macOS, lowercase on Linux; both must count.""" + assert self._run([1, 0]) is True + + def test_false_when_no_pattern_matches(self): + assert self._run([1, 1]) is False + + def test_none_when_pgrep_is_absent(self): + """Unknown, not "not running" — the two lead to different advice.""" + assert self._run([], has_pgrep=False) is None + + def test_none_when_pgrep_fails(self): + with patch("shutil.which", return_value="/usr/bin/pgrep"), \ + patch("subprocess.run", side_effect=OSError("boom")): + assert _logseq_process_running() is None + + def test_none_when_pgrep_times_out(self): + with patch("shutil.which", return_value="/usr/bin/pgrep"), \ + patch("subprocess.run", + side_effect=subprocess.TimeoutExpired("pgrep", 5)): + assert _logseq_process_running() is None + + def test_unknown_is_distinguishable_from_not_running(self): + """``doctor`` gives different remedies for False and None.""" + assert self._run([1, 1]) is False + assert self._run([], has_pgrep=False) is None + assert self._run([1, 1]) is not self._run([], has_pgrep=False) From 8d49b6665fd3d7ec67d6ae9c9d87f780d906db9d Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:23:58 +0200 Subject: [PATCH 11/15] Cover the text output of smart-query _print_results is what a user sees without --json, and returning None from it left all 842 tests green. Its four branches exist because Datalog does not answer in one shape: rows wrapping a block, bare rows, plain maps, or scalars, each with a different place to look for a name. A branch reading the wrong field does not fail. It prints a blank line where a page name belongs, numbered as though a result were there. The 20-item cap is pinned too. It is what keeps a query matching a thousand blocks from filling a terminal, and the count line above the list still reports the full number, so a silent change to it misleads twice. --- tests/test_query_result_output.py | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/test_query_result_output.py diff --git a/tests/test_query_result_output.py b/tests/test_query_result_output.py new file mode 100644 index 0000000..cb8b81b --- /dev/null +++ b/tests/test_query_result_output.py @@ -0,0 +1,90 @@ +"""The text output of ``smart-query``, which nothing exercised. + +``_print_results`` is what a user sees without ``--json``; returning ``None`` +from it left all 842 tests green. Its four branches exist because Datalog does +not answer in one shape: a query may return rows wrapping a block, bare rows, +plain maps, or scalars, and each has a different place to look for a name. + +A branch that picks the wrong field does not fail — it prints a blank line +where a page name belongs, numbered as if a result were there. + +The 20-item cap is part of the contract too. It is what keeps a query matching +a thousand blocks from filling a terminal, and it is invisible in the count +line above the list, so a silent change of it misleads twice. +""" + +import pytest + +from logseq_cli.commands.query import _print_results + + +def _render(results): + """Run the printer and hand back the lines it produced.""" + import click + + lines = [] + original = click.echo + click.echo = lambda msg="", **kw: lines.append(str(msg)) + try: + _print_results(results) + finally: + click.echo = original + return lines + + +class TestResultShapes: + """Datalog answers in several shapes; each must find the name.""" + + def test_row_wrapping_a_page_map(self): + assert _render([[{"name": "alpha"}]]) == [" 1. alpha"] + + def test_row_wrapping_a_block_falls_back_to_content(self): + assert _render([[{"content": "some block text"}]]) == [" 1. some block text"] + + def test_row_prefers_name_over_content(self): + assert _render([[{"name": "alpha", "content": "ignored"}]]) == [" 1. alpha"] + + def test_row_accepts_the_hyphenated_key(self): + """Datalog answers ``:block/original-name``, not ``originalName``.""" + assert _render([[{"original-name": "Alpha"}]]) == [" 1. Alpha"] + + def test_bare_map_uses_the_camelcase_key(self): + """The HTTP API's own shape, as getAllPages returns it.""" + assert _render([{"originalName": "Alpha"}]) == [" 1. Alpha"] + + def test_row_wrapping_a_scalar(self): + assert _render([["just a string"]]) == [" 1. just a string"] + + def test_bare_scalar(self): + assert _render(["just a string"]) == [" 1. just a string"] + + def test_numbering_counts_from_one(self): + assert _render(["a", "b", "c"]) == [" 1. a", " 2. b", " 3. c"] + + +class TestGuards: + def test_non_list_prints_nothing(self): + """A failed query answers a dict; it must not be rendered as results.""" + assert _render({"error": "bad query"}) == [] + + def test_empty_list_prints_nothing(self): + assert _render([]) == [] + + def test_empty_row_is_skipped_not_crashed(self): + assert _render([[]]) == [" 1. []"] + + +class TestOutputIsCapped: + """Twenty items, whatever the result count says.""" + + def test_more_than_twenty_is_truncated(self): + lines = _render([f"item{i}" for i in range(50)]) + assert len(lines) == 20 + assert lines[-1] == " 20. item19" + + def test_exactly_twenty_is_complete(self): + assert len(_render([f"item{i}" for i in range(20)])) == 20 + + def test_long_content_is_cut_to_eighty_characters(self): + long = "x" * 200 + assert _render([{"content": long}]) == [f" 1. {'x' * 80}"] From 2b7dec034aaa3219d433f2a8dd36fa94e1d9ddfb Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:27:35 +0200 Subject: [PATCH 12/15] Cover the checkout entry point and the errors' structured fields Two assertions left after the sweep, both real once traced. pyproject installs the console script as logseq_cli.cli:cli, so main() is not on the installed path at all. It carries `python logseq_cli/cli.py`, the way the tool runs from a checkout without installing. Emptying it left the whole suite green while that invocation fell silent. DatalogQueryError carries api_message and query as attributes because output.py reads e.query to build the JSON error payload, as its docstring states. The two sibling errors follow the same shape with value, and nothing reads them yet, so dropping the assignment changed nothing a test could see. Kept rather than deleted: naming the offending value is why the fields exist, and the three classes are meant to answer alike. Pinning that intent means the next JSON error path still finds the field there. --- tests/test_entry_point_and_error_fields.py | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_entry_point_and_error_fields.py diff --git a/tests/test_entry_point_and_error_fields.py b/tests/test_entry_point_and_error_fields.py new file mode 100644 index 0000000..d1993c5 --- /dev/null +++ b/tests/test_entry_point_and_error_fields.py @@ -0,0 +1,91 @@ +"""Two assertions left over after the mutation sweep, both real. + +**The script entry point.** ``pyproject.toml`` installs the console script as +``logseq_cli.cli:cli``, so ``main()`` is not on the installed path at all. It +exists for ``python logseq_cli/cli.py``, the way someone runs the tool from a +checkout without installing it. Nothing covered that: emptying ``main()`` left +the whole suite green while the direct invocation fell silent. + +**The structured fields on the input errors.** ``DatalogQueryError`` carries +``api_message`` and ``query`` as attributes because ``output.py`` reads +``e.query`` to build the JSON error payload -- its docstring says so. Its two +siblings follow the same shape with ``value``, and nothing reads them yet, so +removing the assignment changed nothing a test could see. + +They are kept, not deleted: a JSON error naming the offending value is the +reason the fields exist, and the three classes are meant to answer alike. That +intent is what is pinned here, so the next JSON error path finds the field +still there. +""" + +import subprocess +import sys +import pathlib + +import pytest + +from logseq_cli.api import InvalidPortError +from logseq_cli.datalog import InvalidKeywordError, edn_keyword + + +REPO = pathlib.Path(__file__).resolve().parent.parent + + +class TestScriptEntryPoint: + """``python logseq_cli/cli.py`` must work from a plain checkout.""" + + def test_direct_invocation_prints_the_version(self): + result = subprocess.run( + [sys.executable, str(REPO / "logseq_cli" / "cli.py"), "--version"], + capture_output=True, text=True, timeout=60, cwd=REPO, + ) + assert result.returncode == 0, result.stderr + assert "logseq-cli" in result.stdout + + def test_direct_invocation_offers_help(self): + result = subprocess.run( + [sys.executable, str(REPO / "logseq_cli" / "cli.py"), "--help"], + capture_output=True, text=True, timeout=60, cwd=REPO, + ) + assert result.returncode == 0, result.stderr + assert "get-page" in result.stdout + + def test_main_delegates_to_the_click_group(self): + """main() must call cli(); an empty body breaks only the checkout path.""" + from unittest.mock import patch + + import logseq_cli.cli as cli_mod + + with patch.object(cli_mod, "cli") as group: + cli_mod.main() + group.assert_called_once_with() + + +class TestErrorsCarryTheOffendingValue: + """Structured fields, so a JSON error can name what was rejected.""" + + def test_invalid_keyword_keeps_the_value(self): + with pytest.raises(InvalidKeywordError) as exc: + edn_keyword("type) ?v] [?p") + assert exc.value.value == "type) ?v] [?p" + + def test_invalid_keyword_message_still_names_it(self): + """The field is additional to the message, not instead of it.""" + with pytest.raises(InvalidKeywordError) as exc: + edn_keyword("bad key") + assert "bad key" in str(exc.value) + assert exc.value.value == "bad key" + + def test_invalid_port_keeps_value_and_source(self): + err = InvalidPortError("not-a-port", source="LOGSEQ_PORT") + assert err.value == "not-a-port" + assert err.source == "LOGSEQ_PORT" + assert "not-a-port" in str(err) + + def test_datalog_query_error_keeps_both_fields(self): + """The one field an existing caller reads, pinned beside the others.""" + from logseq_cli.api import DatalogQueryError + + err = DatalogQueryError("syntax error", "[:find ?x]") + assert err.api_message == "syntax error" + assert err.query == "[:find ?x]" From 1f5ef60da74dafe23a3be7ff4166da45fa38fbb5 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:41:24 +0200 Subject: [PATCH 13/15] Cut the date and metacharacter cases back to the distinct ones Both parametrised lists carried cases that fell together with their neighbours. Measured rather than judged: breaking one suffix branch failed seven date cases where one per suffix class is enough, and of the eight metacharacter names three did not exercise the escaping at all -- "50% capacity" has no metacharacter, and "a|b" and "Notes." still match unescaped, which is a separate claim already asserted on its own. Down 16 tests. Every mutation these files were written against is still caught, and the full sweep still reports no survivors. --- tests/test_backlinks_bruteforce_scan.py | 17 +++++++++-------- tests/test_journal_page_names.py | 11 ++++------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/test_backlinks_bruteforce_scan.py b/tests/test_backlinks_bruteforce_scan.py index 08baded..90fdaf5 100644 --- a/tests/test_backlinks_bruteforce_scan.py +++ b/tests/test_backlinks_bruteforce_scan.py @@ -28,15 +28,16 @@ # Names that are legal Logseq page titles and also regex syntax. Each one is a # different way the unescaped pattern goes wrong: a quantifier with nothing to # repeat, an optional character, a group, an alternation, a wildcard. +# One name per way an unescaped pattern breaks. Measured, not guessed: each of +# these finds nothing without escaping, and each fails for a different reason. +# Names whose metacharacters happen to still match ("a|b", "Notes.") are not +# listed here -- widening is a separate claim, asserted once below. METACHARACTER_NAMES = [ - "C++", - "What is this?", - "Report (2025)", - "a|b", - "Notes.", - "Budget [2025]", - "50% capacity", - "foo*bar", + "C++", # + quantifies the character before it + "What is this?", # ? makes it optional + "Report (2025)", # () opens a group + "Budget [2025]", # [] opens a character class + "foo*bar", # * quantifies ] diff --git a/tests/test_journal_page_names.py b/tests/test_journal_page_names.py index a91ee1a..c364a38 100644 --- a/tests/test_journal_page_names.py +++ b/tests/test_journal_page_names.py @@ -48,17 +48,14 @@ def test_every_day_of_a_month_gets_a_known_suffix(self): class TestDefaultFormatIsLogseqs: """Without configuration the name must match what Logseq itself writes.""" + # The suffix rule itself is exercised above; what is checked here is that + # the default format assembles month, day and year around it. One case per + # distinct suffix plus one teen is enough for that -- 2nd/3rd/22nd and the + # rest fall with any of these when the rule breaks. @pytest.mark.parametrize("date,expected", [ (datetime.date(2025, 3, 1), "mar 1st, 2025"), - (datetime.date(2025, 3, 2), "mar 2nd, 2025"), - (datetime.date(2025, 3, 3), "mar 3rd, 2025"), (datetime.date(2025, 3, 4), "mar 4th, 2025"), - (datetime.date(2025, 3, 11), "mar 11th, 2025"), (datetime.date(2025, 3, 12), "mar 12th, 2025"), - (datetime.date(2025, 3, 13), "mar 13th, 2025"), - (datetime.date(2025, 3, 21), "mar 21st, 2025"), - (datetime.date(2025, 3, 22), "mar 22nd, 2025"), - (datetime.date(2025, 3, 23), "mar 23rd, 2025"), (datetime.date(2025, 12, 31), "dec 31st, 2025"), ]) def test_default_format(self, date, expected): From cca4970da091c4404fb1437f18dac014854ecbfa Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:41:25 +0200 Subject: [PATCH 14/15] Bind both method lists to the wrappers that send them test_api_cache.py has held _CACHEABLE_METHODS to its call sites since the read cache shipped; the mutating list had no such check. The endpoint binding added earlier only looked one way -- every wrapper reaches a listed method -- so an entry with no wrapper at all stayed invisible. Verified by adding a method name that nothing sends: the suite passed before this test and fails after it. --- tests/test_api_endpoint_binding.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_api_endpoint_binding.py b/tests/test_api_endpoint_binding.py index 8b911ca..7dcb878 100644 --- a/tests/test_api_endpoint_binding.py +++ b/tests/test_api_endpoint_binding.py @@ -96,6 +96,33 @@ def test_endpoint_is_classified_for_the_cache(wrapper): ) +def test_every_mutating_method_has_a_wrapper(): + """The other direction: no entry without a call site. + + test_api_cache.py holds _CACHEABLE_METHODS to this and the mutating list + had no counterpart, which is how two entries survived that were never + sent -- setBlockProperty and replaceText, dating from the initial import. + The find was not the entries but the missing check: an inventory nobody + verifies describes what the tool once did, not what it does. + """ + unused = sorted(_MUTATING_METHODS - set(WRAPPERS.values())) + assert not unused, ( + f"listed as mutating but no wrapper sends them: {unused}" + ) + + +def test_every_cacheable_method_has_a_wrapper(): + """Same guard for the read list, bound to wrappers rather than to text. + + test_api_cache.py asserts the name appears somewhere in api.py; that stays + true for an entry whose wrapper was deleted. This binds it to a call site. + """ + unused = sorted(_CACHEABLE_METHODS - set(WRAPPERS.values())) + assert not unused, ( + f"listed as cacheable but no wrapper sends them: {unused}" + ) + + def test_cache_invalidation_covers_every_mutating_wrapper(): """Every wrapper classified as mutating must clear the cache when called. From a08310241a40bb99093fde8b6e71b05a89fa059b Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:41:25 +0200 Subject: [PATCH 15/15] Drop two mutating methods the tool never sends setBlockProperty and replaceText have no wrapper and no call site. All 19 call() invocations pass a literal method name, so nothing could reach them; they date from the initial import and described a tool that does not exist. Behaviour is unchanged for every command -- a method in neither list is read from the network each time and leaves the cache alone, and these two were in no code path. The guard added in the previous commit is what keeps the lists from collecting entries like this again. --- CHANGELOG.md | 16 ++++++++++++++++ logseq_cli/api.py | 2 -- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 776a291..d9078ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `_MUTATING_METHODS` no longer lists `logseq.Editor.setBlockProperty` and + `logseq.Editor.replaceText`. Neither has a wrapper and neither was ever sent: + all 19 `call()` invocations pass a literal method name, so no input could + reach them. They date from the initial import and described a tool that does + not exist. + + The entries are the smaller half. The find is the check that was missing: + `_CACHEABLE_METHODS` has been held to its call sites since the read cache + shipped, and the mutating list had no counterpart, which is why two entries + survived there for the life of the project. Both lists are now bound to the + wrappers that send them, in both directions. + + No behaviour changes for any command. A method in neither list is read from + the network every time and leaves the cache untouched, and these two were in + no code path to begin with. + - The commands moved out of `cli.py` into `logseq_cli/commands/`, one module per group of commands, with the click group in `group.py`, the result and error helpers in `output.py` and the block rendering in `render.py`. `cli.py` is now diff --git a/logseq_cli/api.py b/logseq_cli/api.py index eff8d5e..07156dd 100644 --- a/logseq_cli/api.py +++ b/logseq_cli/api.py @@ -33,8 +33,6 @@ "logseq.Editor.removeBlock", "logseq.Editor.upsertBlockProperty", "logseq.Editor.removeBlockProperty", - "logseq.Editor.setBlockProperty", - "logseq.Editor.replaceText", "logseq.Editor.insertBatchBlock", "logseq.Editor.moveBlock", })