Skip to content

Commit d708dc6

Browse files
committed
LazyToolBox: add test coverage for round-2 fixes
Regular CI does not set use_lazy_toolbox=true, so the bug surfaces fixed in 215638d..912544 are not exercised on push/PR runs unless a test class boots Galaxy with the flag itself. - Add unit tests under test/unit/tool_source_store/ pinning ToolIndex multi-version selection (packaging.version sort), explicit-version lookup, to_dict/from_dict round-trip including legacy backfill, and tokenised search across id/name/description/labels. - Add a single TestLazyToolBoxApi integration class that boots Galaxy once with use_lazy_toolbox=true and asserts the user-visible behaviours the round-2 fixes deliver: <tool_dir> + YAML + ${model_tools_path} bootstrap, multi-version show/run, default-panel shape, panel-views fallback, multi-token search, remove_tool_by_id lifecycle, and admin container-resolver. - Lift tool_source_store config into BaseToolSourceStorageIntegrationTestCase via STORE_KIND so subclasses share the wiring.
1 parent 912544c commit d708dc6

4 files changed

Lines changed: 355 additions & 5 deletions

File tree

test/integration/test_tool_source_storage.py

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@
1010
import os
1111
import tempfile
1212

13+
from galaxy_test.base.populators import DatasetPopulator
1314
from galaxy_test.driver import integration_util
1415

1516

1617
class BaseToolSourceStorageIntegrationTestCase(integration_util.IntegrationTestCase):
1718
"""Base class for tool source storage integration tests."""
1819

1920
framework_tool_and_types = True
21+
STORE_KIND: str = "database"
22+
23+
@classmethod
24+
def handle_galaxy_config_kwds(cls, config):
25+
super().handle_galaxy_config_kwds(config)
26+
config["tool_source_store"] = cls.STORE_KIND
2027

2128
def _test_api_tools_list(self):
2229
response = self._get("tools")
@@ -34,11 +41,6 @@ def _test_api_tools_show(self, tool_id: str = "cat1"):
3441
class TestDatabaseToolSourceStorage(BaseToolSourceStorageIntegrationTestCase):
3542
"""Integration tests with database tool source storage backend."""
3643

37-
@classmethod
38-
def handle_galaxy_config_kwds(cls, config):
39-
super().handle_galaxy_config_kwds(config)
40-
config["tool_source_store"] = "database"
41-
4244
def test_api_tools_list(self):
4345
self._test_api_tools_list()
4446

@@ -127,3 +129,169 @@ def test_api_tools_list_populated_via_bootstrap(self):
127129
f"Bootstrap silently dropped {required!r} from the index "
128130
f"(have {len(tool_ids)} ids: {sorted(tool_ids)[:10]}…)"
129131
)
132+
133+
134+
class TestLazyToolBoxApi(BaseToolSourceStorageIntegrationTestCase):
135+
"""End-to-end coverage of LazyToolBox-served API behaviours.
136+
137+
Regular CI does not run with ``use_lazy_toolbox=true``, so the bug
138+
surfaces fixed in commits 215638d..912544 are not covered by any
139+
push/PR run unless this class boots Galaxy with the flag itself.
140+
Every behaviour the round-2 fixes were meant to deliver is asserted
141+
here as a single API call against one shared boot:
142+
143+
- ``<tool_dir>``, YAML, and ``${model_tools_path}`` bootstrap paths.
144+
- Multi-version index + version-aware ``/api/tools/{id}`` lookup.
145+
- Default panel-view response shape consumed by the UI.
146+
- Tokenised tool search across name + description.
147+
- ``remove_tool_by_id`` lifecycle on the live toolbox.
148+
- Container-resolver admin endpoint (sensitive to placeholder
149+
``None`` Tool entries in ``_LazyToolsByIdView``).
150+
151+
All methods share one boot via the class-scoped ``setUpClass`` —
152+
don't add tests that mutate global state in ways that would leak
153+
into sibling methods (besides ``test_remove_tool_makes_get_tool_return_none``,
154+
which deliberately removes a tool that no other method touches).
155+
"""
156+
157+
dataset_populator: DatasetPopulator
158+
159+
@classmethod
160+
def handle_galaxy_config_kwds(cls, config):
161+
super().handle_galaxy_config_kwds(config)
162+
config["use_lazy_toolbox"] = True
163+
164+
def setUp(self):
165+
super().setUp()
166+
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
167+
168+
# --- Bootstrap correctness ----------------------------------------------
169+
170+
def test_tool_dir_directive_indexes_parameters_tools(self):
171+
# ``gx_int`` lives under ``test/functional/tools/parameters/`` and
172+
# gets pulled in by ``<tool_dir dir="parameters/" />``. The bootstrap
173+
# used to silently drop these because the discovery walker didn't
174+
# honour the directive.
175+
response = self._get("tools/gx_int")
176+
self._assert_status_code_is(response, 200)
177+
assert response.json()["id"] == "gx_int"
178+
179+
def test_yaml_user_defined_tool_indexed_under_yaml_id(self):
180+
# YAML tool's id comes from the body's ``id:`` field, not the
181+
# filename — the previous bootstrap walked ``xml_tree`` and dropped
182+
# every YAML source.
183+
response = self._get("tools/cat_user_defined")
184+
self._assert_status_code_is(response, 200)
185+
assert response.json()["id"] == "cat_user_defined"
186+
187+
def test_model_tools_path_template_substitution(self):
188+
# ``${model_tools_path}/build_list.xml`` resolves to
189+
# ``lib/galaxy/tools/build_list.xml`` (id ``__BUILD_LIST__``).
190+
# Exercises ``_resolve_file_template_kwds`` for the
191+
# ``model_tools_path`` substitution.
192+
response = self._get("tools/__BUILD_LIST__")
193+
self._assert_status_code_is(response, 200)
194+
assert response.json()["id"] == "__BUILD_LIST__"
195+
196+
# --- Multi-version + version-aware lookup -------------------------------
197+
198+
def test_show_unknown_version_falls_back_to_latest(self):
199+
response = self._get("tools/multiple_versions", data={"tool_version": "0.01"})
200+
self._assert_status_code_is(response, 200)
201+
# Default selection uses ``packaging.version.parse``; lex sort would
202+
# have picked ``"0.1+galaxy6"`` over ``"0.2"`` for a different prefix
203+
# so the regression matters even though it's invisible in this case.
204+
assert response.json()["version"] == "0.2"
205+
206+
def test_show_lists_every_indexed_version(self):
207+
response = self._get("tools/multiple_versions_hidden", data={"tool_version": "0.1"})
208+
self._assert_status_code_is(response, 200)
209+
info = response.json()
210+
assert info["version"] == "0.1"
211+
assert info["versions"] == ["0.1", "0.2"]
212+
assert info["hidden_versions"] == ["0.1"]
213+
214+
def test_run_specific_version_executes_that_version(self):
215+
with self.dataset_populator.test_history() as history_id:
216+
payload = self.dataset_populator.run_tool_payload(
217+
tool_id="multiple_versions_hidden",
218+
inputs={},
219+
history_id=history_id,
220+
)
221+
payload["tool_version"] = "0.1"
222+
response = self.dataset_populator._post("tools", data=payload)
223+
self._assert_status_code_is(response, 200)
224+
output = response.json()["outputs"][0]
225+
self.dataset_populator.wait_for_history(history_id, assert_ok=True)
226+
content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output)
227+
assert content.strip() == "Hidden Version 0.1"
228+
229+
# --- Default-panel response shape ---------------------------------------
230+
231+
def test_default_panel_view_returns_root_level_tools_and_section_tools_key(self):
232+
# One call asserts both the root-level dedup fix (``upload1`` survives
233+
# at top level) and the section-shape fix (sections use ``tools`` key
234+
# with id list, mirroring ``ToolSection.to_dict(only_ids=True)``).
235+
response = self._get("tool_panels/default")
236+
self._assert_status_code_is(response, 200)
237+
panel = response.json()
238+
239+
assert "upload1" in panel, (
240+
"upload1 should appear at the top level of the default panel; the "
241+
"previous bug stripped root-level tools that also appeared inside "
242+
"a section."
243+
)
244+
245+
for entry_id, entry in panel.items():
246+
if isinstance(entry, dict) and entry.get("model_class") == "ToolSection":
247+
assert "tools" in entry, f"section {entry_id} missing 'tools' key"
248+
assert all(
249+
isinstance(t, str) for t in entry["tools"]
250+
), f"section {entry_id} should hold tool ids as strings, got {entry['tools'][:3]}"
251+
252+
def test_panel_views_endpoint_returns_views(self):
253+
# ``GET /api/tool_panels`` used to return ``views={}`` when the lazy
254+
# index hadn't pre-computed panel_views. The fallback to
255+
# ``toolbox.panel_view_dicts()`` keeps callers working.
256+
response = self._get("tool_panels")
257+
self._assert_status_code_is(response, 200)
258+
body = response.json()
259+
assert "views" in body and "default_panel_view" in body
260+
assert body["views"], "expected at least one panel view to be registered"
261+
262+
# --- Search -------------------------------------------------------------
263+
264+
def test_search_finds_tool_by_multi_token_query_across_fields(self):
265+
# ``cat1`` (for_workflows/catWrapper.xml) has name "Concatenate
266+
# multiple datasets or collections". A query whose tokens span
267+
# "Concatenate" + "datasets" forces the tokenised conjunction
268+
# path; the previous OR-within-single-field implementation
269+
# returned empty here.
270+
response = self._get("tools", data={"q": "Concatenate multiple datasets"})
271+
self._assert_status_code_is(response, 200)
272+
assert "cat1" in response.json()
273+
274+
# --- Removal lifecycle --------------------------------------------------
275+
276+
def test_remove_tool_makes_get_tool_return_none(self):
277+
# ``remove_tool_by_id`` had to clear ``_tool_index.entries`` /
278+
# ``entries_by_version`` / LRU + populate ``_tools_by_old_id``;
279+
# without that fix the call raised KeyError. We pick
280+
# ``cat_data_and_sleep`` because nothing else in this class
281+
# references it, so we can mutate the live toolbox without
282+
# breaking sibling tests.
283+
toolbox = self._app.toolbox
284+
assert toolbox.get_tool("cat_data_and_sleep") is not None
285+
toolbox.remove_tool_by_id("cat_data_and_sleep")
286+
assert toolbox.get_tool("cat_data_and_sleep") is None
287+
288+
# --- Container resolution -----------------------------------------------
289+
290+
def test_container_resolvers_resolve_tool(self):
291+
# Admin-only endpoint. Used to fail with
292+
# ``'NoneType' object has no attribute 'tool_requirements'`` when
293+
# ``_LazyToolsByIdView`` returned a ``None`` placeholder for an
294+
# un-materialised tool — fixed in c763b03 by populating
295+
# ``_tools_by_old_id`` and exposing a real ``.copy()``.
296+
response = self._get("container_resolvers/resolve", data={"tool_id": "cat1"}, admin=True)
297+
self._assert_status_code_is(response, 200)

test/unit/tool_source_store/__init__.py

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Shared fixtures for tool_source_store unit tests."""
2+
3+
import pytest
4+
5+
from galaxy.tool_source_store.index import (
6+
ToolIndex,
7+
ToolIndexEntry,
8+
)
9+
10+
11+
@pytest.fixture
12+
def index_entry():
13+
def _make(id, version=None, **kwargs):
14+
return ToolIndexEntry(id=id, version=version, **kwargs)
15+
16+
return _make
17+
18+
19+
@pytest.fixture
20+
def tool_index(index_entry):
21+
def _make(*entries):
22+
index = ToolIndex()
23+
for entry in entries:
24+
index.add_entry(entry)
25+
return index
26+
27+
return _make
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Pin ``ToolIndex`` behaviours that drive the LazyToolBox API responses.
2+
3+
These unit tests run against a real ``ToolIndex`` (no mocks, no Galaxy
4+
app) so they're cheap and pinpoint regressions in the round-2
5+
LazyToolBox fixes:
6+
7+
- per-id default selection by ``packaging.version.parse``
8+
- ``entries_by_version`` records every indexed version
9+
- explicit-version lookup via ``ToolIndex.get(tool_id, tool_version)``
10+
- ``to_dict`` / ``from_dict`` round-trips ``entries_by_version``
11+
- tokenised search across id / name / description / labels
12+
"""
13+
14+
from galaxy.tool_source_store.index import ToolIndex
15+
16+
17+
def test_add_entry_default_picks_highest_packaging_version(index_entry, tool_index):
18+
# ``"0.1+galaxy6"`` lex-compares between ``"0.1"`` and ``"0.2"``, so a
19+
# plain string sort would still pick ``"0.2"`` here — but it would pick
20+
# ``"0.1+galaxy6"`` for ``["0.1+galaxy6", "0.10"]``. Cover the
21+
# packaging-aware path explicitly.
22+
index = tool_index(
23+
index_entry("multi", version="0.1+galaxy6"),
24+
index_entry("multi", version="0.1"),
25+
index_entry("multi", version="0.2"),
26+
)
27+
assert index.entries["multi"].version == "0.2"
28+
29+
30+
def test_add_entry_default_handles_post_release_above_two_digits(index_entry, tool_index):
31+
index = tool_index(
32+
index_entry("multi", version="0.10"),
33+
index_entry("multi", version="0.2"),
34+
)
35+
assert index.entries["multi"].version == "0.10"
36+
37+
38+
def test_add_entry_records_every_version_under_entries_by_version(index_entry, tool_index):
39+
index = tool_index(
40+
index_entry("multi", version="0.1"),
41+
index_entry("multi", version="0.2"),
42+
index_entry("multi", version="0.3"),
43+
)
44+
assert set(index.entries_by_version["multi"]) == {"0.1", "0.2", "0.3"}
45+
46+
47+
def test_get_with_explicit_version_returns_that_entry(index_entry, tool_index):
48+
e1 = index_entry("multi", version="0.1", name="V01")
49+
e2 = index_entry("multi", version="0.2", name="V02")
50+
index = tool_index(e1, e2)
51+
assert index.get("multi", tool_version="0.1") is e1
52+
assert index.get("multi", tool_version="0.2") is e2
53+
54+
55+
def test_get_with_unknown_version_returns_none(index_entry, tool_index):
56+
# ``LazyToolBox.get_tool`` uses the ``None`` return to fall back to the
57+
# default-version entry; pin the contract so a future refactor doesn't
58+
# silently start returning the default here.
59+
index = tool_index(index_entry("multi", version="0.1"))
60+
assert index.get("multi", tool_version="9.9") is None
61+
62+
63+
def test_get_with_no_version_returns_default_entry(index_entry, tool_index):
64+
e1 = index_entry("multi", version="0.1")
65+
e2 = index_entry("multi", version="0.2")
66+
index = tool_index(e1, e2)
67+
assert index.get("multi") is e2
68+
69+
70+
def test_to_dict_from_dict_round_trips_entries_by_version(index_entry, tool_index):
71+
index = tool_index(
72+
index_entry("multi", version="0.1"),
73+
index_entry("multi", version="0.2"),
74+
index_entry("solo", version="1.0"),
75+
)
76+
restored = ToolIndex.from_dict(index.to_dict())
77+
assert set(restored.entries_by_version["multi"]) == {"0.1", "0.2"}
78+
assert restored.entries["multi"].version == "0.2"
79+
multi_v01 = restored.get("multi", tool_version="0.1")
80+
assert multi_v01 is not None and multi_v01.version == "0.1"
81+
assert set(restored.entries_by_version["solo"]) == {"1.0"}
82+
83+
84+
def test_from_dict_backfills_entries_by_version_for_legacy_indexes():
85+
# Indexes serialised before ``entries_by_version`` existed should still
86+
# load — ``ToolIndex.get(tool_id, tool_version=...)`` relies on the
87+
# backfill so legacy stores keep working after upgrade.
88+
legacy = {
89+
"entries": {
90+
"tool1": {"id": "tool1", "version": "1.0", "name": "Tool 1"},
91+
},
92+
}
93+
restored = ToolIndex.from_dict(legacy)
94+
tool1 = restored.get("tool1", tool_version="1.0")
95+
assert tool1 is not None and tool1.version == "1.0"
96+
97+
98+
def test_search_tokenised_conjunction_across_fields(index_entry, tool_index):
99+
# The user-visible regression: a multi-token query whose tokens spread
100+
# across name + description fields used to return 0 hits because the
101+
# eager Whoosh impl would AND across fields but the lazy impl only
102+
# OR'd within a single field.
103+
index = tool_index(
104+
index_entry(
105+
"Grep1",
106+
version="1.0",
107+
name="Select",
108+
description="Select lines that match an expression",
109+
),
110+
index_entry(
111+
"filter",
112+
version="1.0",
113+
name="Filter",
114+
description="Filter by column",
115+
),
116+
)
117+
results = index.search("Select lines that match an expression", limit=10)
118+
assert [r.id for r in results] == ["Grep1"]
119+
120+
121+
def test_search_label_match_returns_entry(index_entry, tool_index):
122+
index = tool_index(
123+
index_entry("tool1", version="1.0", name="Tool 1", labels=["genomics"]),
124+
index_entry("tool2", version="1.0", name="Tool 2", labels=["text"]),
125+
)
126+
results = index.search("genomics", limit=10)
127+
assert [r.id for r in results] == ["tool1"]
128+
129+
130+
def test_search_full_phrase_in_description_outranks_partial(index_entry, tool_index):
131+
# Both entries hit every token, but only ``hit_full`` contains the full
132+
# phrase — the ``query_lower in desc_l`` bonus should rank it first.
133+
index = tool_index(
134+
index_entry(
135+
"hit_partial",
136+
version="1.0",
137+
description="alpha and beta separately",
138+
),
139+
index_entry(
140+
"hit_full",
141+
version="1.0",
142+
description="alpha beta gamma in one phrase",
143+
),
144+
)
145+
results = index.search("alpha beta", limit=10)
146+
assert [r.id for r in results][0] == "hit_full"
147+
148+
149+
def test_search_skips_entries_missing_a_token(index_entry, tool_index):
150+
index = tool_index(
151+
index_entry("a", version="1.0", name="alpha bravo"),
152+
index_entry("b", version="1.0", name="alpha"),
153+
)
154+
results = index.search("alpha bravo", limit=10)
155+
assert [r.id for r in results] == ["a"]

0 commit comments

Comments
 (0)