From 5dd473cd53cbf2704af244d4db8ead8456d5708c Mon Sep 17 00:00:00 2001
From: Arthur Suzuki <arthur.suzuki@biblibre.com>
Date: Mon, 9 Aug 2021 17:26:35 +0200
Subject: [PATCH] hotline#139455: fix install skin button

---
 VERSIONS_HOTLINE/139455                       |   1 +
 .../admin/controllers/IndexController.php     |   7 +
 library/Class/Profil/SkinUpdateReader.php     | 370 +++++++++++++++---
 library/Class/TableDescription/CloneSkins.php |  34 ++
 .../Class/TableDescription/UpdateSkins.php    |  28 ++
 .../ZendAfi/View/Helper/Admin/UpdateSkins.php |  72 ++--
 library/startup.php                           |   2 +-
 scripts/update_skins.php                      |   5 +-
 .../IndexControllerUpdateSkinTest.php         | 154 ++++++--
 .../Class/Profil/SkinUpdateReaderTest.php     | 246 +++++++++++-
 10 files changed, 783 insertions(+), 136 deletions(-)
 create mode 100644 VERSIONS_HOTLINE/139455
 create mode 100644 library/Class/TableDescription/CloneSkins.php
 create mode 100644 library/Class/TableDescription/UpdateSkins.php

diff --git a/VERSIONS_HOTLINE/139455 b/VERSIONS_HOTLINE/139455
new file mode 100644
index 00000000000..444fc37ec34
--- /dev/null
+++ b/VERSIONS_HOTLINE/139455
@@ -0,0 +1 @@
+ - ticket #139455 : Administration : Correction de la mécanique d'ajout d'un thème.
\ No newline at end of file
diff --git a/application/modules/admin/controllers/IndexController.php b/application/modules/admin/controllers/IndexController.php
index b1718abf6bc..6c739bbff7f 100644
--- a/application/modules/admin/controllers/IndexController.php
+++ b/application/modules/admin/controllers/IndexController.php
@@ -169,4 +169,11 @@ class Admin_IndexController extends ZendAfi_Controller_Action {
     $this->_helper->notify($message);
     $this->_redirect('admin/index/update-skin');
   }
+
+
+  public function cloneSkinCancelAction() {
+    $this->getHelper('ViewRenderer')->setNoRender();
+    $this->_helper->notify((new Class_Profil_SkinUpdateReader)->cancelClone($this->_getParam('id')));
+    $this->_redirect('admin/index/update-skin');
+  }
 }
\ No newline at end of file
diff --git a/library/Class/Profil/SkinUpdateReader.php b/library/Class/Profil/SkinUpdateReader.php
index 7a5a32479a6..e2f6ec6c920 100644
--- a/library/Class/Profil/SkinUpdateReader.php
+++ b/library/Class/Profil/SkinUpdateReader.php
@@ -21,21 +21,44 @@
 
 
 class Class_Profil_SkinUpdateReader {
-  use Trait_StaticFileWriter,
+  use
+    Trait_StaticFileWriter,
     Trait_Translator,
     Trait_TimeSource,
     Trait_StaticCommand;
 
-  public static $skins_update_log = 'skins_update_log.json',
+  protected static $_realtime;
+
+  public static
+    $skins_update_log = 'skins_update_log.json',
     $command_pull = 'git reset --hard HEAD && git pull --rebase',
+    $skins_clone_log = 'skins_clone_log.json',
     $command_clone = 'git clone';
 
 
-  public static function getLogPath() {
+  /** @category testing */
+  public static function setRealtime($value) {
+    static::$_realtime = (bool)$value;
+  }
+
+
+  protected function _isRealtime() {
+    return isset(static::$_realtime)
+      ? static::$_realtime
+      : GIT_REALTIME;
+  }
+
+
+  public static function getUpdateLogPath() {
     return PATH_TEMP . static::$skins_update_log;
   }
 
 
+  public static function getCloneLogPath() {
+    return PATH_TEMP . static::$skins_clone_log;
+  }
+
+
   public static function getSkinsPath($folder) {
     return ROOT_PATH . (new Class_Profil())->getExtraPath() . $folder;
   }
@@ -44,70 +67,126 @@ class Class_Profil_SkinUpdateReader {
   public function askGitPull() {
     $data = [];
     foreach($this->getUpdatableSkins() as $skin)
-      $data[$skin->getLabel()] = GIT_REALTIME
-      ? $this->_runGitPullIn($skin->getLabel())
-      : ['Status' => $this->_('En attente depuis le %s', static::getCurrentDateTime())];
+      $data[$skin] = $this->_askOneGitPull($skin);
 
-    return $this->_writeInLog($data, true);
+    return $this->_writeInUpdateLog($data, !$this->_isRealtime());
   }
 
 
-  public function askGitClone($skin) {
-    if (GIT_REALTIME)
-      return $this->_runGitClone($skin);
+  protected function _askOneGitPull($label) {
+    $status = $this->_isRealtime()
+      ? $this->_runGitPullIn($label)->getMessage()
+      : $this->_('En attente depuis le %s', static::getCurrentDateTime());
 
-    $data[$skin] = ['Status' => $this->_('En attente depuis le %s', static::getCurrentDateTime()),
-                     'url' => GIT_SKINS . '/' . $skin];
-    return $this->_writeInLog($data, true);
+    return ['Status' => $status];
   }
 
 
   public function runGitPull() {
-    if(!$this->_shouldRun())
-      return $this->_('Aucune demande d\'exécution de la commande "%s" trouvée dans le fichier "%s"', static::$command_pull, static::$skins_update_log);
+    if (!$this->_shouldRunUpdate())
+      return $this->_('Aucune demande d\'exécution de la commande "%s" trouvée dans le fichier "%s"',
+                      static::$command_pull, static::$skins_update_log);
 
     $data = [];
-    foreach($this->getUpdatableSkins() as $skin) {
-      $data[$skin->getLabel()] = ['Status' => $this->_runGitPullIn($skin->getLabel())];
-    }
+    foreach($this->getUpdatableSkins() as $skin)
+      $data[$skin] = ['Status' => $this->_runGitPullIn($skin)->getMessage()];
+
+    return $this->_writeInUpdateLog($data, false);
+  }
+
 
-    $data = ($clone_data = $this->runGitClone())
-      ? array_merge( $data , $clone_data)
-      : array_merge( $data , []);
+  protected function _runGitPullIn($folder) {
+    $commands = ['cd ./' . SKINS . '/' . $folder . ' 2>&1',
+                 static::$command_pull . ' 2>&1',
+                 'cd ../../ 2>&1'];
 
-    return $this->_writeInLog($data, false);
+    return $this->_runAndLogCommands($commands);
+  }
+
+
+  public function askGitClone($label) {
+    $url = GIT_SKINS . '/' . $label . '.git';
+
+    if ($this->_isRealtime())
+      return $this->_runRealtimeClone($label, $url);
+
+    if (!$datas = $this->getCloneJson())
+      $datas = [];
+
+    $datas[$label] = ['status' => $this->_('En attente depuis le %s', static::getCurrentDateTime()),
+                      'url' => $url];
+
+    return $this->_writeInCloneLog($datas, true);
+  }
+
+
+  protected function _runRealtimeClone($label, $url) {
+    $clone_result = $this->_runGitCloneFrom($url);
+    $message = $clone_result->getMessage();
+    if (!$clone_result->isSuccess())
+      return $message;
+
+    $update_datas = $this->getUpdateJson();
+    $update_datas[$label] = ['Status' => $message];
+    $this->_writeInUpdateLog($update_datas, null);
+
+    return $message;
   }
 
 
   public function runGitClone() {
-    $data = [];
-    foreach ($this->getJson() as $skin) {
-      if (!is_array($skin))
-        continue;
+    if (!$this->_shouldRunClone())
+      return $this->_('Aucune demande d\'exécution de la commande "%s" trouvée dans le fichier "%s"',
+                      static::$command_clone, static::$skins_clone_log);
+
+    $clones_datas = $this->getCloneJson();
+    $state = new Class_Profil_SkinUpdateReader_DataState($clones_datas,
+                                                         $this->getUpdateJson());
+
+    foreach ($clones_datas as $label => $data)
+      $this->_runOneGitClone($label, $data, $state);
+
+    if ($state->shouldWriteUpdate())
+      $this->_writeInUpdateLog($state->getUpdate(), null);
 
-      if (!in_array('url',array_keys($skin)))
-        continue;
+    return $this->_writeInCloneLog($state->getClones(), false);
+  }
+
+
+  protected function _runOneGitClone($label, $data, $state) {
+    if (!is_array($data) || !isset($data['url']))
+      return;
 
-      $label = end(explode('/', $skin['url']));
-      $data[$label] = ['Status' => $this->_runGitClone($skin['url'])];
-    }
+    $clone_result = $this->_runGitCloneFrom($data['url']);
+    $message = $clone_result->getMessage();
 
-    return empty($data)
-      ? false
-      : $this->_writeInLog($data, false);
+    $clone_result->isSuccess()
+      ? $state->cloneBecomesUpdatable($label, $message)
+      : $state->setCloneMessage($label, $message);
   }
 
 
-  public function getJson() {
-    return json_decode($this->getFileWriter()->getContents(static::getLogPath()), true);
+  public function cancelClone($label) {
+    if (!$label || 'should_run' == $label)
+      return $this->_('Impossible d\'annuler l\'ajout d\'un thème inconnu');
+
+    $clones_datas = $this->getCloneJson();
+    unset($clones_datas[$label]);
+    $this->_writeInCloneLog($clones_datas, null);
+    return $this->_('Ajout du thème "%s" annulé', $label);
+  }
+
+
+  public function getUpdateJson() {
+    return json_decode($this->getFileWriter()->getContents(static::getUpdateLogPath()), true);
   }
 
 
   public function getStatus($folder) {
-    if(!$folder || !($json = $this->getJson()))
+    if (!$folder || !($json = $this->getUpdateJson()))
       return '';
 
-    if(!isset($json[$folder]))
+    if (!isset($json[$folder]))
       return '';
 
     return $json[$folder]['Status'];
@@ -115,11 +194,43 @@ class Class_Profil_SkinUpdateReader {
 
 
   public function getUpdatableSkins() {
-    $skins = [];
-    foreach((new Class_Profil())->getUpdatableSkins() as $skin_label)
-      $skins[] = (new Class_Entity())->updateAttributes(['Label' => $skin_label,
-                                                         'Status' => $this->getStatus($skin_label)]);
-    return $skins;
+    return (new Class_Profil)->getUpdatableSkins();
+  }
+
+
+  public function getUpdatableSkinsCollection() {
+    $skins = array_map(function($label)
+                       {
+                         return new Class_Profil_SkinUpdateReader_Skin($label,
+                                                                       $this->getStatus($label));
+                       },
+                       $this->getUpdatableSkins());
+
+    return new Storm_Collection($skins);
+  }
+
+
+  public function getWaitingClones() {
+    if (!$datas = $this->getCloneJson())
+      return [];
+
+    $clones = array_map(function($data, $label)
+                        {
+                          return is_array($data)
+                            ? new Class_Profil_SkinUpdateReader_Clone($label,
+                                                                      $data['status'],
+                                                                      $data['url'])
+                            : null;
+                        },
+                        $datas,
+                        array_keys($datas));
+
+    return array_filter($clones);
+  }
+
+
+  public function getCloneJson() {
+    return json_decode($this->getFileWriter()->getContents(static::getCloneLogPath()), true);
   }
 
 
@@ -129,39 +240,174 @@ class Class_Profil_SkinUpdateReader {
     $runner->exec($command);
 
     $output = '<br>' . implode('<br>', $runner->getOutput());
+    $is_success = 0 === $runner->getReturnVar();
+    $message = $is_success
+      ? static::getCurrentDateTime() . $output
+      : $this->_('La commande %s a échoué : %s', $command, $output);
+
+    return new Class_Profil_SkinUpdateReader_ExecResult($is_success, $message);
+  }
 
-    if(0 !== $runner->getReturnVar())
-      return $this->_('La commande %s a échoué : %s', $command, $output);
 
-    return static::getCurrentDateTime() . $output;
+  protected function _runGitCloneFrom($url) {
+    $commands = ['cd ' . SKINS . ' 2>&1',
+                 static::$command_clone . ' ' . $url . ' 2>&1',
+                 'cd ' . ROOT_PATH . ' 2>&1'];
+    return $this->_runAndLogCommands($commands);
   }
 
 
-  protected function _runGitPullIn($folder) {
-    $commands = ['cd ./' . SKINS . '/' . $folder . ' 2>&1',
-                 static::$command_pull . ' 2>&1',
-                 'cd ../../ 2>&1'];
+  protected function _writeInUpdateLog($data, $should_run) {
+    return $this->_writeInLog($data, static::getUpdateLogPath(), $should_run);
+  }
 
-    return $this->_runAndLogCommands($commands);
+
+  protected function _writeInCloneLog($data, $should_run) {
+    return $this->_writeInLog($data, static::getCloneLogPath(), $should_run);
   }
 
 
-  protected function _runGitClone($url) {
-    $commands = ['cd ' . SKINS . ' 2>&1',
-                 static::$command_clone . ' ' . $url . ' 2>&1',
-                 'cd ' . ROOT_PATH . ' 2>&1'];
-    return $this->_runAndLogCommands($commands);
+  protected function _writeInLog($data, $log_path, $should_run) {
+    if (is_bool($should_run))
+      $data['should_run'] = $should_run;
+
+    return static::getFileWriter()->putContents($log_path, json_encode($data));
+  }
+
+
+  protected function _shouldRunUpdate() {
+    $json = $this->getUpdateJson();
+    return isset($json['should_run']) && $json['should_run'];
+  }
+
+
+  protected function _shouldRunClone() {
+    $json = $this->getCloneJson();
+    return isset($json['should_run']) && $json['should_run'];
+  }
+}
+
+
+
+
+class Class_Profil_SkinUpdateReader_DataState {
+  protected
+    $_should_write_update = false,
+    $_clones = [],
+    $_update = [];
+
+  public function __construct($clones, $update) {
+    $this->_clones = $clones;
+    $this->_update = $update;
+  }
+
+
+  public function cloneBecomesUpdatable($label, $message) {
+    $this->_update[$label] = ['Status' => $message];
+    unset($this->_clones[$label]);
+    $this->_should_write_update = true;
+    return $this;
+  }
+
+
+  public function setCloneMessage($label, $message) {
+    if (isset($this->_clones[$label]))
+      $this->_clones[$label]['status'] = $message;
+
+    return $this;
+  }
+
+
+  public function shouldWriteUpdate() {
+    return $this->_should_write_update;
+  }
+
+
+  public function getUpdate() {
+    return $this->_update;
+  }
+
+
+  public function getClones() {
+    return $this->_clones;
+  }
+}
+
+
+
+
+class Class_Profil_SkinUpdateReader_ExecResult {
+  protected $_is_success, $_message;
+
+  public function __construct($is_success, $message) {
+    $this->_is_success = $is_success;
+    $this->_message = $message;
+  }
+
+
+  public function isSuccess() {
+    return $this->_is_success;
+  }
+
+
+  public function getMessage() {
+    return $this->_message;
+  }
+}
+
+
+
+
+class Class_Profil_SkinUpdateReader_Skin {
+  use Trait_GetterByAttributeName;
+
+  protected
+    $_label,
+    $_status;
+
+  public function __construct($label, $status) {
+    $this->_label = $label;
+    $this->_status = $status;
+  }
+
+
+  public function getLabel() {
+    return $this->_label;
+  }
+
+
+  public function getStatus() {
+    return $this->_status;
+  }
+}
+
+
+
+
+class Class_Profil_SkinUpdateReader_Clone {
+  use Trait_GetterByAttributeName;
+
+  protected
+    $_skin,
+    $_url;
+
+  public function __construct($label, $status, $url) {
+    $this->_skin = new Class_Profil_SkinUpdateReader_Skin($label, $status);
+    $this->_url = $url;
+  }
+
+
+  public function getLabel() {
+    return $this->_skin->getLabel();
   }
 
 
-  protected function _writeInLog($data, $should_run) {
-    $data['should_run'] = $should_run;
-    return static::getFileWriter()->putContents(static::getLogPath(), json_encode($data));
+  public function getStatus() {
+    return $this->_skin->getStatus();
   }
 
 
-  protected function _shouldRun() {
-    return $this->getJson()['should_run'];
+  public function getUrl() {
+    return $this->_url;
   }
 }
-?>
\ No newline at end of file
diff --git a/library/Class/TableDescription/CloneSkins.php b/library/Class/TableDescription/CloneSkins.php
new file mode 100644
index 00000000000..13b637a29e9
--- /dev/null
+++ b/library/Class/TableDescription/CloneSkins.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_CloneSkins extends Class_TableDescription_UpdateSkins {
+  public function init() {
+    parent::init();
+    $this->addColumn($this->_('Url'), 'url')
+         ->addRowAction(['url' => ['action' => 'clone-skin-cancel',
+                                   'id' => '%s'],
+                         'id' => function($model) { return $model->getLabel(); },
+                         'icon' => 'delete',
+                         'label' => $this->_('Annuler'),
+                         'anchorOptions' => ['onclick' => 'return confirm(\'Êtes-vous sûr ?\')']]);
+  }
+}
diff --git a/library/Class/TableDescription/UpdateSkins.php b/library/Class/TableDescription/UpdateSkins.php
new file mode 100644
index 00000000000..7c7835be2cc
--- /dev/null
+++ b/library/Class/TableDescription/UpdateSkins.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_UpdateSkins extends Class_TableDescription {
+  public function init() {
+    $this->addColumn($this->_('Thème'), 'label')
+         ->addColumn($this->_('Statut'), 'status');
+  }
+}
diff --git a/library/ZendAfi/View/Helper/Admin/UpdateSkins.php b/library/ZendAfi/View/Helper/Admin/UpdateSkins.php
index a6a1c1b84fb..04a6c9183c9 100644
--- a/library/ZendAfi/View/Helper/Admin/UpdateSkins.php
+++ b/library/ZendAfi/View/Helper/Admin/UpdateSkins.php
@@ -21,49 +21,57 @@
 
 
 class ZendAfi_View_Helper_Admin_UpdateSkins extends ZendAfi_View_Helper_BaseHelper {
+  protected $_reader;
 
   public function Admin_UpdateSkins($reader) {
-    $skins = $reader->getUpdatableSkins();
-    $html = $this->_tag('p', $this->_('Cette fonctionnalité nécessite la mise en place d\'une tâche planifiée sur le serveur d\'hébergement pour fonctionner.'));
+    if (!$this->_reader = $reader)
+      return '';
 
-    if (Class_Users::isCurrentUserSuperAdmin())
-      $html .= $this->view->renderForm((new ZendAfi_Form)
-                                       ->addElement('text',
-                                                    'url',
-                                                    ['label' => $this->_('Nom du thème à installer'),
-                                                     'size' => 50,
-                                                     'required' => true,
-                                                     'allowEmpty' => false])
-                                       ->setAction('/admin/index/update-skin/git/clone'));
+    return $this->_tag('p',
+                       $this->_('Cette fonctionnalité nécessite la mise en place d\'une tâche planifiée sur le serveur d\'hébergement pour fonctionner.'))
+      . $this->_renderExisting()
+      . $this->_renderAdd()
+      ;
+  }
 
-    $html .= $this->view->button((new Class_Button)
-                                 ->setText($this->_('Demander la mise à jour'))
-                                 ->setUrl($this->view->url(['git' => 'pull']))
-                                 ->setImage($this->view->tagImg(Class_Admin_Skin::current()
-                                                                ->getIconUrl('buttons',
-                                                                             'generate'))));
 
-    $lis = '';
-    foreach($skins as $skin)
-      $lis .= $this->_tag('li', $this->_renderSkin($skin));
+  protected function _renderExisting() {
+    return
+      $this->_tag('h2',
+                  $this->_('Thèmes installés')
+                  . $this->view->button((new Class_Button)
+                                        ->setText($this->_('Demander la mise à jour'))
+                                        ->setUrl($this->view->url(['git' => 'pull']))
+                                        ->setImage($this->view->tagImg(Class_Admin_Skin::current()
+                                                                       ->getIconUrl('buttons', 'generate')))))
 
-    $html .= $this->_tag('ul', $lis);
-    return $html;
+      . $this->view->renderTable(new Class_TableDescription_UpdateSkins('skins'),
+                                 $this->_reader->getUpdatableSkinsCollection());
   }
 
 
-  protected function _renderSkin($skin) {
-    $header = $this->_tag('h2', $this->_('Thème %s', $skin->getLabel()));
 
-    $update_time = ($update_time = $skin->getStatus())
-      ? $update_time
-      : $this->_('inconnu');
+  protected function _renderAdd() {
+    if (!Class_Users::isCurrentUserSuperAdmin())
+      return '';
+
+    $group_url = 'https://git.afi-sa.net/opac-skins';
+
+    return
+      $this->_tag('h2', $this->_('Ajouter un thème'))
+      . $this->_tag('p', $this->_('Limité aux dépôts présents dans %s',
+                                  $this->_tagAnchor($group_url, $group_url)))
 
-    $definitions = $this->_tag('dt', $this->_('Statut : '))
-      . $this->_tag('dd', $update_time);
+      . $this->view->renderForm((new ZendAfi_Form)
+                                ->addElement('text',
+                                             'url',
+                                             ['label' => $this->_('Nom du thème à ajouter'),
+                                              'size' => 50,
+                                              'required' => true,
+                                              'allowEmpty' => false])
+                                ->setAction('/admin/index/update-skin/git/clone'))
 
-    $content = $this->_tag('dl', $definitions);
-    return $header . $content;
+      . $this->view->renderTable(new Class_TableDescription_CloneSkins('waiting-clones'),
+                                 $this->_reader->getWaitingClones());
   }
 }
-?>
\ No newline at end of file
diff --git a/library/startup.php b/library/startup.php
index 5e7781548a2..ce18e681e85 100644
--- a/library/startup.php
+++ b/library/startup.php
@@ -101,7 +101,7 @@ class Bokeh_Engine {
 
     defineConstant('SKINS', 'skins');
     defineConstant('GIT_REALTIME', false);
-    defineConstant('GIT_SKINS', 'git://git.afi-sa.net/opac-skins');
+    defineConstant('GIT_SKINS', 'git@git.afi-sa.net:opac-skins');
 
     defineConstant('PATH_TEMP',  ROOT_PATH . 'temp/');
 
diff --git a/scripts/update_skins.php b/scripts/update_skins.php
index f2a73112a98..5ca6cb701de 100644
--- a/scripts/update_skins.php
+++ b/scripts/update_skins.php
@@ -1,4 +1,5 @@
 <?php
 require('console.php');
-return (new Class_Profil_SkinUpdateReader())->runGitPull();
-?>
\ No newline at end of file
+$updater = new Class_Profil_SkinUpdateReader;
+$updater->runGitPull();
+$updater->runGitClone();
diff --git a/tests/application/modules/admin/controllers/IndexControllerUpdateSkinTest.php b/tests/application/modules/admin/controllers/IndexControllerUpdateSkinTest.php
index 990e4b02578..a443297099f 100644
--- a/tests/application/modules/admin/controllers/IndexControllerUpdateSkinTest.php
+++ b/tests/application/modules/admin/controllers/IndexControllerUpdateSkinTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Copyright (c) 2012-2014, Agence Française Informatique (AFI). All rights reserved.
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
  *
  * BOKEH is free software; you can redistribute it and/or modify
  * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
@@ -21,7 +21,12 @@
 
 
 abstract class IndexControllerUpdateSkinTestCase extends Admin_AbstractControllerTestCase {
-  protected $_file_writer;
+  protected
+    $_file_writer,
+    $_update_log_content,
+    $_update_log_data,
+    $_clone_log_content,
+    $_clone_log_data;
 
   public function setUp() {
     parent::setUp();
@@ -37,17 +42,57 @@ abstract class IndexControllerUpdateSkinTestCase extends Admin_AbstractControlle
     $this->_file_writer = $this->mock();
     Class_Profil_SkinUpdateReader::setFileWriter($this->_file_writer);
 
+    $this->_update_log_content = json_encode(['Valence' => ['Status' => '25/04/2016 15:01:37']]);
+    $this->_clone_log_content = json_encode(['my-super-skin' => ['status' => 'En attente depuis longtemps...',
+                                                                 'url' => 'git@git.afi-sa.net:opac-skins/my-super-skin.git'],
+                                             'should_run' => false]);
+
     $this->_file_writer
       ->whenCalled('getContents')
-      ->with(Class_Profil_SkinUpdateReader::getLogPath())
-      ->answers(json_encode(['Valence' => ['Status' => '25/04/2016 15:01:37']]))
+      ->willDo(function($path)
+               {
+                 if ($this->_updateLogPath() == $path)
+                   return $this->_update_log_content;
+
+                 if ($this->_cloneLogPath() == $path)
+                   return $this->_clone_log_content;
+
+                 return '';
+               })
 
       ->whenCalled('putContents')
-      ->with(Class_Profil_SkinUpdateReader::getLogPath(),
-             '{"Valence":{"Status":"En attente depuis le 2016-05-02 12:30:00"},"should_run":true}')
-      ->answers(true)
+      ->willDo(function($path, $contents)
+               {
+                 if ($this->_updateLogPath() == $path) {
+                   $this->_update_log_content = $contents;
+                   return true;
+                 }
+
+                 if ($this->_cloneLogPath() == $path) {
+                   $this->_clone_log_content = $contents;
+                   return true;
+                 }
+
+                 return false;
+               });
+  }
+
+
+  protected function _updateLogPath() {
+    return Class_Profil_SkinUpdateReader::getUpdateLogPath();
+  }
+
 
-      ->beStrict();
+  protected function _cloneLogPath() {
+    return Class_Profil_SkinUpdateReader::getCloneLogPath();
+  }
+
+
+  public function tearDown() {
+    Class_Profil_Skin::setFileSystem(null);
+    Class_Profil_SkinUpdateReader::setFileWriter(null);
+    Class_Profil_SkinUpdateReader::setTimeSource(null);
+    parent::tearDown();
   }
 }
 
@@ -69,13 +114,13 @@ class IndexControllerUpdateSkinDispatchTest extends IndexControllerUpdateSkinTes
 
   /** @test */
   public function listShouldContainsSkinValence() {
-    $this->assertXPathContentContains('//ul/li/h2', 'Thème Valence');
+    $this->assertXPathContentContains('//table[@id="skins"]//td', 'Valence');
   }
 
 
   /** @test */
   public function valenceShouldhaveBeenUpdatedOn25Slash04slash2016() {
-    $this->assertXPathContentContains('//ul/li/dl/dd', '25/04/2016 15:01:37');
+    $this->assertXPathContentContains('//table[@id="skins"]//td', '25/04/2016 15:01:37');
   }
 
 
@@ -89,7 +134,43 @@ class IndexControllerUpdateSkinDispatchTest extends IndexControllerUpdateSkinTes
   /** @test */
   public function formInstallSkinShouldBePresent() {
     $this->assertXPathContentContains('//form[@action="/admin/index/update-skin/git/clone"]',
-                                      'Nom du thème à installer');
+                                      'Nom du thème à ajouter');
+  }
+
+
+  /** @test */
+  public function waitingCloneMySuperSkinShouldBePresent() {
+    $this->assertXPathContentContains('//table[@id="waiting-clones"]//td',
+                                      'my-super-skin');
+  }
+
+
+  /** @test */
+  public function linkToCancelWaitingMySuperSkinCloneShouldBePresent() {
+    $this->assertXPath('//table[@id="waiting-clones"]//a[contains(@href, "/clone-skin-cancel/id/my-super-skin")]');
+  }
+}
+
+
+
+
+class IndexControllerUpdateSkinCancelCloneTest extends IndexControllerUpdateSkinTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('admin/index/clone-skin-cancel/id/my-super-skin');
+    $this->_clone_log_data = json_decode($this->_clone_log_content, true);
+  }
+
+
+  /** @test */
+  public function messageShouldBeAjoutAnnule() {
+    $this->assertFlashMessengerContentContains('Ajout du thème "my-super-skin" annulé');
+  }
+
+
+  /** @test */
+  public function mySuperSkinShouldNotBeInCloneData() {
+    $this->assertFalse(isset($this->_clone_log_data['my-super-skin']));
   }
 }
 
@@ -100,12 +181,26 @@ class IndexControllerUpdateSkinDispatchTest extends IndexControllerUpdateSkinTes
 class IndexControllerUpdateSkinGitPullTest extends IndexControllerUpdateSkinTestCase {
   public function setUp() {
     parent::setUp();
-    $this->dispatch('admin/index/update-skin/git/pull',  true);
+    $this->dispatch('admin/index/update-skin/git/pull');
+    $this->_update_log_data = json_decode($this->_update_log_content, true);
+  }
+
+
+  /** @test */
+  public function logShouldBeMarkedAsRunnable() {
+    $this->assertTrue($this->_update_log_data['should_run']);
+  }
+
+
+  /** @test */
+  public function valenceShouldBeWaiting() {
+    $this->assertEquals('En attente depuis le 2016-05-02 12:30:00',
+                        $this->_update_log_data['Valence']['Status']);
   }
 
 
   /** @test */
-  public function askSendShouldBeDisplay() {
+  public function updateAskedMessageShouldBeDisplay() {
     $this->assertFlashMessengerContentContains('La demande de mise à jour a été envoyée au serveur');
   }
 }
@@ -113,22 +208,35 @@ class IndexControllerUpdateSkinGitPullTest extends IndexControllerUpdateSkinTest
 
 
 
-class IndexControllerInstallSkinGitCloneTest extends IndexControllerUpdateSkinTestCase {
+class IndexControllerUpdateSkinGitCloneTest extends IndexControllerUpdateSkinTestCase {
   public function setUp() {
     parent::setUp();
-    $this->_file_writer
-      ->whenCalled('putContents')
-      ->with(Class_Profil_SkinUpdateReader::getLogPath(),
-             '{"Valence":{"Status":"En attente depuis le 2016-05-02 12:30:00","url":"git:\/\/git.afi-sa.net\/opac-skins\/Valence"},"should_run":true}')
-      ->answers(true)
-      ->beStrict();
+    $this->dispatch('admin/index/update-skin/git/clone/url/my-super-skin');
+    $this->_clone_log_data = json_decode($this->_clone_log_content, true);
+  }
+
+
+  /** @test */
+  public function mySuperSkinShouldBeWaitingClone() {
+    $this->assertTrue(isset($this->_clone_log_data['my-super-skin']));
+  }
 
-    $this->dispatch('admin/index/update-skin/git/clone/url/Valence');
+
+  /** @test */
+  public function mySuperSkinUrlShouldBeInOpacSkins() {
+    $this->assertEquals('git@git.afi-sa.net:opac-skins/my-super-skin.git',
+                        $this->_clone_log_data['my-super-skin']['url']);
   }
 
 
   /** @test */
-  public function askInstallShouldBeDisplay() {
+  public function waitingClonesShouldBeRunnable() {
+    $this->assertTrue($this->_clone_log_data['should_run']);
+  }
+
+
+  /** @test */
+  public function installAskedMessageShouldBeDisplay() {
     $this->assertFlashMessengerContentContains('La demande d\'installation a été envoyée au serveur');
   }
-}
\ No newline at end of file
+}
diff --git a/tests/library/Class/Profil/SkinUpdateReaderTest.php b/tests/library/Class/Profil/SkinUpdateReaderTest.php
index dfd0520c43e..b95db2f96c2 100644
--- a/tests/library/Class/Profil/SkinUpdateReaderTest.php
+++ b/tests/library/Class/Profil/SkinUpdateReaderTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Copyright (c) 2012-2014, Agence Française Informatique (AFI). All rights reserved.
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
  *
  * BOKEH is free software; you can redistribute it and/or modify
  * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
@@ -19,9 +19,12 @@
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
-
-class Class_SkinUpdateReaderTest extends ModelTestCase {
-  protected $_reader;
+abstract class Class_SkinUpdateReaderTestCase extends ModelTestCase {
+  protected
+    $_reader,
+    $_file_writer,
+    $_update_log_content,
+    $_clone_log_content;
 
   public function setUp() {
     parent::setUp();
@@ -31,8 +34,8 @@ class Class_SkinUpdateReaderTest extends ModelTestCase {
 
     Class_Profil_Skin::setFileSystem($file_system);
 
-    $file_writer = $this->mock();
-    Class_Profil_SkinUpdateReader::setFileWriter($file_writer);
+    $this->_file_writer = $this->mock();
+    Class_Profil_SkinUpdateReader::setFileWriter($this->_file_writer);
 
     $time_source = new TimeSourceForTest('2016-05-02 12:30:00');
     Class_Profil_SkinUpdateReader::setTimeSource($time_source);
@@ -42,7 +45,16 @@ class Class_SkinUpdateReaderTest extends ModelTestCase {
 
     $command
       ->whenCalled('exec')
-      ->with('cd ./skins/Valence 2>&1 && git reset --hard HEAD && git pull --rebase 2>&1 && cd ../../ 2>&1')
+      ->with('cd ./skins/Valence 2>&1'
+             . ' && git reset --hard HEAD'
+             . ' && git pull --rebase 2>&1'
+             . ' && cd ../../ 2>&1')
+      ->answers('')
+
+      ->whenCalled('exec')
+      ->with('cd skins 2>&1'
+             . ' && git clone git@git.afi-sa.net:opac-skins/Iep38.git 2>&1'
+             . ' && cd ' . ROOT_PATH . ' 2>&1')
       ->answers('')
 
       ->whenCalled('getOutput')
@@ -53,26 +65,228 @@ class Class_SkinUpdateReaderTest extends ModelTestCase {
 
       ->beStrict();
 
-    $file_writer
+    $this->_update_log_content = json_encode(['Valence' => ['Status' => 'En attente depuis le 2016-05-02 12:30:00'],
+                                              'should_run' => true]);
+
+    $this->_clone_log_content = json_encode(['Iep38' => ['Status' => 'En attente depuis le 2016-05-02 12:30:00',
+                                                         'url' => 'git@git.afi-sa.net:opac-skins/Iep38.git'],
+                                             'should_run' => true]);
+
+    $this->_file_writer
       ->whenCalled('getContents')
-      ->with(Class_Profil_SkinUpdateReader::getLogPath())
-      ->answers(json_encode(['Valence' => ['Status' => '2016-05-02 12:30:00'],
-                             'should_run' => true]))
+      ->willDo(function($path)
+               {
+                 if ($this->_updateLogPath() == $path)
+                   return $this->_update_log_content;
+
+                 if ($this->_cloneLogPath() == $path)
+                   return $this->_clone_log_content;
+
+                 return '';
+               })
 
       ->whenCalled('putContents')
-      ->with(Class_Profil_SkinUpdateReader::getLogPath(),
-             '{"Valence":{"Status":"2016-05-02 12:30:00<br>"},"should_run":false}')
-      ->answers(true)
+      ->willDo(function($path, $contents)
+               {
+                 if ($this->_updateLogPath() == $path) {
+                   $this->_update_log_content = $contents;
+                   return true;
+                 }
 
-      ->beStrict();
+                 if ($this->_cloneLogPath() == $path) {
+                   $this->_clone_log_content = $contents;
+                   return true;
+                 }
+
+                 return false;
+               });
 
     $this->_reader = new Class_Profil_SkinUpdateReader();
+  }
+
+
+  protected function _updateLogPath() {
+    return Class_Profil_SkinUpdateReader::getUpdateLogPath();
+  }
+
+
+  protected function _cloneLogPath() {
+    return Class_Profil_SkinUpdateReader::getCloneLogPath();
+  }
+
+
+  public function tearDown() {
+    Class_Profil_Skin::setFileSystem(null);
+    Class_Profil_SkinUpdateReader::setFileWriter(null);
+    Class_Profil_SkinUpdateReader::setTimeSource(null);
+    Class_Profil_SkinUpdateReader::setCommand(null);
+    Class_Profil_SkinUpdateReader::setRealtime(null);
+
+    parent::tearDown();
+  }
+}
+
+
+
+
+class Class_SkinUpdateReaderRunPullTest extends Class_SkinUpdateReaderTestCase {
+  public function setUp() {
+    parent::setUp();
     $this->_reader->runGitPull();
   }
 
 
   /** @test */
   public function valenceShouldHaveBeenUpdated() {
-    $this->assertEquals('2016-05-02 12:30:00', $this->_reader->getStatus('Valence'));
+    $this->assertEquals('2016-05-02 12:30:00<br>', $this->_reader->getStatus('Valence'));
+  }
+
+
+  /** @test */
+  public function iep38ShouldNotHaveBeenCloned() {
+    $this->assertEquals('', $this->_reader->getStatus('Iep38'));
+  }
+}
+
+
+
+
+class Class_SkinUpdateReaderAskRealtimePullTest extends Class_SkinUpdateReaderTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_Profil_SkinUpdateReader::setRealtime(true);
+    $this->_reader->askGitPull();
+  }
+
+
+  /** @test */
+  public function valenceShouldHaveBeenUpdated() {
+    $this->assertEquals('2016-05-02 12:30:00<br>', $this->_reader->getStatus('Valence'));
+  }
+
+
+  /** @test */
+  public function iep38ShouldNotHaveBeenCloned() {
+    $this->assertEquals('', $this->_reader->getStatus('Iep38'));
+  }
+}
+
+
+
+
+class Class_SkinUpdateReaderRunCloneTest extends Class_SkinUpdateReaderTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->_reader->runGitClone();
+  }
+
+
+  /** @test */
+  public function valenceShouldNotHaveBeenUpdated() {
+    $this->assertEquals('En attente depuis le 2016-05-02 12:30:00',
+                        $this->_reader->getStatus('Valence'));
+  }
+
+
+  /** @test */
+  public function iep38ShouldHaveBeenCloned() {
+    $this->assertEquals('2016-05-02 12:30:00<br>',
+                        $this->_reader->getStatus('Iep38'));
+  }
+
+
+  /** @test */
+  public function iep38ShouldNotBeWaitingClone() {
+    $this->assertNotContains('Iep38', $this->_clone_log_content);
+  }
+}
+
+
+
+
+class Class_SkinUpdateReaderRunCloneWithErrorTest extends Class_SkinUpdateReaderTestCase {
+  public function setUp() {
+    parent::setUp();
+    $command = $this->mock();
+    Class_Profil_SkinUpdateReader::setCommand($command);
+
+    $command
+      ->whenCalled('exec')
+      ->with('cd skins 2>&1'
+             . ' && git clone git@git.afi-sa.net:opac-skins/Iep38.git 2>&1'
+             . ' && cd ' . ROOT_PATH . ' 2>&1')
+      ->answers('')
+
+      ->whenCalled('getOutput')
+      ->answers(['error: git: bad trip'])
+
+      ->whenCalled('getReturnVar')
+      ->answers(99)
+
+      ->beStrict();
+
+    $this->_reader->runGitClone();
+  }
+
+
+  /** @test */
+  public function valenceShouldNotHaveBeenUpdated() {
+    $this->assertEquals('En attente depuis le 2016-05-02 12:30:00',
+                        $this->_reader->getStatus('Valence'));
+  }
+
+
+  /** @test */
+  public function iep38ShouldNotHaveBeenCloned() {
+    $this->assertEquals('', $this->_reader->getStatus('Iep38'));
+  }
+
+
+  /** @test */
+  public function iep38ErrorShouldBeInWaitingCloneStatus() {
+    $this->assertContains('error: git: bad trip', $this->_clone_log_content);
+  }
+}
+
+
+
+
+class Class_SkinUpdateReaderAskRealtimeCloneTest extends Class_SkinUpdateReaderTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_Profil_SkinUpdateReader::setRealtime(true);
+    $datas = ['Valence' => ['Status' => 'En attente depuis le 2016-05-02 12:30:00'],
+              'should_run' => true];
+    $this->_clone_log_content = json_encode($datas);
+
+    $this->_reader->askGitClone('Iep38');
+  }
+
+
+  /** @test */
+  public function valenceShouldNotHaveBeenUpdated() {
+    $this->assertEquals('En attente depuis le 2016-05-02 12:30:00',
+                        $this->_reader->getStatus('Valence'));
+  }
+
+
+  /** @test */
+  public function updateShouldStillBeRunnable() {
+    $this->assertTrue(json_decode($this->_update_log_content, true)['should_run']);
+  }
+
+
+  /** @test */
+  public function cloneIep38CommandShouldHaveBeenExecuted() {
+    $this->assertContains('git clone git@git.afi-sa.net:opac-skins/Iep38.git',
+                          Class_Profil_SkinUpdateReader::getCommand()
+                          ->getFirstAttributeForLastCallOn('exec'));
+  }
+
+
+  /** @test */
+  public function iep38ShouldHaveUpdatableStatus() {
+    $this->assertEquals('2016-05-02 12:30:00<br>',
+                        $this->_reader->getStatus('Iep38'));
   }
 }
-- 
GitLab