diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 7a7ceb0..c484709 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: write jobs: build: @@ -28,7 +29,11 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with pylint run: | - pylint $(git ls-files '*.py') --disable=C0301,R0801,R0903,R0913,R0917,W0622,W0707 -# - name: Test with pytest -# run: | -# pytest + pylint $(git ls-files '*.py') --disable=C0301,R0801,R0903,R0913,R0917,W0622,W0707 --ignore-patterns=test_.* + - name: Test with pytest + run: | + pytest tests/ \ + --cov=. \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml \ + --cov-fail-under=80 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96761ed..bc41d6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,4 +3,4 @@ repos: rev: v4.0.4 hooks: - id: pylint - args: ["--disable=C0301,E0401,R0801,R0903,R0913,R0917,W0622,W0707"] \ No newline at end of file + args: ["--disable=C0301,E0401,R0801,R0903,R0913,R0917,W0622,W0707", "--ignore-patterns=test_.*"] \ No newline at end of file diff --git a/Makefile b/Makefile index a6c5ef0..bfc55be 100644 --- a/Makefile +++ b/Makefile @@ -27,3 +27,6 @@ init-pre-commit: ## Init pre-commit pre-commit clean pre-commit install pre-commit run --all-files + +tests: ## Run tests in the Docker container + docker compose -f docker-compose-dev.yml run --rm test diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 5eeb622..e584f16 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -4,3 +4,27 @@ services: container_name: rage-fastapi ports: - "8000:80" + + coverage-report: + image: nginx:alpine + ports: + - "8080:80" # ← localhost:8080 pour voir les rapports + volumes: + - ./htmlcov:/usr/share/nginx/html:ro # monte htmlcov statique + depends_on: + - test + + test: + build: . + volumes: + - .:/app + environment: + - PYTHONPATH=/app + entrypoint: | + bash -c " + pip install coverage pytest pytest-cov && + coverage run --source=. -m pytest tests/ -v && + coverage report && + coverage html && + echo 'HTML Report generated at htmlcov/index.html' + " diff --git a/models.py b/models.py index 3934713..d298d8c 100644 --- a/models.py +++ b/models.py @@ -14,11 +14,11 @@ class IdAndImageLink: """ id: int = Field( description="Unique identifier for the model", - example=1, + json_schema_extra={"example": 1}, ) image_link: str = Field( description="Link to an image of the model", - example="https://example.com/model_image.png" + json_schema_extra={"example": "https://example.com/model_image.png"}, ) @@ -51,15 +51,14 @@ class PedModel: """ name: str = Field( description="Name of the ped model", - example="player_zero" ) hash: str = Field( description="Hexadecimal hash of the ped model", - example="0x92A27487" + json_schema_extra={"example": "0x92A27487"}, ) image_link: str = Field( description="Link to an image of the ped model", - example="https://example.com/ped_image.png" + json_schema_extra={"example": "https://example.com/ped_image.png"}, ) @@ -70,13 +69,13 @@ class Weapon: """ name: str = Field( description="Name of the weapon", - example="WEAPON_DAGGER", + json_schema_extra={"example": "WEAPON_DAGGER"}, ) hash: str = Field( description="Hexadecimal hash of the weapon", - example="0x92A27487", + json_schema_extra={"example": "0x92A27487"}, ) type: str = Field( description="Type of weapon (melee, handguns, smg, shotguns, assault rifles, machine guns, sniper rifles, heavy weapons, throwables, misc)", - example="melee", + json_schema_extra={"example": "melee"}, ) diff --git a/requirements.txt b/requirements.txt index 7ff6f56..7524914 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ cffi==2.0.0 charset-normalizer==3.4.4 colorama==0.4.6 comm==0.2.3 +coverage==7.6.1 debugpy==1.8.17 decorator==5.2.1 defusedxml==0.7.1 @@ -75,6 +76,8 @@ pydantic==2.12.5 pydantic-extra-types==2.11.0 pydantic_core==2.41.5 Pygments==2.19.2 +pytest==8.3.3 +pytest-cov==5.0.0 python-dateutil==2.9.0.post0 python-dotenv==1.2.1 python-json-logger==4.0.0 diff --git a/services.py b/services.py index f0283a2..c32e29b 100644 --- a/services.py +++ b/services.py @@ -23,6 +23,7 @@ def get_model(model_name: str, filters) -> Union[List[BlipModel], List[BlipColor raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No model founded") return data + def get_model_with_id(id: int = None): """ Get a model with identifier. @@ -74,7 +75,7 @@ def get_blip_models(filters = Depends(get_model_with_id)) -> List[BlipModel]: """ Get filtered or not blip models. """ - return get_model("blip_colors", filters) + return get_model("blip_models", filters) def get_markers(filters = Depends(get_model_with_id)) -> List[Marker]: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..867b2f7 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,39 @@ +""" +This file contains unit tests for the application models. +""" + + +from models import IdAndImageLink, BlipColor, PedModel + + +def test_id_and_image_link_creation(): + """ + Test the IdAndImageLink creation. + """ + obj = IdAndImageLink(id=42, image_link="https:/example.com/foo.png") + assert obj.id == 42 + assert obj.image_link == "https:/example.com/foo.png" + + +def test_blip_color_inherits_id_and_image_link(): + """ + Test the BlipColor creation. + """ + obj = BlipColor(id=1, image_link="https:/example.com/blip.png") + assert isinstance(obj, IdAndImageLink) + assert obj.id == 1 + assert obj.image_link == "https:/example.com/blip.png" + + +def test_ped_model_creation(): + """ + Test the PedModel creation. + """ + ped = PedModel( + name="player_zero", + hash="0x92A27487", + image_link="https://example.com/ped.png", + ) + assert ped.name == "player_zero" + assert ped.hash == "0x92A27487" + assert ped.image_link == "https://example.com/ped.png" diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..ae08981 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,166 @@ +""" +This file contains unit tests for the application services. +""" + +from fastapi import HTTPException +import json +import pytest +from unittest.mock import mock_open, patch + +from services import ( + get_model, + get_model_with_id, + get_model_with_name, + get_model_with_hash, + get_model_with_type, + get_ped_models, + get_blip_colors, + get_blip_models, + get_markers, + get_weapons, +) + + +SAMPLE_DATA = [ + {"id": 1, "name": "A", "type": "melee"}, + {"id": 2, "name": "B", "type": "rifle"}, +] + + +def _mock_file(data): + """ + Mock the open function to return the provided data as a file-like object. + """ + return mock_open(read_data=json.dumps(data)) + + +@patch("services.open", new_callable=lambda: _mock_file(SAMPLE_DATA)) +def test_get_model_no_filters(mock_file): + """ + Test get_model without filters. + """ + result = get_model("weapons", {}) + assert result == SAMPLE_DATA + + +@patch("services.open", new_callable=lambda: _mock_file(SAMPLE_DATA)) +def test_get_model_with_id_filter(mock_file): + """ + Test get_model with id filter. + """ + result = get_model("weapons", {"id": 1}) + assert result == [{"id": 1, "name": "A", "type": "melee"}] + + +@patch("services.open", new_callable=lambda: _mock_file(SAMPLE_DATA)) +def test_get_model_with_type_filter(mock_file): + """ + Test get_model with type filter. + """ + result = get_model("weapons", {"type": "rifle"}) + assert result == [{"id": 2, "name": "B", "type": "rifle"}] + + +@patch("services.open", side_effect=FileNotFoundError) +def test_get_model_file_not_found(mock_file): + """ + Test get_model when the file is not found. + """ + with pytest.raises(HTTPException) as exc: + get_model("weapons", {"id": 999}) + assert exc.value.status_code == 404 + assert exc.value.detail == "No model founded" + + +def test_get_model_with_id(): + """ + Test get_model_with_id function. + """ + assert get_model_with_id(1) == {"id": 1} + assert get_model_with_id() == {} + + +def test_get_model_with_name(): + """ + Test get_model_with_name function. + """ + assert get_model_with_name("A") == {"name": "A"} + assert get_model_with_name() == {} + + +def test_get_model_with_hash(): + """ + Test get_model_with_hash function. + """ + assert get_model_with_hash("0x123") == {"hash": "0x123"} + assert get_model_with_hash() == {} + + +def test_get_model_with_type(): + """ + Test get_model_with_type function. + """ + assert get_model_with_type("melee") == {"type": "melee"} + assert get_model_with_type() == {} + + +@patch("services.get_model") +def test_get_ped_models(mock_get_model): + name_filter = get_model_with_name("A") + hash_filter = get_model_with_hash("0x123") + mock_get_model.return_value = [{"name": "A", "hash": "0x123"}] + result = get_ped_models(name_filter=name_filter, hash_filter=hash_filter) + mock_get_model.assert_called_once_with( + "ped_models", + {"name": "A", "hash": "0x123"} + ) + assert isinstance(result, list) + + +@patch("services.get_model") +def test_get_blip_colors(mock_get_model): + filters = get_model_with_id(1) + mock_get_model.return_value = [{"id": 1}] + result = get_blip_colors(filters) + mock_get_model.assert_called_once_with("blip_colors", filters) + assert isinstance(result, list) + + +@patch("services.get_model") +def test_get_blip_models(mock_get_model): + filters = get_model_with_id(1) + mock_get_model.return_value = [{"id": 1}] + result = get_blip_models(filters) + mock_get_model.assert_called_once_with("blip_models", filters) + assert isinstance(result, list) + + +@patch("services.get_model") +def test_get_markers(mock_get_model): + filters = get_model_with_id(1) + mock_get_model.return_value = [{"id": 1}] + result = get_markers(filters) + mock_get_model.assert_called_once_with("markers", filters) + assert isinstance(result, list) + + +@patch("services.get_model") +def test_get_weapons(mock_get_model): + name_filter = get_model_with_name("A") + hash_filter = get_model_with_hash("0x123") + type_filter = get_model_with_type("melee") + mock_get_model.return_value = [{"name": "A", "type": "melee"}] + result = get_weapons( + name_filter=name_filter, + hash_filter=hash_filter, + type_filter=type_filter + ) + mock_get_model.assert_called_once_with( + "weapons", + { + **name_filter, + **hash_filter, + **type_filter + } + ) + assert isinstance(result, list)