Summary
[!note] 📚 Série Smart Assets Manager
- Pourquoi l’Abstraction de Stockage Compte — mai 2026
- Quatre Backends, Une Interface — mai 2026
- L’API Unifiée : Crédits et Limitation de Débit — avril 2026
- Stratégie de Test : Tests Unitaires vs E2E — avril 2026
- 5 Cas Limites qui Brisent les APIs d’Images ← vous êtes ici
- Documentation API : Swagger + Postman — mars 2026
Le billet sur la stratégie de test a établi le principe : tests unitaires pour la logique des fonctions, tests E2E pour le comportement d’intégration. Ce billet porte sur cinq cas spécifiques où ce principe a dû être appliqué — des scénarios qui semblent corrects en utilisation normale mais qui surgissent en production dans des conditions difficiles à prévoir et coûteuses à déboguer.
Aucun de ces cas n’est théorique. Chacun représente soit un bug détecté par le test, soit une garantie que le test valide à travers toute la pile.
Cas 1 : Échec de Lot Partiel → Facturation Proportionnelle
Quand un utilisateur demande 10 tailles d’image et que 3 échouent en cours de génération, que facture l’API ?
La réponse évidente — facturer 7 succès, pas 10 — nécessite que trois services se coordonnent correctement : le générateur, le backend de stockage et le service de crédits. Le générateur sait combien d’images ont réussi. Le service de crédits détient la réservation. Pour que la facturation reflète la réalité, ces deux informations doivent se rejoindre au bon endroit, dans le bon ordre.
L’implémentation initiale se trompait. La confirmation de crédit s’exécutait après la boucle de lot, en utilisant le nombre demandé, pas le nombre de succès. La logique était au bon endroit conceptuellement mais lisait la mauvaise variable.
@pytest.mark.asyncio
async def test_partial_failure_proportional_credit_charge(
async_client, test_user, monkeypatch
):
call_count = 0
def mock_generator_with_failures(data, width, height):
nonlocal call_count
call_count += 1
# Échoue aux tentatives 4, 7, et 9 — simule des erreurs de génération intermittentes
if call_count in [4, 7, 9]:
raise RuntimeError(f"Generation failed for {width}x{height}")
return b"\x89PNG\r\n\x1a\n" # En-tête PNG minimal valide
monkeypatch.setattr(
"app.services.generators.SocialCardGenerator.generate",
mock_generator_with_failures
)
response = await async_client.post(
"/api/v1/deterministic/generate",
json={
"type": "social_card",
"storage": "direct",
"generate_sizes": True,
"preset_name": "custom_10",
"data": {"title": "Test", "brand_color": "#000"},
},
headers={"Authorization": f"Bearer {test_user.api_key}"},
)
assert response.status_code == 207 # Multi-statut : succès partiel
data = response.json()
assert data["success_count"] == 7
assert data["error_count"] == 3
# Facturation : 0.25 de base + 6 variantes supplémentaires × 0.1 = 0.85
assert abs(data["credits_used"] - 0.85) < 0.01
Le test utilise monkeypatch pour injecter des échecs déterministes à des nombres d’appels spécifiques. L’assertion sur credits_used est celle qui a détecté le bug — quand la facturation était calculée à partir du nombre demandé, elle valait toujours 1.15, pas 0.85.
À retenir : La facturation proportionnelle nécessite que le nombre de succès atteigne le système de facturation. Testez cela avec des échecs injectés à des positions spécifiques dans le lot, puis vérifiez que la facturation correspond aux succès.
Cas 2 : Injection SVG → Assainir, Ne Pas Rejeter
Celui-ci concerne une question de sécurité avec une réponse correcte non évidente.
Smart Assets Manager accepte des modèles SVG fournis par l’utilisateur. Un utilisateur malveillant peut soumettre un SVG contenant des balises <script>, des gestionnaires onclick et des éléments <foreignObject> encapsulant des iframes avec des URI javascript:. La mauvaise réponse est de rejeter la requête. La bonne réponse est de supprimer les éléments dangereux et de quand même générer l’image.
Pourquoi ? Parce que le rejet à la détection est trop agressif. De vrais SVGs d’outils légitimes incluent parfois des déclarations d’espaces de noms, des instructions de traitement conditionnelles ou des éléments que les parseurs signalent comme suspects mais qui ne constituent pas de vraies attaques. Rejeter ces entrées crée des faux négatifs et frustre les utilisateurs légitimes.
@pytest.mark.asyncio
async def test_svg_injection_sanitized_and_renders(async_client, test_user):
malicious_svg = """<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
<script>document.cookie = 'stolen=' + document.cookie;</script>
<rect onclick="alert('xss')" width="800" height="600" fill=""/>
<foreignObject><div xmlns="http://www.w3.org/1999/xhtml">
<iframe src="javascript:alert('xss')"></iframe>
</div></foreignObject>
<text x="400" y="300"></text>
</svg>"""
response = await async_client.post(
"/api/v1/deterministic/generate",
json={
"type": "svg_template",
"storage": "direct",
"data": {
"template_content": malicious_svg,
"variables": {"bg_color": "#1A2980", "title": "Safe"},
},
},
headers={"Authorization": f"Bearer {test_user.api_key}"},
)
# La génération réussit — l'assainissement supprime sans bloquer
assert response.status_code == 200
data = response.json()
assert data["urls"][0]["url"].startswith("data:image/")
# Le rapport d'assainissement fait partie de la réponse
assert data["sanitization"]["elements_removed"] >= 3
removed_types = data["sanitization"]["elements_removed_types"]
assert "script" in removed_types
assert "foreignObject" in removed_types
Le test affirme deux choses qui s’opposent : la réponse est 200 (pas rejetée), et les éléments dangereux ont été supprimés (pas transmis). Les deux doivent être vrais simultanément.
Le bug détecté : l’assainisseur original levait une exception en rencontrant des éléments <foreignObject> — il traitait les espaces de noms XML imbriqués comme un SVG malformé plutôt que de les supprimer. La correction consistait à ajouter foreignObject à la liste de suppression et à continuer le rendu, pas à le traiter comme une erreur de parsing.
Cas 3 et 4 : Crédits — Avant et Après
Deux cas limites liés aux crédits vont ensemble car ils testent des modes d’échec opposés : l’API surfacturant en commençant trop tôt, et l’API perdant de l’argent en ne récupérant pas après une erreur serveur.
Cas 3 : Crédits insuffisants → 402 avant le début de la génération
Si la vérification de crédit se produit après la génération, vous livrez l’image et ne pouvez pas la facturer. La vérification doit passer en premier — avant tout calcul.
@pytest.mark.asyncio
async def test_insufficient_credits_returns_402_before_generation(
async_client, db_session
):
poor_user = User(email="[email protected]", credits=0.5, api_key="poor-key")
db_session.add(poor_user)
db_session.commit()
response = await async_client.post(
"/api/v1/deterministic/generate",
json={
"type": "social_card",
"storage": "direct",
"generate_sizes": True,
"preset_name": "blog_images", # Coût total : 1.75
"data": {"title": "Test"},
},
headers={"Authorization": "Bearer poor-key"},
)
assert response.status_code == 402
data = response.json()
assert data["detail"]["error"] == "insufficient_credits"
assert data["detail"]["available"] == 0.5
assert data["detail"]["required"] == 1.75
Le test vérifie que le corps de la réponse inclut à la fois le solde disponible et le montant requis — donnant à l’appelant suffisamment d’informations pour expliquer l’erreur à l’utilisateur sans un second appel API.
Cas 4 : Erreur serveur → remboursement automatique de crédits
Les crédits sont réservés avant le début de la génération (la réservation atomique décrite dans le billet 3). Si une erreur serveur se produit en cours de génération, ces crédits réservés doivent retourner au solde de l’utilisateur. C’est un bug destructeur de confiance s’il échoue silencieusement.
@pytest.mark.asyncio
async def test_server_error_triggers_credit_refund(
async_client, test_user, monkeypatch, db_session
):
initial_credits = test_user.credits
def mock_generation_failure(*args, **kwargs):
raise RuntimeError("Simulated server error during generation")
monkeypatch.setattr(
"app.services.generators.SocialCardGenerator.generate",
mock_generation_failure
)
response = await async_client.post(
"/api/v1/deterministic/generate",
json={"type": "social_card", "storage": "direct", "data": {"title": "Test"}},
headers={"Authorization": f"Bearer {test_user.api_key}"},
)
assert response.status_code == 500
# Le solde de l'utilisateur doit être inchangé — pas de facturation nette en cas d'erreur serveur
db_session.refresh(test_user)
assert test_user.credits == initial_credits
Le modèle critique ici est db_session.refresh(test_user) — sans actualisation depuis la base de données, l’objet en mémoire conserve l’état avant remboursement. Le test passe par la couche HTTP (sans mocker directement le endpoint) car la logique de remboursement doit s’exécuter dans le gestionnaire d’erreurs, pas dans la fixture de test.
[!info] Le modèle de test de remboursement Pour tester les remboursements de crédits, injectez une erreur dans la couche de génération (pas la couche de crédits), effectuez un vrai appel HTTP, puis actualisez l’objet base de données et vérifiez que le solde est inchangé. Tout raccourci qui contourne la couche HTTP contourne également le gestionnaire d’erreurs où vit le remboursement.
Cas 5 : État de Limitation de Débit sur des Requêtes Séquentielles
La limitation de débit est l’exemple le plus clair d’un test uniquement E2E. L’état du token bucket vit dans Redis. Les tests unitaires ne touchent pas Redis. Seules des requêtes réelles séquentielles à travers toute la pile peuvent vérifier que le limiteur de débit compte correctement et applique les limites là où il le devrait.
@pytest.mark.asyncio
async def test_free_tier_rate_limit_enforced(async_client, free_tier_user):
# Le niveau gratuit autorise 5 requêtes par minute — toutes devraient réussir
for i in range(5):
r = await async_client.post(
"/api/v1/deterministic/generate",
json={
"type": "url_personalization",
"storage": "direct",
"data": {
"text": f"Test {i}",
"background_url": "https://example.com/bg.jpg",
},
},
headers={"Authorization": f"Bearer {free_tier_user.api_key}"},
)
assert r.status_code == 200, f"Requête {i+1} a échoué de manière inattendue : {r.json()}"
# La 6ème requête atteint la limite
r = await async_client.post(
"/api/v1/deterministic/generate",
json={
"type": "url_personalization",
"storage": "direct",
"data": {"text": "Over limit", "background_url": "https://example.com/bg.jpg"},
},
headers={"Authorization": f"Bearer {free_tier_user.api_key}"},
)
assert r.status_code == 429
assert "Retry-After" in r.headers
assert 0 < int(r.headers["Retry-After"]) <= 60
Deux assertions sur la réponse 429 : le code de statut et l’en-tête Retry-After. L’en-tête est vérifié pour une plage (entre 1 et 60 secondes) plutôt qu’une valeur exacte — car le temps de recharge du bucket du limiteur de débit est relatif au moment d’exécution du test, pas un nombre fixe. Vérifier une plage est le comportement correct ici ; vérifier une valeur exacte rendrait le test dépendant du timing et instable.
Le Résultat : 90 % de Couverture E2E
Après ces cinq cas — plus les tests de chemin heureux de base du billet sur la stratégie de test — la couverture E2E s’est stabilisée à 90 %, correspondant à l’objectif. Les 10 % restants représentent des scénarios véritablement difficiles à automatiser : la validation de l’expiration des URL signées (dépend du comportement réel de l’horloge d’une manière qui déjoue les environnements de test) et les réponses d’erreur spécifiques à Cloudinary (les simuler sans mocker l’intégralité du SDK contrecarre l’objectif des tests E2E). Les deux sont documentés comme scénarios de test manuels.
Les cinq cas limites partagent un modèle : ce sont tous des scénarios où le comportement du système à travers les frontières des composants est ce qui doit être testé, pas la logique d’une fonction individuelle. Le générateur ne connaît pas les remboursements de crédits. Le service de crédits ne connaît pas les limitations de débit. Seul un test qui exerce toute la pile détecte les échecs de coordination.
→ Suivant : Documentation API : Swagger + Postman comme Livrables de Première Classe