1010import os
1111import tempfile
1212
13+ from galaxy_test .base .populators import DatasetPopulator
1314from galaxy_test .driver import integration_util
1415
1516
1617class 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"):
3441class 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 )
0 commit comments