Repository: AlexandreSenpai/Enma (language: python)

Issue:
[ FEAT ] - Caching
This pull request introduce caching capabilities to enma


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/dev-pypi.yml b/.github/workflows/dev-pypi.yml
index be936f3..54d0dee 100644
--- a/.github/workflows/dev-pypi.yml
+++ b/.github/workflows/dev-pypi.yml
@@ -7,6 +7,11 @@ jobs:
   testing-deploy:
     name: Pypi
     runs-on: ubuntu-latest
+    environment:
+      name: pypi
+      url: https://pypi.org/p/enma
+    permissions:
+      id-token: write
 
     steps:
       - uses: actions/checkout@v2
@@ -19,11 +24,23 @@ jobs:
       - name: Preparing the environment
         run: |
           python -m pip install --upgrade pip
-          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; else python -m pip install beautifulsoup4 requests pydantic; fi
+          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; else python -m pip install beautifulsoup4 requests pydantic expiringdict pytest; fi
 
       - name: Starting Testing Suites
         run: |
-          python -m pytest ./tests
+          ENMA_CACHING_PAGINATE_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_SEARCH_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_GET_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_AUTHOR_TTL_IN_SECONDS='0' \
+          pytest ./tests
+
+      - name: Upload Report to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          file: ./coverage.xml
+          flags: pytest
 
       - name: Building and Deploying
         run: |
@@ -31,7 +48,7 @@ jobs:
           python -m build
 
       - name: Publish a Python distribution to PyPI
-        uses: pypa/gh-action-pypi-publish@release/v1
+        uses: pypa/gh-action-pypi-publish@v1.8.14
         with:
           repository-url: https://test.pypi.org/legacy/
           user: __token__
diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml
index acfcd2f..4fd43d8 100644
--- a/.github/workflows/pypi.yml
+++ b/.github/workflows/pypi.yml
@@ -19,11 +19,23 @@ jobs:
       - name: Preparing the Environment
         run: |
           python -m pip install --upgrade pip
-          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; else python -m pip install beautifulsoup4 requests pydantic; fi
+          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; else python -m pip install beautifulsoup4 requests pydantic expiringdict pytest; fi
   
       - name: Starting Testing Suites
         run: |
-          python -m pytest ./tests
+          ENMA_CACHING_PAGINATE_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_SEARCH_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_GET_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_AUTHOR_TTL_IN_SECONDS='0' \
+          pytest ./tests
+
+      - name: Upload Report to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          file: ./coverage.xml
+          flags: pytest
 
       - name: Building and Deploying
         run: |
diff --git a/.gitignore b/.gitignore
index 9c95da2..f7ae7d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ var/
 *.egg-info/
 .installed.cfg
 *.egg
+*.sh
 
 # Installer logs
 pip-log.txt
diff --git a/.python-version b/.python-version
deleted file mode 100644
index 92536a9..0000000
--- a/.python-version
+++ /dev/null
@@ -1,1 +0,0 @@
-3.12.0
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..7430640
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,15 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Enma",
+            "type": "debugpy",
+            "request": "launch",
+            "program": "/home/alexandresenpai/scripts/Enma/main.py",
+            "console": "integratedTerminal"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index ae93530..9426d01 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,9 @@
 {
     "python.analysis.typeCheckingMode": "basic",
-    "cloudcode.duetAI.inlineSuggestions.enableAuto": false
+    "cloudcode.duetAI.inlineSuggestions.enableAuto": false,
+    "python.testing.pytestArgs": [
+        "tests"
+    ],
+    "python.testing.unittestEnabled": false,
+    "python.testing.pytestEnabled": true
 }
\ No newline at end of file
diff --git a/README.md b/README.md
index a0c1035..dcc96bb 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,9 @@
 
 Enma is a Python library designed to fetch manga and doujinshi data from many sources. It provides a unified interface to interact with different manga repositories, making it easier to retrieve manga details, search for manga, paginate through results, and fetch random manga.
 
+## :warning: Warning
+> **:exclamation: Important: Enma is not intended for mass querying or placing heavy loads on supported sources. Please use responsibly, adhering to the terms of service of the data sources. Misuse may result in service disruption or access denial.**
+
 ## Requirements
 
 - Python 3.9+
@@ -46,15 +49,16 @@ You can consult full Enma documentation at <strong><a href="https://enma.gitbook
 
 ## Features Comparison
 
-Feature    | NHentai | Manganato
------------|---------|-----------
-search     |    ✅   |     ✅    
-random     |    ✅   |     🚫    
-get        |    ✅   |     ✅    
-paginate   |    ✅   |     ✅
-download   |    ✅   |     ✅       
-author_page|    ✅   |     🚫       
-set_config |    ✅   |     🚫
+Feature    | NHentai | Manganato | Mangadex
+-----------|---------|-----------|-----------
+search     |    ✅   |     ✅   |   ✅
+random     |    ✅   |     🚫   |   ✅  
+get        |    ✅   |     ✅   |   ✅
+paginate   |    ✅   |     ✅   |   ✅
+download   |    ✅   |     ✅   |   ✅  
+author_page|    ✅   |     🚫   |   🚫   
+set_config |    ✅   |     🚫   |   🚫
+caching    |    ✅   |     ✅   |   ✅
 
 ## Usage
 
@@ -98,6 +102,40 @@ manga = enma.random()
 print(manga)
 ```
 
+## Caching
+Caching is a key feature in Enma that improves your application's efficiency by storing the results of data requests. This means when the same data is requested again, Enma can quickly retrieve it from the cache instead of repeatedly calling the external source. This results in faster response times and less strain on both your application and the external APIs.
+
+### How Caching Benefits You
+- **Speed**: Retrieving data from the cache is faster than making a new request to a manga repository.
+- **Efficiency**: Reduces the number of network requests, which is especially useful when dealing with rate-limited APIs.
+- **Reliability**: Provides more consistent application performance even with varying network conditions.
+
+### Customizing Cache Settings
+While Enma provides default caching settings that suit most needs, you may want to customize these settings based on your specific requirements, like how often you expect data to change or specific API rate limits.
+
+#### Adjusting Cache Duration via Environment Variables
+You can control how long data is kept in the cache by setting environment variables. This allows you to fine-tune the balance between data freshness and retrieval speed without modifying the core library code.
+
+For example, to change the cache expiration for fetching chapters, you can set the `ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS` environment variable:
+
+```sh
+# Sets the cache duration to 30 minutes
+export ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS=1800
+```
+
+This customization capability ensures that you can adapt the caching behavior to best fit your application's performance and efficiency needs.
+
+By leveraging caching, Enma helps make your manga-related applications faster and more reliable, all while giving you the flexibility to tailor caching behavior as needed.
+
+#### Available Caching TTL Settings
+| KEY                                            | DEFAULT |
+|------------------------------------------------|---------|
+| ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS| 100     |
+| ENMA_CACHING_PAGINATE_TTL_IN_SECONDS           | 100     |
+| ENMA_CACHING_SEARCH_TTL_IN_SECONDS             | 100     |
+| ENMA_CACHING_GET_TTL_IN_SECONDS                | 300     |
+| ENMA_CACHING_AUTHOR_TTL_IN_SECONDS             | 100     |
+
 ## Downloading Chapters
 Using Enma you're able to download chapter pages to your local storage or any other storage that implements `ISaverAdapter`.
 
@@ -146,23 +184,6 @@ enma = Enma()
 enma.source_manager.set_source('manganato')
 doujin = enma.get(identifier='manga-kb951984', with_symbolic_links=True)
 
-# Manga(id='manga-kb951984', 
-#       created_at=datetime.datetime(2024, 1, 22, 10, 5), 
-#       updated_at=datetime.datetime(2024, 1, 22, 10, 5), 
-#       title=Title(english='Monster Musume No Iru Nichijou', 
-#                   japanese='モンスター娘のいる日常', 
-#                   other='魔物娘的同居日常'), 
-#       language=None, 
-#       cover=Image(uri='https://avt.mkklcdnv6temp.com/23/p/1-1583464626.jpg', name='image.jpg', width=0, height=0, mime=<MIME.J: 'jpg'>), 
-#       thumbnail=Image(uri='https://avt.mkklcdnv6temp.com/23/p/1-1583464626.jpg', name='image.jpg', width=0, height=0, mime=<MIME.J: 'jpg'>), 
-#       authors=[Author(name='Okayado', id=0)], 
-#       genres=[Genre(name='Comedy', id=0), 
-#               Genre(name='Fantasy', id=0), 
-#               Genre(name='Harem', id=0)], 
-#       chapters=[Chapter(id='chapter-84', pages=[], pages_count=0, link=SymbolicLink(link='https://chapmanganato.to/manga-kb951984/chapter-84')), 
-#                 Chapter(id='chapter-83', pages=[], pages_count=0, link=SymbolicLink(link='https://chapmanganato.to/manga-kb951984/chapter-83'))], 
-#       chapters_count=95)
-
 if doujin is not None:
     chapter_ref = doujin.chapters[0]
     chapter = enma.fetch_chapter_by_symbolic_link(link=chapter_ref.link)
@@ -228,6 +249,23 @@ While using the library, you might encounter some specific errors. Here's a desc
     - **Description**: Raised when trying to perform an action with an invalid data type.
     - **Common Cause**: Making an action with wrong parameter data type.
 
+7. **Unknown**:
+    - **Description**: Raised when was not possible to determine the error root cause.
+    - **Common Cause**: Not properly handled error.
+
+7. **NotFound**:
+    - **Description**: Raised when was not possible to find the requested resource..
+    - **Common Cause**: Fetching an inexistent resource.
+
+7. **Forbidden**:
+    - **Description**: Raised when trying to perform a request to the source without right credentials.
+    - **Common Cause**: Making a request with no or invalid credentials.
+
+7. **ExceedRateLimit**:
+    - **Description**: Raised when trying to perform more requests than a server can handle.
+    - **Common Cause**: Looping through many pages without cooling down.
+
+
 When encountering one of these errors, refer to the description and common cause to assist in troubleshooting.
 
 ## Future Plans
@@ -259,6 +297,18 @@ We welcome contributions! If you'd like to contribute:
 
 Ensure you follow the coding standards and write tests for new features.
 
+## Disclaimer
+
+This software is provided "as is", without warranty of any kind, express or implied. The developers and contributors of the Enma library shall not be liable for any misuse, damages, or other consequences arising from the use of this software.
+
+It is important to emphasize that the Enma library was developed with the goal of facilitating efficient and responsible access and manipulation of data. We do not encourage or support the use of this tool for conducting mass queries or accesses that could overload, harm, or in any way negatively affect the servers or services of the supported sources.
+
+Users of the Enma library must always follow the guidelines, terms of use, and limitations imposed by the accessed data sources. We strongly recommend the implementation of responsible rate limiting practices and obtaining appropriate permissions when necessary, to ensure that the use of the library complies with all applicable laws and regulations, in addition to respecting ethical principles of data use.
+
+By using the Enma library, you agree to use the tool in an ethical and responsible manner, acknowledging that the developers of Enma will not be responsible for any use that violates these guidelines.
+
+We remind you that respect for the services and APIs of the supported sources is fundamental for the sustainability and longevity of both the Enma and the services used. We value the community and the development ecosystem and encourage all users to contribute to a safer, more respectful, and collaborative digital environment.
+
 ## License
 
 MIT
diff --git a/enma/__init__.py b/enma/__init__.py
index 2d43d74..4b25e27 100644
--- a/enma/__init__.py
+++ b/enma/__init__.py
@@ -1,18 +1,20 @@
 import sys
 from enma.application.core.utils.logger import LogMode, logger
 from enma.application.use_cases.download_chapter import Threaded
-from enma.infra.entrypoints.lib import Enma, SourcesEnum, DefaultAvailableSources
-from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai, Sort
+from enma.infra.entrypoints.lib import Enma, Sources
+from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai, Sort as NHentaiSort
+from enma.infra.adapters.repositories.mangadex import Mangadex, Sort as MangadexSort
 from enma.infra.adapters.repositories.manganato import Manganato
 from enma.infra.adapters.downloaders.default import DefaultDownloader
 from enma.infra.adapters.downloaders.manganato import ManganatoDownloader
 from enma.application.core.interfaces.downloader_adapter import IDownloaderAdapter
 from enma.application.core.interfaces.saver_adapter import ISaverAdapter
 from enma.infra.adapters.storage.local import LocalStorage
-from enma.domain.entities.manga import Manga
+from enma.domain.entities.manga import Manga, Chapter, SymbolicLink
 from enma.domain.entities.search_result import SearchResult
 from enma.domain.entities.pagination import Pagination
 from enma.domain.entities.author_page import AuthorPage
+from enma.application.core.interfaces.manga_repository import IMangaRepository
 
 package_name = "enma"
 python_major = "3"
diff --git a/enma/_version.py b/enma/_version.py
index ba0fb3f..62e4c86 100644
--- a/enma/_version.py
+++ b/enma/_version.py
@@ -1,1 +1,1 @@
-__version__ = '2.3.0'
\ No newline at end of file
+__version__ = '2.4.1'
\ No newline at end of file
diff --git a/enma/application/core/handlers/error.py b/enma/application/core/handlers/error.py
index a409f10..8b729d2 100644
--- a/enma/application/core/handlers/error.py
+++ b/enma/application/core/handlers/error.py
@@ -7,6 +7,30 @@ def __init__(self, message: str) -> None:
         self.desc: str = 'This error occurs when the client chooses nonexistent source.'
         self.critical: bool = False
 
+class Unknown(Exception):
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+
+        self.code: str = 'UNKNOWN'
+        self.desc: str = 'This error occours when was not possible to determine the error root cause.'
+        self.critical: bool = True
+
+class NotFound(Exception):
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+
+        self.code: str = 'NOT_FOUND'
+        self.desc: str = 'This error occours when was not possible to find the requested resource.'
+        self.critical: bool = True
+
+class Forbidden(Exception):
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+
+        self.code: str = 'FORBIDDEN'
+        self.desc: str = 'This error occours when the client can\'t perform a request to the source due lack of credentials.'
+        self.critical: bool = True
+
 class InvalidRequest(Exception):
     def __init__(self, message: str) -> None:
         super().__init__(message)
@@ -67,4 +91,13 @@ def __init__(self, message: str) -> None:
         self.message: str = message
         self.code: str = 'EXCEED_RETRY_COUNT'
         self.desc: str = 'This error occurs when enma tries perform some action but something went wrong.'
-        self.critical: bool = True
\ No newline at end of file
+        self.critical: bool = True
+
+class ExceedRateLimit(Exception):
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+
+        self.message: str = message
+        self.code: str = 'EXCEED_RATE_EXCEED'
+        self.desc: str = 'This error occurs when enma perform more requests than a server can handle. Cool down your requests to this source!'
+        self.critical: bool = False
\ No newline at end of file
diff --git a/enma/application/use_cases/get_manga.py b/enma/application/use_cases/get_manga.py
index a0c0c19..ba78f9c 100644
--- a/enma/application/use_cases/get_manga.py
+++ b/enma/application/use_cases/get_manga.py
@@ -2,7 +2,7 @@
 from typing import Union
 
 from pydantic import BaseModel, Field, validator
-from enma.application.core.handlers.error import InvalidRequest
+from enma.application.core.handlers import error
 from enma.application.core.interfaces.manga_repository import IMangaRepository
 from enma.application.core.interfaces.use_case import DTO, IUseCase
 from enma.application.core.utils.logger import logger
@@ -24,9 +24,10 @@ def __init__(self, manga_repository: IMangaRepository):
     def execute(self, dto: DTO[GetMangaRequestDTO]) -> GetMangaResponseDTO:
         logger.info(f'Fetching manga with identifier: {dto.data.identifier}.')
 
-        manga = self.__manga_repository.get(identifier=dto.data.identifier,
-                                            with_symbolic_links=dto.data.with_symbolic_links)
-        
-        if manga is None: return GetMangaResponseDTO(found=False, manga=None)
-        
-        return GetMangaResponseDTO(found=True, manga=manga)
\ No newline at end of file
+        try:
+            manga = self.__manga_repository.get(identifier=dto.data.identifier,
+                                                with_symbolic_links=dto.data.with_symbolic_links)
+            return GetMangaResponseDTO(found=True, manga=manga)
+        except error.NotFound:
+            logger.error(f'Could not find the manga using the provided identifier: {dto.data.identifier}')
+            return GetMangaResponseDTO(found=False, manga=None)
\ No newline at end of file
diff --git a/enma/domain/entities/author_page.py b/enma/domain/entities/author_page.py
index 95270cf..f14de2d 100644
--- a/enma/domain/entities/author_page.py
+++ b/enma/domain/entities/author_page.py
@@ -2,7 +2,6 @@
 This module defines the SearchResult entity for the Enma application.
 It represents the result of a manga search operation.
 """
-from dataclasses import dataclass
 from datetime import datetime
 from typing import TypedDict, Union
 from enma.domain.entities.manga import Manga
@@ -17,7 +16,6 @@ class ISearchResultProps(TypedDict):
     total_results: int
     results: list[Manga]
 
-@dataclass
 class AuthorPage(Pagination):
     """
     Entity class representing a search result in the Enma application.
diff --git a/enma/domain/entities/base.py b/enma/domain/entities/base.py
index 64762c5..81e36a1 100644
--- a/enma/domain/entities/base.py
+++ b/enma/domain/entities/base.py
@@ -1,10 +1,9 @@
 from datetime import datetime
-import json
 from typing import Generic, TypeVar, Union
+from uuid import uuid4
 
 T = TypeVar('T')
 
-
 class Entity(Generic[T]):
     """Base class for entities in the domain model.
 
@@ -23,18 +22,19 @@ def __init__(self,
         """Initializes an Entity with given or default values.
 
         Args:
-            id: A Union of int, str, and None representing the entity's ID. Defaults to 0.
+            id: A Union of int, str, and None representing the entity's ID. Defaults to uuidv4.
             created_at: A Union of datetime and None representing when the entity was created. Defaults to current UTC time.
             updated_at: A Union of datetime and None representing when the entity was last updated. Defaults to current UTC time.
         """
 
-        self.id = id if id is not None else 0
+        self.id = id if id is not None else str(uuid4())
         self.created_at = created_at if created_at is not None else datetime.utcnow()
         self.updated_at = updated_at if updated_at is not None else datetime.utcnow()
 
     def __repr__(self) -> str:
-        attrs = ', '.join([f"{chave}={valor!r}" for chave, valor in self.__dict__.items()])
-        return f"{self.__class__.__name__}({attrs})"
+        non_special_attrs = [f"{chave}={valor!r}" for chave, valor in self.__dict__.items() if not isinstance(valor, list) and not isinstance(valor, dict)]
+        special_attrs = [f"{chave}={valor!r}" for chave, valor in self.__dict__.items() if isinstance(valor, list) or isinstance(valor, dict)]
+        return f"{self.__class__.__name__}({', '.join([*non_special_attrs, *special_attrs])})"
 
     def to_dict(self) -> T:
         """Converts the entity to a dictionary.
diff --git a/enma/domain/entities/manga.py b/enma/domain/entities/manga.py
index f2a777c..124f152 100644
--- a/enma/domain/entities/manga.py
+++ b/enma/domain/entities/manga.py
@@ -1,15 +1,18 @@
 from dataclasses import dataclass, field
 from datetime import datetime
 from enum import Enum
-from typing import TypedDict, Union
+from typing import Literal, TypedDict, Union
 
 from enma.domain.entities.base import Entity
 
 class MIME(Enum):
+    JPG = 'jpg'
     J = 'jpg'
+    PNG = 'png'
     P = 'png'
+    GIF = 'gif'
     G = 'gif'
-
+    
 @dataclass
 class Image:
     uri: str
@@ -29,6 +32,7 @@ class IMangaProps(TypedDict):
     created_at: datetime
     updated_at: datetime
     title: Title
+    url: str
     pages_count: int
     pages: list[Image]
 
@@ -59,9 +63,59 @@ class Genre:
 class Author(Genre):
     ...
 
+class ILanguage(TypedDict):
+    ja: Literal['japanese']
+    jp: Literal['japanese']
+    japanese: Literal['japanese']
+    portuguese: Literal['portuguese']
+    pt: Literal['portuguese']
+    pt_br: Literal['portuguese']
+    english: Literal['english']
+    en: Literal['english']
+    en_us: Literal['english']
+    chinese: Literal['chinese']
+    cn: Literal['chinese']
+    zh: Literal['chinese']
+    russian: Literal['russian']
+    ru: Literal['russian']
+    turkish: Literal['turkish']
+    tr: Literal['turkish']
+    spanish: Literal['spanish']
+    es_la: Literal['spanish']
+    malay: Literal['malay']
+    ms: Literal['malay']
+    korean: Literal['korean']
+    ko: Literal['korean']
+
+Language: ILanguage = {
+    'ja': 'japanese',
+    'jp': 'japanese',
+    'japanese': 'japanese',
+    'portuguese': 'portuguese',
+    'pt': 'portuguese',
+    'pt_br': 'portuguese',
+    'english': 'english',
+    'en': 'english',
+    'en_us': 'english',
+    'chinese': 'chinese',
+    'cn': 'chinese',
+    'zh': 'chinese',
+    'ru': 'russian',
+    'russian': 'russian',
+    'turkish': 'turkish',
+    'tr': 'turkish',
+    'spanish': 'spanish',
+    'es_la': 'spanish',
+    'malay': 'malay',
+    'ms': 'malay',
+    'korean': 'korean',
+    'ko': 'korean'
+}
+    
 class Manga(Entity[IMangaProps]):
     def __init__(self,
                  title: Title,
+                 url: str,
                  chapters: Union[list[Chapter], None] = None,
                  language: Union[str, None] = None,
                  genres: Union[list[Genre], None] = None,
@@ -79,9 +133,15 @@ def __init__(self,
         self.title = title
         self.language = language
         self.cover = cover
+        self.url = url
         self.thumbnail = thumbnail
         self.authors = authors or []
         self.genres = genres or []
         self.chapters = chapters or []
 
         self.chapters_count = len(self.chapters if self.chapters else [])
+
+    def add_chapter(self,
+                    chapter: Chapter):
+        self.chapters.append(chapter)
+        self.chapters_count += 1
diff --git a/enma/domain/entities/pagination.py b/enma/domain/entities/pagination.py
index cf4e243..1ff6e48 100644
--- a/enma/domain/entities/pagination.py
+++ b/enma/domain/entities/pagination.py
@@ -11,6 +11,7 @@
 @dataclass
 class Thumb:
     id: str
+    url: str
     title: str
     cover: Image
 
@@ -23,7 +24,6 @@ class ISearchResultProps(TypedDict):
     total_results: int
     results: list[Manga]
 
-@dataclass
 class Pagination(Entity[ISearchResultProps]):
     """
     Entity class representing a search result in the Enma application.
@@ -48,6 +48,5 @@ def __init__(self,
         
         self.page = page
         self.results = results or list()
-        self.total_pages = total_pages or 1 if len(self.results) <= 20 else total_pages
-        self.total_results = total_results or 20 * self.total_pages if self.total_pages > 1 else len(self.results) 
-        
\ No newline at end of file
+        self.total_pages = total_pages
+        self.total_results = total_results
diff --git a/enma/domain/entities/search_result.py b/enma/domain/entities/search_result.py
index 992432f..faa5e9a 100644
--- a/enma/domain/entities/search_result.py
+++ b/enma/domain/entities/search_result.py
@@ -2,7 +2,6 @@
 This module defines the SearchResult entity for the Enma application.
 It represents the result of a manga search operation.
 """
-from dataclasses import dataclass
 from datetime import datetime
 from typing import TypedDict, Union
 from enma.domain.entities.manga import Manga
@@ -17,7 +16,6 @@ class ISearchResultProps(TypedDict):
     total_results: int
     results: list[Manga]
 
-@dataclass
 class SearchResult(Pagination):
     """
     Entity class representing a search result in the Enma application.
diff --git a/enma/infra/adapters/repositories/mangadex.py b/enma/infra/adapters/repositories/mangadex.py
new file mode 100644
index 0000000..cf09176
--- /dev/null
+++ b/enma/infra/adapters/repositories/mangadex.py
@@ -0,0 +1,551 @@
+"""
+This module provides an adapter for the mangadex repository.
+It contains functions and classes to interact with the mangadex API and retrieve manga data.
+"""
+from datetime import datetime
+from enum import Enum
+import os
+from typing import Any, Optional, Union, cast
+from urllib.parse import urljoin, urlparse
+
+import requests
+
+from enma._version import __version__
+from enma.application.core.handlers.error import (ExceedRateLimit, 
+                                                  Forbidden, 
+                                                  NotFound, 
+                                                  Unknown)
+from enma.application.core.interfaces.manga_repository import IMangaRepository
+from enma.application.core.utils.logger import logger
+from enma.domain.entities.author_page import AuthorPage
+from enma.domain.entities.manga import (MIME, 
+                                        Chapter, 
+                                        Genre, 
+                                        Author, 
+                                        Image, 
+                                        Language, 
+                                        Manga, 
+                                        SymbolicLink,
+                                        Title)
+from enma.domain.entities.search_result import Pagination, SearchResult, Thumb
+from enma.infra.core.interfaces.mangadex_response import (AuthorRelation, 
+                                                          CoverArtRelation, IAltTitles, 
+                                                          IGetResult, 
+                                                          IHash, IManga, IMangaTag, IRelations, 
+                                                          ISearchResult, 
+                                                          IVolumesResponse)
+from enma.infra.core.utils.cache import Cache
+
+
+class Sort(Enum):
+    ALL_TIME = 'relevance'
+    RECENT = 'createdAt'
+
+class Mangadex(IMangaRepository):
+    """
+    Repository class for interacting with the Mangadex API.
+    Provides methods to fetch manga details, search for manga, etc.
+    """
+
+    def __init__(self) -> None:
+        self.__SITE_URL = 'https://mangadex.org/'
+        self.__API_URL = 'https://api.mangadex.org/'
+        self.__COVER_URL = 'https://mangadex.org/covers/'
+        self.__HASH_URL = 'https://api.mangadex.org/at-home/server/'
+        self.__CHAPTER_PAGE_URL = 'https://cmdxd98sb0x3yprd.mangadex.network/data/'
+
+    def __handle_source_response(self, response: requests.Response):
+        """
+        Evaluates the HTTP response from the Mangadex API, raising specific exceptions based on the HTTP status code
+        to indicate various error conditions such as rate limits exceeded, forbidden access, or resource not found.
+
+        Args:
+            response (Response): The HTTP response object from a request to the Mangadex API.
+
+        Raises:
+            Forbidden: Indicates a 403 Forbidden HTTP status code.
+            NotFound: Indicates a 404 Not Found HTTP status code.
+            ExceedRateLimit: Indicates a 429 Too Many Requests HTTP status code.
+            Unknown: Indicates any other unexpected HTTP status code.
+        """
+
+        logger.debug(f'Fetched {response.url} with response status code {response.status_code} and text {response.text}')
+
+        if response.status_code == 200:
+            return
+        if response.status_code == 403:
+            raise Forbidden(message='Could not perform a successful request to the source due to credentials issues. Check your credentials and try again.')
+        if response.status_code == 404:
+            raise NotFound(message=f'Could not find the requested resource at "{response.url}". Check the provided request parameters and try again.')
+        if response.status_code == 429:
+            raise ExceedRateLimit(message='You have exceeded the Mangadex rate limit!')
+        raise Unknown(message='Something unexpected happened while trying to fetch source content. Set the logging mode to debug and try again.')
+
+    def __make_request(self,
+                       url: str,
+                       headers: Union[dict[str, Any], None] = None,
+                       params: Optional[Union[dict[str, Union[str, int]], list[tuple[str, Union[str, int]]]]] = None) -> requests.Response:
+        """
+        Makes a request to the specified URL with the given headers and parameters.
+
+        Args:
+            url (str): The URL to make the request to.
+            headers (dict[str, Any], optional): The headers to include in the request. Defaults to None.
+            params (Optional[Union[dict[str, Union[str, int]], list[tuple[str, Union[str, int]]]]], optional): The parameters to include in the request. Defaults to None.
+
+        Returns:
+            requests.Response: The response object from the API request.
+        """
+        headers = headers if headers is not None else {}
+        params = params if params is not None else {}
+
+        logger.debug(f'Fetching {url} with headers {headers} and params {params}')
+
+        response = requests.get(url=urlparse(url).geturl(),
+                                headers={**headers, "User-Agent": f"Enma/{__version__}"},
+                                params=params)
+        
+        self.__handle_source_response(response)
+
+        return response
+
+    def set_config(self, config) -> None:
+        raise NotImplementedError('Manganato does not support set config')
+
+    def __create_cover_uri(self, 
+                           manga_id: str, 
+                           file_name: str) -> str:
+        """
+        Constructs a URL for a manga's cover image based on its identifier and the file name of the cover image.
+
+        Args:
+            manga_id (str): The unique identifier of the manga.
+            file_name (str): The file name of the cover image.
+
+        Returns:
+            str: The fully qualified URL to the cover image.
+        """
+        return urljoin(self.__COVER_URL, f'{manga_id}/{file_name}.512.jpg')
+    
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS', 100)), 
+           max_size=20).cache
+    def fetch_chapter_by_symbolic_link(self, 
+                                       link: SymbolicLink) -> Chapter:
+        """
+        Retrieves manga chapter details including pages and images by following a symbolic link. This method is particularly
+        useful for fetching chapters that have been directly linked.
+
+        Args:
+            link (SymbolicLink): An object representing the symbolic link to the chapter.
+
+        Returns:
+            Chapter: An object containing the fetched chapter details such as pages and images.
+        """
+        response = self.__make_request(url=link.link)
+        
+        ch: IHash = response.json()
+        chapter = Chapter()
+    
+        for index, page in enumerate(ch.get('chapter').get('data')):
+            extension = page.split('.')[-1]
+            chapter.add_page(Image(uri=self.__create_chapter_page_uri(ch.get('chapter').get('hash'), page),
+                                   name=f'{index}.{extension}',
+                                   mime=MIME[extension.upper()]))
+        return chapter
+
+    def __fetch_chapter_hashes(self, chapter_id: str) -> tuple[str, list[str]]:
+        """
+        Fetches the chapter hashes and page file names for a given chapter ID. These details are necessary to construct
+        the URLs for individual chapter pages.
+
+        Args:
+            chapter_id (str): The unique identifier of the chapter.
+
+        Returns:
+            tuple[str, list[str]]: A tuple containing the chapter hash and a list of page file names.
+        """
+        response = self.__make_request(url=urljoin(self.__HASH_URL, chapter_id))
+        data: IHash = response.json()
+
+        return (data.get('chapter').get('hash'), 
+                data.get('chapter').get('data'))
+    
+    def __create_chapter_page_uri(self, hash: str, filename: str) -> str:
+        """
+        Constructs the URL for a chapter page given the chapter hash and the page file name.
+
+        Args:
+            hash (str): The hash of the chapter, used as part of the URL path.
+            filename (str): The file name of the chapter page.
+
+        Returns:
+            str: The fully qualified URL to the chapter page.
+        """
+        return urljoin(self.__CHAPTER_PAGE_URL, f'{hash}/{filename}')
+
+    def __create_chapter(self,
+                         chapter: tuple[int, str],
+                         with_symbolic_links: bool = False) -> Chapter:
+        """
+        Constructs a Chapter object for a given chapter tuple, optionally using symbolic links. If symbolic links are
+        used, chapter pages are not pre-fetched but are instead represented as links.
+
+        Args:
+            chapter (tuple[int, str]): A tuple containing the chapter number and the chapter ID.
+            with_symbolic_links (bool, optional): A flag indicating whether to use symbolic links for chapter pages. Defaults to False.
+
+        Returns:
+            Chapter: The constructed Chapter object.
+        """        
+        curr_chapter, chapter_id = chapter
+
+        if with_symbolic_links:
+            return Chapter(id=curr_chapter, 
+                           link=SymbolicLink(link=urljoin(self.__HASH_URL, chapter_id)))
+        else:
+            ch = Chapter(id=curr_chapter)
+            hash, files = self.__fetch_chapter_hashes(chapter_id)
+        
+            for index, page in enumerate(files):
+                extension = page.split('.')[-1]
+                ch.add_page(Image(uri=self.__create_chapter_page_uri(hash, page),
+                                  name=f'{index}.{extension}',
+                                  mime=MIME[extension.upper()]))
+        
+            return ch
+    
+    def __list_chapters(self, manga_id: str) -> list[tuple[int, str]]:
+        """
+        Retrieves a list of chapters for a given manga ID. Each chapter is represented as a tuple containing the chapter
+        number and the chapter ID.
+
+        Args:
+            manga_id (str): The unique identifier of the manga.
+
+        Returns:
+            list[tuple[int, str]]: A list of tuples, each representing a chapter of the manga.
+        """
+        response = self.__make_request(url=urljoin(self.__API_URL, f'manga/{manga_id}/aggregate'))
+        
+        data: IVolumesResponse = response.json()
+
+        chapters = []
+        for volume in data.get('volumes', []):
+            volume = data.get('volumes').get(volume)
+            
+            if volume is None: continue
+
+            volume_chapters = volume.get('chapters')
+            
+            for volume_key in volume_chapters:
+                current_vol = volume_chapters.get(volume_key)
+
+                if current_vol is None: continue
+
+                chapters.append((current_vol.get('chapter'), current_vol.get('id')))
+        
+        return chapters
+    
+    def __extract_authors(self, relations: IRelations) -> list[Author]:
+        """
+        Extracts author information from a list of relationships within manga metadata, constructing Author objects
+        for each author found.
+
+        Args:
+            relations (IRelations): A list of relationship objects from the manga metadata.
+
+        Returns:
+            list[Author]: A list of Author objects extracted from the relationships.
+        """
+        authors_data = [relationship for relationship in relations if relationship.get('type') == 'author']
+        authors: list[Author] = []
+
+        if len(authors_data) > 0:
+            for author in authors_data:
+                author = cast(AuthorRelation, author)
+                authors.append(Author(name=author.get('attributes').get('name'),
+                                      id=author.get('id')))
+                
+        return authors
+    
+    def __extract_genres(self, tags: list[IMangaTag]) -> list[Genre]:
+        """
+        Extracts genre information from a list of tags within manga metadata, constructing Genre objects for each tag
+        that represents a genre.
+
+        Args:
+            tags (list[IMangaTag]): A list of tag objects from the manga metadata.
+
+        Returns:
+            list[Genre]: A list of Genre objects extracted from the tags.
+        """
+        return [Genre(id=tag.get('id'),
+                      name=tag.get('attributes', {}).get('name', {}).get('en', 'unknown')) 
+                    for tag in tags or []
+                    if tag.get('type') == 'tag']
+    
+    def __get_cover(self, 
+                    manga_id: str, 
+                    relations: IRelations) -> Image:
+        """
+        Retrieves the cover image for a given manga ID from a list of relationships. If a cover image is found, an
+        Image object is constructed and returned.
+
+        Args:
+            manga_id (str): The unique identifier of the manga.
+            relations (IRelations): A list of relationship objects from the manga metadata.
+
+        Returns:
+            Image: An Image object representing the manga's cover image. Returns an Image object with an empty URI if no cover is found.
+        """
+        covers = [tag for tag in relations if tag.get('type') == 'cover_art']
+        if len(covers) == 0: return Image(uri='')
+        cover = cast(CoverArtRelation, covers[0])
+        return Image(uri=self.__create_cover_uri(manga_id, cover.get("attributes").get("fileName")),
+                     width=512)
+    
+    def __get_title(self, alt_titles: IAltTitles, title: str) -> Title:
+        """
+        Constructs a Title object for the manga, incorporating the English title, a Japanese title if available,
+        and an alternative title.
+
+        Args:
+            alt_titles (IAltTitles): A list of alternative titles for the manga.
+            title (str): The primary English title of the manga.
+
+        Returns:
+            Title: A Title object containing the English, Japanese, and an alternative title for the manga.
+        """
+        japanese_titles = [ title.get('ja-ro') for title in alt_titles if title.get('ja-ro') is not None ]
+        japanese_title = japanese_titles[0] if len(japanese_titles) > 0 else None
+
+        other_keys = list(alt_titles[-1].keys()) if len(alt_titles) > 0 else []
+        other_key = other_keys[0] if len(other_keys) > 0 else ''
+
+        return Title(english=title,
+                     japanese=japanese_title or '',
+                     other=alt_titles[-1].get(other_key, '') if len(alt_titles) > 0 else '')
+
+    def __parse_full_manga(self, 
+                           manga_data: IManga, 
+                           with_symbolic_links: bool = False) -> Manga:
+        """
+        Parses the complete manga data retrieved from the Mangadex API, constructing a Manga object that includes
+        details such as title, authors, genres, cover image, and chapters.
+
+        Args:
+            manga_data (IManga): The raw manga data from the Mangadex API.
+            with_symbolic_links (bool, optional): Indicates whether to use symbolic links for chapters. Defaults to False.
+
+        Returns:
+            Manga: A fully constructed Manga object.
+        """
+        attrs = manga_data.get('attributes', dict())
+
+        thumbnail = self.__get_cover(manga_data.get('id'), 
+                                     manga_data.get('relationships'))
+        
+
+        manga = Manga(title=self.__get_title(alt_titles=attrs.get('altTitles'),
+                                             title=attrs.get('title', dict()).get('en') or ''),
+                      id=manga_data.get('id'),
+                      created_at=datetime.fromisoformat(attrs.get('createdAt')),
+                      updated_at=datetime.fromisoformat(attrs.get('updatedAt')),
+                      url=urljoin(self.__SITE_URL, f'title/{manga_data.get("id")}'),
+                      language=Language.get(attrs.get('originalLanguage').strip().lower().replace('-', '_'), 'unknown'),
+                      authors=self.__extract_authors(manga_data.get('relationships', list())),
+                      genres=self.__extract_genres(attrs.get('tags', list())),
+                      thumbnail=thumbnail,
+                      cover=thumbnail)
+        
+        chapter_list = self.__list_chapters(manga_id=str(manga.id))
+
+        for chapter in chapter_list:
+            manga.add_chapter(self.__create_chapter(chapter=chapter,
+                                                    with_symbolic_links=with_symbolic_links))
+            
+        return manga
+    
+    def __parse_thumb(self, manga: IManga) -> Thumb:
+        """
+        Extracts minimal manga information to construct a Thumb object, primarily used for search results
+        where detailed information is not necessary.
+
+        Args:
+            manga (IManga): The raw manga data from the Mangadex API.
+
+        Returns:
+            Thumb: A Thumb object containing the manga's ID, title, and cover image.
+        """
+
+        title = manga.get('attributes').get('title').get('en')
+        return Thumb(id=manga.get('id'),
+                     title=title,
+                     url=urljoin(self.__SITE_URL, f'title/{manga.get("id")}'),
+                     cover=self.__get_cover(manga_id=manga.get('id'),
+                                            relations=manga.get('relationships', list())))
+    
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_GET_TTL_IN_SECONDS', 300)), 
+           max_size=20).cache
+    def get(self, 
+            identifier: str,
+            with_symbolic_links: bool = False) -> Manga:
+        """
+        Retrieves detailed information for a specific manga identified by its ID, constructing a Manga object.
+
+        Args:
+            identifier (str): The unique identifier of the manga to retrieve.
+            with_symbolic_links (bool, optional): Indicates whether to construct the Manga object with symbolic links for chapters. Defaults to False.
+
+        Returns:
+            Manga: The Manga object containing detailed information about the specified manga.
+        """
+        response = self.__make_request(url=urljoin(self.__API_URL, f'manga/{identifier}'),
+                                       params=[('includes[]', 'cover_art'),
+                                               ('includes[]', 'author'),
+                                               ('includes[]', 'artist')])
+
+        result: IGetResult = response.json()
+        manga_data = result.get('data')
+
+        return self.__parse_full_manga(manga_data=manga_data,
+                                       with_symbolic_links=with_symbolic_links)
+        
+    def __make_sort_query(self, sort: Sort) -> dict[str, str]:
+        """
+        Constructs a query parameter dictionary to define the sorting order for search results based on a Sort enumeration value.
+
+        Args:
+            sort (Sort): An enumeration value specifying the desired sort order for search results.
+
+        Returns:
+            dict[str, str]: A dictionary of query parameters to define the sorting order.
+        """
+        return { f'order[{sort.value if isinstance(sort, Sort) else sort}]': 'desc' }
+
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_SEARCH_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
+    def search(self,
+               query: str,
+               page: int,
+               sort: Sort = Sort.RECENT,
+               per_page: int = 25) -> SearchResult:
+        """
+        Searches the Mangadex API for manga that match the given query string, optionally sorting the results
+        and paginating them. Constructs and returns a SearchResult object containing the search results.
+        
+        Args:
+            query (str): The search query string.
+            page (int): The page number of the search results to retrieve.
+            sort (Sort, optional): The sorting order of the search results. Defaults to Sort.RECENT.
+            per_page (int, optional): The number of results per page. Defaults to 25.
+
+        Returns:
+            SearchResult: An object containing the paginated search results, including manga thumbnails.
+        """
+        logger.debug(f'Searching into Mangadex with args query={query};page={page};sort={sort}')
+
+        params = [('title', query), *tuple(self.__make_sort_query(sort).items()),
+                  ('includes[]', 'cover_art'), ('limit', per_page),
+                  ('offset', per_page * (page - 1) if page > 1 else 0), ('contentRating[]', 'safe'),
+                  ('contentRating[]', 'suggestive'), ('contentRating[]', 'erotica'),
+                  ('order[createdAt]', 'desc'), ('hasAvailableChapters', 'true')]
+        
+        request_response = self.__make_request(url=urljoin(self.__API_URL, 'manga'),
+                                               params=params)
+        
+        response: ISearchResult = request_response.json()
+
+        total_results = response.get('total')
+        total_pages = int(total_results / per_page)
+        
+        search_result = SearchResult(query=query,
+                                     total_pages=total_pages,
+                                     page=page,
+                                     total_results=total_results)
+        
+        for result in response.get('data', []):
+            search_result.results.append(self.__parse_thumb(manga=result))
+
+        return search_result
+
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_PAGINATE_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
+    def paginate(self, page: int) -> Pagination:
+        """
+        Retrieves a specific page of manga listings from the Mangadex API, returning a Pagination object
+        that includes a list of manga thumbnails for that page.
+
+        Args:
+            page (int): The page number of manga listings to retrieve.
+
+        Returns:
+            Pagination: An object containing the paginated list of manga thumbnails and pagination details.
+        """
+        logger.debug(f'Paginating with args page={page}')
+        per_page = 25
+        request_response = self.__make_request(url=urljoin(self.__API_URL, 'manga'),
+                                               params=[('limit', per_page),
+                                                       ('offset', per_page * (page - 1) if page > 1 else 0),
+                                                       ('order[createdAt]', 'desc'),
+                                                       ('includes[]', 'cover_art'),
+                                                       ('contentRating[]', 'safe'),
+                                                       ('contentRating[]', 'suggestive'),
+                                                       ('contentRating[]', 'erotica'),
+                                                       ('order[createdAt]', 'desc'),
+                                                       ('hasAvailableChapters', 'true')])
+        
+        response: ISearchResult = request_response.json()
+
+        pagination = Pagination(page=page,
+                                total_pages=int(response.get('total') / per_page),
+                                total_results=response.get('total'))
+
+        for result in response.get('data', []):            
+            pagination.results.append(self.__parse_thumb(manga=result))
+
+        return pagination
+
+    def random(self, retry=0) -> Manga:
+        """
+        Fetches a random manga from the Mangadex API. If the first attempt fails, it will retry up to a specified number of times.
+
+        Args:
+            retry (int, optional): The number of retries to attempt in case of failure. Defaults to 0.
+
+        Returns:
+            Manga: A Manga object for the randomly selected manga.
+        """
+        response = self.__make_request(url=urljoin(self.__API_URL, f'manga/random'),
+                                       params=[('includes[]', 'cover_art'),
+                                               ('contentRating[]', 'safe'),
+                                               ('contentRating[]', 'suggestive'),
+                                               ('contentRating[]', 'erotica'),
+                                               ('includes[]', 'author'),
+                                               ('includes[]', 'artist')])
+
+        result: IGetResult = response.json()
+
+        manga = result.get('data')
+
+        return self.__parse_full_manga(manga_data=manga,
+                                       with_symbolic_links=True)
+    
+    def author_page(self,
+                    author: str,
+                    page: int) -> AuthorPage:
+        """
+        Fetches manga authored by a specific author. This method is not currently implemented for Mangadex
+        and serves as a placeholder for potential future functionality.
+
+        Args:
+            author (str): The name or identifier of the author.
+            page (int): The page number of results to retrieve.
+
+        Raises:
+            NotImplementedError: Indicates that this method is not supported or implemented.
+
+        Returns:
+            AuthorPage: An object containing a list of manga by the specified author. This is currently not implemented.
+        """
+        raise NotImplementedError('Mangadex does not support author page.')
\ No newline at end of file
diff --git a/enma/infra/adapters/repositories/manganato.py b/enma/infra/adapters/repositories/manganato.py
index c42b085..c030db6 100644
--- a/enma/infra/adapters/repositories/manganato.py
+++ b/enma/infra/adapters/repositories/manganato.py
@@ -1,22 +1,25 @@
 """
-This module provides an adapter for the nhentai repository.
-It contains functions and classes to interact with the nhentai API and retrieve manga data.
+This module provides an adapter for the MANGANATO repository.
+It contains functions and classes to interact with the MANGANATO API and retrieve manga data.
 """
 
 from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime
 from multiprocessing import cpu_count
+import os
 from typing import Any, Optional, Union, cast
 from urllib.parse import urlparse, urljoin
 from bs4 import BeautifulSoup, Tag
 
 import requests
 
+from enma._version import __version__
 from enma.application.core.interfaces.manga_repository import IMangaRepository
 from enma.application.core.utils.logger import logger
 from enma.domain.entities.author_page import AuthorPage
 from enma.domain.entities.manga import Author, Chapter, Genre, Image, Manga, SymbolicLink, Title
 from enma.domain.entities.search_result import Pagination, SearchResult, Thumb
+from enma.infra.core.utils.cache import Cache
 
 class Manganato(IMangaRepository):
     """
@@ -38,7 +41,9 @@ def __make_request(self,
         logger.debug(f'Fetching {url} with headers {headers} and params {params}')
 
         return requests.get(url=urlparse(url).geturl(), 
-                            headers={**headers, 'Referer': 'https://chapmanganato.com/'},
+                            headers={**headers, 
+                                     'Referer': 'https://chapmanganato.com/', 
+                                     "User-Agent": f"Enma/{__version__}"},
                             params={**params})
     
     def __create_title(self, 
@@ -59,7 +64,7 @@ def __create_title(self,
                      japanese=jp.strip(),
                      other=cn.strip())
     
-    def __find_chapets_list(self, html: BeautifulSoup) -> list[str]:
+    def __find_chapters_list(self, html: BeautifulSoup) -> list[str]:
         chapter_list = cast(Tag, html.find('ul', {'class': 'row-content-chapter'}))
         chapters = chapter_list.find_all('li') if chapter_list else []
         return [chapter.find('a')['href'] for chapter in chapters]
@@ -76,7 +81,6 @@ def __create_chapter(self, url: str, symbolic: bool = False) -> Union[Chapter, N
             logger.error(f'Could not fetch the chapter with url: {url}. status code: {response.status_code}')
             return
         
-        
         chapter = Chapter(id=response.url.split('/')[-1])
         html = BeautifulSoup(response.text, 'html.parser')
         images_container = cast(Tag, html.find('div', {'class': 'container-chapter-reader'}))
@@ -87,7 +91,12 @@ def __create_chapter(self, url: str, symbolic: bool = False) -> Union[Chapter, N
                                    width=0,
                                    height=0))
         return chapter
+    
+    def set_config(self, **kwargs) -> None:
+        raise NotImplementedError('Manganato does not support set_config')
 
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_GET_TTL_IN_SECONDS', 300)), 
+           max_size=20).cache
     def get(self, 
             identifier: str,
             with_symbolic_links: bool = False) -> Union[Manga, None]:
@@ -143,20 +152,21 @@ def get(self,
             updated_at = updated_at_field.find('span', {'class': 'stre-value'}).text
 
         if with_symbolic_links:
-            chapters_links = self.__find_chapets_list(html=soup)
+            chapters_links = self.__find_chapters_list(html=soup)
             chapters = [self.__create_chapter(link, symbolic=True) for link in chapters_links]
         else:
             workers = cpu_count()
             logger.debug(f'Initializing {workers} workers to fetch chapters of {identifier}.')
 
             with ThreadPoolExecutor(max_workers=workers) as executor:
-                chapters = executor.map(self.__create_chapter, self.__find_chapets_list(html=soup))
+                chapters = executor.map(self.__create_chapter, self.__find_chapters_list(html=soup))
                 chapters = list(filter(lambda x: isinstance(x, Chapter), list(chapters)))
                 executor.shutdown()
         
         return Manga(title=title,
                      authors=[Author(name=author)] if author is not None else None,
                      genres=[Genre(name=genre_name) for genre_name in genres],
+                     url=urljoin(self.__BASE_URL, identifier),
                      id=identifier,
                      created_at=datetime.strptime(updated_at, "%b %d,%Y - %H:%M %p") if updated_at else None,
                      updated_at=datetime.strptime(updated_at, "%b %d,%Y - %H:%M %p") if updated_at else None,
@@ -164,6 +174,8 @@ def get(self,
                      cover=Image(uri=cover), # type: ignore
                      chapters=chapters) # type: ignore
     
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_SEARCH_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
     def search(self, 
                query: str,
                page: int) -> SearchResult:
@@ -188,6 +200,7 @@ def search(self,
 
         for result in results:
             thumbs.append(Thumb(title=result.find('h3').text.replace('\n', '').strip(),
+                                url=result.find('a', {'class': 'a-h text-nowrap item-title'})['href'],
                                 cover=Image(uri=result.find('img')['src'], width=0, height=0),
                                 id=result.find('a', {'class': 'a-h text-nowrap item-title'})['href'].split('/')[-1]))
 
@@ -196,6 +209,8 @@ def search(self,
                             total_pages=total_pages,
                             results=thumbs)
 
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_PAGINATE_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
     def paginate(self, page: int) -> Pagination:
         response = self.__make_request(url=f'{self.__BASE_URL}/genre-all/{page}')
         
@@ -219,6 +234,7 @@ def paginate(self, page: int) -> Pagination:
             info = cast(Tag, item.find('a', {'class': 'genres-item-img bookmark_check'}))
             cover = info.find('img')
             pagination.results.append(Thumb(id=info['href'].split('/')[-1], # type: ignore
+                                            url=info['href'],
                                             title=info['title'] if info is not None else "",
                                             cover=Image(uri=cover['src'], width=0, height=0))) # type: ignore
             
@@ -231,12 +247,11 @@ def paginate(self, page: int) -> Pagination:
     def random(self) -> Manga:
         raise NotImplementedError('Manganato does not support random')
     
-    def set_config(self, **kwargs) -> None:
-        raise NotImplementedError('Manganato does not support set_config')
-    
     def author_page(self, author: str, page: int) -> AuthorPage:
         raise NotImplementedError('Manganato does not support author_page')
     
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS', 100)), 
+           max_size=20).cache
     def fetch_chapter_by_symbolic_link(self, link: SymbolicLink) -> Chapter:
         chapter =  self.__create_chapter(url=link.link)
         
diff --git a/enma/infra/adapters/repositories/nhentai.py b/enma/infra/adapters/repositories/nhentai.py
index da22d26..a024290 100644
--- a/enma/infra/adapters/repositories/nhentai.py
+++ b/enma/infra/adapters/repositories/nhentai.py
@@ -4,6 +4,7 @@
 """
 from datetime import datetime, timezone
 from enum import Enum
+import os
 from typing import Any, Literal, Optional, Union, cast
 from urllib.parse import urljoin, urlparse
 from pydantic import BaseModel, field_validator
@@ -11,8 +12,8 @@
 import requests
 from bs4 import BeautifulSoup, Tag
 
-from enma.application.core.handlers.error import (ExceedRetryCount, InvalidConfig, InvalidRequest,
-                                                  NhentaiSourceWithoutConfig)
+from enma.application.core.handlers.error import (ExceedRetryCount, Forbidden, InvalidConfig, InvalidRequest,
+                                                  NhentaiSourceWithoutConfig, NotFound, Unknown)
 from enma.application.core.interfaces.manga_repository import IMangaRepository
 from enma.application.core.utils.logger import logger
 from enma.domain.entities.author_page import AuthorPage
@@ -20,6 +21,7 @@
                                         Title)
 from enma.domain.entities.search_result import Pagination, SearchResult, Thumb
 from enma.infra.core.interfaces.nhentai_response import NHentaiImage, NHentaiResponse
+from enma.infra.core.utils.cache import Cache
 
 
 class CloudFlareConfig(BaseModel):
@@ -52,12 +54,24 @@ class NHentai(IMangaRepository):
     def __init__(self,
                  config: Optional[CloudFlareConfig] = None) -> None:
         self.__config = config
-        self.__BASE_URL = 'https://nhentai.net'
+        self.__BASE_URL = 'https://nhentai.net/'
         self.__API_URL = 'https://nhentai.net/api/'
         self.__IMAGE_BASE_URL = 'https://i.nhentai.net/galleries/'
         self.__AVATAR_URL = 'https://i5.nhentai.net/'
         self.__TINY_IMAGE_BASE_URL = self.__IMAGE_BASE_URL.replace('/i.', '/t.')
 
+    def __handle_source_response(self, response: requests.Response):
+        logger.debug(f'Fetched {response.url} with response status code {response.status_code} and text {response.text}')
+        if response.status_code == 200: return
+        if response.status_code == 403: 
+            raise Forbidden(message='Could not perform a successfull request to the source due credentials issues. \
+Check your credentials and try again.')
+        if response.status_code == 404:
+            raise NotFound(message=f'Could not find the requested resource at "{response.url}". \
+Check the provided request parameters and try again.')
+        raise Unknown(message='Something unexpected happened while trying to fetch source content. \
+Set the logging mode to debug and try again.')
+
     def __make_request(self,
                        url: str,
                        headers: Union[dict[str, Any], None] = None,
@@ -72,21 +86,17 @@ def __make_request(self,
         logger.debug(f'Fetching {url} with headers {headers} and params {params} the current config cf_clearance: {self.__config.cf_clearance}')
 
         response = requests.get(url=urlparse(url).geturl(),
-                                headers={**headers, 'User-Agent': self.__config.user_agent},
+                                headers={**headers, 'User-Agent': f'{self.__config.user_agent}'},
                                 params={**params},
                                 cookies={'cf_clearance': self.__config.cf_clearance})
         
-        logger.debug(f'Fetched {url} with response status code {response.status_code} and text {response.text}')
+        self.__handle_source_response(response)
 
         return response
 
     def set_config(self, config: CloudFlareConfig) -> None:
         if not isinstance(config, CloudFlareConfig): raise InvalidConfig(message='You must provide a CloudFlareConfig object.') 
         self.__config = config
-
-    def __handle_request_error(self, msg: str) -> None:
-        logger.error(msg)
-        return None
     
     def __make_page_uri(self,
                         type: Union[Literal['cover'], Literal['page'], Literal['thumbnail']],
@@ -108,13 +118,11 @@ def __make_page_uri(self,
 
         return url
     
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS', 100)), 
+           max_size=20).cache
     def fetch_chapter_by_symbolic_link(self, 
                                        link: SymbolicLink) -> Chapter:
         response = self.__make_request(url=link.link)
-
-        if response.status_code != 200:
-            self.__handle_request_error(msg=f'Could not fetch {link.link} because nhentai\'s request ends up with {response.status_code} status code.')
-            return Chapter()
         
         doujin: NHentaiResponse = response.json()
 
@@ -148,16 +156,15 @@ def __create_chapter(self,
                                 height=page.get('h')))
             return chapter
     
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_GET_TTL_IN_SECONDS', 300)), 
+           max_size=20).cache
     def get(self, 
             identifier: str,
             with_symbolic_links: bool = False) -> Union[Manga, None]:
 
-        url = f'{self.__API_URL}/gallery/{identifier}'
+        url = urljoin(self.__API_URL, f'gallery/{identifier}')
         response = self.__make_request(url=url)
 
-        if response.status_code != 200:
-            return self.__handle_request_error(msg=f'Could not fetch {identifier} because nhentai\'s request ends up with {response.status_code} status code.')
-
         doujin: NHentaiResponse = response.json()
         media_id = doujin.get('media_id')
 
@@ -194,6 +201,7 @@ def get(self,
                       id=doujin.get('id'),
                       created_at=datetime.fromtimestamp(doujin.get('upload_date'), tz=timezone.utc),
                       updated_at=datetime.fromtimestamp(doujin.get('upload_date'), tz=timezone.utc),
+                      url=urljoin(self.__BASE_URL, f'g/{doujin.get("id")}'),
                       language=language[0] if len(language) > 0 else None,
                       authors=authors,
                       genres=genres,
@@ -202,7 +210,9 @@ def get(self,
                       chapters=[chapter])
 
         return manga
-
+    
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_SEARCH_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
     def search(self,
                query: str,
                page: int,
@@ -221,10 +231,6 @@ def search(self,
                                      total_results=0,
                                      results=[])
         
-        if request_response.status_code != 200:
-            self.__handle_request_error(f'Could not search by {query} because nhentai\'s request ends up with {request_response.status_code} status code.')
-            return search_result
-
         soup = BeautifulSoup(request_response.text, 'html.parser')
 
         search_results_container = soup.find('div', {'class': 'container'})
@@ -274,6 +280,7 @@ def search(self,
                 caption = result_caption.text
 
             thumbs.append(Thumb(id=doujin_id,
+                                url=urljoin(self.__BASE_URL, f'g/{doujin_id}'),
                                 cover=Image(uri=cover_uri or '',
                                             mime=MIME.J,
                                             width=int(width or 0),
@@ -286,14 +293,12 @@ def search(self,
 
         return search_result
 
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_PAGINATE_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
     def paginate(self, page: int) -> Pagination:
         response = self.__make_request(url=urljoin(self.__API_URL, f'galleries/all'),
                                        params={'page': page})
 
-        if response.status_code != 200:
-            self.__handle_request_error(f'Could not paginate to page {page} because nhentai\'s request ends up with {response.status_code} status code.')
-            return Pagination(page=page)
-
         data = response.json()
 
         PAGES = data.get('num_pages', 0)
@@ -305,6 +310,7 @@ def paginate(self, page: int) -> Pagination:
                           total_pages=PAGES,
                           results=[Thumb(id=result.get('id'),
                                          title=result.get('title').get('english'),
+                                         url=urljoin(self.__BASE_URL, f'g/{result.get("id")}'),
                                          cover=Image(uri=self.__make_page_uri(type='cover',
                                                                               media_id=result.get('media_id'),
                                                                               mime=MIME[result.get('images').get('cover').get('t').upper()]),
@@ -315,9 +321,6 @@ def paginate(self, page: int) -> Pagination:
     def random(self, retry=0) -> Manga:
         response = self.__make_request(url=urljoin(self.__BASE_URL, 'random'))
 
-        if response.status_code != 200:
-            self.__handle_request_error(f'Could not fetch a random manga because nhentai\'s request ends up with {response.status_code} status code.')
-
         soup = BeautifulSoup(response.text, 'html.parser')
 
         id = cast(Tag, soup.find('h3', id='gallery_id')).text.replace('#', '')
@@ -335,6 +338,8 @@ def random(self, retry=0) -> Manga:
 
         return doujin
     
+    @Cache(max_age_seconds=int(os.getenv('ENMA_CACHING_AUTHOR_TTL_IN_SECONDS', 100)), 
+           max_size=5).cache
     def author_page(self,
                     author: str,
                     page: int) -> AuthorPage:
@@ -345,11 +350,7 @@ def author_page(self,
                             total_pages=0,
                             page=page,
                             total_results=0,
-                            results=[])
-        
-        if request_response.status_code != 200:
-            logger.error('Could not fetch author page properly')
-            return result    
+                            results=[]) 
    
         soup = BeautifulSoup(request_response.text, 'html.parser')
 
@@ -396,6 +397,7 @@ def author_page(self,
                 caption = result_caption.text
 
             thumbs.append(Thumb(id=doujin_id,
+                                url=urljoin(self.__BASE_URL, f'g/{doujin_id}'),
                                 cover=Image(uri=cover_uri or '',
                                             mime=MIME.J,
                                             width=int(width or 0),
diff --git a/enma/infra/core/interfaces/mangadex_response.py b/enma/infra/core/interfaces/mangadex_response.py
new file mode 100644
index 0000000..777ed0c
--- /dev/null
+++ b/enma/infra/core/interfaces/mangadex_response.py
@@ -0,0 +1,134 @@
+from typing import Any, Literal, TypedDict, Union, TypeVar
+
+
+class Title(TypedDict):
+    en: str
+
+class MangaDesc(TypedDict):
+    en: str
+
+class TagAttrs(TypedDict):
+    name: dict[str, str]
+    description: dict[str, str]
+    group: Union[Literal["theme"], Literal["genre"], 
+                 Literal["format"]]
+    version: int
+
+class IMangaTag(TypedDict):
+    id: str
+    type: str
+    attributes: TagAttrs
+    relationships: list[Any]
+
+IAltTitles = list[dict[str, str]]
+
+class MangaAttrs(TypedDict):
+    title: Title
+    altTitles: IAltTitles
+    description: MangaDesc
+    isLocked: bool
+    links: dict[str, str]
+    originalLanguage: str
+    lastVolume: str
+    lastChapter: str
+    publicationDemographic: Any
+    status: str
+    year: int
+    contentRating: str
+    tags: list[IMangaTag]
+    state: str
+    chapterNumbersResetOnNewVolume: bool
+    createdAt: str
+    updatedAt: str
+    version: int
+    availableTranslatedLanguages: list[str]
+    latestUploadedChapter: str
+
+class CoverAttrs(TypedDict):
+    description: str
+    volume: int
+    fileName: str
+    locale: str
+    createdAt: str
+    updatedAt: str
+    version: int
+
+class CoverArtRelation(TypedDict):
+    id: str
+    type: Literal["cover_art"]
+    attributes: CoverAttrs
+
+class PersonAttrs(TypedDict):
+    name: str
+    imageUrl: str
+    biography: dict[str, str]
+    twitter: str
+    pixiv: str
+    melonBook: str
+    fanBox: str
+    booth: str
+    namicomi: str
+    nicoVideo: str
+    skeb: str
+    fantia: str
+    tumblr: str
+    youtube: str
+    weibo: str
+    naver: str
+    website: str
+    createdAt: str
+    updatedAt: str
+    version: int
+
+class AuthorRelation(TypedDict):
+    id: str
+    type: Union[Literal["author"], Literal["artist"]]
+    attributes: PersonAttrs
+
+IRelations = list[Union[CoverArtRelation,
+                       AuthorRelation,
+                       dict[str, str]]]
+
+class IManga(TypedDict):
+    id: str
+    type: str
+    attributes: MangaAttrs
+    relationships: IRelations
+
+class IGetResult(TypedDict):
+    result: str
+    response: str
+    data: IManga
+
+class ISearchResult(IGetResult):
+    limit: int
+    offset: int
+    total: int
+    result: str
+    response: str
+    data: list[IManga]
+
+class IChapter(TypedDict):
+    chapter: str
+    id: str
+    others: list
+    count: int
+
+class IVolume(TypedDict):
+    volume: str
+    count: int
+    chapters: dict[str, IChapter]
+
+class IVolumesResponse(TypedDict):
+    result: str
+    volumes: dict[str, IVolume]
+
+class IChapterHash(TypedDict):
+    hash: str
+    data: list[str]
+    dataSaver: list[str]
+
+class IHash(TypedDict):
+    result: str
+    baseUrl: str
+    chapter: IChapterHash
\ No newline at end of file
diff --git a/enma/infra/core/utils/cache.py b/enma/infra/core/utils/cache.py
new file mode 100644
index 0000000..71f515a
--- /dev/null
+++ b/enma/infra/core/utils/cache.py
@@ -0,0 +1,30 @@
+from expiringdict import ExpiringDict
+from typing import Callable
+from enma.application.core.utils.logger import logger
+
+class Cache:
+
+  def __init__(self, max_age_seconds: int=3600, max_size: int=100):
+    self._CACHE = ExpiringDict(max_len=max_size, 
+                               max_age_seconds=max_age_seconds, 
+                               items=None)
+  
+  def cache(self, function: Callable):
+    def wrapper(*args, **kwargs):
+        
+        _args = list()
+
+        if len(kwargs.keys()) == 0:
+            _args = list(args[1:])
+        else:
+            _args = list([*list(kwargs.values()), *args[1:]])
+
+        if self._CACHE.get(str(_args)) is not None:
+            logger.debug(f'Retrieving cached object with key {str(_args)}')
+            return self._CACHE.get(str(_args))
+        else:
+            new_execution = function(*args, **kwargs)
+            self._CACHE[str(_args)] = new_execution
+            return new_execution
+
+    return wrapper
\ No newline at end of file
diff --git a/enma/infra/entrypoints/lib/__init__.py b/enma/infra/entrypoints/lib/__init__.py
index ad4e952..85c4e6f 100644
--- a/enma/infra/entrypoints/lib/__init__.py
+++ b/enma/infra/entrypoints/lib/__init__.py
@@ -5,7 +5,7 @@
 from enum import Enum
 from typing import Any, Generic, Optional, TypeVar, TypedDict, Union
 
-from enma.application.core.handlers.error import InstanceError, SourceNotAvailable, SourceWasNotDefined
+from enma.application.core.handlers.error import InstanceError, InvalidResource, SourceNotAvailable, SourceWasNotDefined
 from enma.application.core.interfaces.downloader_adapter import IDownloaderAdapter
 from enma.application.core.interfaces.manga_repository import IMangaRepository
 from enma.application.core.interfaces.saver_adapter import ISaverAdapter
@@ -18,35 +18,61 @@
 from enma.application.use_cases.paginate import PaginateRequestDTO, PaginateResponseDTO, PaginateUseCase
 from enma.application.use_cases.search_manga import SearchMangaRequestDTO, SearchMangaResponseDTO, SearchMangaUseCase
 from enma.domain.entities.author_page import AuthorPage
-from enma.domain.entities.manga import Chapter, Manga, SymbolicLink
+from enma.domain.entities.manga import Chapter, Manga
 from enma.domain.entities.pagination import Pagination
 from enma.domain.entities.search_result import SearchResult
+from enma.infra.adapters.repositories.mangadex import Mangadex
 from enma.infra.adapters.repositories.manganato import Manganato
 from enma.infra.adapters.repositories.nhentai import NHentai, CloudFlareConfig
 from enma.infra.core.interfaces.lib import IEnma
+from enma.application.core.utils.logger import logger
 
-class SourcesEnum(str, Enum):
-    ...
-
-class DefaultAvailableSources(SourcesEnum):
+class Sources(Enum):
     NHENTAI = 'nhentai'
+    MANGADEX = 'mangadex'
     MANGANATO = 'manganato'
 
 class ExtraConfigs(TypedDict):
     cloudflare_config: CloudFlareConfig
 
-AvailableSources = TypeVar('AvailableSources', bound=SourcesEnum)
+Source = TypeVar('Source', Sources, str)
+
+class SourceManager(Generic[Source]):
+    """
+    Manages manga source repositories available to the Enma application, allowing for dynamic source selection at runtime.
+
+    Attributes:
+        source (Union[IMangaRepository, None]): The currently selected manga repository source.
+        source_name (str): The name of the currently selected source.
+    """
 
-class SourceManager(Generic[AvailableSources]):
-    def __init__(self, **kwargs) -> None:
-        self.__SOURCES: dict[str, IMangaRepository] = {'nhentai': NHentai(config=kwargs.get('cloudflare_config')),
-                                                       'manganato': Manganato()}
-        self.source: Union[IMangaRepository, None] = None
+    def __init__(self) -> None:
+        """
+        Initializes the SourceManager with empty sources and no selected source.
+        """
+        self.__SOURCES: dict[str, IMangaRepository] = {}
+        self.__CURRENT_SOURCE: Union[IMangaRepository, None] = None
         self.source_name = ''
     
+    @property
+    def source(self) -> Union[IMangaRepository, None]:
+        return self.__CURRENT_SOURCE
+
     def get_source(self,
-                   source_name: Union[AvailableSources, str]) -> IMangaRepository:
-        
+                   source_name: Union[Sources, Source]) -> IMangaRepository:
+        """
+        Retrieves a source repository by name.
+
+        Args:
+            source_name (Union[Sources, str]): The name of the source to retrieve, either as a string or an enum.
+
+        Returns:
+            IMangaRepository: The manga repository source.
+
+        Raises:
+            SourceNotAvailable: If the requested source is not available.
+        """
+
         source_name = source_name.value if isinstance(source_name, Enum) else source_name
         source = self.__SOURCES.get(source_name)
 
@@ -56,21 +82,61 @@ def get_source(self,
         return source
     
     def set_source(self,
-                   source_name: Union[AvailableSources, str]) -> None:
+                   source_name: Union[Sources, Source]) -> None:
+        """
+        Sets the currently active source to the specified source name.
+
+        Args:
+            source_name (Union[Source, str]): The name of the source to activate, either as a string or an enum.
+        """
         source = self.get_source(source_name=source_name)
-        self.source = source
+        self.__CURRENT_SOURCE = source
         self.source_name = source_name
 
     def add_source(self,
-                   source_name: str,
+                   source_name: Union[str, Sources, Source],
                    source: IMangaRepository) -> None:
+        """
+        Adds a new source repository to the available sources.
+
+        Args:
+            source_name (Union[str, Sources]): The name of the source to add.
+            source (IMangaRepository): The manga repository source instance.
 
+        Raises:
+            InstanceError: If the provided source is not an instance of IMangaRepository.
+        """
         if not isinstance(source, IMangaRepository):
             raise InstanceError('Provided source is not an instance of IMangaRepository.')
         
-        self.__SOURCES[source_name] = source
+        self.__SOURCES[source_name if isinstance(source_name, str) else source_name.value] = source
+
+    def remove_source(self,
+                      source_name: Union[str, Source]) -> bool:
+
+        name = source_name if isinstance(source_name, str) else source_name.value
+
+        if name not in self.__SOURCES: 
+            logger.warning(f"Source {name} not found.")
+            return False
+        
+        del self.__SOURCES[name]
+        return True
+    
+    def clear_sources(self) -> None:
+        self.__SOURCES = dict()
 
 def instantiate_source(callable):
+    """
+    Decorator function to ensure the current use case is instantiated with the current source.
+    This is used to decorate methods of the Enma class that require a source to have been set.
+
+    Args:
+        callable: The method to be decorated.
+    
+    Returns:
+        The wrapped method with source initialization logic.
+    """
     def wrapper(self, *args, **kwargs):
         if self.source_manager.source is not None and \
             self._Enma__current_source_name != self.source_manager.source_name:
@@ -80,11 +146,23 @@ def wrapper(self, *args, **kwargs):
         return callable(self, *args, **kwargs)
     return wrapper
 
-class Enma(IEnma, Generic[AvailableSources]):
+class Enma(IEnma, Generic[Source]):
+    """
+    Main application class for Enma, providing interfaces to execute various manga-related use cases.
+    Allows dynamic selection of manga sources and performs actions like fetching manga, searching, and downloading chapters.
+
+    Attributes:
+        source_manager (SourceManager[Source]): Manages the available sources and the current source selection.
+    """
     def __init__(self, 
-                 source: Optional[AvailableSources] = None, 
+                 source: Optional[Source] = None, 
                  **kwargs) -> None:
+        """
+        Initializes the Enma application with optional default source selection and extra configurations.
 
+        Args:
+            source (Optional[Source], optional): The default source to be used. If provided, use cases will be initialized with this source.
+        """
         self.__get_manga_use_case: Optional[IUseCase[GetMangaRequestDTO, GetMangaResponseDTO]] = None
         self.__search_manga_use_case: Optional[IUseCase[SearchMangaRequestDTO, SearchMangaResponseDTO]] = None
         self.__paginate_use_case: Optional[IUseCase[PaginateRequestDTO, PaginateResponseDTO]] = None
@@ -92,12 +170,27 @@ def __init__(self,
         self.__downloader_use_case: Optional[IUseCase[DownloadChapterRequestDTO, DownloadChapterResponseDTO]] = None
         self.__get_author_page_use_case: Optional[IUseCase[GetAuthorPageRequestDTO, GetAuthorPageResponseDTO]] = None
         self.__fetch_chapter_by_symbolic_link_use_case: Optional[IUseCase[FetchChapterBySymbolicLinkRequestDTO, FetchChapterBySymbolicLinkResponseDTO]] = None
-        self.__current_source_name = None
+        self.__current_source_name: Optional[str] = None
 
-        self.source_manager = SourceManager[AvailableSources](**kwargs)
+        self.source_manager = SourceManager[Source](**kwargs)
+        self.__create_default_sources()
         if source is not None: self.__initialize_use_case(source=self.source_manager.get_source(source_name=source))
 
+    def __create_default_sources(self) -> None:
+        """
+        Creates and adds the default manga sources to the source manager. Currently, NHentai, Manganato, and Mangadex are added.
+        """
+        self.source_manager.add_source(Sources.NHENTAI, NHentai())
+        self.source_manager.add_source(Sources.MANGANATO, Manganato())
+        self.source_manager.add_source(Sources.MANGADEX, Mangadex())
+
     def __initialize_use_case(self, source: IMangaRepository) -> None:
+        """
+        Initializes the use cases with the given source repository. This method sets up all use cases available in Enma.
+
+        Args:
+            source (IMangaRepository): The source repository to initialize use cases with.
+        """
         self.__get_manga_use_case = GetMangaUseCase(manga_repository=source)
         self.__search_manga_use_case = SearchMangaUseCase(manga_repository=source)     
         self.__paginate_use_case = PaginateUseCase(manga_repository=source)     
@@ -110,7 +203,20 @@ def __initialize_use_case(self, source: IMangaRepository) -> None:
     @instantiate_source
     def get(self, 
             identifier: str,
-            with_symbolic_links: bool = False) -> Union[Manga, None]:
+            with_symbolic_links: bool = True) -> Union[Manga, None]:
+        """
+        Retrieves detailed information for a specific manga identified by its ID.
+
+        Args:
+            identifier (str): The unique identifier of the manga to retrieve.
+            with_symbolic_links (bool, optional): If True, fetches the manga with symbolic links to chapters. Defaults to True.
+
+        Returns:
+            Union[Manga, None]: The Manga object if found, None otherwise.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+        """
         if self.__get_manga_use_case is None:
             raise SourceWasNotDefined('You must define a source before of performing actions.')
 
@@ -121,7 +227,24 @@ def get(self,
         return response.manga
     
     @instantiate_source
-    def search(self, query: str, page: int=1, **kwargs) -> SearchResult:
+    def search(self, 
+               query: str, 
+               page: int=1, 
+               **kwargs) -> SearchResult:
+        """
+        Searches for manga that match the given query string.
+
+        Args:
+            query (str): The search query string.
+            page (int, optional): The page number of the search results to retrieve. Defaults to 1.
+            **kwargs: Additional parameters for search customization.
+
+        Returns:
+            SearchResult: An object containing the paginated search results, including manga thumbnails.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+        """
         if self.__search_manga_use_case is None:
             raise SourceWasNotDefined('You must define a source before of performing actions.')
         
@@ -132,7 +255,20 @@ def search(self, query: str, page: int=1, **kwargs) -> SearchResult:
         return response.result
     
     @instantiate_source
-    def paginate(self, page: int) -> Pagination:
+    def paginate(self, 
+                 page: int=1) -> Pagination:
+        """
+        Retrieves a specific page of manga listings.
+
+        Args:
+            page (int): The page number of manga listings to retrieve.
+
+        Returns:
+            Pagination: An object containing the paginated list of manga thumbnails and pagination details.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+        """
         if self.__paginate_use_case is None:
             raise SourceWasNotDefined('You must define a source before of performing actions.')
         
@@ -142,7 +278,20 @@ def paginate(self, page: int) -> Pagination:
     
     @instantiate_source
     def random(self) -> Manga:
-        response = self.__random_use_case.execute() # type: ignore
+        """
+        Fetches a random manga from the currently selected source.
+
+        Returns:
+            Manga: A Manga object for the randomly selected manga.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+            NotImplementedError: If the current source does not support fetching an author's page.
+        """
+        if self.__random_use_case is None:
+            raise SourceWasNotDefined('You must define a source before of performing actions.')
+        
+        response = self.__random_use_case.execute() 
         return response.result
     
     
@@ -153,6 +302,19 @@ def download_chapter(self,
                          downloader: IDownloaderAdapter,
                          saver: ISaverAdapter,
                          threaded: Threaded) -> None:
+        """
+        Downloads a manga chapter to the specified path using the provided downloader and saver adapters.
+
+        Args:
+            path (str): The filesystem path where the chapter should be saved.
+            chapter (Chapter): The manga chapter to download.
+            downloader (IDownloaderAdapter): The adapter to use for downloading the chapter pages.
+            saver (ISaverAdapter): The adapter to use for saving the downloaded pages.
+            threaded (Threaded): Determines whether the download should be performed in a threaded manner for concurrency.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+        """
         if self.__downloader_use_case is None:
             raise SourceWasNotDefined('You must define a source before of performing actions.')
         
@@ -163,7 +325,23 @@ def download_chapter(self,
                                                                                   threaded=threaded)))
     
     @instantiate_source
-    def author_page(self, author: str, page: int) -> AuthorPage:
+    def author_page(self, 
+                    author: str, 
+                    page: int=1) -> AuthorPage:
+        """
+        Fetches manga authored by a specific author.
+
+        Args:
+            author (str): The name or identifier of the author.
+            page (int): The page number of results to retrieve.
+
+        Returns:
+            AuthorPage: An object containing a list of manga by the specified author.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+            NotImplementedError: If the current source does not support fetching an author's page.
+        """
         if self.__get_author_page_use_case is None:
             raise SourceWasNotDefined('You must define a source before of performing actions.')
         
@@ -171,10 +349,29 @@ def author_page(self, author: str, page: int) -> AuthorPage:
                                                                                             page=page))).result
 
     @instantiate_source
-    def fetch_chapter_by_symbolic_link(self, link: SymbolicLink) -> Chapter:
+    def fetch_chapter_by_symbolic_link(self, 
+                                       chapter: Chapter) -> Chapter:
+        """
+        Fetches a manga chapter's details including pages and images by its symbolic link.
+
+        Args:
+            chapter (Chapter): The manga chapter to fetch, which must include a valid symbolic link.
+
+        Returns:
+            Chapter: An object containing the fetched chapter details such as pages and images.
+
+        Raises:
+            SourceWasNotDefined: If no source has been defined prior to calling this method.
+            InvalidResource: If the provided chapter does not have a valid symbolic link.
+        """
         if self.__fetch_chapter_by_symbolic_link_use_case is None:
             raise SourceWasNotDefined('You must define a source before of performing actions.')
         
-        response = self.__fetch_chapter_by_symbolic_link_use_case.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=link)))
+        if chapter.link is None or chapter.link.link is None:
+            raise InvalidResource('Chapter does not have a symbolic link.')
         
+        response = self.__fetch_chapter_by_symbolic_link_use_case.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=chapter.link)))
+        
+        response.chapter.id = chapter.id
+
         return response.chapter
diff --git a/pyproject.toml b/pyproject.toml
index 7936580..16e504f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,4 +3,5 @@ requires = ["setuptools>=64", "setuptools_scm[toml]>=6.2"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools_scm]
-version_scheme = "post-release"
\ No newline at end of file
+version_scheme = "post-release"
+write_to = "enma/_version.py"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index a0cda0d..efe9eee 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,5 @@ requests==2.31.0
 beautifulsoup4==4.10.0
 pydantic==2.5.3
 pytest==8.0.2
+expiringdict==1.2.2
+pytest-cov==4.1.0
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index c578b70..f5429f5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,6 +20,7 @@ install_requires =
     requests==2.31.0
     beautifulsoup4==4.10.0
     pydantic==2.5.3
+    expiringdict==1.2.2
 setup_requires =
    setuptools_scm
 
@@ -27,4 +28,10 @@ setup_requires =
 enma = *.txt, *.rst, *.md
 
 [tool.setuptools_scm]
-write_to = enma/_version.py
\ No newline at end of file
+write_to = enma/_version.py
+
+[coverage:run]
+omit =
+    */interfaces/*
+    */tests/*
+    enma/__init__.py


Test patch:
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index b019b2b..6fe1cda 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -24,11 +24,15 @@ jobs:
       - name: Preparing the environment
         run: |
           python -m pip install --upgrade pip
-          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; else python -m pip install beautifulsoup4 requests pydantic; fi
-          python -m pip install pytest==7.0.1 pytest-cov==4.1.0
+          if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; else python -m pip install beautifulsoup4 requests pydantic expiringdict pytest pytest-cov; fi
 
       - name: Starting Testing Suites
         run: |
+          ENMA_CACHING_PAGINATE_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_SEARCH_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_GET_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS='0' \
+          ENMA_CACHING_AUTHOR_TTL_IN_SECONDS='0' \
           pytest --cov=. --cov-report=xml ./tests
 
       - name: Upload Report to Codecov
diff --git a/tests/data/mangadex_empty_chapters.json b/tests/data/mangadex_empty_chapters.json
new file mode 100644
index 0000000..901b32f
--- /dev/null
+++ b/tests/data/mangadex_empty_chapters.json
@@ -0,0 +1,8 @@
+{
+	"result": "ok",
+	"response": "collection",
+	"data": [],
+	"limit": 32,
+	"offset": 0,
+	"total": 0
+}
\ No newline at end of file
diff --git a/tests/data/mangadex_empty_pagination_mock.json b/tests/data/mangadex_empty_pagination_mock.json
new file mode 100644
index 0000000..901b32f
--- /dev/null
+++ b/tests/data/mangadex_empty_pagination_mock.json
@@ -0,0 +1,8 @@
+{
+	"result": "ok",
+	"response": "collection",
+	"data": [],
+	"limit": 32,
+	"offset": 0,
+	"total": 0
+}
\ No newline at end of file
diff --git a/tests/data/mocked_doujins.py b/tests/data/mocked_doujins.py
index dbdde8f..c3eb030 100644
--- a/tests/data/mocked_doujins.py
+++ b/tests/data/mocked_doujins.py
@@ -1,5 +1,6 @@
 import json
 
+from enma.infra.core.interfaces.mangadex_response import ISearchResult
 from enma.infra.core.interfaces.nhentai_response import NHentaiPaginateResponse, NHentaiResponse
 
 with open('./tests/data/get.json', 'r') as get:
@@ -24,4 +25,7 @@
     manganato_manga_page_empty_chapters_mocked = empty_ch.read()
 
 with open('./tests/data/manganato_manga_page_empty_pagination_mock.txt', 'r') as empty_pag:
-    manganato_manga_page_empty_pagination_mocked = empty_pag.read()
\ No newline at end of file
+    manganato_manga_page_empty_pagination_mocked = empty_pag.read()
+
+with open('./tests/data/mangadex_empty_pagination_mock.json', 'r') as paginate:
+    mangadex_paginate_mocked: ISearchResult = json.loads(paginate.read())
\ No newline at end of file
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..eea504a
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,92 @@
+import sys
+import os
+
+import pytest
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
+
+import time
+from enma.infra.core.utils.cache import Cache
+
+def test_cache_decorator():
+    # Create an instance of the Cache class
+    cache = Cache(max_age_seconds=10, max_size=5)
+
+    class Fibonacci:
+        @cache.cache
+        def fibonacci(self, n):
+            if n <= 1:
+                return n
+            else:
+                return self.fibonacci(n-1) + self.fibonacci(n-2)
+
+    fib = Fibonacci()
+    # Call the fibonacci function multiple times with the same arguments
+    result1 = fib.fibonacci(5)
+    result2 = fib.fibonacci(5)
+    result3 = fib.fibonacci(5)
+
+    # Assert that the function is only executed once and the cached result is returned
+    assert result1 == result2 == result3
+
+    assert cache._CACHE.get(str([5])) == result1
+
+    # Call the fibonacci function with a different argument
+    result4 = fib.fibonacci(10)
+
+    # Assert that a new execution is performed and the result is cached
+    assert result4 == 55
+    assert cache._CACHE.get(str([10])) == result4
+
+def test_cache_expiration():
+    # Create an instance of the Cache class with a short expiration time
+    cache = Cache(max_age_seconds=2, max_size=5)
+
+    @cache.cache
+    def get_current_time():
+        return time.time()
+
+    # Call the function to cache its result
+    result1 = get_current_time()
+
+    # Wait for the cache entry to expire
+    time.sleep(3)
+
+    # Call the function again and expect a different result due to cache expiration
+    result2 = get_current_time()
+
+    assert result1 != result2, "Cache did not expire as expected."
+
+def test_cache_with_various_argument_types():
+    cache = Cache(max_age_seconds=2, max_size=5)
+
+    class Sut:
+        @cache.cache
+        def concat_elements(self, *args):
+            return ''.join(map(str, args))
+
+    sut = Sut()
+    # Test with string arguments
+    assert sut.concat_elements("hello", "world") == "helloworld"
+    assert sut.concat_elements("hello", "world") == "helloworld"  # Cached result
+
+    # Test with a mix of types
+    assert sut.concat_elements("number", 123, (1, 2, 3)) == "number123(1, 2, 3)"
+    assert sut.concat_elements("number", 123, (1, 2, 3)) == "number123(1, 2, 3)"  # Cached result
+
+def test_cache_function_no_arguments():
+    call_count = 0
+    cache = Cache(max_age_seconds=2, max_size=5)
+
+    class Sut:
+        @cache.cache
+        def get_fixed_value(self):
+            nonlocal call_count
+            call_count += 1
+            return 42
+        
+    sut = Sut()
+
+    assert sut.get_fixed_value() == 42  # First call, cache miss
+    assert sut.get_fixed_value() == 42  # Second call, should hit cache
+    assert call_count == 1, "Function was called more than once, indicating a cache miss"
\ No newline at end of file
diff --git a/tests/test_mangadex_source.py b/tests/test_mangadex_source.py
new file mode 100644
index 0000000..889f576
--- /dev/null
+++ b/tests/test_mangadex_source.py
@@ -0,0 +1,211 @@
+from unittest.mock import MagicMock, Mock, patch
+import sys
+import os
+
+import pytest
+
+os.environ['ENMA_CACHING_PAGINATE_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_SEARCH_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_GET_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS'] = '0'
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
+
+from enma.application.core.handlers.error import Forbidden, NotFound, Unknown
+from enma.domain.entities.pagination import Thumb
+from enma.infra.adapters.repositories.mangadex import Mangadex
+from enma.domain.entities.manga import Author, Chapter, Genre, Image, SymbolicLink
+
+from tests.data.mocked_doujins import mangadex_paginate_mocked
+
+class TestMangadexSourceGetMethod:
+
+    sut = Mangadex()
+
+    def test_success_doujin_retrieve(self):
+        
+        res = self.sut.get(identifier='65498ee8-3c32-4228-b433-73a4d08f8927', 
+                           with_symbolic_links=True)
+
+        assert res is not None
+        assert res.id == '65498ee8-3c32-4228-b433-73a4d08f8927'
+        assert res.title.english == "Monster Musume no Iru Nichijou"
+        assert res.title.japanese == "Monmusu"
+        assert res.title.other != ''
+        assert res.url != ''
+        
+        for genre in res.genres:
+            assert isinstance(genre, Genre)
+        
+        for author in res.authors:
+            assert isinstance(author, Author)
+
+        assert isinstance(res.thumbnail, Image)
+        assert isinstance(res.cover, Image)
+
+        for chapter in res.chapters:
+            assert isinstance(chapter, Chapter)
+            assert isinstance(chapter.link, SymbolicLink)
+
+    def test_response_when_it_could_not_get_doujin(self):
+        with pytest.raises(NotFound):
+            self.sut.get(identifier='manga-kb951984', with_symbolic_links=True)
+        
+    @patch('requests.get')
+    def test_raise_forbidden_in_case_of_403_status_code(self, mock_method: MagicMock):
+            mock = Mock()
+            mock.status_code = 403
+            mock_method.return_value = mock
+
+            with pytest.raises(Forbidden):
+                self.sut.get(identifier='manga-kb951984')
+                mock_method.assert_called_with(url='https://chapMangadex.com/manga-kb951984', 
+                                            headers={'Referer': 'https://chapMangadex.com/'}, 
+                                            params={})
+
+    @patch.object(sut, '_Mangadex__list_chapters')
+    def test_return_empty_chapters(self, mock_method: MagicMock):
+        mock_method.return_value = []
+
+        doujin = self.sut.get(identifier='65498ee8-3c32-4228-b433-73a4d08f8927', 
+                              with_symbolic_links=True)
+        
+        assert doujin is not None
+
+        assert len(doujin.chapters) == 0
+        assert doujin.id == '65498ee8-3c32-4228-b433-73a4d08f8927'
+        assert doujin.title.english == "Monster Musume no Iru Nichijou"
+        assert doujin.title.japanese == "Monmusu"
+        
+        for genre in doujin.genres:
+            assert isinstance(genre, Genre)
+        
+        for author in doujin.authors:
+            assert isinstance(author, Author)
+
+        assert isinstance(doujin.thumbnail, Image)
+        assert isinstance(doujin.cover, Image)
+    
+    def test_get_with_symbolic_link(self):
+
+            doujin = self.sut.get(identifier='65498ee8-3c32-4228-b433-73a4d08f8927', with_symbolic_links=True)
+
+            assert doujin is not None
+            assert isinstance(doujin.chapters[0], Chapter)
+            assert doujin.chapters[0].link is not None
+            assert doujin.chapters[0].link != ""
+
+class TestMangadexSourcePaginationMethod:
+    sut = Mangadex()
+
+    def test_success_pagination(self):
+        res = self.sut.paginate(page=2)
+
+        assert res is not None
+        assert res.id is not None
+        assert res.page == 2
+        assert res.total_pages > 0
+        assert res.total_results > 0
+        assert len(res.results) == 25
+        
+    @patch('requests.get')
+    def test_must_return_empty_search_result(self, mock_method: MagicMock):
+        mock = Mock()
+        mock.status_code = 200
+        mock.json.return_value = mangadex_paginate_mocked
+        mock_method.return_value = mock
+        
+        res = self.sut.paginate(page=2)
+
+        assert res is not None
+        assert res.id is not None
+        assert res.page == 2
+        assert res.total_pages == 0
+        assert res.total_results == 0
+        assert len(res.results) == 0
+
+    @patch('requests.get')
+    def test_response_when_forbidden(self, mock_method: MagicMock):
+        mock = Mock()
+        mock.status_code = 403
+        mock_method.return_value = mock
+        
+        with pytest.raises(Forbidden):
+            self.sut.paginate(page=2)
+
+class TestMangadexSourceSearchMethod:
+
+    sut = Mangadex()
+
+    def test_success_searching(self):
+
+        res = self.sut.search(query='GATE', page=1)
+
+        assert res is not None
+        assert res.query == 'GATE'
+        assert res.id is not None
+        assert res.page == 1
+        assert res.total_pages > 0
+        assert len(res.results) > 0
+
+        for result in res.results:
+            assert isinstance(result, Thumb)
+            assert result.cover is not None
+            assert isinstance(result.cover, Image)
+            assert isinstance(result.cover.width, int)
+            assert isinstance(result.cover.height, int)
+            assert result.cover.width == 512
+            assert result.cover.height == 0
+            assert result.title is not None
+            assert result.id != 0
+
+    def test_success_searching_using_per_page_param(self):
+
+        res = self.sut.search(query='GATE', page=1, per_page=1)
+
+        assert res is not None
+        assert res.query == 'GATE'
+        assert res.id is not None
+        assert res.page == 1
+        assert res.total_pages > 0
+        assert len(res.results) == 1
+
+        for result in res.results:
+            assert isinstance(result, Thumb)
+            assert result.cover is not None
+            assert isinstance(result.cover, Image)
+            assert isinstance(result.cover.width, int)
+            assert isinstance(result.cover.height, int)
+            assert result.cover.width == 512
+            assert result.cover.height == 0
+            assert result.title is not None
+            assert result.id != 0
+        
+    @patch('requests.get')
+    def test_response_when_forbidden(self, mock_method: MagicMock):
+        mock = Mock()
+        mock.status_code = 403
+        mock_method.return_value = mock
+
+        with pytest.raises(Forbidden):
+            self.sut.search(query='Monster Musume no Iru Nichijou', page=1)
+    
+    @patch('requests.get')
+    def test_response_when_not_found(self, mock_method: MagicMock):
+        mock = Mock()
+        mock.status_code = 404
+        mock_method.return_value = mock
+
+        with pytest.raises(NotFound):
+            self.sut.search(query='Monster Musume no Iru Nichijou', page=1)
+    
+    @patch('requests.get')
+    def test_response_when_unknown(self, mock_method: MagicMock):
+        mock = Mock()
+        mock.status_code = 500
+        mock_method.return_value = mock
+
+        with pytest.raises(Unknown):
+            self.sut.search(query='Monster Musume no Iru Nichijou', page=1)
+
+    
\ No newline at end of file
diff --git a/tests/test_manganato_source.py b/tests/test_manganato_source.py
index 2276541..d557fa9 100644
--- a/tests/test_manganato_source.py
+++ b/tests/test_manganato_source.py
@@ -4,12 +4,18 @@
 
 import pytest
 
+os.environ['ENMA_CACHING_PAGINATE_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_SEARCH_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_GET_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS'] = '0'
+
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
 
 from enma.domain.entities.pagination import Thumb
 from enma.infra.adapters.repositories.manganato import Manganato
 from enma.domain.entities.manga import MIME, Author, Chapter, Genre, Image, SymbolicLink
 from tests.data.mocked_doujins import manganato_manga_page_empty_chapters_mocked, manganato_manga_page_empty_pagination_mocked
+from enma._version import __version__
 
 class TestManganatoSourceGetMethod:
 
@@ -24,6 +30,7 @@ def test_success_doujin_retrieve(self):
         assert res.title.english == "Monster Musume No Iru Nichijou"
         assert res.title.japanese == "モンスター娘のいる日常"
         assert res.title.other == "魔物娘的同居日常"
+        assert res.url != ''
         
         for genre in res.genres:
             assert isinstance(genre, Genre)
@@ -53,7 +60,8 @@ def test_return_none_when_not_receive_200_status_code(self, mock_method: MagicMo
 
             assert doujin is None
             mock_method.assert_called_with(url='https://chapmanganato.com/manga-kb951984', 
-                                           headers={'Referer': 'https://chapmanganato.com/'}, 
+                                           headers={'Referer': 'https://chapmanganato.com/',
+                                                    'User-Agent': f'Enma/{__version__}'}, 
                                            params={})
     
     @patch('requests.get')
@@ -98,7 +106,7 @@ def test_success_searching(self):
         res = self.sut.paginate(page=2)
 
         assert res is not None
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
         assert res.total_pages > 0
         assert res.total_results > 0
@@ -116,7 +124,7 @@ def test_must_return_empty_search_result(self, mock_method: MagicMock):
         res = self.sut.paginate(page=2)
 
         assert res is not None
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
         assert res.total_pages > 0
         assert res.total_results > 0
@@ -131,9 +139,9 @@ def test_response_when_forbidden(self, mock_method: MagicMock):
         res = self.sut.paginate(page=2)
         
         assert res is not None
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
-        assert res.total_pages == 1
+        assert res.total_pages == 0
         assert res.total_results == 0
         assert len(res.results) == 0
 
@@ -147,7 +155,7 @@ def test_success_searching(self):
 
         assert res is not None
         assert res.query == 'GATE'
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 1
         assert res.total_pages == 4
         assert len(res.results) == 20
@@ -173,7 +181,7 @@ def test_response_when_forbidden(self, mock_method: MagicMock):
         
         assert search is not None
         assert search.query == 'Monster Musume no Iru Nichijou'
-        assert search.id == 0
+        assert search.id is not None
         assert search.page == 1
-        assert search.total_pages == 1
+        assert search.total_pages == 0
         assert len(search.results) == 0
diff --git a/tests/test_nhentai_fetch_chapter_by_symbolic_link.py b/tests/test_nhentai_fetch_chapter_by_symbolic_link.py
index 31886a7..4fc1f25 100644
--- a/tests/test_nhentai_fetch_chapter_by_symbolic_link.py
+++ b/tests/test_nhentai_fetch_chapter_by_symbolic_link.py
@@ -6,8 +6,15 @@
 
 import pytest
 
+os.environ['ENMA_CACHING_PAGINATE_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_SEARCH_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_GET_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_AUTHOR_TTL_IN_SECONDS'] = '0'
+
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
 
+from enma.application.core.handlers.error import NotFound
 from enma.application.use_cases.fetch_chapter_by_symbolic_link import FetchChapterBySymbolicLinkRequestDTO, FetchChapterBySymbolicLinkUseCase
 from enma.application.core.interfaces.use_case import DTO
 from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai
@@ -22,6 +29,7 @@ class TestFetchChapterWithSymbolicLink:
     mocked_manga = Manga(title=Title(english="[Hikoushiki (CowBow)] Marine Senchou no Yopparai Archive | Marine's Drunken Archives (Houshou Marine) [English] [Watson] [Digital]",
                                      japanese='[飛行式 (矼房)] マリン船長の酔っぱっぱアーカイブ (宝鐘マリン) [英訳] [DL版]',
                                      other="Marine Senchou no Yopparai Archive | Marine's Drunken Archives"),
+                         url="mocked",
                          id=489504,
                          created_at=datetime.datetime(2024, 1, 7, 0, 3, 25, tzinfo=datetime.timezone.utc),
                          updated_at=datetime.datetime(2024, 1, 7, 0, 3, 25, tzinfo=datetime.timezone.utc),
@@ -51,7 +59,7 @@ def test_fetch_chapter_by_symbolic_link(self):
             assert response.chapter.id == 0
             assert len(response.chapter.pages) == 14
 
-    def test_should_return_empty_chapter_for_broken_link(self):
+    def test_should_raise_exception_for_broken_link(self):
         with patch('requests.get') as mock_method:
             mock = Mock()
             mock.status_code = 404
@@ -59,14 +67,10 @@ def test_should_return_empty_chapter_for_broken_link(self):
             mock_method.return_value = mock
 
             link = SymbolicLink(link='https://nhentai.net')
-            response = self.sut.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=link)))
             
-            assert isinstance(response.chapter, Chapter)
-            assert response.chapter.link is None
-            assert response.chapter.pages_count == 0
-            assert response.chapter.id == 0
-            assert len(response.chapter.pages) == 0
-
+            with pytest.raises(NotFound):
+                self.sut.execute(dto=DTO(data=FetchChapterBySymbolicLinkRequestDTO(link=link)))
+        
     def test_should_return_empty_chapter_for_broken_response(self):
         with patch('requests.get') as mock_method:
             mock = Mock()
diff --git a/tests/test_nhentai_get_manga_use_case.py b/tests/test_nhentai_get_manga_use_case.py
index 295f716..f43b4e3 100644
--- a/tests/test_nhentai_get_manga_use_case.py
+++ b/tests/test_nhentai_get_manga_use_case.py
@@ -7,9 +7,15 @@
 import pytest
 from pydantic import ValidationError
 
+os.environ['ENMA_CACHING_PAGINATE_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_SEARCH_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_GET_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_AUTHOR_TTL_IN_SECONDS'] = '0'
+
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
 
-from enma.application.core.handlers.error import InvalidRequest
+from enma.application.core.handlers.error import Forbidden, NotFound
 from enma.infra.core.interfaces.nhentai_response import NHentaiResponse
 from enma.application.use_cases.get_manga import GetMangaRequestDTO, GetMangaUseCase
 from enma.application.core.interfaces.use_case import DTO
@@ -25,6 +31,7 @@ class TestNHentaiGetDoujin:
     mocked_manga = Manga(title=Title(english="[Hikoushiki (CowBow)] Marine Senchou no Yopparai Archive | Marine's Drunken Archives (Houshou Marine) [English] [Watson] [Digital]",
                                      japanese='[飛行式 (矼房)] マリン船長の酔っぱっぱアーカイブ (宝鐘マリン) [英訳] [DL版]',
                                      other="Marine Senchou no Yopparai Archive | Marine's Drunken Archives"),
+                         url='mocked',
                          id=489504,
                          created_at=datetime.datetime(2024, 1, 7, 0, 3, 25, tzinfo=datetime.timezone.utc),
                          updated_at=datetime.datetime(2024, 1, 7, 0, 3, 25, tzinfo=datetime.timezone.utc),
@@ -69,8 +76,8 @@ def test_must_return_other_titles_as_none_if_doesnt_exists(self):
             with open('./tests/data/get.json', 'r') as get:
                 data: NHentaiResponse = json.loads(get.read())
                 
-                data['title']['japanese'] = None
-                data['title']['pretty'] = None
+                data['title']['japanese'] = None # type: ignore
+                data['title']['pretty'] = None # type: ignore
 
                 mock.json.return_value = data
             
@@ -86,27 +93,23 @@ def test_must_return_other_titles_as_none_if_doesnt_exists(self):
             assert res.manga.title.other == None
         
     def test_response_when_it_could_not_get_doujin(self):
-        with patch('enma.infra.adapters.repositories.nhentai.NHentai.get') as mock_method:
-            mock_method.return_value = None
-
+        with patch('enma.infra.adapters.repositories.nhentai.NHentai.get', side_effect=NotFound("Could not find the manga")) as mock_method:
             doujin = self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='1')))
-            
             assert doujin.found == False
             assert doujin.manga is None
     
-    def test_return_none_when_not_receive_200_status_code(self):
+    def test_raise_forbidden_in_case_of_403(self):
         with patch('requests.get') as mock_method:
             mock = Mock()
             mock.status_code = 403
             mock_method.return_value = mock
 
-            doujin = self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='1')))
-            assert doujin.found == False
-            assert doujin.manga is None
-            mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1',
-                                           headers={'User-Agent': 'mocked'},
-                                           params={},
-                                           cookies={'cf_clearance': 'mocked'})
+            with pytest.raises(Forbidden):
+                self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='1')))
+                mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1',
+                                            headers={'User-Agent': 'mocked'},
+                                            params={},
+                                            cookies={'cf_clearance': 'mocked'})
 
     def test_return_empty_chapters(self):
         with patch('requests.get') as mock_method:
@@ -207,8 +210,12 @@ def test_images_mime_types_must_be_correct(self):
             cover_mime = data['images']['cover']['t']
             thumb_mime = data['images']['thumbnail']['t']
 
-            assert cover_mime.upper() == doujin.manga.cover.mime.name
-            assert thumb_mime.upper() == doujin.manga.thumbnail.mime.name
+            assert doujin.manga.thumbnail is not None
+            assert doujin.manga.cover is not None
+            assert cover_mime.upper() == 'P'
+            assert doujin.manga.cover.mime.value == 'png'
+            assert thumb_mime.upper() == 'J'
+            assert doujin.manga.thumbnail.mime.value == 'jpg'
 
     @patch('enma.application.use_cases.get_manga.GetMangaUseCase.execute')
     def test_symbolic_links_must_be_disabled_by_default(self, use_case_mock: MagicMock):
@@ -217,4 +224,4 @@ def test_symbolic_links_must_be_disabled_by_default(self, use_case_mock: MagicMo
 
     def test_must_raise_an_exception_case_user_has_provided_wrong_data_type(self):
         with pytest.raises(ValidationError) as _:
-            self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='420719', with_symbolic_links='nao')))
\ No newline at end of file
+            self.sut.execute(dto=DTO(data=GetMangaRequestDTO(identifier='420719', with_symbolic_links='nao'))) # type: ignore
\ No newline at end of file
diff --git a/tests/test_nhentai_source.py b/tests/test_nhentai_source.py
index 6b4688b..9bd0262 100644
--- a/tests/test_nhentai_source.py
+++ b/tests/test_nhentai_source.py
@@ -5,11 +5,16 @@
 
 import pytest
 
+os.environ['ENMA_CACHING_PAGINATE_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_SEARCH_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_GET_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_FETCH_SYMBOLIC_LINK_TTL_IN_SECONDS'] = '0'
+os.environ['ENMA_CACHING_AUTHOR_TTL_IN_SECONDS'] = '0'
 
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
 
 from enma.infra.core.interfaces.nhentai_response import NHentaiImage
-from enma.application.core.handlers.error import InvalidConfig, InvalidRequest, NhentaiSourceWithoutConfig
+from enma.application.core.handlers.error import Forbidden, InvalidConfig, InvalidRequest, NhentaiSourceWithoutConfig, NotFound
 from enma.domain.entities.pagination import Thumb
 from tests.data.mocked_doujins import (nhentai_doujin_mocked, 
                                        nhentai_search_mocked, 
@@ -18,17 +23,11 @@
                                        nhentai_author_mocked,
                                        nhentai_author_not_found_mocked)
 from enma.infra.adapters.repositories.nhentai import CloudFlareConfig, NHentai, Sort
-from enma.domain.entities.manga import MIME, Author, Chapter, Genre, Image, Manga, SymbolicLink
+from enma.domain.entities.manga import MIME, Author, Chapter, Genre, Image, SymbolicLink
 from enma.application.core.utils.logger import logger
 
 class TestNHentaiUtils:
     sut = NHentai(config=CloudFlareConfig(user_agent='mock', cf_clearance='mock'))
-
-    @patch.object(logger, 'error')
-    def test_request_error_handler(self, logger_mock: MagicMock):
-        res = self.sut._NHentai__handle_request_error('teste') # type: ignore
-        assert res is None
-        logger_mock.assert_called_with('teste')
     
     def test_raise_error_if_passing_wrong_config(self):
         with pytest.raises(InvalidConfig) as err:
@@ -91,7 +90,7 @@ def test_chapter_creator(self):
         assert isinstance(chapter.pages[0], Image)
         assert chapter.pages[0].width == 123
         assert chapter.pages[0].height == 123
-        assert chapter.pages[0].mime.name == 'J'
+        assert chapter.pages[0].mime.value == 'jpg'
         assert chapter.link is None
 
     def test_chapter_creator_with_symbolic_links(self):
@@ -161,25 +160,25 @@ def test_must_return_other_titles_as_none_if_doesnt_exists(self, mock_method: Ma
 
     @patch('requests.get')
     def test_response_when_it_could_not_get_doujin(self, mock_method: MagicMock):
-        mock_method.status_code = 404
+        mock = Mock()
+        mock.status_code = 404
+        mock_method.return_value = mock
 
-        doujin = self.sut.get(identifier='1')
-        
-        assert doujin is None
+        with pytest.raises(NotFound):
+            self.sut.get(identifier='1')
     
     @patch('requests.get')
-    def test_return_none_when_not_receive_200_status_code(self, mock_method: MagicMock):
+    def test_raise_forbidden_when_receive_403_status_code(self, mock_method: MagicMock):
             mock = Mock()
             mock.status_code = 403
             mock_method.return_value = mock
 
-            doujin = self.sut.get(identifier='1')
-
-            assert doujin is None
-            mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1',
-                                           headers={'User-Agent': 'mock'},
-                                           params={},
-                                           cookies={'cf_clearance': 'mock'})
+            with pytest.raises(Forbidden):
+                self.sut.get(identifier='1')
+                mock_method.assert_called_with(url=f'https://nhentai.net/api//gallery/1',
+                                            headers={'User-Agent': 'mock'},
+                                            params={},
+                                            cookies={'cf_clearance': 'mock'})
     
     @patch('requests.get')
     def test_return_empty_chapters(self, mock_method: MagicMock):
@@ -266,8 +265,12 @@ def test_images_mime_types_must_be_correct(self, mock_method: MagicMock):
         cover_mime = nhentai_doujin_mocked['images']['cover']['t']
         thumb_mime = nhentai_doujin_mocked['images']['thumbnail']['t']
 
-        assert cover_mime.upper() == doujin.cover.mime.name
-        assert thumb_mime.upper() == doujin.thumbnail.mime.name
+        assert doujin.thumbnail is not None
+        assert doujin.cover is not None
+        assert cover_mime.upper() == 'P'
+        assert doujin.cover.mime.value == 'png'
+        assert thumb_mime.upper() == 'J'
+        assert doujin.thumbnail.mime.value == 'jpg'
 
 class TestNHentaiSourcePaginationMethod:
     sut = NHentai(config=CloudFlareConfig(user_agent='mock', cf_clearance='mock'))
@@ -283,7 +286,7 @@ def test_success_searching(self, mock_method: MagicMock):
         res = self.sut.paginate(page=2)
 
         assert res is not None
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
         assert res.total_pages == nhentai_paginate_mocked['num_pages']
         assert res.total_results == 25 * 19163
@@ -303,21 +306,15 @@ def test_must_return_empty_search_result(self, mock_method: MagicMock):
         res = self.sut.paginate(page=2)
 
         assert res is not None
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
         assert res.total_pages == nhentai_paginate_mocked['num_pages']
         assert res.total_results == 25 * 19163
         assert len(res.results) == 0
 
     def test_response_when_forbidden(self):
-        res = self.sut.paginate(page=2)
-        
-        assert res is not None
-        assert res.id == 0
-        assert res.page == 2
-        assert res.total_pages == 1
-        assert res.total_results == 0
-        assert len(res.results) == 0
+        with pytest.raises(Forbidden):
+            self.sut.paginate(page=2)
 
 class TestNHentaiSourceSearchMethod:
 
@@ -335,7 +332,7 @@ def test_success_searching(self, mock_method: MagicMock):
 
         assert res is not None
         assert res.query == 'GATE'
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
         assert res.total_pages == 3
         assert len(res.results) == 25
@@ -369,21 +366,15 @@ def test_must_return_empty_search_result(self, mock_method: MagicMock):
 
         assert res is not None
         assert res.query == 'GATE'
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
-        assert res.total_pages == 1
+        assert res.total_pages == 0
         assert len(res.results) == 0
 
     def test_response_when_forbidden(self):
-        search = self.sut.search(query='Monster Musume no Iru Nichijou', page=4)
-        
-        assert search is not None
-        assert search is not None
-        assert search.query == 'Monster Musume no Iru Nichijou'
-        assert search.id == 0
-        assert search.page == 4
-        assert search.total_pages == 1
-        assert len(search.results) == 0
+        with pytest.raises(Forbidden):
+            self.sut.search(query='Monster Musume no Iru Nichijou', page=4)
+            
 
 class TestNHentaiSourceAuthorPageMethod:
 
@@ -401,7 +392,7 @@ def test_success_author_page_fetching(self, mock_method: MagicMock):
 
         assert res is not None
         assert res.author == 'akaneman'
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 2
         assert res.total_pages == 2
         assert len(res.results) == 25
@@ -433,18 +424,11 @@ def test_must_return_empty_author_page_result(self, mock_method: MagicMock):
 
         assert res is not None
         assert res.author == 'asdsadadasd'
-        assert res.id == 0
+        assert res.id is not None
         assert res.page == 1
-        assert res.total_pages == 1
+        assert res.total_pages == 0
         assert len(res.results) == 0
 
     def test_response_when_forbidden(self):
-        search = self.sut.author_page(author='akaneman', page=1)
-        
-        assert search is not None
-        assert search is not None
-        assert search.author == 'akaneman'
-        assert search.id == 0
-        assert search.page == 1
-        assert search.total_pages == 1
-        assert len(search.results) == 0
+        with pytest.raises(Forbidden):
+            self.sut.author_page(author='akaneman', page=1)
diff --git a/tests/test_search_result_entity.py b/tests/test_search_result_entity.py
new file mode 100644
index 0000000..b8bdbcf
--- /dev/null
+++ b/tests/test_search_result_entity.py
@@ -0,0 +1,55 @@
+import sys
+import os
+
+import pytest
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
+
+from enma.domain.entities.manga import Image
+from enma.domain.entities.pagination import Thumb
+from enma.domain.entities.search_result import SearchResult
+
+class TestSearchResultEntity:
+
+    def test_default_values(self):
+        sut = SearchResult(query='teste',
+                           page=1,
+                           results=[])
+        assert sut.results == []
+        assert sut.total_pages == 0
+        assert sut.total_pages == 0
+
+    def test_set_results_at_instantiation_shouldnt_effect_counters(self):
+
+        thumbs = [Thumb(id='1', url='mocked', title='teste', cover=Image(uri='teste')),
+                  Thumb(id='2', url='mocked', title='teste', cover=Image(uri='teste')),
+                  Thumb(id='3', url='mocked', title='teste', cover=Image(uri='teste'))]
+
+        sut = SearchResult(query='teste',
+                           page=1,
+                           results=thumbs)
+        
+        assert sut.results == thumbs
+        assert sut.page == 1
+        assert sut.total_results == 0
+        assert sut.total_pages == 0
+        assert sut.results[0].url == 'mocked'
+
+    def test_set_results_at_instantiation_shouldnt_effect_counters_after(self):
+
+        thumbs = [Thumb(id='1', url='mocked', title='teste', cover=Image(uri='teste')),
+                  Thumb(id='2', url='mocked', title='teste', cover=Image(uri='teste')),
+                  Thumb(id='3', url='mocked', title='teste', cover=Image(uri='teste'))]
+
+        sut = SearchResult(query='teste',
+                           page=1,
+                           total_pages=100,
+                           total_results=300,
+                           results=thumbs)
+        
+        assert sut.results == thumbs
+        assert sut.page == 1
+        assert sut.total_results == 300
+        assert sut.total_pages == 100
+
+    
\ No newline at end of file
diff --git a/tests/test_source_manager.py b/tests/test_source_manager.py
new file mode 100644
index 0000000..d831a6d
--- /dev/null
+++ b/tests/test_source_manager.py
@@ -0,0 +1,56 @@
+import sys
+import os
+
+import pytest
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
+
+from enma.application.core.interfaces.manga_repository import IMangaRepository
+from enma.infra.adapters.repositories.mangadex import Mangadex
+from enma.infra.entrypoints.lib import SourceManager, Sources
+from enma.application.core.handlers.error import InstanceError, SourceNotAvailable
+
+class TestSourceManager:
+
+    sut = SourceManager()
+
+    @pytest.fixture(autouse=True)
+    def run_around_tests(self):
+        yield
+        self.sut.clear_sources()
+
+    def test_add_source_successfully(self):
+        self.sut.add_source(source_name=Sources.MANGADEX, source=Mangadex())
+        assert self.sut.get_source(Sources.MANGADEX) is not None
+
+    def test_get_source_should_raise_exception_if_source_doesnt_exists(self):
+        with pytest.raises(SourceNotAvailable):
+            self.sut.get_source(Sources.NHENTAI)
+
+    def test_get_source_successfully_with_string_parameter(self):
+        self.sut.add_source(source_name=Sources.MANGADEX, source=Mangadex())
+        assert isinstance(self.sut.get_source('mangadex'), IMangaRepository)
+
+    def test_clear_sources_successfully(self):
+        self.sut.add_source(source_name=Sources.MANGADEX, source=Mangadex())
+        self.sut.clear_sources()
+        with pytest.raises(SourceNotAvailable):
+            self.sut.get_source(Sources.MANGADEX)
+
+    def test_remove_source_successfully(self):
+        self.sut.add_source(source_name=Sources.MANGADEX, source=Mangadex())
+        assert self.sut.remove_source(Sources.MANGADEX) is True
+        with pytest.raises(SourceNotAvailable):
+            self.sut.get_source(Sources.MANGADEX)
+
+    def test_remove_source_should_return_false_if_source_doesnt_exists(self):
+        assert self.sut.remove_source(Sources.MANGADEX) is False
+
+    def test_set_source_successfully(self):
+        self.sut.add_source(source_name=Sources.MANGADEX, source=Mangadex())
+        self.sut.set_source(Sources.MANGADEX)
+        assert self.sut.source is not None
+
+    def test_should_not_be_able_to_add_an_source_that_is_not_imanga_repository(self):
+        with pytest.raises(InstanceError):
+            self.sut.add_source(source_name=Sources.MANGADEX, source=object()) # type: ignore