diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7706c58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + test: + name: Test on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Check code style (Black) + run: black --check src/ tests/ + + - name: Lint (Ruff) + run: ruff check src/ tests/ + + - name: Type checking (Mypy) + run: mypy src/ + + - name: Run unit tests + run: pytest tests/unit/ diff --git a/.gitignore b/.gitignore index b7faf40..b4e1c44 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,8 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Documents de formation / présentations — ne pas versionner dans le SDK +*.pdf +*.pptx +*.docx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c21884..e4a3dc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ git --version # git version 2.x ### 1. Forker et cloner le dépôt -`````bash +```bash # 1. Forkez le projet depuis GitHub (bouton "Fork" en haut à droite) # 2. Clonez votre fork localement @@ -75,18 +75,16 @@ cd parsepy # 3. Ajoutez le dépôt original comme remote "upstream" git remote add upstream https://github.com/Kether-Labs/parsepy.git +``` -### 4. Créer un environnement virtuel +### 2. Créer un environnement virtuel ```bash # Avec venv (standard) python -m venv .venv source .venv/bin/activate # Linux / macOS .venv\Scripts\activate # Windows -```` - -````` - +``` ### 3. Installer les dépendances de développement ```bash diff --git a/README.md b/README.md index a2e32fd..d4480cc 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Parse Server dispose de SDK officiels pour JavaScript, iOS, Android et .NET — | Module | Statut | |---|---| -| `ParseClient` — configuration et HTTP | 🚧 En cours | -| `ParseObject` — CRUD | 📋 Planifié | +| `ParseClient` — configuration et HTTP | ✅ Terminé | +| `ParseObject` — CRUD | ✅ Terminé | | `ParseQuery` — requêtes | 📋 Planifié | | `ParseUser` — authentification | 📋 Planifié | | `ParseFile` — fichiers | 📋 Planifié | diff --git a/pyproject.toml b/pyproject.toml index 6ff98d8..99ec38d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,19 +28,23 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "httpx>=0.25.0", - "pydantic>=2.0", - "websockets>=12.0", ] [project.optional-dependencies] django = ["django>=4.0"] fastapi = ["fastapi>=0.100", "python-multipart>=0.0.6"] flask = ["flask>=3.0"] +# Activé automatiquement quand ParseObject utilisera la validation Pydantic +validation = ["pydantic>=2.0"] +# Activé quand le module LiveQuery (WebSocket) sera implémenté +livequery = ["websockets>=12.0"] all = [ "django>=4.0", "fastapi>=0.100", "python-multipart>=0.0.6", "flask>=3.0", + "pydantic>=2.0", + "websockets>=12.0", ] dev = [ "pytest>=7.4", diff --git a/src/parse_sdk/__init__.py b/src/parse_sdk/__init__.py index f56d9f9..91b8f55 100644 --- a/src/parse_sdk/__init__.py +++ b/src/parse_sdk/__init__.py @@ -65,8 +65,9 @@ ParseTimeoutError, ParseUsernameTakenError, ) +from .object import ParseObject -# NOTE : ParseClient, ParseObject, ParseQuery, ParseUser, ParseFile, etc. +# NOTE : ParseQuery, ParseUser, ParseFile, etc. # seront ajoutés ici au fur et à mesure de leur implémentation. # Chaque contributeur qui implémente un module doit aussi l'exporter ici. @@ -78,6 +79,7 @@ # Version "__version__", "ParseClient", + "ParseObject", "get_client", # Types spéciaux "GeoPoint", diff --git a/src/parse_sdk/_http.py b/src/parse_sdk/_http.py index c99d2cc..08ef614 100644 --- a/src/parse_sdk/_http.py +++ b/src/parse_sdk/_http.py @@ -1,16 +1,8 @@ """ -Couche HTTP interne du SDK Parse Server Python. - -Ce module est PRIVÉ. Il ne doit jamais être importé directement par les -utilisateurs du SDK. Tous les modules publics (ParseObject, ParseQuery, etc.) -passent exclusivement par ce module pour leurs requêtes HTTP. - -Responsabilités : -- Gérer le client httpx (async + sync) -- Injecter les headers Parse obligatoires -- Retry automatique avec backoff exponentiel -- Convertir les erreurs HTTP en exceptions ParseError -- Logger les requêtes/réponses pour le debug +Couche HTTP interne pour Parse Server. + +Usage interne uniquement — ne pas importer manuellement. +Tous les modules (ParseObject, ParseQuery, etc.) passent par ici pour leurs requêtes. """ from __future__ import annotations @@ -75,25 +67,16 @@ def __init__( self._max_retries = max_retries self._session_token: str | None = None - # Client async partagé — évite de rouvrir une connexion à chaque requête + # Client async partagé pour réutiliser les connexions self._async_client: httpx.AsyncClient | None = None - # ------------------------------------------------------------------ - # Gestion du session token (défini par ParseUser après login) - # ------------------------------------------------------------------ - def set_session_token(self, token: str | None) -> None: """Définit le session token à envoyer dans les requêtes suivantes.""" self._session_token = token def clear_session_token(self) -> None: - """Supprime le session token (après logout).""" self._session_token = None - # ------------------------------------------------------------------ - # Construction des headers - # ------------------------------------------------------------------ - def _build_headers( self, use_master_key: bool = False, @@ -125,10 +108,6 @@ def _build_headers( return headers - # ------------------------------------------------------------------ - # Gestion du client async - # ------------------------------------------------------------------ - async def _get_async_client(self) -> httpx.AsyncClient: """Retourne le client async partagé, en le créant si nécessaire.""" if self._async_client is None or self._async_client.is_closed: @@ -139,15 +118,10 @@ async def _get_async_client(self) -> httpx.AsyncClient: return self._async_client async def close(self) -> None: - """Ferme proprement le client HTTP async.""" if self._async_client and not self._async_client.is_closed: await self._async_client.aclose() self._async_client = None - # ------------------------------------------------------------------ - # Méthode principale : requête async avec retry - # ------------------------------------------------------------------ - async def request( self, method: str, @@ -246,10 +220,6 @@ async def request( raise last_error or ParseConnectionError(f"Échec de la requête {method} {path}") - # ------------------------------------------------------------------ - # Wrapper synchrone - # ------------------------------------------------------------------ - def request_sync( self, method: str, @@ -312,10 +282,6 @@ def request_sync( raise last_error or ParseConnectionError(f"Échec de la requête {method} {path}") - # ------------------------------------------------------------------ - # Traitement de la réponse HTTP - # ------------------------------------------------------------------ - def _handle_response(self, response: httpx.Response) -> dict[str, Any]: """Parse la réponse HTTP et lève l'exception appropriée si erreur. @@ -342,10 +308,6 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]: return body - # ------------------------------------------------------------------ - # Helpers HTTP raccourcis - # ------------------------------------------------------------------ - async def get(self, path: str, **kwargs: Any) -> dict[str, Any]: """GET asynchrone.""" return await self.request("GET", path, **kwargs) @@ -359,5 +321,20 @@ async def put(self, path: str, **kwargs: Any) -> dict[str, Any]: return await self.request("PUT", path, **kwargs) async def delete(self, path: str, **kwargs: Any) -> dict[str, Any]: - """DELETE asynchrone.""" return await self.request("DELETE", path, **kwargs) + + def get_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """GET synchrone.""" + return self.request_sync("GET", path, **kwargs) + + def post_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """POST synchrone.""" + return self.request_sync("POST", path, **kwargs) + + def put_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """PUT synchrone.""" + return self.request_sync("PUT", path, **kwargs) + + def delete_sync(self, path: str, **kwargs: Any) -> dict[str, Any]: + """DELETE synchrone.""" + return self.request_sync("DELETE", path, **kwargs) diff --git a/src/parse_sdk/_types.py b/src/parse_sdk/_types.py index 1dfe790..c92c4f5 100644 --- a/src/parse_sdk/_types.py +++ b/src/parse_sdk/_types.py @@ -318,7 +318,7 @@ def to_parse(self) -> dict[str, Any]: return {"__op": "Delete"} @classmethod - def from_parse(cls, data: dict[str, Any]) -> DeleteField: + def from_parse(cls, _data: dict[str, Any]) -> DeleteField: return cls() diff --git a/src/parse_sdk/client.py b/src/parse_sdk/client.py index 6216680..78ebf88 100644 --- a/src/parse_sdk/client.py +++ b/src/parse_sdk/client.py @@ -1,20 +1,12 @@ """ -ParseClient — Point d'entrée principal du SDK. - -Ce module fournit la classe publique ParseClient que chaque utilisateur -installe pour configurer sa connexion à Parse Server. +Point d'entrée principal pour configurer le SDK. """ from __future__ import annotations -from typing import TYPE_CHECKING - from ._http import ParseHTTPClient -if TYPE_CHECKING: - pass - -# Variable de module pour le pattern singleton/global +# Instance globale partagée (singleton) _current_client: ParseHTTPClient | None = None @@ -72,7 +64,6 @@ def __init__( self._validate_required_param(rest_key, "rest_key") self._validate_required_param(server_url, "server_url") - # Stocker la configuration (optionnel mais utile pour debugging) self._app_id = app_id self._rest_key = rest_key self._server_url = server_url @@ -80,7 +71,7 @@ def __init__( self._timeout = timeout self._max_retries = max_retries - # Créer le client HTTP interne + # Client HTTP interne self._http_client = ParseHTTPClient( app_id=app_id, rest_key=rest_key, @@ -90,7 +81,6 @@ def __init__( max_retries=max_retries, ) - # Enregistrer comme client global global _current_client _current_client = self._http_client diff --git a/src/parse_sdk/exceptions.py b/src/parse_sdk/exceptions.py index 7272e60..79b1588 100644 --- a/src/parse_sdk/exceptions.py +++ b/src/parse_sdk/exceptions.py @@ -354,4 +354,11 @@ def raise_parse_error(code: int, message: str) -> None: exc_class = PARSE_ERROR_MAP.get(code, ParseError) if exc_class is ParseError: raise ParseError(code=code, message=message) - raise exc_class(message=message) # type: ignore[call-arg] + + # Les classes spécialisées ont des constructeurs qui ne correspondent pas + # tous à la signature (code, message). On crée l'instance via __new__ et + # on l'initialise avec ParseError.__init__ pour conserver le bon type + # tout en passant les bons arguments — isinstance() fonctionne toujours. + exc = exc_class.__new__(exc_class) + ParseError.__init__(exc, code=code, message=message) + raise exc diff --git a/src/parse_sdk/object.py b/src/parse_sdk/object.py new file mode 100644 index 0000000..c8eaf68 --- /dev/null +++ b/src/parse_sdk/object.py @@ -0,0 +1,318 @@ +""" +Gestion des objets Parse (ParseObject). +Lecture, modification et sauvegarde des données. +""" + +from __future__ import annotations + +from typing import Any + +from ._types import decode_parse_value, encode_parse_value +from .client import get_client + + +class ParseObject: + """Représente un objet stocké sur Parse Server. + + Args: + class_name: Le nom de la classe Parse (ex: "GameScore"). + object_id: L'identifiant unique de l'objet (si déjà existant). + """ + + def __init__(self, class_name: str, object_id: str | None = None) -> None: + self.class_name = class_name + self.object_id = object_id + + self._data: dict[str, Any] = {} + self._dirty_keys: set[str] = set() + + def get(self, key: str, default: Any = None) -> Any: + """Récupère la valeur d'un champ. + + Args: + key: Le nom du champ. + default: Valeur par défaut si le champ n'existe pas. + + Returns: + La valeur du champ (décodée si type spécial Parse). + """ + value = self._data.get(key, default) + return decode_parse_value(value) + + def set(self, key: str, value: Any) -> ParseObject: + """Définit la valeur d'un champ. + + Args: + key: Le nom du champ. + value: La valeur à enregistrer. + + Returns: + L'instance actuelle (pour le chaînage). + """ + self._data[key] = value + self._dirty_keys.add(key) + + return self + + async def save( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Sauvegarde l'objet sur Parse Server. + + Cette méthode envoie uniquement les champs modifiés (dirty). + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + ParseError: Si le serveur retourne une erreur. + """ + if not self._dirty_keys: + return + + payload = {key: encode_parse_value(self._data[key]) for key in self._dirty_keys} + client = get_client() + + if self.object_id: + path = f"/classes/{self.class_name}/{self.object_id}" + response = await client.put( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + else: + path = f"/classes/{self.class_name}" + response = await client.post( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + + if "objectId" in response: + self.object_id = response["objectId"] + + self._data.update(response) + self._dirty_keys.clear() + + def save_sync( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Version synchrone de `save()`. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + ParseError: Si le serveur retourne une erreur. + """ + if not self._dirty_keys: + return + + payload = {key: encode_parse_value(self._data[key]) for key in self._dirty_keys} + client = get_client() + + if self.object_id: + path = f"/classes/{self.class_name}/{self.object_id}" + response = client.put_sync( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + else: + path = f"/classes/{self.class_name}" + response = client.post_sync( + path, + json=payload, + use_master_key=use_master_key, + session_token=session_token, + ) + + if "objectId" in response: + self.object_id = response["objectId"] + + self._data.update(response) + self._dirty_keys.clear() + + async def fetch( + self, use_master_key: bool = False, session_token: str | None = None + ) -> ParseObject: + """Récupère les dernières données de l'objet depuis le serveur. + + Met à jour l'instance actuelle et efface les modifications locales non sauvegardées. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Returns: + L'instance actuelle de ParseObject. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + ParseError: Si le serveur retourne une erreur. + """ + if not self.object_id: + raise RuntimeError("Impossible de fetch un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + response = await client.get( + path, use_master_key=use_master_key, session_token=session_token + ) + + self._data = response + self._dirty_keys.clear() + return self + + def fetch_sync( + self, use_master_key: bool = False, session_token: str | None = None + ) -> ParseObject: + """Version synchrone de `fetch()`. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Returns: + L'instance actuelle de ParseObject. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + """ + if not self.object_id: + raise RuntimeError("Impossible de fetch un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + response = client.get_sync( + path, use_master_key=use_master_key, session_token=session_token + ) + + self._data = response + self._dirty_keys.clear() + return self + + async def delete( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Supprime l'objet sur Parse Server. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + ParseError: Si le serveur retourne une erreur. + """ + if not self.object_id: + raise RuntimeError("Impossible de supprimer un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + await client.delete( + path, use_master_key=use_master_key, session_token=session_token + ) + + self.object_id = None + self._data.clear() + self._dirty_keys.clear() + + def delete_sync( + self, use_master_key: bool = False, session_token: str | None = None + ) -> None: + """Version synchrone de `delete()`. + + Args: + use_master_key: Utilise le Master Key si True. + session_token: Session token à utiliser pour cette requête. + + Raises: + RuntimeError: Si l'objet n'a pas d'objectId. + """ + if not self.object_id: + raise RuntimeError("Impossible de supprimer un objet sans objectId") + + client = get_client() + path = f"/classes/{self.class_name}/{self.object_id}" + client.delete_sync( + path, use_master_key=use_master_key, session_token=session_token + ) + + self.object_id = None + self._data.clear() + self._dirty_keys.clear() + + def increment(self, key: str, amount: int = 1) -> ParseObject: + """Incrémente un champ numérique. + + Args: + key: Le nom du champ. + amount: La valeur à ajouter (défaut: 1). + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import Increment + + return self.set(key, Increment(amount)) + + def add_to_array(self, key: str, values: list[Any]) -> ParseObject: + """Ajoute des éléments à un champ tableau. + + Args: + key: Le nom du champ. + values: Liste des valeurs à ajouter. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import AddToArray + + return self.set(key, AddToArray(values)) + + def add_unique(self, key: str, values: list[Any]) -> ParseObject: + """Ajoute des éléments à un tableau seulement s'ils sont absents. + + Args: + key: Le nom du champ. + values: Liste des valeurs à ajouter. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import AddUniqueToArray + + return self.set(key, AddUniqueToArray(values)) + + def remove_from_array(self, key: str, values: list[Any]) -> ParseObject: + """Supprime des éléments d'un champ tableau. + + Args: + key: Le nom du champ. + values: Liste des éléments à supprimer. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import RemoveFromArray + + return self.set(key, RemoveFromArray(values)) + + def unset(self, key: str) -> ParseObject: + """Supprime un champ de l'objet. + + Args: + key: Le nom du champ à supprimer. + + Returns: + L'instance actuelle (pour le chaînage). + """ + from ._types import DeleteField + + return self.set(key, DeleteField()) diff --git a/tests/unit/test_http_extra.py b/tests/unit/test_http_extra.py new file mode 100644 index 0000000..d804416 --- /dev/null +++ b/tests/unit/test_http_extra.py @@ -0,0 +1,102 @@ +# ruff: noqa: SIM117 +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from parse_sdk._http import ParseHTTPClient +from parse_sdk.exceptions import ParseConnectionError, ParseTimeoutError + + +@pytest.mark.asyncio +async def test_http_use_master_key(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + master_key="secret", + ) + + with patch("httpx.AsyncClient.request") as mock_request: + mock_request.return_value = MagicMock( + status_code=200, json=lambda: {}, is_error=False + ) + + await client.get("/classes/GameScore", use_master_key=True) + + args, kwargs = mock_request.call_args + assert kwargs["headers"]["X-Parse-Master-Key"] == "secret" + + +@pytest.mark.asyncio +async def test_http_retry_logic(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=2, + ) + + with patch("httpx.AsyncClient.request") as mock_request: + # Premier appel : 503, Deuxième appel : 200 + mock_request.side_effect = [ + MagicMock(status_code=503, is_error=True, text="Service Unavailable"), + MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False), + ] + + with patch("asyncio.sleep", return_value=None): # Skip sleep + response = await client.get("/classes/GameScore") + assert response == {"ok": True} + assert mock_request.call_count == 2 + + +@pytest.mark.asyncio +async def test_http_timeout_exception(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=1, + ) + + with patch( + "httpx.AsyncClient.request", side_effect=httpx.TimeoutException("Timeout") + ): # noqa: SIM117 + with pytest.raises(ParseTimeoutError): + await client.get("/classes/GameScore") + + +@pytest.mark.asyncio +async def test_http_network_error(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=1, + ) + + with patch( + "httpx.AsyncClient.request", side_effect=httpx.NetworkError("Network") + ): # noqa: SIM117 + with pytest.raises(ParseConnectionError): + await client.get("/classes/GameScore") + + +def test_http_sync_retry_and_errors(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + max_retries=2, + ) + + with patch("httpx.Client.request") as mock_request: # noqa: SIM117 + with patch("time.sleep", return_value=None): + # Simulation d'un timeout puis succès + mock_request.side_effect = [ + httpx.TimeoutException("Timeout"), + MagicMock(status_code=200, json=lambda: {"ok": True}, is_error=False), + ] + response = client.get_sync("/classes/GameScore") + assert response == {"ok": True} + assert mock_request.call_count == 2 diff --git a/tests/unit/test_http_sync.py b/tests/unit/test_http_sync.py new file mode 100644 index 0000000..ca68840 --- /dev/null +++ b/tests/unit/test_http_sync.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch + +from parse_sdk._http import ParseHTTPClient + + +def test_http_get_sync(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + ) + + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.request.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + is_error=False, + ) + mock_client_class.return_value.__enter__.return_value = mock_client + + response = client.get_sync("/classes/GameScore") + + assert response == {"results": []} + mock_client.request.assert_called_once() + args, kwargs = mock_client.request.call_args + assert kwargs["method"] == "GET" + assert kwargs["url"] == "https://api.parse.com/parse/classes/GameScore" + + +def test_http_post_sync(): + client = ParseHTTPClient( + app_id="app", + rest_key="rest", + server_url="https://api.parse.com/parse", + ) + + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.request.return_value = MagicMock( + status_code=201, + json=lambda: {"objectId": "123"}, + is_error=False, + ) + mock_client_class.return_value.__enter__.return_value = mock_client + + response = client.post_sync("/classes/GameScore", json={"score": 100}) + + assert response == {"objectId": "123"} + mock_client.request.assert_called_once() + args, kwargs = mock_client.request.call_args + assert kwargs["method"] == "POST" + assert kwargs["json"] == {"score": 100} diff --git a/tests/unit/test_object.py b/tests/unit/test_object.py new file mode 100644 index 0000000..3f48f3a --- /dev/null +++ b/tests/unit/test_object.py @@ -0,0 +1,193 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from parse_sdk import ParseObject +from parse_sdk._types import ( + AddToArray, + AddUniqueToArray, + DeleteField, + GeoPoint, + Increment, + Pointer, + RemoveFromArray, +) + + +def test_object_initialization(): + obj = ParseObject("GameScore") + assert obj.class_name == "GameScore" + assert obj.object_id is None + + obj_with_id = ParseObject("GameScore", "abc123") + assert obj_with_id.object_id == "abc123" + + +def test_object_set_get(): + obj = ParseObject("GameScore") + obj.set("playerName", "Alice") + assert obj.get("playerName") == "Alice" + assert obj.get("nonExistent", "default") == "default" + + +def test_object_increment(): + obj = ParseObject("GameScore") + result = obj.increment("score", 5) + + # Vérifie le chaînage + assert result == obj + # Vérifie que la valeur stockée est un objet Increment + val = obj.get("score") + assert isinstance(val, Increment) + assert val.amount == 5 + # Vérifie que le champ est marqué comme modifié + assert "score" in obj._dirty_keys + + +def test_object_add_to_array(): + obj = ParseObject("GameScore") + obj.add_to_array("tags", ["python", "sdk"]) + + val = obj.get("tags") + assert isinstance(val, AddToArray) + assert val.objects == ["python", "sdk"] + assert "tags" in obj._dirty_keys + + +def test_object_add_unique(): + obj = ParseObject("GameScore") + obj.add_unique("skills", ["async"]) + + val = obj.get("skills") + assert isinstance(val, AddUniqueToArray) + assert val.objects == ["async"] + + +def test_object_remove_from_array(): + obj = ParseObject("GameScore") + obj.remove_from_array("tags", ["old"]) + + val = obj.get("tags") + assert isinstance(val, RemoveFromArray) + assert val.objects == ["old"] + + +def test_object_unset(): + obj = ParseObject("GameScore") + obj.unset("temporaryField") + + val = obj.get("temporaryField") + assert isinstance(val, DeleteField) + + +def test_object_chaining(): + obj = ParseObject("GameScore") + result = obj.increment("score").add_to_array("tags", ["test"]).unset("old") + assert result == obj + + +@pytest.mark.asyncio +async def test_object_save_dirty_tracking(): + # On mocke get_client pour ne pas faire de vraie requête réseau + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + # On définit une fonction asynchrone pour simuler l'appel réseau + async def mock_post(*_args, **_kwargs): + return {"objectId": "newId", "createdAt": "..."} + + mock_http.post.side_effect = mock_post + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore") + obj.set("score", 100) + assert len(obj._dirty_keys) == 1 + + await obj.save() + + # Après sauvegarde, les dirty_keys doivent être vides + assert len(obj._dirty_keys) == 0 + assert obj.object_id == "newId" + assert obj.get("score") == 100 + + +@pytest.mark.asyncio +async def test_object_fetch(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + async def mock_get(*_args, **_kwargs): + return {"objectId": "abc", "playerName": "Bob", "score": 500} + + mock_http.get.side_effect = mock_get + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.set("score", 100) # Modification locale + assert len(obj._dirty_keys) == 1 + + await obj.fetch() + + assert obj.get("playerName") == "Bob" + assert obj.get("score") == 500 + assert len(obj._dirty_keys) == 0 + + +@pytest.mark.asyncio +async def test_object_delete(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + async def mock_delete(*_args, **_kwargs): + return {} + + mock_http.delete.side_effect = mock_delete + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + await obj.delete() + + assert obj.object_id is None + assert obj._data == {} + mock_http.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_object_save_with_geopoint(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + async def mock_post(_path, json, **_kwargs): + assert json["location"]["__type"] == "GeoPoint" + assert json["location"]["latitude"] == 48.8566 + return {"objectId": "geoId"} + + mock_http.post.side_effect = mock_post + mock_get_client.return_value = mock_http + + obj = ParseObject("Place") + obj.set("location", GeoPoint(48.8566, 2.3522)) + await obj.save() + + assert obj.object_id == "geoId" + + +@pytest.mark.asyncio +async def test_object_save_with_pointer(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + + async def mock_post(_path, json, **_kwargs): + assert json["owner"]["__type"] == "Pointer" + assert json["owner"]["className"] == "_User" + assert json["owner"]["objectId"] == "user123" + return {"objectId": "postId"} + + mock_http.post.side_effect = mock_post + mock_get_client.return_value = mock_http + + obj = ParseObject("Post") + obj.set("owner", Pointer("_User", "user123")) + await obj.save() + + assert obj.object_id == "postId" diff --git a/tests/unit/test_object_sync.py b/tests/unit/test_object_sync.py new file mode 100644 index 0000000..bb8d795 --- /dev/null +++ b/tests/unit/test_object_sync.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock, patch + +from parse_sdk import ParseObject + + +def test_object_save_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.post_sync.return_value = {"objectId": "syncId"} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore") + obj.set("score", 200) + obj.save_sync() + + assert obj.object_id == "syncId" + assert len(obj._dirty_keys) == 0 + mock_http.post_sync.assert_called_once() + + +def test_object_update_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.put_sync.return_value = {"updatedAt": "..."} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.set("score", 300) + obj.save_sync() + + assert len(obj._dirty_keys) == 0 + mock_http.put_sync.assert_called_once() + + +def test_object_fetch_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.get_sync.return_value = {"objectId": "abc", "score": 999} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.fetch_sync() + + assert obj.get("score") == 999 + mock_http.get_sync.assert_called_once() + + +def test_object_delete_sync(): + with patch("parse_sdk.object.get_client") as mock_get_client: + mock_http = MagicMock() + mock_http.delete_sync.return_value = {} + mock_get_client.return_value = mock_http + + obj = ParseObject("GameScore", "abc") + obj.delete_sync() + + assert obj.object_id is None + mock_http.delete_sync.assert_called_once() diff --git a/tests/unit/test_types_and_exceptions.py b/tests/unit/test_types_and_exceptions.py index 57b6598..2eb8461 100644 --- a/tests/unit/test_types_and_exceptions.py +++ b/tests/unit/test_types_and_exceptions.py @@ -252,3 +252,34 @@ def test_exception_hierarchy(self) -> None: err = ParseSessionExpiredError() assert isinstance(err, ParseError) assert isinstance(err, Exception) + + def test_raise_parse_error_specialized_constructors(self) -> None: + """raise_parse_error ne doit pas crasher pour les classes avec + un constructeur spécialisé (ex: ParseObjectNotFoundError). + L'instance levée doit être du bon type ET avoir code/message corrects. + """ + from parse_sdk.exceptions import ( + ParseDuplicateValueError, + ParseObjectNotFoundError, + ParseUsernameTakenError, + ) + + # Code 101 → ParseObjectNotFoundError (constructeur: class_name, object_id) + with pytest.raises(ParseObjectNotFoundError) as exc_info: + raise_parse_error(101, "Object not found") + assert exc_info.value.code == 101 + assert exc_info.value.message == "Object not found" + + # Code 137 → ParseDuplicateValueError (constructeur: field) + with pytest.raises(ParseDuplicateValueError) as exc_info2: + raise_parse_error(137, "Duplicate value") + assert exc_info2.value.code == 137 + + # Code 202 → ParseUsernameTakenError (constructeur: username) + with pytest.raises(ParseUsernameTakenError) as exc_info3: + raise_parse_error(202, "Username taken") + assert exc_info3.value.code == 202 + + # Tous doivent être instanceof ParseError + for exc in [exc_info, exc_info2, exc_info3]: + assert isinstance(exc.value, ParseError)