Summary
- Cas limite 1 : Échec partiel multi-tailles — facturation proportionnelle
- Cas limite 2 : Injection SVG — la sortie assainie génère quand même
- Cas limite 3 : Crédits insuffisants → 402 avant le démarrage de la génération
- Cas limite 4 : Erreur serveur → remboursement automatique des crédits
- Cas limite 5 : Application du rate limiting par niveau
- Le résultat de couverture
- Conclusions clés
- La suite
[!note] 📚 Série Smart Assets Manager
- Pourquoi l’abstraction du stockage est essentielle
- Quatre backends de stockage, une seule interface
- L’API unifiée : réservation de crédits et rate limiting
- Stratégie de test : tests unitaires vs tests E2E
- Les 5 cas limites qui cassent les APIs d’images ← vous êtes ici
- Documentation API : Swagger + Postman comme livrables — 10 avril
Dans le billet précédent, la stratégie de test a été établie : tests unitaires pour la logique de fonctions, tests E2E pour le comportement d’intégration. Voici les cinq scénarios spécifiques où cette stratégie a dû être appliquée — des cas invisibles en utilisation normale, mais qui remontent en production aux pires moments.
Chacun possède un cas de test. Chacun a permis de détecter un bug ou de valider une garantie de correction que les tests unitaires ne pouvaient pas fournir.
Cas limite 1 : Échec partiel multi-tailles — facturation proportionnelle
Le scénario : Un utilisateur demande 10 tailles d’image. Sept se génèrent et s’envoient vers le stockage avec succès. Trois échouent suite à une erreur de génération. Que facture-t-on à l’utilisateur ?
Pourquoi c’est difficile : La réponse « correcte » (facturer 7 et non 10) exige que trois services coordonnent correctement leurs actions : le générateur, le backend de stockage et le service de crédits. Mocker l’un d’eux masque si la coordination est juste.
@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
# Fail on attempts 4, 7, and 9 to simulate intermittent errors
if call_count in [4, 7, 9]:
raise RuntimeError(f"Generation failed for {width}x{height}")
return b"\x89PNG\r\n\x1a\n" # Minimal valid PNG header
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-status: partial success
data = response.json()
assert data["success_count"] == 7
assert data["error_count"] == 3
# Charge: 0.25 base + 6 additional variants × 0.1 = 0.85
assert abs(data["credits_used"] - 0.85) < 0.01
Bug détecté : L’implémentation initiale facturait le coût total des 10 tailles quelles que soient les erreurs. La logique de facturation partielle se trouvait au mauvais endroit — elle s’exécutait après la confirmation des crédits, et non avant.
Cas limite 2 : Injection SVG — la sortie assainie génère quand même
Le scénario : Un utilisateur malveillant soumet un template SVG contenant des balises <script>, des attributs onclick et un <foreignObject> avec des iframes embarquées. L’API doit assainir le SVG et produire quand même une image — non pas rejeter la requête.
Pourquoi c’est difficile : Un assainissement qui rejette toute entrée malveillante est facile. Assainir qui supprime les éléments dangereux et produit quand même une image valide exige que l’assainisseur et le moteur de rendu fonctionnent correctement en séquence. Si le SVG assaini est mal formé, le rendu échoue. Si le moteur reçoit une entrée non assainie, l’attaque réussit.
@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}"}
)
# Generation should succeed — sanitization doesn't mean rejection
assert response.status_code == 200
data = response.json()
assert data["urls"][0]["url"].startswith("data:image/")
# The sanitization report confirms dangerous elements were removed
assert data["sanitization"]["elements_removed"] >= 3
removed_types = data["sanitization"]["elements_removed_types"]
assert "script" in removed_types
assert "foreignObject" in removed_types
Bug détecté : L’assainisseur d’origine levait une exception en rencontrant <foreignObject> — il traitait les espaces de noms XML imbriqués comme mal formés plutôt que de les supprimer. Le test a montré que le rejet à la rencontre était le mauvais comportement ; « supprimer et continuer » était la bonne approche.
Cas limite 3 : Crédits insuffisants → 402 avant le démarrage de la génération
Le scénario : Un utilisateur avec 0,5 crédit demande une opération qui coûte 1,0 crédit. L’API doit retourner une erreur 402 avant que toute génération ne commence — pas après.
Pourquoi cela compte : Si la génération démarre avant la vérification des crédits, l’image est livrée sans pouvoir la facturer. Si la vérification lève correctement l’erreur, aucun calcul n’est gaspillé.
@pytest.mark.asyncio
async def test_insufficient_credits_returns_402_before_generation(
async_client, db_session
):
# Create a user with less than the required credit amount
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", # costs 0.25 base
"generate_sizes": True, "preset_name": "blog_images", # total cost: 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
Cas limite 4 : Erreur serveur → remboursement automatique des crédits
Le scénario : Un utilisateur effectue une requête, les crédits sont réservés, la génération démarre — puis une erreur serveur survient. Les crédits réservés doivent automatiquement retourner au solde de l’utilisateur.
Pourquoi c’est difficile : La logique de remboursement doit s’exécuter dans le gestionnaire d’exception. Si ce gestionnaire lève lui-même une exception (ou saute le remboursement dans un chemin de code quelconque), les utilisateurs perdent définitivement des crédits lors d’erreurs serveur. C’est un bug qui détruit la confiance.
@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
# Verify credits were fully refunded
db_session.refresh(test_user)
assert test_user.credits == initial_credits # No net charge
Cas limite 5 : Application du rate limiting par niveau
Le scénario : Un utilisateur du niveau gratuit effectue 6 requêtes en moins d’une minute. La 6e doit être rejetée avec un 429 et un en-tête Retry-After.
Pourquoi cela nécessite un test E2E : L’état du rate limiting est stocké dans Redis. Les tests unitaires n’interagissent pas avec Redis. Seul un test qui effectue de vraies requêtes API séquentielles à travers toute la pile peut vérifier que l’état du jeton de débit est correctement mis à jour et vérifié à chaque requête.
@pytest.mark.asyncio
async def test_free_tier_rate_limit_enforced(async_client, free_tier_user):
# 5 requests should succeed (free tier limit)
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"Request {i+1} failed unexpectedly: {r.json()}"
# The 6th request should be rate-limited
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
# Retry-After should be between 1 and 60 seconds
assert 0 < int(r.headers["Retry-After"]) <= 60
Le résultat de couverture
Après ces cinq cas limites (et les tests du parcours nominal décrits dans le billet sur la stratégie de test), la couverture E2E a atteint 90% — l’objectif atteint en couvrant chaque scénario ayant un mode de défaillance réel en production.
Les 10% restants correspondent à des scénarios réellement difficiles à automatiser : validation de l’expiration des URL signées (dépend du comportement réel de l’horloge) et réponses d’erreur spécifiques à Cloudinary (difficiles à simuler sans mocker l’intégralité du SDK, ce qui annule l’intérêt des tests E2E). Ces cas sont documentés comme scénarios de test manuel.
Conclusions clés
- Les cinq cas limites à tester dans toute API : échecs partiels de lot, attaques par injection (assainir, ne pas rejeter), crédits insuffisants avant la génération, erreurs serveur déclenchant des remboursements, et état du rate limiting sur des requêtes séquentielles.
- « Assainir et continuer » est presque toujours le bon comportement pour les entrées de template fournies par l’utilisateur. Rejeter à la rencontre crée des faux négatifs pour des cas limites légitimes.
- La logique de remboursement des crédits doit être testée avec une vraie erreur serveur — pas un mock coopératif. Le test doit réellement déclencher une exception dans le gestionnaire.
- Les tests de rate limiting nécessitent de vraies requêtes séquentielles. L’état Redis est invisible pour les tests unitaires.
La suite
Le système fonctionne et est testé. La dernière pièce consiste à le rendre utilisable par d’autres développeurs : une documentation Swagger vraiment informative, et une collection Postman comme livrable de premier ordre.
→ Suite : Documentation API : Swagger + Postman comme livrables
← Précédent : Stratégie de test : tests unitaires vs tests E2E
— Kékéli