Ctrl K
ComunidadGuías…

test_serve_choisir_dossier.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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()