Repository: fractal-analytics-platform/fractal-client (language: python)

Issue:
Prevent `httpx.ConnectError`s in CI
See e.g.
* https://github.com/fractal-analytics-platform/fractal/pull/493#issuecomment-1563963117
* https://github.com/fractal-analytics-platform/fractal/pull/495 

This can likely be fixed by adding more checks in the `testserver` fixture.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ee7fa5c3..f36cad04e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,7 +25,7 @@ jobs:
           fetch-depth: 1
 
       - name: Install poetry
-        run: pipx install poetry==1.3
+        run: pipx install poetry==1.5
 
       - name: Set up Python ${{ matrix.python-version }}
         uses: actions/setup-python@v4
diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml
index f9dcf1bc7..b67a7d3b4 100644
--- a/.github/workflows/documentation.yaml
+++ b/.github/workflows/documentation.yaml
@@ -16,11 +16,11 @@ jobs:
       - uses: actions/checkout@v3
 
       - name: Install poetry
-        run: pipx install poetry==1.3
+        run: pipx install poetry==1.5
 
       - uses: actions/setup-python@v4
         with:
-          python-version: '3.9'
+          python-version: '3.10'
           cache: 'poetry'
 
       - name: Install dependencies
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ab13ae46..a2245505d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 * Align with [fractal-server 1.3.0](https://fractal-analytics-platform.github.io/fractal-server/changelog/#130), by updating all relevant endpoint path/query/body parameters (\#479).
 * Add `fractal job stop` command (\#485).
+* Add task ID/name/version disambiguation to `task edit` and `workflow add-task` (\#499).
 * Specific changes to existing commands:
     * Make `project_id` a required positional argument of `fractal {workflow,dataset,job}` commands (\#479).
     * For `edit` commands, always prepend the new arguments with `new`, e.g. as in `task edit ... --new-version` (\#498).
diff --git a/fractal/cmd/_aux_task_caching.py b/fractal/cmd/_aux_task_caching.py
index b7e3ad316..8c433d1ae 100644
--- a/fractal/cmd/_aux_task_caching.py
+++ b/fractal/cmd/_aux_task_caching.py
@@ -10,6 +10,14 @@
 TASKS_CACHE_FILENAME = "tasks"
 
 
+class FractalCacheError(RuntimeError):
+    """
+    Custom error raised by functions of this module
+    """
+
+    pass
+
+
 # Define a useful type
 _TaskList = list[dict[str, Any]]
 
@@ -38,7 +46,7 @@ def _sort_task_list(task_list: _TaskList) -> _TaskList:
     return new_task_list
 
 
-def _write_task_list(task_list: _TaskList) -> None:
+def _write_task_list_to_cache(task_list: _TaskList) -> None:
     """
     Write task list to cache file
     """
@@ -48,92 +56,164 @@ def _write_task_list(task_list: _TaskList) -> None:
         json.dump(task_list, f, indent=4)
 
 
-async def refresh_task_cache(client: AuthClient) -> list[dict[str, Any]]:
+async def refresh_task_cache(client: AuthClient) -> _TaskList:
     """
     Return task list after fetching it, sorting it and writing to cache file.
     """
     task_list = await _fetch_task_list(client)
     task_list = _sort_task_list(task_list)
-    _write_task_list(task_list)
+    _write_task_list_to_cache(task_list)
     return task_list
 
 
-def _get_task_id(
-    task_list: list[dict],
+def _get_matching_tasks(
+    task_list: _TaskList,
     *,
-    name: Optional[str] = None,
-    _id: Optional[int] = None,
-    version: Optional[str] = "___VERSION_DEFAULT___",
-    owner: Optional[str] = "___OWNER_DEFAULT___",
-):
+    name: str,
+    version: Optional[str] = None,
+) -> _TaskList:
     """
-    Given a task list, extract the ID of the single task matching some
-    conditions.
-
-    Note: this function also lets the user find tasks with `version` or `owner`
-    that are `None`; this won't be necessarily used in other public
-    functions.
+    Given a task list, extract all the tasks matching some conditions.
     """
 
     def _condition(_task):
-        if name and _task["name"] != name:
-            return False
-        if version != "___VERSION_DEFAULT___" and _task["version"] != version:
-            return False
-        if owner != "___OWNER_DEFAULT___" and _task["owner"] != owner:
+        if _task["name"] == name:
+            if (version is None) or (_task["version"] == version):
+                return True
             return False
-        if _id and _task["id"] != _id:
+        else:
             return False
-        return True
-
-    tasks = [_task for _task in task_list if _condition(_task)]
-    if len(tasks) == 0:
-        raise ValueError("No task matches required attributes")
-    elif len(tasks) > 1:
-        raise ValueError(
-            f"Multiple tasks ({len(tasks)}) match required attributes"
-        )
-    return tasks[0]["id"]
 
+    return [_task for _task in task_list if _condition(_task)]
 
-async def get_cached_task_by_name(name: str, client: AuthClient) -> int:
 
-    """ """
+def _format_task_list(task_list: _TaskList) -> str:
+    """
+    Helper function to print a formatted task list with only a few task
+    attributes, to be used in error messages.
+    """
+    header = "  ID, Name, Version, Owner, Source"
+    formatted_list = "\n".join(
+        [
+            (
+                f'  {task["id"]}, "{task["name"]}", {task["version"]}, '
+                f'{task.get("owner")}, {task["source"]}'
+            )
+            for task in task_list
+        ]
+    )
+    return f"{header}\n{formatted_list}"
 
-    # Set paths
-    cache_dir = Path(f"{settings.FRACTAL_CACHE_PATH}").expanduser()
-    cache_file = cache_dir / TASKS_CACHE_FILENAME
+
+def _search_in_task_list(
+    *,
+    task_list: _TaskList,
+    name: str,
+    version: Optional[str] = None,
+) -> int:
+    """
+    Search for a single task in `task_list` based on the provided `name`
+    and `version`, and return its `id`.
+
+    If `version` is not provided, use the maximum available version (that is,
+    the latest version).
+
+    If the task is not found or is not unique, raise a `FractalCacheError`.
+    """
+    matching_task_list = _get_matching_tasks(
+        task_list, name=name, version=version
+    )
+    formatted_matching_task_list = _format_task_list(matching_task_list)
+
+    if len(matching_task_list) == 0:
+        formatted_task_list = _format_task_list(task_list)
+        if version is not None:
+            raise FractalCacheError(
+                f'There is no task with (name, version)=("{name}", {version}) '
+                f"in the following task list:\n{formatted_task_list}\n"
+            )
+        else:
+            raise FractalCacheError(
+                f'There is no task with name "{name}" '
+                f"in the following task list:\n{formatted_task_list}\n"
+            )
+    elif len(matching_task_list) == 1:
+        return matching_task_list[0]["id"]
+    else:  # i.e. len(matching_task_list) > 1
+        if version is not None:
+            raise FractalCacheError(
+                f"Multiple tasks with version {version} in the following "
+                f"task list:\n{formatted_matching_task_list}"
+                "Please make your request more specific.\n"
+            )
+        else:  # i.e. version is None
+            if any(task["version"] is None for task in matching_task_list):
+                raise FractalCacheError(
+                    "Cannot determine the latest version in the following "
+                    f"task list:\n{formatted_matching_task_list}"
+                    "Please make your request more specific.\n"
+                )
+            max_version = max(_task["version"] for _task in matching_task_list)
+            max_version_tasks = [
+                _task
+                for _task in matching_task_list
+                if _task["version"] == max_version
+            ]
+            formatted_matching_task_list = _format_task_list(max_version_tasks)
+            if len(max_version_tasks) == 1:
+                return max_version_tasks[0]["id"]
+            else:
+                raise FractalCacheError(
+                    "Multiple tasks with latest version "
+                    f"({max_version}) in the following task "
+                    f"list:\n{formatted_matching_task_list}"
+                    "Please make your request more specific.\n"
+                )
+
+
+async def get_task_id_from_cache(
+    client: AuthClient, task_name: str, version: Optional[str] = None
+) -> int:
+    """
+    Retrieve the `id` of a task from the cache based on the provided
+    `task_name` and `version`.
+
+    If `version` is not provided, the latest (i.e. maximum) available version
+    is used.
+
+    Return the `id` of the single matching task, if found.
+
+    If the task is not found or is not unique, re-try after refreshing the
+    cache, and then raise a `FractalCacheError`.
+    """
 
     # If cache is missing, create it
-    cache_up_to_date = False
-    if not cache_file.exists():
-        await refresh_task_cache(client)
-        cache_up_to_date = True
-
-    # Read task list and create cache of (name, id) pairs
-    # FIMXE: this step will be modified in view of
-    # https://github.com/fractal-analytics-platform/fractal/issues/345
-    with cache_file.open("r") as f:
-        task_list = json.load(f)
-    task_cache = {}
-    for task in task_list:
-        if task["name"] in task_cache.keys():
-            raise ValueError("Cannot parse task_list")
-        task_cache[task["name"]] = task["id"]
-
-    # Look for name in cache
-    # Case 1: name exists in cache
-    if name in task_cache.keys():
-        return task_cache[name]
-    # Case 2: name is missing, and cache was just updated
-    elif cache_up_to_date:
-        raise KeyError(f'Task "{name}" not in {cache_file}\n')
-    # Case 3: name is missing but cache may be out of date
-    else:
-        await refresh_task_cache(client)
+    cache_dir = Path(f"{settings.FRACTAL_CACHE_PATH}").expanduser()
+    cache_file = cache_dir / TASKS_CACHE_FILENAME
+    if cache_file.exists():
         with cache_file.open("r") as f:
-            task_cache = json.load(f)
-        try:
-            return task_cache[name]
-        except KeyError as e:
-            raise KeyError(f'Task "{name}" not in {cache_file}\n', str(e))
+            task_list = json.load(f)
+        already_refreshed_cache = False
+    else:
+        task_list = await refresh_task_cache(client)
+        already_refreshed_cache = True
+
+    try:
+        task_id = _search_in_task_list(
+            task_list=task_list,
+            name=task_name,
+            version=version,
+        )
+    except FractalCacheError as e:
+        if already_refreshed_cache:
+            # Cache is already up to date, fail
+            raise e
+        else:
+            # Cache may be out-of-date, refresh it and try again
+            task_list = await refresh_task_cache(client)
+            task_id = _search_in_task_list(
+                task_list=task_list,
+                name=task_name,
+                version=version,
+            )
+    return task_id
diff --git a/fractal/cmd/_project.py b/fractal/cmd/_project.py
index b201cbd3e..aa1cff1fd 100644
--- a/fractal/cmd/_project.py
+++ b/fractal/cmd/_project.py
@@ -1,4 +1,3 @@
-import logging
 from typing import Optional
 from typing import Union
 
@@ -28,7 +27,6 @@ async def post_project(
     if dataset:
         project_dict["default_dataset_name"] = dataset
     project = ProjectCreate(**project_dict)
-    logging.info(project)
     # Send API request
     res = await client.post(
         f"{settings.BASE_URL}/project/",
diff --git a/fractal/cmd/_task.py b/fractal/cmd/_task.py
index bedb06320..b6671790f 100644
--- a/fractal/cmd/_task.py
+++ b/fractal/cmd/_task.py
@@ -1,4 +1,6 @@
 import json
+import logging
+import sys
 from typing import Optional
 
 from ..authclient import AuthClient
@@ -12,7 +14,8 @@
 from ..interface import PrintInterface
 from ..interface import RichJsonInterface
 from ..response import check_response
-from ._aux_task_caching import get_cached_task_by_name
+from ._aux_task_caching import FractalCacheError
+from ._aux_task_caching import get_task_id_from_cache
 from ._aux_task_caching import refresh_task_cache
 
 
@@ -132,6 +135,7 @@ async def patch_task(
     client: AuthClient,
     *,
     task_id_or_name: str,
+    version: Optional[str] = None,
     new_name: Optional[str] = None,
     new_command: Optional[str] = None,
     new_input_type: Optional[str] = None,
@@ -140,6 +144,21 @@ async def patch_task(
     default_args_file: Optional[str] = None,
     meta_file: Optional[str] = None,
 ) -> BaseInterface:
+
+    try:
+        task_id = int(task_id_or_name)
+        if version:
+            logging.warning("Task Version is ignored because Task ID provided")
+    except ValueError:
+        task_name = task_id_or_name
+        try:
+            task_id = await get_task_id_from_cache(
+                client=client, task_name=task_name, version=version
+            )
+        except FractalCacheError as e:
+            print(e)
+            sys.exit(1)
+
     update = {}
     if new_name:
         update["name"] = new_name
@@ -163,13 +182,6 @@ async def patch_task(
     if not payload:
         return PrintInterface(retcode=1, data="Nothing to update")
 
-    try:
-        task_id = int(task_id_or_name)
-    except ValueError:
-        task_id = await get_cached_task_by_name(
-            name=task_id_or_name, client=client
-        )
-
     res = await client.patch(
         f"{settings.BASE_URL}/task/{task_id}", json=payload
     )
diff --git a/fractal/cmd/_workflow.py b/fractal/cmd/_workflow.py
index 65d11b604..c132aa1aa 100644
--- a/fractal/cmd/_workflow.py
+++ b/fractal/cmd/_workflow.py
@@ -1,5 +1,5 @@
 import json
-import logging
+import sys
 from pathlib import Path
 from typing import Optional
 
@@ -19,7 +19,8 @@
 from ..interface import PrintInterface
 from ..interface import RichJsonInterface
 from ..response import check_response
-from ._aux_task_caching import get_cached_task_by_name
+from ._aux_task_caching import FractalCacheError
+from ._aux_task_caching import get_task_id_from_cache
 
 
 async def post_workflow(
@@ -33,7 +34,6 @@ async def post_workflow(
         name=name,
         project_id=project_id,
     )
-    logging.info(workflow)
     res = await client.post(
         f"{settings.BASE_URL}/project/{project_id}/workflow/",
         json=workflow.dict(),
@@ -96,8 +96,9 @@ async def post_workflowtask(
     *,
     project_id: int,
     workflow_id: int,
-    batch: bool = False,
     task_id_or_name: str,
+    batch: bool = False,
+    version: Optional[str] = None,
     order: Optional[int] = None,
     args_file: Optional[str] = None,
     meta_file: Optional[str] = None,
@@ -106,7 +107,13 @@ async def post_workflowtask(
     try:
         task_id = int(task_id_or_name)
     except ValueError:
-        task_id = await get_cached_task_by_name(task_id_or_name, client)
+        try:
+            task_id = await get_task_id_from_cache(
+                client=client, task_name=task_id_or_name, version=version
+            )
+        except FractalCacheError as e:
+            print(e)
+            sys.exit(1)
 
     if order is None:
         workflow_task = WorkflowTaskCreate()
diff --git a/fractal/parser.py b/fractal/parser.py
index e78b90b5a..81c1cee40 100644
--- a/fractal/parser.py
+++ b/fractal/parser.py
@@ -376,6 +376,9 @@
 task_edit_parser.add_argument(
     "task_id_or_name", help="ID or name of task to edit", type=str
 )
+task_edit_parser.add_argument(
+    "--version", type=str, help="Version of the task to edit"
+)
 task_edit_parser.add_argument("--new-name", help="New task name", type=str)
 task_edit_parser.add_argument(
     "--new-command", help="New task command", type=str
@@ -511,6 +514,9 @@
 workflow_add_task_parser.add_argument(
     "task_id_or_name", help="ID or name of the new task", type=str
 )
+workflow_add_task_parser.add_argument(
+    "--version", type=str, help="Version of the new task"
+)
 workflow_add_task_parser.add_argument(
     "--order", help="Order of this task within the workflow's task list"
 )


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ea2c18a10..9cbffcfe4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,6 +6,8 @@
 import pytest
 from httpx import AsyncClient
 
+TASKS_CACHE_FILENAME = "tasks"
+
 
 environ["FRACTAL_USER"] = "test@fake-exact-lab.it"
 environ["FRACTAL_PASSWORD"] = "password"
@@ -86,7 +88,9 @@ def clear_task_cache():
     # would be to inject a new (function-scoped) FRACTAL_CACHE_PATH variable
     # for each test
     cache_dir = Path(settings.FRACTAL_CACHE_PATH).expanduser()
-    cache_file = cache_dir / "tasks"
+    cache_file = cache_dir / TASKS_CACHE_FILENAME
+    cache_file.unlink(missing_ok=True)
+    yield
     cache_file.unlink(missing_ok=True)
 
 
diff --git a/tests/test_task.py b/tests/test_task.py
index 95cb0a2c0..4e45521dd 100644
--- a/tests/test_task.py
+++ b/tests/test_task.py
@@ -5,6 +5,9 @@
 import pytest
 from devtools import debug
 
+from fractal.cmd._aux_task_caching import TASKS_CACHE_FILENAME
+from fractal.config import settings
+
 
 COLLECTION_TIMEOUT = 15.0
 
@@ -163,6 +166,7 @@ async def test_task_new(register_user, invoke):
 
 
 async def test_task_edit(
+    caplog,
     register_user,
     invoke,
     invoke_as_superuser,
@@ -179,19 +183,32 @@ async def test_task_edit(
         res = await invoke(f"task edit {task_id} --new-name {NEW}")
 
     # Test successful edit of string attributes
-    res = await invoke_as_superuser(f"task edit {task_id} --new-name {NEW}")
-    assert res.data["name"] == NEW
+    NAME = "new-name"
+    res = await invoke_as_superuser(f"task edit {task_id} --new-name {NAME}")
+    assert res.data["name"] == NAME
     assert res.retcode == 0
     res = await invoke_as_superuser(f"task edit {task_id} --new-command {NEW}")
     assert res.data["command"] == NEW
     assert res.retcode == 0
+
+    # Test version ignored
+    res = await invoke_as_superuser(
+        f"task edit {task_id} --version 1.2.3.4.5.6"
+    )
+    assert caplog.records[-1].msg == (
+        "Task Version is ignored because Task ID provided"
+    )
+    assert res.retcode == 1
+    assert res.data == "Nothing to update"
+
+    # Test regular updates (both by id and name)
     res = await invoke_as_superuser(
         f"task edit {task_id} --new-input-type {NEW}"
     )
     assert res.data["input_type"] == NEW
     assert res.retcode == 0
     res = await invoke_as_superuser(
-        f"task edit {task_id} --new-output-type {NEW}"
+        f"task edit {NAME} --new-output-type {NEW}"
     )
     assert res.data["output_type"] == NEW
     assert res.retcode == 0
@@ -199,6 +216,36 @@ async def test_task_edit(
     assert res.data["version"] == NEW
     assert res.retcode == 0
 
+    # Test regular update by name, after deleting cache
+    cache_dir = Path(settings.FRACTAL_CACHE_PATH).expanduser()
+    cache_file = cache_dir / TASKS_CACHE_FILENAME
+    cache_file.unlink(missing_ok=True)
+    NEW_TYPE = "something"
+    res = await invoke_as_superuser(
+        f"task edit {NAME} --new-output-type {NEW_TYPE}"
+    )
+    assert res.data["output_type"] == NEW_TYPE
+    assert res.retcode == 0
+
+    # Test failed update by name, after deleting cache
+    cache_file.unlink(missing_ok=True)
+    NEW_TYPE = "something-here"
+    with pytest.raises(SystemExit):
+        res = await invoke_as_superuser(
+            f"task edit INVALID_NAME --new-output-type {NEW_TYPE}"
+        )
+
+    # Test regular update by name, after creating an invalid cache
+    with cache_file.open("w") as f:
+        json.dump([], f)
+    NEW_TYPE = "something-else"
+    debug(f"task edit {NAME} --new-output-type {NEW_TYPE}")
+    res = await invoke_as_superuser(
+        f"task edit {NAME} --new-output-type {NEW_TYPE}"
+    )
+    assert res.data["output_type"] == NEW_TYPE
+    assert res.retcode == 0
+
     # Test `file not found` errors
     with pytest.raises(FileNotFoundError):
         res = await invoke_as_superuser(
@@ -290,7 +337,8 @@ async def test_task_list(register_user, invoke, testdata_path):
             "input_type",
             "output_type",
         ]:
-            task.pop(key)
+            if key in task:
+                task.pop(key)
     debug(task_list)
 
     # Check that tasks are sorted as expected
diff --git a/tests/test_unit_task_cache.py b/tests/test_unit_task_cache.py
index e70e52cc6..4617a52fc 100644
--- a/tests/test_unit_task_cache.py
+++ b/tests/test_unit_task_cache.py
@@ -1,47 +1,99 @@
 import pytest
-from devtools import debug
-
-from fractal.cmd._aux_task_caching import _get_task_id
-
-
-TASK_LIST = [
-    # Common tasks (with owner=None)
-    dict(name="Task A", id=1, version="0.1.1", owner=None),
-    dict(name="Task A", id=2, version="1.2.0", owner=None),
-    dict(name="Task A", id=3, version="1.3.1", owner=None),
-    dict(name="Task B", id=4, version="0.1.1", owner=None),
-    dict(name="Task B", id=5, version="1.2.0", owner=None),
-    dict(name="Task B", id=6, version="1.3.1", owner=None),
-    # UserA tasks
-    dict(name="Task A", id=7, version="1.3.1", owner="User1"),
-]
-
-NOTSET = "__THIS_VALUE_IS_NOT_SET__"
-RAISE = "__THIS_MUST_FAIL__"
-
-CASES = [
-    (NOTSET, NOTSET, NOTSET, 1, 1),
-    (NOTSET, NOTSET, "User1", NOTSET, 7),
-    (NOTSET, NOTSET, NOTSET, 999, RAISE),
-    # FIXME: add more cases
-]
-
-
-def test_get_task_id():
-    for (name, version, owner, _id, expected_id) in CASES:
-        kwargs = {}
-        if name != NOTSET:
-            kwargs["name"] = name
-        if version != NOTSET:
-            kwargs["version"] = version
-        if _id != NOTSET:
-            kwargs["_id"] = _id
-        if owner != NOTSET:
-            kwargs["owner"] = owner
-        debug(kwargs)
-
-        if expected_id == RAISE:
-            with pytest.raises(ValueError):
-                _get_task_id(TASK_LIST, **kwargs)
-        else:
-            assert _get_task_id(TASK_LIST, **kwargs) == expected_id
+
+from fractal.cmd._aux_task_caching import _get_matching_tasks
+from fractal.cmd._aux_task_caching import _search_in_task_list
+from fractal.cmd._aux_task_caching import FractalCacheError
+
+
+def test_get_matching_tasks(clear_task_cache):
+    """Test all possible cases for function `_get_matching_tasks`"""
+
+    a = dict(name="bob", id=1, version="0.1.1")
+    b = dict(name="bob", id=2, version="1.2.0")
+    c = dict(name="bob", id=3, version="1.3.1")
+    d = dict(name="bar", id=4, version="0.0.0")
+    e = dict(name="bar", id=5, version=None)
+
+    TASK_LIST = [a, b, c, d, e]
+
+    res = _get_matching_tasks(TASK_LIST, name="alice")
+    assert res == []
+
+    res = _get_matching_tasks(TASK_LIST, name="bob")
+    assert res == [a, b, c]
+
+    res = _get_matching_tasks(TASK_LIST, name="bob", version="1.2.0")
+    assert res == [b]
+
+    res = _get_matching_tasks(TASK_LIST, name="bar")
+    assert res == [d, e]
+
+    res = _get_matching_tasks(TASK_LIST, name="bar", version="3.1.4")
+    assert res == []
+
+
+async def test_search_in_task_list(clear_task_cache):
+    """Test all possible cases for function `_search_in_task_list`"""
+
+    TASK_LIST = [
+        dict(name="dummy1", id=101, version="1.0.1", source="a"),
+        dict(name="dummy2", id=201, version=None, source="b"),
+        dict(name="dummy2", id=202, version="2.0.0", source="c"),
+        dict(name="dummy3", id=301, version="3.0.0", source="d"),
+        dict(name="dummy3", id=302, version="3.1.4", source="e"),
+        dict(name="dummy4", id=401, version="4.0.0", source="f"),
+        dict(name="dummy4", id=402, version="4.1.1", source="g"),
+        dict(name="dummy4", id=401, version="4.1.1", source="h"),
+    ]
+
+    # TEST zero matching
+
+    # case 1
+    with pytest.raises(FractalCacheError) as err:
+        res = _search_in_task_list(task_list=TASK_LIST, name="dummy0")
+    print(err.value.args[0])
+    assert 'There is no task with name "dummy0"' in err.value.args[0]  # noqa
+
+    # case 2
+    with pytest.raises(FractalCacheError) as err:
+        res = _search_in_task_list(
+            task_list=TASK_LIST, name="dummy1", version="3.1.4"
+        )
+    print(err.value.args[0])
+    assert (
+        'There is no task with (name, version)=("dummy1", 3.1.4)'
+        in err.value.args[0]
+    )  # noqa
+
+    # TEST one matching
+    # case 1
+    res = _search_in_task_list(task_list=TASK_LIST, name="dummy1")
+    assert res == 101
+    # case 2
+    res = _search_in_task_list(
+        task_list=TASK_LIST, name="dummy1", version="1.0.1"
+    )
+    assert res == 101
+
+    # TEST multiple matching
+    # case 1
+    with pytest.raises(FractalCacheError) as err:
+        res = _search_in_task_list(task_list=TASK_LIST, name="dummy2")
+    print(err.value.args[0])
+    assert "Cannot determine the latest version" in err.value.args[0]
+    # case 2
+    res = _search_in_task_list(task_list=TASK_LIST, name="dummy3")
+    assert res == 302
+    # case 3
+    with pytest.raises(FractalCacheError) as err:
+        res = _search_in_task_list(task_list=TASK_LIST, name="dummy4")
+    print(err.value.args[0])
+    assert "Multiple tasks with latest version (4.1.1)" in err.value.args[0]
+    print(err.value.args[0])
+    # case 4
+    with pytest.raises(FractalCacheError) as err:
+        res = _search_in_task_list(
+            task_list=TASK_LIST, name="dummy4", version="4.1.1"
+        )
+    print(err.value.args[0])
+    assert "Multiple tasks with version 4.1.1" in err.value.args[0]
diff --git a/tests/test_workflow.py b/tests/test_workflow.py
index e704d402d..d6905ac64 100644
--- a/tests/test_workflow.py
+++ b/tests/test_workflow.py
@@ -159,6 +159,7 @@ async def test_workflow_add_task(
     meta_file = tmp_path / "meta_file.json"
     with meta_file.open("w") as f:
         json.dump(META, f)
+
     cmd = (
         f"workflow add-task {project_id} {wf.id} {t.id} "
         f"--args-file {args_file} --meta-file {meta_file}"
@@ -166,6 +167,7 @@ async def test_workflow_add_task(
     debug(cmd)
     res = await invoke(cmd)
     assert res.retcode == 0
+
     workflow_task = res.data
     workflow_task_id_1 = workflow_task["id"]
     debug(workflow_task)
@@ -201,7 +203,8 @@ async def test_workflow_add_task_by_name(
     """
     GIVEN a workflow and a task
     WHEN the client is invoked to add a task *by name*
-    THEN the WorkflowTask is correctly registered in the db
+    THEN the WorkflowTask is added (for a valid name) or an error is raised
+    (for invalid name)
     """
     res = await invoke("project new MyProject")
     project_id = res.data["id"]
@@ -216,6 +219,12 @@ async def test_workflow_add_task_by_name(
     debug(res.data)
     assert res.data["task"]["id"] == task.id
 
+    # Fail when adding task via a wrong name
+    with pytest.raises(SystemExit):
+        cmd = f"workflow add-task {project_id} {wf.id} INVALID_NAME"
+        debug(cmd)
+        res = await invoke(cmd)
+
 
 @pytest.mark.skip(reason="Definition of expected behavior is ongoing")
 async def test_task_cache_with_non_unique_names(