test_serve_choisir_dossier.py
Rama main · réplica de lectura del repositorio original.
"""Non-régression de /api/choisir-dossier (AUDIT-2026-08-13.md, section
28.41) : sélecteur de dossier natif côté serveur. PowerShell/WinForms sous
Windows (voir _choisir_dossier_windows, revue du 27/08 : tkinter n'est pas
garanti dans le bundle Windows 7, "Sélecteur de dossier indisponible"
constaté en conditions réelles) ; tkinter en repli ailleurs, importé à la
demande pour ne jamais peser sur un environnement sans affichage (CI Linux
headless, qui n'emprunte jamais cette route)."""
from __future__ import annotations
import io
import os
import sys
import tempfile
import types
import unittest
from unittest import mock
try:
from zoneinfo import ZoneInfo
except ImportError: # Python 3.8, édition Windows 7
from backports.zoneinfo import ZoneInfo
os.environ["BLINK_BOOTSTRAP"] = "none"
_TEST_HOME = tempfile.TemporaryDirectory(prefix="blink-choisir-dossier-")
os.environ["BLINK_HOME"] = _TEST_HOME.name
import runtime # noqa: E402 - bootstrap neutralisé avant import
import serve # noqa: E402
def construire_faux_tkinter(chemin_retourne):
"""Simule tkinter.Tk()/filedialog.askdirectory() sans jamais ouvrir de
vraie fenêtre : seule la valeur de retour d'askdirectory() compte ici,
la route ne fait rien d'autre du module."""
faux_tkinter = types.ModuleType("tkinter")
class FauxTk:
def withdraw(self):
pass
def attributes(self, *args, **kwargs):
pass
def destroy(self):
pass
faux_tkinter.Tk = FauxTk
faux_filedialog = types.ModuleType("tkinter.filedialog")
faux_filedialog.askdirectory = mock.Mock(return_value=chemin_retourne)
faux_tkinter.filedialog = faux_filedialog
return faux_tkinter, faux_filedialog
class TestChoisirDossier(unittest.TestCase):
def construire_handler(self):
handler = serve.Handler.__new__(serve.Handler)
handler.paths = {}
handler.timezone = ZoneInfo("UTC")
handler.path = "/api/choisir-dossier"
# Host requis depuis 28.60 (protection CSRF/Origin sur do_GET) :
# sans lui, ce handler construit à la main est rejeté en 403.
handler.headers = {
"Host": "127.0.0.1",
"X-Blink-Token": serve.TOKEN,
}
handler.rfile = io.BytesIO(b"")
return handler
def appeler(self, handler):
reponses = []
handler.send_json = lambda payload, code=200: reponses.append((code, payload))
handler.do_GET()
return reponses
# --- Windows : PowerShell/WinForms (_choisir_dossier_windows) ---
def test_windows_renvoie_le_chemin_choisi(self):
handler = self.construire_handler()
with mock.patch.object(serve.os, "name", "nt"), \
mock.patch.object(serve.runtime, "lire_dossier_stockage",
return_value="C:/depart"), \
mock.patch.object(serve, "_choisir_dossier_windows",
return_value="D:/photos/blink") as appel:
reponses = self.appeler(handler)
self.assertEqual(reponses, [(200, {"path": "D:/photos/blink"})])
appel.assert_called_once_with("C:/depart")
def test_windows_annulation_renvoie_un_chemin_vide(self):
handler = self.construire_handler()
with mock.patch.object(serve.os, "name", "nt"), \
mock.patch.object(serve.runtime, "lire_dossier_stockage",
return_value="C:/depart"), \
mock.patch.object(serve, "_choisir_dossier_windows", return_value=""):
reponses = self.appeler(handler)
self.assertEqual(reponses, [(200, {"path": ""})])
def test_windows_echec_du_selecteur_repond_en_erreur_plutot_que_de_planter(self):
handler = self.construire_handler()
with mock.patch.object(serve.os, "name", "nt"), \
mock.patch.object(serve.runtime, "lire_dossier_stockage",
return_value="C:/depart"), \
mock.patch.object(
serve, "_choisir_dossier_windows",
side_effect=RuntimeError("PowerShell indisponible"),
) as appel:
reponses = self.appeler(handler)
self.assertEqual(len(reponses), 1)
code, payload = reponses[0]
self.assertEqual(code, 500)
self.assertEqual(payload["error"], "PowerShell indisponible")
appel.assert_called_once_with("C:/depart")
# --- Hors Windows : repli tkinter ---
def test_repli_renvoie_le_chemin_choisi(self):
# Handler construit AVANT le patch de os.name : os.name est un
# attribut du module os réel (partagé), pas une copie propre à
# serve - le patcher pendant la construction (ZoneInfo(), etc. y
# touchent indirectement) fait planter pathlib sur ce système
# réellement Windows, hors sujet ici.
handler = self.construire_handler()
faux_tkinter, faux_filedialog = construire_faux_tkinter("D:/photos/blink")
with mock.patch.object(serve.os, "name", "posix"), \
mock.patch.object(serve.runtime, "lire_dossier_stockage", return_value=""), \
mock.patch.dict(sys.modules,
{"tkinter": faux_tkinter, "tkinter.filedialog": faux_filedialog}):
reponses = self.appeler(handler)
self.assertEqual(reponses, [(200, {"path": "D:/photos/blink"})])
def test_repli_annulation_renvoie_un_chemin_vide(self):
# tkinter.filedialog.askdirectory() renvoie "" (tuple vide en
# interne) quand l'utilisateur ferme la boîte sans choisir.
handler = self.construire_handler()
faux_tkinter, faux_filedialog = construire_faux_tkinter("")
with mock.patch.object(serve.os, "name", "posix"), \
mock.patch.object(serve.runtime, "lire_dossier_stockage", return_value=""), \
mock.patch.dict(sys.modules,
{"tkinter": faux_tkinter, "tkinter.filedialog": faux_filedialog}):
reponses = self.appeler(handler)
self.assertEqual(reponses, [(200, {"path": ""})])
def test_repli_echec_du_selecteur_repond_en_erreur_plutot_que_de_planter(self):
handler = self.construire_handler()
faux_tkinter = types.ModuleType("tkinter")
class TkQuiEchoue:
def __init__(self):
raise RuntimeError("no display")
faux_tkinter.Tk = TkQuiEchoue
faux_filedialog = types.ModuleType("tkinter.filedialog")
faux_filedialog.askdirectory = mock.Mock()
faux_tkinter.filedialog = faux_filedialog
with mock.patch.object(serve.os, "name", "posix"), \
mock.patch.object(serve.runtime, "lire_dossier_stockage", return_value=""), \
mock.patch.dict(sys.modules,
{"tkinter": faux_tkinter, "tkinter.filedialog": faux_filedialog}):
reponses = self.appeler(handler)
self.assertEqual(len(reponses), 1)
code, payload = reponses[0]
self.assertEqual(code, 500)
self.assertIn("error", payload)
class TestChoisirDossierWindows(unittest.TestCase):
"""_choisir_dossier_windows() elle-même, sans jamais lancer un vrai
PowerShell : seule la construction de la commande et l'interprétation de
sa sortie sont en jeu ici."""
def test_chemin_choisi_est_lu_sur_stdout(self):
resultat = mock.Mock(returncode=0, stdout="D:\\photos\\blink\r\n", stderr="")
with mock.patch.object(runtime, "lancer", return_value=resultat) as appel:
chemin = serve._choisir_dossier_windows("C:\\depart")
self.assertEqual(chemin, "D:\\photos\\blink")
commande = appel.call_args[0][0]
self.assertEqual(commande[0], "powershell")
def test_annulation_renvoie_une_chaine_vide(self):
resultat = mock.Mock(returncode=0, stdout="", stderr="")
with mock.patch.object(runtime, "lancer", return_value=resultat):
self.assertEqual(serve._choisir_dossier_windows("C:\\depart"), "")
def test_echec_powershell_leve_avec_le_message_stderr(self):
resultat = mock.Mock(returncode=1, stdout="", stderr="Add-Type : introuvable")
with mock.patch.object(runtime, "lancer", return_value=resultat):
with self.assertRaises(RuntimeError) as capture:
serve._choisir_dossier_windows("C:\\depart")
self.assertIn("Add-Type", str(capture.exception))
def test_apostrophe_dans_le_chemin_de_depart_ne_casse_pas_le_script(self):
# PowerShell échappe un guillemet simple littéral en le doublant à
# l'intérieur d'une chaîne à guillemets simples : un chemin qui en
# contient un (rare, mais un nom de dossier utilisateur peut en
# avoir un) ne doit pas casser la commande générée.
resultat = mock.Mock(returncode=0, stdout="", stderr="")
with mock.patch.object(runtime, "lancer", return_value=resultat) as appel:
serve._choisir_dossier_windows("C:\\Users\\O'Brien")
script = appel.call_args[0][0][-1]
self.assertIn("O''Brien", script)
if __name__ == "__main__":
unittest.main()