|
11 | 11 |
|
12 | 12 | import datetime |
13 | 13 | import json |
| 14 | +import sys |
14 | 15 | import unittest |
15 | 16 | from typing import Any, ClassVar |
16 | 17 | from unittest.mock import patch |
|
59 | 60 | from .proxy import call_view |
60 | 61 | from .schema_introspection import paginated_schema, serializer_schema |
61 | 62 | from .settings import get_plugin_setting |
| 63 | +from .tools import discovery |
62 | 64 | from .tools._common import DEFAULT_LIMIT, MAX_LIMIT, build_query_params, clamp_limit |
63 | 65 | from .tools.attachments import get_attachment, list_attachments |
64 | 66 | from .tools.bom import ( |
|
84 | 86 | list_companies, |
85 | 87 | list_contacts, |
86 | 88 | ) |
87 | | -from .tools.discovery import RESOURCE_LOADERS, describe_filters |
| 89 | +from .tools.discovery import RESOURCE_LOADERS, describe_filters, make_web_link |
88 | 90 | from .tools.locations import get_location, list_locations |
89 | 91 | from .tools.parameters import ( |
90 | 92 | get_parameter, |
@@ -1081,6 +1083,22 @@ async def test_unauthorized_user_can_still_read_project_codes(self): |
1081 | 1083 | detail = await get_project_code(self.project_code.pk) |
1082 | 1084 | self.assertEqual(detail["pk"], self.project_code.pk) |
1083 | 1085 |
|
| 1086 | + async def test_make_web_link_builds_a_real_url_end_to_end(self): |
| 1087 | + """Exercise the actual registered async tool (not just the sync helper |
| 1088 | + MakeWebLinkTest covers) - confirms the sync_to_async wrapping in |
| 1089 | + discovery.make_web_link() doesn't swallow or mis-route either the |
| 1090 | + return value or a raised ToolError. |
| 1091 | + """ |
| 1092 | + result = await make_web_link("purchase_order", self.purchase_order.pk) |
| 1093 | + self.assertTrue( |
| 1094 | + result["web_url"].endswith( |
| 1095 | + f"/web/purchasing/purchase-order/{self.purchase_order.pk}" |
| 1096 | + ) |
| 1097 | + ) |
| 1098 | + |
| 1099 | + with self.assertRaises(ToolError): |
| 1100 | + await make_web_link("purchase_order_line", self.po_line.pk) |
| 1101 | + |
1084 | 1102 | async def test_tools_list_reflects_oauth2_scope_narrowing(self): |
1085 | 1103 | """Regression test for a real design bug caught during development, not a |
1086 | 1104 | hypothetical: a literal HTTP OPTIONS-based capability check (the obvious |
@@ -1162,6 +1180,7 @@ async def test_ungated_tools_are_always_visible_to_a_zero_role_user(self): |
1162 | 1180 | names, |
1163 | 1181 | { |
1164 | 1182 | "describe_filters", |
| 1183 | + "make_web_link", |
1165 | 1184 | "list_attachments", |
1166 | 1185 | "get_attachment", |
1167 | 1186 | "list_parameters", |
@@ -1241,7 +1260,7 @@ async def test_unauthenticated_bound_identity_sees_only_tools_needing_no_auth(se |
1241 | 1260 | tool.name for tool in await mcp.list_tools() |
1242 | 1261 | ) |
1243 | 1262 |
|
1244 | | - self.assertEqual(names, {"describe_filters"}) |
| 1263 | + self.assertEqual(names, {"describe_filters", "make_web_link"}) |
1245 | 1264 |
|
1246 | 1265 | async def test_unavailable_resource_hides_its_tools_without_crashing(self): |
1247 | 1266 | """A resource whose loader can't resolve its view class (e.g. a |
@@ -1273,7 +1292,14 @@ async def test_every_gated_tool_has_a_visibility_entry(self): |
1273 | 1292 | """ |
1274 | 1293 | all_names = {tool.name for tool in await mcp.list_tools()} |
1275 | 1294 | unmapped = ( |
1276 | | - all_names - set(tool_visibility._TOOL_RESOURCES) - {"describe_filters"} |
| 1295 | + all_names |
| 1296 | + - set(tool_visibility._TOOL_RESOURCES) |
| 1297 | + # Both pure metadata/utility tools with no underlying gated view: |
| 1298 | + # describe_filters only reads static filterset/serializer |
| 1299 | + # definitions, make_web_link only builds a URL string - neither |
| 1300 | + # touches the database in a way any RolePermission/RuleSet check |
| 1301 | + # applies to. |
| 1302 | + - {"describe_filters", "make_web_link"} |
1277 | 1303 | ) |
1278 | 1304 |
|
1279 | 1305 | self.assertEqual(unmapped, set()) |
@@ -1376,6 +1402,65 @@ def test_caches_a_total_failure_without_re_importing(self): |
1376 | 1402 | mock_import.assert_called_once() |
1377 | 1403 |
|
1378 | 1404 |
|
| 1405 | +class MakeWebLinkTest(unittest.TestCase): |
| 1406 | + """discovery._build_web_link() must build the right InvenTree web-UI URL, and know when not to. |
| 1407 | +
|
| 1408 | + Exercises the sync helper directly rather than the registered async |
| 1409 | + make_web_link() tool - same reasoning as ViewResolutionTest testing |
| 1410 | + resolve_view() directly: this needs no MCP/DB fixtures, just the plain |
| 1411 | + function ToolError. |
| 1412 | + """ |
| 1413 | + |
| 1414 | + def test_raises_for_a_resource_with_no_standalone_page(self): |
| 1415 | + with self.assertRaises(ToolError): |
| 1416 | + discovery._build_web_link("purchase_order_line", 1) |
| 1417 | + |
| 1418 | + def test_returns_an_error_message_without_a_configured_base_url(self): |
| 1419 | + with patch("InvenTree.helpers_model.get_base_url", return_value=""): |
| 1420 | + result = discovery._build_web_link("part", 5) |
| 1421 | + self.assertIsNone(result["web_url"]) |
| 1422 | + self.assertIn("error", result) |
| 1423 | + |
| 1424 | + def test_returns_an_error_message_on_a_core_version_mismatch(self): |
| 1425 | + # Setting a module to None in sys.modules is what actually forces |
| 1426 | + # ImportError out of a `from X import Y` statement - patching an |
| 1427 | + # attribute on the already-imported module (as |
| 1428 | + # test_returns_an_error_message_without_a_configured_base_url does |
| 1429 | + # above) can't simulate that, since the `from` import itself would |
| 1430 | + # still succeed. |
| 1431 | + with patch.dict(sys.modules, {"InvenTree.helpers": None}): |
| 1432 | + result = discovery._build_web_link("part", 5) |
| 1433 | + self.assertIsNone(result["web_url"]) |
| 1434 | + self.assertIn("error", result) |
| 1435 | + |
| 1436 | + def test_builds_the_expected_url_per_resource(self): |
| 1437 | + with patch("InvenTree.helpers_model.get_base_url", return_value="http://x/"): |
| 1438 | + cases = { |
| 1439 | + "part": (5, "http://x/web/part/5"), |
| 1440 | + "category": (2, "http://x/web/part/category/2"), |
| 1441 | + "stock": (7, "http://x/web/stock/item/7"), |
| 1442 | + "location": (3, "http://x/web/stock/location/3"), |
| 1443 | + "purchase_order": (9, "http://x/web/purchasing/purchase-order/9"), |
| 1444 | + "supplier_part": (4, "http://x/web/purchasing/supplier-part/4"), |
| 1445 | + "manufacturer_part": ( |
| 1446 | + 6, |
| 1447 | + "http://x/web/purchasing/manufacturer-part/6", |
| 1448 | + ), |
| 1449 | + # The generic 'company/{pk}' route, not core's own Company. |
| 1450 | + # get_absolute_url() - see _WEB_LINK_PATHS's comment for why. |
| 1451 | + "company": (8, "http://x/web/company/8"), |
| 1452 | + "sales_order": (1, "http://x/web/sales/sales-order/1"), |
| 1453 | + "return_order": (10, "http://x/web/sales/return-order/10"), |
| 1454 | + "build_order": (11, "http://x/web/manufacturing/build-order/11"), |
| 1455 | + } |
| 1456 | + for resource, (pk, expected) in cases.items(): |
| 1457 | + self.assertEqual( |
| 1458 | + discovery._build_web_link(resource, pk)["web_url"], |
| 1459 | + expected, |
| 1460 | + resource, |
| 1461 | + ) |
| 1462 | + |
| 1463 | + |
1379 | 1464 | class CallViewImportSafetyTest(InvenTreeTestCase): |
1380 | 1465 | """call_view(None, ...) must raise a clean ToolError, not an AttributeError. |
1381 | 1466 |
|
|
0 commit comments