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", }) 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 diff --git a/tests/test_api_endpoint_binding.py b/tests/test_api_endpoint_binding.py new file mode 100644 index 0000000..7dcb878 --- /dev/null +++ b/tests/test_api_endpoint_binding.py @@ -0,0 +1,153 @@ +"""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_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. + + ``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" + ) diff --git a/tests/test_backlinks_bruteforce_scan.py b/tests/test_backlinks_bruteforce_scan.py new file mode 100644 index 0000000..90fdaf5 --- /dev/null +++ b/tests/test_backlinks_bruteforce_scan.py @@ -0,0 +1,122 @@ +"""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. +# 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++", # + 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 +] + + +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 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" + ) 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) 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 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]" diff --git a/tests/test_journal_flag_on_create.py b/tests/test_journal_flag_on_create.py new file mode 100644 index 0000000..6054200 --- /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", [ + "Weekly Review", + "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", "Weekly Review"]) + 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} diff --git a/tests/test_journal_page_names.py b/tests/test_journal_page_names.py new file mode 100644 index 0000000..c364a38 --- /dev/null +++ b/tests/test_journal_page_names.py @@ -0,0 +1,106 @@ +"""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.""" + + # 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, 4), "mar 4th, 2025"), + (datetime.date(2025, 3, 12), "mar 12th, 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 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 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}"]