Repository: Senth/minecraft-mod-manager (python)

Issue:
Unable to find mod "dcch"
### Describe the bug 
MMM is unable to find mod "Don't Clear Chat History" from https://modrinth.com/mod/dcch/

`
dcch
    🔺 Mod not found on any site...
🔺 Mods not found
dcch
`

### Steps to reproduce
Steps to reproduce the behavior:
mmm configure dcch=modrinth:dcch
mmm --minecraft-version "1.17.1" --mod-loader fabric update dcch

### Expected Behavior
A clear and concise description of what you expected to happen.

https://modrinth.com/mod/dcch/
MMM should update this mod, but is unable to find it.

### Info
 - OS: Windows 11
 - Python --version: 3.10.0
 - minecraft-mod-manager --version: 1.2.5


Pull request:
When searching on Modrinth, also check slug directly
In Modrinth you can get the mod either by mod_id (site_id) or directly by slug,
as search results don't return any results when using the slug,

Fixes #135

### What changed?
- When searching on modrinth we not only search by `/mod?query=` but also check if we can find the mod directly by the slug on `/mod/slug`

### Testing
- [X] Added unit tests
- [X] Added integration tests
- [ ] ...Your own tests...

### Checklist
- [ ] I have reviewed my own code

### Related Issues
Fixes #135


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c3529b..d6a4cb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   It can still break the program in an invalid state [#140](https://github.com/Senth/minecraft-mod-manager/issues/140)
 - Now retries to fetch/download file 5 times before skipping [#169](https://github.com/Senth/minecraft-mod-manager/issues/169)
 - Now identifies installed mods correctly by slug as well [#149](https://github.com/Senth/minecraft-mod-manager/issues/149)
+- Can now search by slug on Modrinth [[#135](https://github.com/Senth/minecraft-mod-manager/issues/135)].
+  When searching on Modrinth include on their site modrinth.com/mods, searching by slug can return an empty result,
+  so we now get the mod directly by slug.
 
 ## [1.2.7] - 2022-03-06
 
diff --git a/README.md b/README.md
index 5b989e5..57c6d99 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ _[(News Archive)](./NEWS.md)_
 | `update -v "1.16.5"`                            | Updates to latest mod version which works with specified MC version.                                |
 | `update -v "1.16.1"`                            | If you upgraded the mods, to a higher version (e.g. snapshot), you can easily downgrade them again. |
 | `configure sodium=modrinth`                     | Change the download site for a mod.                                                                 |
-| `configure sodium=`                             | Reset download sites (downloads from all sites again)                                               |
+| `configure sodium=`                             | Doesn't work, known bug! Reset download sites (downloads from all sites again)                                               |
 | `configure carpet=curse:fabric-carpet`          | Change site slug for a mod.                                                                         |
 | `configure carpet=curse`                        | If you don't define a slug, you will reset the slug for that mod.                                   |
 | `configure sodium=modrinth carpet=curse`        | Easily configure multiple mods at the same time.                                                    |
diff --git a/minecraft_mod_manager/app/configure/configure.py b/minecraft_mod_manager/app/configure/configure.py
index 1dbd36f..add096f 100644
--- a/minecraft_mod_manager/app/configure/configure.py
+++ b/minecraft_mod_manager/app/configure/configure.py
@@ -30,7 +30,7 @@ class Configure:
                 )
                 return
 
-            if isinstance(mod_arg.sites, dict):
+            if len(mod_arg.sites) > 0:
                 Configure._update_sites(found_mod, mod_arg.sites)
 
             # Updating mod
@@ -53,7 +53,7 @@ class Configure:
         old_sites = mod.sites
         mod.sites = sites
 
-        if old_sites:
+        if len(old_sites) > 0:
             for old_site in old_sites.values():
                 if old_site.id:
                     if mod.sites and old_site.name in mod.sites:
diff --git a/minecraft_mod_manager/core/entities/mod.py b/minecraft_mod_manager/core/entities/mod.py
index 89aa402..19c27b9 100644
--- a/minecraft_mod_manager/core/entities/mod.py
+++ b/minecraft_mod_manager/core/entities/mod.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import re
-from typing import Dict, Set, Union
+from typing import Dict, Optional, Set
 
 from .mod_loaders import ModLoaders
 from .sites import Site, Sites
@@ -10,7 +10,7 @@ from .sites import Site, Sites
 class ModArg:
     """Mod argument from the CLI"""
 
-    def __init__(self, id: str, sites: Union[Dict[Sites, Site], None] = None) -> None:
+    def __init__(self, id: str, sites: Dict[Sites, Site] = {}) -> None:
         self.sites = sites
         """Where the mod is downloaded from"""
         self.id = id
@@ -70,9 +70,9 @@ class Mod(ModArg):
         self,
         id: str,
         name: str,
-        sites: Union[Dict[Sites, Site], None] = None,
-        version: Union[str, None] = None,
-        file: Union[str, None] = None,
+        sites: Dict[Sites, Site] = {},
+        version: Optional[str] = None,
+        file: Optional[str] = None,
         upload_time: int = 0,
         mod_loader: ModLoaders = ModLoaders.unknown,
     ):
diff --git a/minecraft_mod_manager/gateways/api/modrinth_api.py b/minecraft_mod_manager/gateways/api/modrinth_api.py
index f2dc1ee..3f24dcb 100644
--- a/minecraft_mod_manager/gateways/api/modrinth_api.py
+++ b/minecraft_mod_manager/gateways/api/modrinth_api.py
@@ -42,20 +42,42 @@ class ModrinthApi(Api):
 
     @staticmethod
     def _make_versions_url(mod: Mod) -> str:
-        if mod.sites:
+        if Sites.modrinth in mod.sites:
             return f"{_base_url}/mod/{mod.sites[Sites.modrinth].id}/version"
         raise RuntimeError("No site id found")
 
     def search_mod(self, search: str) -> List[Site]:
-        mods: List[Site] = []
+        sites: List[Site] = []
+
+        # Search by query
+        mods = self._search_mod(search)
+
+        # Get by mod slug
+        try:
+            mod = self.get_mod_info(search)
+            mods.append(mod)
+        except ModNotFoundException:
+            # Not found by slug
+            pass
+
+        # Convert to site
+        for mod in mods:
+            sites.append(mod.sites[Sites.modrinth])
+
+        return sites
+
+    def _search_mod(self, search: str) -> List[Mod]:
+        mods: List[Mod] = []
         json = self.http.get(ModrinthApi._make_search_url(search))
 
         for mod_info in json["hits"]:
-            if "slug" in mod_info and "mod_id" in mod_info:
+            if {"slug", "mod_id", "title"}.issubset(mod_info):
                 slug = mod_info["slug"]
                 site_id = str(mod_info["mod_id"])
                 site_id = site_id.replace("local-", "")
-                mods.append(Site(Sites.modrinth, site_id, slug))
+                name = mod_info["title"]
+                mods.append(Mod(id="", name=name, sites={Sites.modrinth: Site(Sites.modrinth, site_id, slug)}))
+
         return mods
 
     @staticmethod
@@ -70,7 +92,7 @@ class ModrinthApi(Api):
 
     def get_mod_info(self, site_id: str) -> Mod:
         json = self.http.get(f"{_base_url}/mod/{site_id}")
-        if json and "id" in json and "slug" in json and "title" in json:
+        if {"id", "slug", "title"}.issubset(json):
             return Mod(
                 id="",
                 name=json["title"],
diff --git a/minecraft_mod_manager/gateways/sqlite.py b/minecraft_mod_manager/gateways/sqlite.py
index f372169..2cb79b3 100644
--- a/minecraft_mod_manager/gateways/sqlite.py
+++ b/minecraft_mod_manager/gateways/sqlite.py
@@ -31,7 +31,7 @@ class _Column:
         self.active = bool(active)
 
     @staticmethod
-    def string_sites_to_dict(full_str: str) -> Union[Dict[Sites, Site], None]:
+    def string_sites_to_dict(full_str: str) -> Dict[Sites, Site]:
         if "," in full_str:
             sites_strings = full_str.split(",")
         else:
@@ -57,9 +57,6 @@ class _Column:
             except KeyError:
                 TealPrint.error(f"DB site {site_str} not a valid site, full: '{full_str}'")
 
-        if not sites:
-            return None
-
         return sites
 
     @staticmethod
@@ -145,7 +142,7 @@ class Sqlite:
                 mod.upload_time = db_mod.upload_time
 
                 # Update new mod sites info
-                if isinstance(mod.sites, dict):
+                if len(mod.sites) > 0:
                     self.update_mod(mod)
                 else:
                     mod.sites = db_mod.sites


Test patch:
diff --git a/minecraft_mod_manager/app/configure/configure_test.py b/minecraft_mod_manager/app/configure/configure_test.py
index 2dd206a..e40c8ec 100644
--- a/minecraft_mod_manager/app/configure/configure_test.py
+++ b/minecraft_mod_manager/app/configure/configure_test.py
@@ -61,10 +61,10 @@ def test_abort_before_configuring_when_later_mod_not_found(mock_repo):
             Mod("carpet", "", {Sites.curse: Site(Sites.curse)}),
         ),
         (
-            "Mod sites remove when no is specified",
+            "Mod sites keep when no is specified",
             Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}),
             [ModArg("carpet", {})],
-            Mod("carpet", "", {}),
+            Mod("carpet", "", {Sites.modrinth: Site(Sites.modrinth)}),
         ),
         (
             "Keep old info if no site is specified",
diff --git a/minecraft_mod_manager/gateways/api/modrinth_api_test.py b/minecraft_mod_manager/gateways/api/modrinth_api_test.py
index e5edb28..5880495 100644
--- a/minecraft_mod_manager/gateways/api/modrinth_api_test.py
+++ b/minecraft_mod_manager/gateways/api/modrinth_api_test.py
@@ -11,7 +11,7 @@ from ...core.entities.sites import Site, Sites
 from ...core.entities.version_info import Stabilities, VersionInfo
 from ...core.errors.mod_not_found_exception import ModNotFoundException
 from ..http import Http
-from .modrinth_api import ModrinthApi
+from .modrinth_api import ModrinthApi, _base_url
 
 testdata_dir = Path(__file__).parent.joinpath("testdata").joinpath("modrinth_api")
 search_result_file = testdata_dir.joinpath("search_fabric-api.json")
@@ -70,8 +70,9 @@ def mod(id="fabric-api", name="Fabric API", site_slug="fabric-api", site_id=site
     return Mod(id=id, name=name, sites={Sites.modrinth: Site(Sites.modrinth, site_id, site_slug)}, file=file)
 
 
-def test_search_mod(api: ModrinthApi, search_result):
-    when(api.http).get(...).thenReturn(search_result)
+def test_search_mod(api: ModrinthApi, search_result, mod_info):
+    when(api.http).get(ModrinthApi._make_search_url("search-slug")).thenReturn(search_result)
+    when(api.http).get(_base_url + "/mod/search-slug").thenReturn(mod_info)
     expected = [
         Site(Sites.modrinth, "P7dR8mSH", "fabric-api"),
         Site(Sites.modrinth, "720sJXM2", "bineclaims"),
@@ -83,9 +84,34 @@ def test_search_mod(api: ModrinthApi, search_result):
         Site(Sites.modrinth, "BahnQObN", "chat-icon-api"),
         Site(Sites.modrinth, "oq1VV8nB", "splashesAPI"),
         Site(Sites.modrinth, "gno5mxtx", "grand-economy"),
+        Site(Sites.modrinth, "aC3cM3Vq", "mouse-tweaks"),
     ]
 
-    actual = api.search_mod("search string")
+    actual = api.search_mod("search-slug")
+
+    verifyStubbedInvocationsAreUsed()
+    unstub()
+
+    assert expected == actual
+
+
+def test_search_mod_with_get_mod_info_for_slug(api: ModrinthApi, search_result):
+    when(api.http).get(ModrinthApi._make_search_url("search-slug")).thenReturn(search_result)
+    when(api.http).get(_base_url + "/mod/search-slug").thenReturn("")
+    expected = [
+        Site(Sites.modrinth, "P7dR8mSH", "fabric-api"),
+        Site(Sites.modrinth, "720sJXM2", "bineclaims"),
+        Site(Sites.modrinth, "iA9GjB4v", "BoxOfPlaceholders"),
+        Site(Sites.modrinth, "ZfVQ3Rjs", "mealapi"),
+        Site(Sites.modrinth, "MLYQ9VGP", "cardboard"),
+        Site(Sites.modrinth, "meZK2DCX", "dawn"),
+        Site(Sites.modrinth, "ssUbhMkL", "gravestones"),
+        Site(Sites.modrinth, "BahnQObN", "chat-icon-api"),
+        Site(Sites.modrinth, "oq1VV8nB", "splashesAPI"),
+        Site(Sites.modrinth, "gno5mxtx", "grand-economy"),
+    ]
+
+    actual = api.search_mod("search-slug")
 
     verifyStubbedInvocationsAreUsed()
     unstub()
diff --git a/minecraft_mod_manager/gateways/sqlite_test.py b/minecraft_mod_manager/gateways/sqlite_test.py
index 58c6d6f..889d546 100644
--- a/minecraft_mod_manager/gateways/sqlite_test.py
+++ b/minecraft_mod_manager/gateways/sqlite_test.py
@@ -183,14 +183,13 @@ class SyncWithDirTest:
         self.expected = expected
         self.db_after = db_after
 
+    def __repr__(self) -> str:
+        return self.name
 
-def row(
-    id: str, sites: Union[str, Dict[Sites, Site], None] = "", upload_time=0, active=1
-) -> Tuple[str, str, int, int]:
+
+def row(id: str, sites: Union[str, Dict[Sites, Site]] = "", upload_time=0, active=1) -> Tuple[str, str, int, int]:
     if isinstance(sites, dict):
         sites = _Column.dict_sites_to_string(sites)
-    elif sites is None:
-        sites = ""
     return (id, sites, upload_time, active)
 
 
@@ -307,21 +306,21 @@ def row(
             ],
         ),
         SyncWithDirTest(
-            name="Replace dict when specified",
+            name="Replace dict when specified, but not empty",
             db_before=[
                 row("1", sites="curse:id:slug", upload_time=1337),
                 row("2", sites="curse::"),
             ],
             input=[
-                Mod("1", "name1", {}),
+                Mod("1", "name1", {Sites.curse: Site(Sites.curse, "id", "slug")}),
                 Mod("2", "name2", {Sites.modrinth: Site(Sites.modrinth)}),
             ],
             expected=[
-                Mod("1", "name1", {}, upload_time=1337),
+                Mod("1", "name1", {Sites.curse: Site(Sites.curse, "id", "slug")}, upload_time=1337),
                 Mod("2", "name2", {Sites.modrinth: Site(Sites.modrinth)}),
             ],
             db_after=[
-                row("1", upload_time=1337),
+                row("1", sites="curse:id:slug", upload_time=1337),
                 row("2", sites="modrinth::"),
             ],
         ),
@@ -354,14 +353,14 @@ def test_sync_with_dir(test: SyncWithDirTest, sqlite: Sqlite, db: sqlite3.Connec
     "name,input,expected",
     [
         (
-            "None when no input",
+            "Empty dict when no input",
             "",
-            None,
+            {},
         ),
         (
-            "None when only colons",
+            "Empty dict when only colons",
             "::",
-            None,
+            {},
         ),
         (
             "Set site name when specified",
@@ -401,11 +400,6 @@ def test_string_sites_to_dict(name, input, expected):
             {},
             "",
         ),
-        (
-            "Empty string when no dict",
-            None,
-            "",
-        ),
         (
             "Set site name when specified",
             {Sites.curse: Site(Sites.curse)},
diff --git a/tests/install_test.py b/tests/install_test.py
index 6e2cbb0..a756d04 100644
--- a/tests/install_test.py
+++ b/tests/install_test.py
@@ -72,3 +72,11 @@ def test_install_dependencies(helper: Helper):
     assert code == 0
     assert lamba_better_crass is not None
     assert dependency_fabric_api is not None
+
+
+def test_install_dcch(helper: Helper):
+    code = helper.run("install", "dcch")
+    dcch = helper.get_mod_in_dir_like("DCCH*.jar")
+
+    assert code == 0
+    assert dcch is not None