From 3ed35cc2eb1dd84748accf1119101272d3d88b3c Mon Sep 17 00:00:00 2001
From: Laurent Laffont <llaffont@afi-sa.fr>
Date: Tue, 26 Nov 2019 16:59:36 +0100
Subject: [PATCH] dev#100949 : import events from openagenda (JSON Format)

---
 FEATURES/100949                               |  10 +
 VERSIONS_WIP/100949                           |   1 +
 .../controllers/ExternalAgendasController.php |   9 +-
 cosmogramme/sql/patch/patch_381.php           |  10 +
 library/Class/Batch/ExternalAgenda.php        |   1 -
 library/Class/ExternalAgenda.php              | 100 ++++-
 .../ICalendar.php                             |  32 +-
 library/Class/ExternalAgenda/OpenAgenda.php   | 206 ++++++++++
 library/Class/ExternalAgenda/Provider.php     |  44 +++
 library/ZendAfi/Form/Admin/ExternalAgenda.php |  23 +-
 .../controllers/RecordCustomLinksTest.php     |  14 +-
 tests/db/UpgradeDBTest.php                    |  23 ++
 .../ExternalAgendasBatchTest.php              |  12 +-
 .../ExternalAgendasOpenAgendaTest.php         | 220 +++++++++++
 .../ExternalAgendas/ExternalAgendasTest.php   |  40 +-
 .../ExternalAgendas/open-agenda-1.json        | 136 +++++++
 .../ExternalAgendas/open-agenda-2.json        | 374 ++++++++++++++++++
 .../ExternalAgendas/open-agenda-3.json        |   7 +
 18 files changed, 1206 insertions(+), 56 deletions(-)
 create mode 100644 FEATURES/100949
 create mode 100644 VERSIONS_WIP/100949
 create mode 100644 cosmogramme/sql/patch/patch_381.php
 rename library/Class/{WebService => ExternalAgenda}/ICalendar.php (88%)
 create mode 100644 library/Class/ExternalAgenda/OpenAgenda.php
 create mode 100644 library/Class/ExternalAgenda/Provider.php
 create mode 100644 tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php
 create mode 100644 tests/scenarios/ExternalAgendas/open-agenda-1.json
 create mode 100644 tests/scenarios/ExternalAgendas/open-agenda-2.json
 create mode 100644 tests/scenarios/ExternalAgendas/open-agenda-3.json

diff --git a/FEATURES/100949 b/FEATURES/100949
new file mode 100644
index 00000000000..125c0094fac
--- /dev/null
+++ b/FEATURES/100949
@@ -0,0 +1,10 @@
+        '100949' =>
+            ['Label' => $this->_('Intégration d'Evénements publiés dans un Open-Agenda (JSON)'),
+             'Desc' => $this->_('Les Calendriers Externes au format OpenAgenda peuvent être importés.'),
+             'Image' => '',
+             'Video' => 'https://youtube.com/watch?v=5leZZihcMKk',
+             'Category' => $this->_('Import Calendriers'),
+             'Right' => function($feature_description, $user) {return true;},
+             'Wiki' => 'http://wiki.bokeh-library-portal.org/index.php?title=Agendas_Externes',
+             'Test' => 'http://git.afi-sa.fr/afi/opacce/merge_requests/3329',
+             'Date' => '2019-11-26'],
\ No newline at end of file
diff --git a/VERSIONS_WIP/100949 b/VERSIONS_WIP/100949
new file mode 100644
index 00000000000..d389b2730a5
--- /dev/null
+++ b/VERSIONS_WIP/100949
@@ -0,0 +1 @@
+ - ticket #100949 : Agenda Externe : Intégration d'événements publiés sur Open-Agenda (format JSON)
\ No newline at end of file
diff --git a/application/modules/admin/controllers/ExternalAgendasController.php b/application/modules/admin/controllers/ExternalAgendasController.php
index 0c78b90ddca..d72ef5435ff 100644
--- a/application/modules/admin/controllers/ExternalAgendasController.php
+++ b/application/modules/admin/controllers/ExternalAgendasController.php
@@ -32,9 +32,12 @@ class Admin_ExternalAgendasController extends ZendAfi_Controller_Action {
       return $this->_redirectToIndex();
 
     $this->view->titre = $this->_('Moissonnage des évènements de l\'agenda "%s"', $agenda->getLibelle());
-    $results = $agenda->import();
-    $this->view->new_events = $results['new'];
-    $this->view->updated_events = $results['update'];
+
+    $agenda->import(function($created, $updated)
+                    {
+                      $this->view->new_events = $created;
+                      $this->view->updated_events = $updated;
+                    });
   }
 
 
diff --git a/cosmogramme/sql/patch/patch_381.php b/cosmogramme/sql/patch/patch_381.php
new file mode 100644
index 00000000000..1149b2094d1
--- /dev/null
+++ b/cosmogramme/sql/patch/patch_381.php
@@ -0,0 +1,10 @@
+<?php
+$adapter = Zend_Db_Table_Abstract::getDefaultAdapter();
+try {
+  $adapter->query("alter table external_agenda
+                       add column `provider` text not null default ''" );
+} catch (Exception $e) {}
+try {
+  $adapter->query("alter table external_agenda
+                       add column `delete_orphan_events` tinyint(1) not null default 0" );
+} catch (Exception $e) {}
diff --git a/library/Class/Batch/ExternalAgenda.php b/library/Class/Batch/ExternalAgenda.php
index f69be445238..268c1ada38a 100644
--- a/library/Class/Batch/ExternalAgenda.php
+++ b/library/Class/Batch/ExternalAgenda.php
@@ -31,4 +31,3 @@ class Class_Batch_ExternalAgenda extends Class_Batch_Abstract {
     Class_ExternalAgenda::harvest($this->getLogger());
   }
 }
-?>
\ No newline at end of file
diff --git a/library/Class/ExternalAgenda.php b/library/Class/ExternalAgenda.php
index a3584003760..7a97ee78788 100644
--- a/library/Class/ExternalAgenda.php
+++ b/library/Class/ExternalAgenda.php
@@ -21,22 +21,32 @@
 
 
 class Class_ExternalAgendaLoader extends Storm_Model_Loader {
+  use Trait_Translator;
 
   public function harvest($logger) {
-    foreach (Class_ExternalAgenda::findAllBy(['autoharvest' => 1]) as $agenda) {
-      $results = $agenda->import();
-      $logger->log($agenda->getLabel().":\n");
-      $logger->log($agenda->_("Nombre d\'événements créés : %s\n",count($results['new'])));
-      Class_AdminVar::get('AGENDA_KEEP_LOCAL_CONTENT')
-        ? $logger->log($agenda->_("Nombre d\'événements non mis à jour : %s\n",count($results['update'])))
-        : $logger->log($agenda->_("Nombre d\'événements mis à jour : %s\n",count($results['update'])));
-    }
+    foreach (Class_ExternalAgenda::findAllBy(['autoharvest' => 1]) as $agenda)
+      $agenda
+        ->import(function($created, $updated) use($logger, $agenda)
+                 {
+                   $agenda->logImportOn($logger, $created->count(), $updated->count());
+                 });
+  }
+
+
+  public function getAllProviderOptions() {
+    return [ Class_ExternalAgenda::ICALENDAR => $this->_('iCalendar (.ics)'),
+            Class_ExternalAgenda::OPEN_AGENDA => $this->_('OpenAgenda (.json)')];
   }
 }
 
 
+
+
 class Class_ExternalAgenda extends Storm_Model_Abstract {
-  use Trait_Translator;
+  use Trait_Translator, Trait_TimeSource;
+
+  const ICALENDAR   = 1;
+  const OPEN_AGENDA = 2;
 
   protected $_table_name = 'external_agenda';
   protected $_belongs_to = ['category' => ['model' => 'Class_ArticleCategorie',
@@ -47,6 +57,13 @@ class Class_ExternalAgenda extends Storm_Model_Abstract {
 
   protected $_loader_class = 'Class_ExternalAgendaLoader';
 
+  protected $_default_attribute_values =
+    [
+     'provider'=> self::ICALENDAR,
+     'delete_orphan_events' => 0
+    ];
+
+
   public function getLibelle() {
     return $this->getLabel();
   }
@@ -59,17 +76,68 @@ class Class_ExternalAgenda extends Storm_Model_Abstract {
   }
 
 
-  public function import() {
-    $service = new Class_WebService_ICalendar();
+  public function import($after_import=null) {
+    $service = $this->_newProvider();
     $events = $service->import($this);
-    $results['new'] = $events->select('isNew');
-    $results['update'] = $events->reject('isNew');
-    $results['new']->eachDo('save');
+    $events->eachDo([$this, 'deduplicateEvent']);
+
+    $created = $events->select('isNew');
+    $updated = $events->reject('isNew');
+
+    // must be after updated detection
+    $created->eachDo(function($model)
+                     {
+                       $model
+                         ->setDateCreation(date('Y-m-d H:i:s', $this->getCurrentTime()))
+                         ->save();
+                     });
 
     if (!Class_AdminVar::get('AGENDA_KEEP_LOCAL_CONTENT'))
-      $results['update']->eachDo('save');
+      $updated->eachDo(function ($model) { $model->updateDateMaj()->save(); });
+
+    if ($this->getDeleteOrphanEvents())
+      $this->_deleteOrphanEvents($events);
+
+    if ($after_import)
+      $after_import($created, $updated);
+  }
+
+
+  public function logImportOn($logger, $created_count, $updated_count) {
+    $logger->log($this->getLabel().":\n");
+    $logger->log($this->_("Nombre d'événements créés : %s\n", $created_count));
+
+    $message = Class_AdminVar::get('AGENDA_KEEP_LOCAL_CONTENT')
+      ? $this->_("Nombre d'événements non mis à jour : %s\n", $updated_count)
+      : $this->_("Nombre d'événements mis à jour : %s\n", $updated_count);
+
+    $logger->log($message);
+  }
+
+
+  public function deduplicateEvent($event){
+    if ($existing_event = $this->findEventByUID($event->getIdOrigine()))
+      $event->setId($existing_event->getId());
+  }
+
+
+  protected function _newProvider() {
+    $map = [static::OPEN_AGENDA => 'Class_ExternalAgenda_OpenAgenda',
+            static::ICALENDAR => 'Class_ExternalAgenda_ICalendar'];
+
+    return array_key_exists($this->getProvider(), $map)
+      ? new $map[$this->getProvider()]
+      : new Class_ExternalAgenda_Provider();
+  }
+
+
+  protected function _deleteOrphanEvents($updated_events) {
+    $delete_params = ['repository_origine' => $this->getRepositoryKey()];
+
+    if (!$updated_events->isEmpty())
+      $delete_params['ID_ARTICLE not'] = $updated_events->collect('id')->getArrayCopy();
 
-    return $results;
+    Class_Article::deleteBy($delete_params);
   }
 
 
diff --git a/library/Class/WebService/ICalendar.php b/library/Class/ExternalAgenda/ICalendar.php
similarity index 88%
rename from library/Class/WebService/ICalendar.php
rename to library/Class/ExternalAgenda/ICalendar.php
index 1f5f9a74e43..a68eb95f3b6 100644
--- a/library/Class/WebService/ICalendar.php
+++ b/library/Class/ExternalAgenda/ICalendar.php
@@ -20,34 +20,25 @@
  */
 
 
-class Class_WebService_ICalendar extends Class_WebService_Abstract {
-  use Trait_Translator;
-
+class Class_ExternalAgenda_ICalendar extends Class_ExternalAgenda_Provider {
   protected
-    $_events,
     $_current_event,
-    $_current_url,
-    $_external_agenda;
+    $_current_url;
 
-  public function import($external_agenda) {
-    $this->_external_agenda = $external_agenda;
-    $this->_events = new Storm_Model_Collection();
-    $this->_current_event = null;
 
+  protected function _import() {
+    $this->_current_event = null;
 
-    $ics_content = $this->httpGet($external_agenda->getUrl());
+    $ics_content = $this->httpGet($this->_external_agenda->getUrl());
     $ics_content = preg_replace('|\n\s|', '', $ics_content); //see RFC2445
     $lines = preg_split('|\r?\n|', $ics_content);
 
     array_map([$this, '_importLine'], $lines);
 
-    return $this->_events;
+    return $this;
   }
 
 
-  public function __call($method, $params) {}
-
-
   protected function _importLine($line) {
     if (!$line)
       return $this;
@@ -59,6 +50,14 @@ class Class_WebService_ICalendar extends Class_WebService_Abstract {
   }
 
 
+  public function __call($name, $args) {
+    if ('on' == substr($name, 0, 2))
+      return;
+
+    throw new RuntimeException('Call to undefined method Class_ExternalAgenda_ICalendar::' . $name);
+  }
+
+
   protected function _importData($key, $value) {
     if (in_array($key, ['BEGIN', 'END']))
       return $this->{'on' . $key . $value}();
@@ -116,9 +115,6 @@ class Class_WebService_ICalendar extends Class_WebService_Abstract {
   protected function onEventUID($value) {
     $value = md5($value);
 
-    if ($existing_event = $this->_external_agenda->findEventByUID($value))
-      $this->_current_event->setId($existing_event->getId());
-
     $this->_current_event->setIdOrigine($value);
     $this->_events->append($this->_current_event);
 
diff --git a/library/Class/ExternalAgenda/OpenAgenda.php b/library/Class/ExternalAgenda/OpenAgenda.php
new file mode 100644
index 00000000000..f5f3f7a2307
--- /dev/null
+++ b/library/Class/ExternalAgenda/OpenAgenda.php
@@ -0,0 +1,206 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, 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_ExternalAgenda_OpenAgenda extends Class_ExternalAgenda_Provider {
+
+
+  public function _import() {
+    $this->_loadPage();
+    return $this;
+  }
+
+
+  protected function _loadPage($offset = 0, $event_count = 0){
+    $content = json_decode($this->httpGet($this->_external_agenda->getUrl().'&offset='. (int) $offset),true);
+
+    if (!$content)
+      return $this;
+
+    array_map([$this, '_processEvent'], $content['events']);
+    $event_count += count($content['events']);
+
+    return ($event_count < $content['total'])
+      ? $this->_loadPage( $offset + 1, $event_count)
+      : $this;
+  }
+
+
+  protected function _processEvent($datas){
+    $event = new Class_ExternalAgenda_OpenAgenda_Event($datas);
+
+    foreach($event->get('timings') as $timing) {
+      $article = $this->_buildArticleForTiming($event, $timing);
+      $this->_events->append($article);
+    }
+
+    return $this;
+  }
+
+
+  protected function _buildArticleForTiming($event, $timing) {
+    return $this->_external_agenda
+      ->newEvent()
+      ->setTitre($event->getString('title'))
+      ->setContenu($event->getImageTagWithCredits().$event->getHtml().$event->getInfosTag())
+      ->setIdOrigine($event->get('uid') . '_' . base64_encode($timing['start']))
+      ->setDescription($event->getImageTag().'<p>'.$event->getString('description').'</p>')
+      ->setTags(implode(';', $event->getKeywords()))
+      ->setLieu($event->getLocation())
+      ->setEventsDebut(date('Y-m-d H:i', strtotime($timing['start'])))
+      ->setEventsFin(date('Y-m-d H:i', strtotime($timing['end'])));
+  }
+}
+
+
+
+
+class Class_ExternalAgenda_OpenAgenda_Event {
+  use Trait_Translator;
+
+  protected
+    $_event;
+
+  public function __construct($event){
+    $this->_event = $event;
+  }
+
+  public function getImageTagWithCredits(){
+    $imgsrc = $this->getImageTag();
+    return ($credits = $this->getString('imageCredits'))
+      ? sprintf('<figure>%s<figcaption>%s</figcaption></figure>',
+                $imgsrc,
+                $this->_('Credits : %s', $credits))
+      : $imgsrc;
+  }
+
+
+  public function getImageTag(){
+    return ($src = $this->_event['image'])
+      ? sprintf('<img src="%s" alt=""/>',$src)
+      : '';
+  }
+
+
+  public function getInfosTag() {
+    $infos = '';
+
+    $infos .= $this->_prepareConditionsString();
+    $infos .= $this->_prepareAgeString();
+
+    if (!$infos)
+      return '';
+
+    return '<p>' . $this->_('Infos pratiques :') . '</p><dl>' . $infos . '</dl>';
+  }
+
+  protected function _addInfoElement($label, $description){
+    return '<dt>' . $label . '</dt>'
+        . '<dd>' . $description . '</dd>';
+  }
+
+
+  public function getKeywords(){
+    if (! $keywords = $this->getArray('keywords'))
+      return [];
+
+    return isset( $keywords['fr'] )
+      ? $keywords['fr']
+      : [];
+  }
+
+  protected function _prepareConditionsString() {
+    return ($conditions = $this->getString('conditions'))
+      ? $this->_addInfoElement(
+                               $this->_('Conditions'),
+                               $conditions)
+      : "";
+  }
+
+
+  protected function _prepareAgeString() {
+    return ($this->_event['age'])
+      ? $this->_addInfoElement(
+                               $this->_('Âge'),
+                               $this->_('de %s à %s ans',
+                                        $this->_event['age']['min'],
+                                        $this->_event['age']['max']))
+      : "";
+  }
+
+
+
+
+  public function getString($name){
+    if (!(isset($this->_event[$name]) && $this->_event[$name]))
+      return '';
+
+    if (is_string($this->_event[$name]))
+      return $this->_event[$name];
+
+    return is_array($this->_event[$name])
+      ? reset($this->_event[$name])
+      : '';
+  }
+
+
+  public function getArray($name){
+    return ($data = $this->get($name)) && is_array($data)
+      ? $data
+      : [];
+  }
+
+
+  public function get($name) {
+    return $this->_event[$name];
+  }
+
+
+  public function getHtml() {
+    return ($description_html = $this->getString('html'))
+      ? $description_html
+      : $this->getString('description');
+  }
+
+
+  public function getLocation(){
+    if ($lieu = Class_Lieu::findFirstBy(['latitude'  => $this->get('latitude'),
+                                         'longitude' => $this->get('longitude')]))
+      return $lieu;
+
+    $lieu = new Class_Lieu();
+    $lieu
+      ->setLibelle($this->getString('locationName'))
+      ->setLatitude($this->get('latitude'))
+      ->setLongitude($this->get('longitude'))
+      ->setAdresse(implode(',', explode(',', $this->getString('address'), -1)))
+      ->setCodePostal($this->getString('postalCode'))
+      ->setVille($this->getString('city'));
+
+    $location = $this->getArray('location');
+    $lieu
+      ->setTelephone(isset($location['phone']) ? (string)$location['phone'] : '')
+      ->setMail(isset($location['email']) ? (string)$location['email'] : '')
+      ->setUrl(isset($location['website']) ? (string)$location['website'] : '')
+      ->save();
+    return $lieu;
+  }
+}
diff --git a/library/Class/ExternalAgenda/Provider.php b/library/Class/ExternalAgenda/Provider.php
new file mode 100644
index 00000000000..31bc1e91460
--- /dev/null
+++ b/library/Class/ExternalAgenda/Provider.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, 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_ExternalAgenda_Provider extends Class_WebService_Abstract {
+  use Trait_Translator;
+
+  protected
+    $_events,
+    $_external_agenda;
+
+
+  public function import($external_agenda) {
+    $this->_events = new Storm_Model_Collection();
+    $this->_external_agenda = $external_agenda;
+
+    $this->_import();
+
+    return $this->_events;
+  }
+
+
+  protected function _import() {
+    return $this;
+  }
+}
diff --git a/library/ZendAfi/Form/Admin/ExternalAgenda.php b/library/ZendAfi/Form/Admin/ExternalAgenda.php
index 295cf12deba..fedf9843e09 100644
--- a/library/ZendAfi/Form/Admin/ExternalAgenda.php
+++ b/library/ZendAfi/Form/Admin/ExternalAgenda.php
@@ -42,6 +42,10 @@ class ZendAfi_Form_Admin_ExternalAgenda extends ZendAfi_Form {
                    'autoharvest',
                    ['label' => $this->_('Moissonnage automatique'),
                     ])
+      ->addElement('checkbox',
+                   'delete_orphan_events',
+                   ['label' => $this->_('Supprimer localement les événements supprimés dans le calendrier source'),
+                    ])
 
       ->addElement('comboCategories',
                    'cat_id',
@@ -54,9 +58,22 @@ class ZendAfi_Form_Admin_ExternalAgenda extends ZendAfi_Form {
       ->addElement('select',
                    'id_lieu',
                    ['label' => $this->_('Lieu'),
-                    'multiOptions' => ['0' => $this->_('Aucun')] + Class_Lieu::getAllLibelles()]);
+                    'multiOptions' => ['0' => $this->_('Aucun')] + Class_Lieu::getAllLibelles()])
+      ->addElement('select',
+                   'provider',
+                   ['label' => $this->_('Source'),
+                    'multiOptions' => Class_ExternalAgenda::getAllProviderOptions()]);
 
-    $elements = ['label', 'url','autoharvest', 'cat_id', 'id_lieu'];
+    $elements =
+      [
+       'label',
+       'provider',
+       'url',
+       'autoharvest',
+       'delete_orphan_events',
+       'cat_id',
+       'id_lieu'
+      ];
 
     if (Class_AdminVar::isWorkFlowEnabled()) {
       $this->addElement('radio', 'status',
@@ -68,5 +85,7 @@ class ZendAfi_Form_Admin_ExternalAgenda extends ZendAfi_Form {
     }
 
     $this->addDisplayGroup($elements, 'agenda', ['legend' => $this->_('Agenda')]);
+    Class_ScriptLoader::getInstance()
+      ->addJqueryReady('formSelectToggleVisibilityForElement( "#provider", $("select[name=\'id_lieu\']").closest("tr"), ["1"]);');
   }
 }
diff --git a/tests/application/modules/opac/controllers/RecordCustomLinksTest.php b/tests/application/modules/opac/controllers/RecordCustomLinksTest.php
index 616be8c87f6..19c105fa14f 100644
--- a/tests/application/modules/opac/controllers/RecordCustomLinksTest.php
+++ b/tests/application/modules/opac/controllers/RecordCustomLinksTest.php
@@ -118,6 +118,8 @@ class RecordCustomLinksRechercheControllerWithBrazilTest extends RecordCustomLin
   public function setUp() {
     parent::setUp();
 
+    Class_AdminVar::set('FEATURES_TRACKING_ENABLE', '0');
+
     $notice = $this->fixture('Class_Notice', ['id' => '888',
                                               'type_doc' => Class_CodifTypeDoc::SONORE,
                                               'unimarc' => file_get_contents(__DIR__ . '/../../../../fixtures/unimarc_brazil.txt')]);
@@ -128,9 +130,17 @@ class RecordCustomLinksRechercheControllerWithBrazilTest extends RecordCustomLin
                                                             'liste_codes' => "TAN98"],
                                        'viewnotice3' => ['links_zones' => '856-u-a']]]);
 
+
+    $this->mock_sql = $this->mock()
+                           ->whenCalled('fetchAll')
+                           ->answers([ [888, ''] ]);
+    Zend_Registry::set('sql', $this->mock_sql);
+
     Storm_Test_ObjectWrapper::onLoaderOfModel('Class_Notice')
-      ->whenCalled('findAllBy')
-      ->answers([Class_Notice::find(888)]);
+      ->whenCalled('findAllByIds')
+      ->with([888], 10, null)
+      ->answers([Class_Notice::find(888)])
+      ->beStrict();
 
     $this->dispatch('/opac/recherche/simple/expressionRecherche/brazil');
   }
diff --git a/tests/db/UpgradeDBTest.php b/tests/db/UpgradeDBTest.php
index 956a2e1ec61..5cbf84e6960 100644
--- a/tests/db/UpgradeDBTest.php
+++ b/tests/db/UpgradeDBTest.php
@@ -2967,3 +2967,26 @@ class UpgradeDB_380_Test extends UpgradeDBTestCase {
     $this->assertIndex('hold_pnb', 'subscriber_id');
   }
 }
+
+
+
+
+class UpgradeDB_381_Test extends UpgradeDBTestCase {
+  public function prepare() {
+    $this
+      ->silentQuery('ALTER TABLE external_agenda DROP COLUMN provider')
+      ->silentQuery('ALTER TABLE external_agenda DROP COLUMN delete_orphan_events');
+  }
+
+
+  /** @test */
+  public function tableExternalAgendasShouldHaveColumnProviderText() {
+    $this->assertFieldType('external_agenda', 'provider', 'text');
+  }
+
+
+  /** @test */
+  public function tableExternalAgendasShouldHaveColumnDeleteOrphanEvents() {
+    $this->assertFieldType('external_agenda', 'delete_orphan_events', 'tinyint(1)');
+  }
+}
diff --git a/tests/scenarios/ExternalAgendas/ExternalAgendasBatchTest.php b/tests/scenarios/ExternalAgendas/ExternalAgendasBatchTest.php
index ac3c5ee4e20..078f97bbca1 100644
--- a/tests/scenarios/ExternalAgendas/ExternalAgendasBatchTest.php
+++ b/tests/scenarios/ExternalAgendas/ExternalAgendasBatchTest.php
@@ -67,6 +67,7 @@ class Class_Batch_ExternalAgendasBatchSimpleTest extends Class_Batch_ExternalAge
                     'label' => 'Personal Agenda',
                     'url' => 'http://my.server.com/calendar.ics',
                     'autoharvest' => 0,
+                    'delete_orphan_events' => 0,
                     'status' => Class_Article::STATUS_VALIDATED,
                     'category' => $events_category]);
 
@@ -77,9 +78,10 @@ class Class_Batch_ExternalAgendasBatchSimpleTest extends Class_Batch_ExternalAge
                     'category' => $events_category,
                     'id_lieu' => 0,
                     'autoharvest' => 1,
+                    'delete_orphan_events' => 0,
                     'status' => Class_Article::STATUS_VALIDATED]);
 
-    Class_WebService_ICalendar::setDefaultHttpClient($this->mock()
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient($this->mock()
                                                      ->whenCalled('open_url')
                                                      ->with('http://external.agenda.com/export?type=ics')
                                                      ->answers(file_get_contents(__DIR__.'/ical-event-second.ics')));
@@ -98,7 +100,7 @@ class Class_Batch_ExternalAgendasBatchSimpleTest extends Class_Batch_ExternalAge
 
   /** @test */
   public function logShouldDisplayNumberOfCreatedEvents() {
-    $this->assertEquals("Extra Agenda:\nNombre d\'événements créés : 4\nNombre d\'événements mis à jour : 0\n",$this->_log);
+    $this->assertEquals("Extra Agenda:\nNombre d'événements créés : 4\nNombre d'événements mis à jour : 0\n",$this->_log);
   }
 }
 
@@ -156,7 +158,7 @@ class Class_Batch_ExternalAgendasBatchRewriteTest extends Class_Batch_ExternalAg
                     'autoharvest' => 1,
                     'status' => Class_Article::STATUS_VALIDATED]);
 
-    Class_WebService_ICalendar::setDefaultHttpClient($this->mock()
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient($this->mock()
                                                      ->whenCalled('open_url')
                                                      ->with('http://external.agenda.com/export?type=ics')
                                                      ->answers(file_get_contents(__DIR__.'/ical-event-second.ics')));
@@ -177,7 +179,7 @@ class Class_Batch_ExternalAgendasBatchRewriteTest extends Class_Batch_ExternalAg
   /** @test */
   public function logShouldDisplayNumberOfCreatedEvents() {
     $this->_batch->run();
-    $this->assertEquals("Extra Agenda:\nNombre d\'événements créés : 3\nNombre d\'événements non mis à jour : 1\n",$this->_log);
+    $this->assertEquals("Extra Agenda:\nNombre d'événements créés : 3\nNombre d'événements non mis à jour : 1\n",$this->_log);
   }
 
 
@@ -196,6 +198,6 @@ class Class_Batch_ExternalAgendasBatchRewriteTest extends Class_Batch_ExternalAg
     $this->_batch->run();
     Class_Article::clearCache();
     $this->assertContains("animé par l'école et son quartier Parents/Enfants",Class_Article::find(1)->getContenu());
-    $this->assertEquals("Extra Agenda:\nNombre d\'événements créés : 3\nNombre d\'événements mis à jour : 1\n",$this->_log);
+    $this->assertEquals("Extra Agenda:\nNombre d'événements créés : 3\nNombre d'événements mis à jour : 1\n",$this->_log);
   }
 }
diff --git a/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php b/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php
new file mode 100644
index 00000000000..572c63b31f3
--- /dev/null
+++ b/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php
@@ -0,0 +1,220 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, 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 ExternalAgendasOpenAgendaAdminTest extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile=true;
+
+  public function setup() {
+    parent::setup();
+
+
+
+    $events_category = $this->fixture('Class_ArticleCategorie',
+                                      ['id'=>123,
+                                       'libelle' => 'Coding Gouter',
+                                       'bib'=> $this->fixture('Class_Bib',
+                                                              ['id'=>7,
+                                                               'libelle'=>'Joliville'])]);
+
+    Class_AdminVar::set('AGENDA_KEEP_LOCAL_CONTENT',0);
+
+    $time_source = new TimeSourceForTest('2019-11-01 08:00:00');
+
+    Class_ExternalAgenda::setTimeSource($time_source);
+    Class_Article::setTimeSource($time_source);
+
+    $this->fixture('Class_ExternalAgenda',
+                   [ 'id' => 12,
+                    'label' => 'agenda PNB',
+                    'url' => 'https://openagenda.com/agendas/36758196/events.json?lang=fr&key=e0f9e6b4302a439f99b78549b9d63fd6',
+                    'provider' => Class_ExternalAgenda::OPEN_AGENDA,
+                    'status'   => Class_Article::STATUS_DRAFT,
+                    'delete_orphan_events' => 1,
+                    'category' => $events_category]);
+    Class_ExternalAgenda_OpenAgenda::setDefaultHttpClient($this->mock()
+                                                          ->whenCalled('open_url')
+                                                          ->with('https://openagenda.com/agendas/36758196/events.json?lang=fr&key=e0f9e6b4302a439f99b78549b9d63fd6&offset=0')
+                                                          ->answers(file_get_contents(__DIR__.'/open-agenda-1.json'))
+                                                          ->whenCalled('open_url')
+                                                          ->with('https://openagenda.com/agendas/36758196/events.json?lang=fr&key=e0f9e6b4302a439f99b78549b9d63fd6&offset=1')
+                                                          ->answers(file_get_contents(__DIR__.'/open-agenda-2.json'))
+                                                          ->whenCalled('open_url')
+                                                          ->with('https://openagenda.com/agendas/36758196/events.json?lang=fr&key=e0f9e6b4302a439f99b78549b9d63fd6&offset=2')
+                                                          ->answers(file_get_contents(__DIR__.'/open-agenda-3.json'))
+                                                          ->beStrict()
+    );
+
+    Class_ExternalAgenda::find(12)->import();
+  }
+
+  public function teardown() {
+    Class_ExternalAgenda::setTimeSource(null);
+    Class_Article::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function countArticlesShouldBeSixteen() {
+    $this->assertCount(16, Class_Article::findAll());
+  }
+
+
+  /** @test */
+  public function firstArticleCreationDateShouldContains20191101() {
+    $this->assertContains('2019-11-01', Class_Article::find(1)->getDateCreation());
+  }
+
+
+  /** @test */
+  public function firstArticleDateMajShouldBeEmpty() {
+    $this->assertEquals('', Class_Article::find(1)->getDateMaj());
+  }
+
+
+  /** @test */
+  public function afterSecondImportCountArticlesShouldRemainsSixteen() {
+    Class_ExternalAgenda::find(12)->import();
+    $this->assertCount(16, Class_Article::findAll());
+  }
+
+
+  /** @test */
+  public function afterImportOldArticleShouldBeDeleted() {
+    $this->fixture('Class_Article',
+                   ['id'    => 234,
+                    'titre' => 'Test',
+                    'description' => 'test',
+                    'contenu' => 'test',
+                    'repository_origine' => Class_Article::find(2)->getRepositoryOrigine()
+                   ]
+    );
+    Class_ExternalAgenda::find(12)->import();
+    $this->assertCount(16, Class_Article::findAll());
+    $this->assertNull(Class_Article::find(234));
+  }
+
+
+  /** @test */
+  public function afterImportWhenNotDeleteOrphanOldArticleShouldBePresent() {
+    Class_ExternalAgenda::find(12)->setDeleteOrphanEvents(0);
+    $this->fixture('Class_Article',
+                   ['id'    => 234,
+                    'titre' => 'Test',
+                    'description' => 'test',
+                    'contenu' => 'test',
+                    'repository_origine' => Class_Article::find(2)->getRepositoryOrigine()
+                   ]
+    );
+    Class_ExternalAgenda::find(12)->import();
+    $this->assertCount(17, Class_Article::findAll());
+    $this->assertNotNull(Class_Article::find(234));
+  }
+
+
+  /** @test */
+  public function firstArticleTitleShouldContainsPNB() {
+    $this->assertContains('PNB', Class_Article::find(1)->getTitre());
+  }
+
+
+  /** @test */
+  public function firstArticleDateMajShouldBe20191101() {
+    Class_ExternalAgenda::find(12)->import();
+    Class_Article::clearCache();
+    $this->assertEquals('2019-11-01 08:00:00', Class_Article::find(1)->getDateMaj());
+  }
+
+
+  /** @test */
+  public function firstArticleTagsShouldBeBugAndPNB() {
+    $this->assertEquals('bug;pnb', Class_Article::find(1)->getTags());
+  }
+
+
+  /** @test */
+  public function firstArticleSummaryShouldBeParfoisLesChosesNeFonctionnentPas() {
+    $this->assertEquals('<img src="https://cibul.s3.amazonaws.com/9c3729cce33140c5a011056c8168ec5b.base.image.jpg" alt=""/><p>parfois, les choses ne fonctionnent pas</p>', Class_Article::find(1)->getDescription());
+  }
+
+
+  /** @test */
+  public function firstArticleEventsDateShouldBe2019_11_25_10_30_To_12_30() {
+    $this->assertArraySubset(['events_debut' => '2019-11-25 10:30',
+                              'events_fin' => '2019-11-25 12:30'],
+                             Class_Article::find(1)->getRawAttributes());
+  }
+
+
+  /** @test */
+  public function secondArticleEventsDateShouldBe2019_11_29_10_00_To_12_00() {
+    $this->assertArraySubset(['titre' => 'Une erreur PNB',
+                              'events_debut' => '2019-11-29 10:00',
+                              'events_fin' => '2019-11-29 12:00'],
+                             Class_Article::find(2)->getRawAttributes());
+  }
+
+
+  /** @test */
+  public function secondArticleTitleShouldContainsPlanifBokeh() {
+    $this->assertContains('Planif Bokeh', Class_Article::find(4)->getTitre());
+  }
+
+
+
+  /** @test */
+  public function numberOfLocationsShouldBeTwo() {
+    $this->assertCount(2, Class_Lieu::findAll());
+  }
+
+
+  /** @test */
+  public function secondLocationShouldBeAFIAnnecy() {
+    $this->assertEquals(['id' => 2,
+                         'libelle' => 'AFI Annecy',
+                         'adresse' => '11 boulevard du fier',
+                         'code_postal' => '74000',
+                         'ville' => 'Annecy',
+                         'pays' => 'FRANCE',
+                         'telephone' => '0123456789',
+                         'mail' => 'bokeh@afi-sa.net',
+                         'url' => 'https://www.afi-sa.net',
+                         'latitude' => 45.913614,
+                         'longitude' => 6.114212],
+                        Class_Lieu::find(2)->getRawAttributes());
+  }
+
+
+
+  /** @test */
+  public function firstArticleImageShouldContainsHTMLAndImage() {
+    $this->assertEquals('<figure><img src="https://cibul.s3.amazonaws.com/9c3729cce33140c5a011056c8168ec5b.base.image.jpg" alt=""/><figcaption>Credits : moi</figcaption></figure><p>Voyons ça dans une session de coding dojo</p><p>Infos pratiques :</p><dl><dt>Conditions</dt><dd>être geek</dd><dt>Âge</dt><dd>de 6 à 99 ans</dd></dl>',
+                        Class_Article::find(1)->getContenu());
+  }
+
+
+  /** @test */
+  public function firstArticleIdOrigineShouldBe5519006_start_base64encoded() {
+    $this->assertEquals('5519006_MjAxOS0xMS0yNVQwOTozMDowMC4wMDBa',
+                        Class_Article::find(1)->getIdOrigine());
+  }
+}
diff --git a/tests/scenarios/ExternalAgendas/ExternalAgendasTest.php b/tests/scenarios/ExternalAgendas/ExternalAgendasTest.php
index fb0581b801f..07475d01798 100644
--- a/tests/scenarios/ExternalAgendas/ExternalAgendasTest.php
+++ b/tests/scenarios/ExternalAgendas/ExternalAgendasTest.php
@@ -55,7 +55,7 @@ abstract class ExternalAgendasAdminTestCase extends Admin_AbstractControllerTest
                     'status' => Class_Article::STATUS_DRAFT,
                     'category' => $this->events_category]);
 
-    Class_WebService_ICalendar::setDefaultHttpClient($this->mock()
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient($this->mock()
                                                      ->whenCalled('open_url')
                                                      ->with('http://my.server.com/calendar.ics')
                                                      ->answers(file_get_contents(__DIR__.'/ical-event-first.ics'))
@@ -71,7 +71,7 @@ abstract class ExternalAgendasAdminTestCase extends Admin_AbstractControllerTest
 
 
   public function tearDown() {
-    Class_WebService_ICalendar::setDefaultHttpClient(null);
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient(null);
     parent::tearDown();
   }
 }
@@ -95,7 +95,7 @@ class ExternalAgendasAdminIndexTest extends ExternalAgendasAdminTestCase {
                     'cat_id' => 2]);
 
     Class_ArticleCategorie::find(2)->delete();
-    Class_ExternalAgenda::find(125)->import($this);
+    Class_ExternalAgenda::find(125)->import();
 
     $this->dispatch('/admin/external-agendas', true);
   }
@@ -207,6 +207,17 @@ abstract class ExternalAgendasAdminAddTestCase extends ExternalAgendasAdminTestC
                                       'Events');
   }
 
+  /** @test */
+  public function formShouldContainsSelectForProvider() {
+    $this->assertXPathContentContains('//form//select[@name="provider"]/option[@value="1"]','iCalendar (.ics)',$this->_response->getBody());
+  }
+
+
+  /** @test */
+  public function formShouldContainsCheckBoxDeleteOrphanEvents() {
+    $this->assertXPath('//form//input[@type="checkbox"][@name="delete_orphan_events"]');
+    $this->assertXPathContentContains('//form//label[@for="delete_orphan_events"]','Supprimer localement les événements supprimés dans le calendrier source');
+  }
 
   /** @test */
   public function categorySelectorShouldContainsNoneOption() {
@@ -525,7 +536,7 @@ class ExternalAgendasAdminDestroyEventsTest extends ExternalAgendasAdminTestCase
                     'cat_id' => 1,
                     'id_lieu' => 0,
                     'status' => Class_Article::STATUS_VALIDATED]);
-    Class_WebService_ICalendar::setDefaultHttpClient($this->mock()
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient($this->mock()
                                                      ->whenCalled('open_url')
                                                      ->with('http://external.agenda.com/export?type=ics')
                                                      ->answers(file_get_contents(__DIR__.'/ical-event-second.ics'))
@@ -533,8 +544,9 @@ class ExternalAgendasAdminDestroyEventsTest extends ExternalAgendasAdminTestCase
     Class_ExternalAgenda::find(34)
       ->setIdLieu(22)
       ->setStatus(Class_Article::STATUS_VALIDATED)
-      ->import($this);
-    $this->assertCount(4,Class_Article::findAllBy(['repository_origine' => 'External_Agenda:34']));
+      ->import();
+
+    $this->assertCount(4, Class_Article::findAllBy(['repository_origine' => 'External_Agenda:34']));
     $this->dispatch('/admin/external-agendas/destroy-events/id/34', true);
 
   }
@@ -571,10 +583,20 @@ class ExternalAgendasAdminImportTest extends ExternalAgendasAdminTestCase {
 
     Class_AdminVar::set('WORKFLOW', 1);
 
+    $timesource = new TimeSourceForTest('2016-02-06 09:19:32');
+    Class_Article::setTimeSource($timesource);
+    Class_ExternalAgenda::setTimeSource($timesource);
+
     $this->dispatch('/admin/external-agendas/import/id/124', true);
     Class_Article::clearCache();
   }
 
+  public function tearDown() {
+    Class_Article::setTimeSource(null);
+    Class_ExternalAgenda::setTimeSource(null);
+    parent::tearDown();
+  }
+
 
   /** @test */
   public function titleShouldBeMoissonageDesEvenements() {
@@ -708,9 +730,9 @@ class ExternalAgendasAdminSecondImportTest extends ExternalAgendasAdminTestCase
     Class_ExternalAgenda::find(124)
       ->setIdLieu(22)
       ->setStatus(Class_Article::STATUS_VALIDATED)
-      ->import($this);
+      ->import();
 
-    Class_WebService_ICalendar::setDefaultHttpClient($this->mock()
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient($this->mock()
                                                      ->whenCalled('open_url')
                                                      ->with('http://my.server.com/calendar.ics')
                                                      ->answers(file_get_contents(__DIR__.'/ical-event-second.ics'))
@@ -769,7 +791,7 @@ class ExternalAgendasAdminErmesImportTest extends ExternalAgendasAdminTestCase {
   public function setUp() {
     parent::setUp();
 
-    Class_WebService_ICalendar::setDefaultHttpClient($this->mock()
+    Class_ExternalAgenda_ICalendar::setDefaultHttpClient($this->mock()
                                                      ->whenCalled('open_url')
                                                      ->with('http://my.server.com/calendar.ics')
                                                      ->answers(file_get_contents(__DIR__.'/ermes.ics'))
diff --git a/tests/scenarios/ExternalAgendas/open-agenda-1.json b/tests/scenarios/ExternalAgendas/open-agenda-1.json
new file mode 100644
index 00000000000..0a3068dea76
--- /dev/null
+++ b/tests/scenarios/ExternalAgendas/open-agenda-1.json
@@ -0,0 +1,136 @@
+{
+  "readme": "Results are paginated. See: https://openagenda.zendesk.com/hc/fr/articles/203034982-L-export-JSON-d-un-agenda",
+  "total": 4,
+  "offset": 0,
+  "limit": 2,
+  "events": [
+    {
+      "uid": 5519006,
+      "slug": "une-erreur-pnb",
+      "canonicalUrl": "https://openagenda.com/test-laurent/event/une-erreur-pnb",
+      "title": {
+        "fr": "Une erreur PNB"
+      },
+      "description": {
+        "fr": "parfois, les choses ne fonctionnent pas"
+      },
+      "longDescription": {
+        "fr": "Voyons ça dans une sessino de coding doja"
+      },
+      "keywords": {
+        "fr": [
+          "bug",
+          "pnb"
+        ]
+      },
+      "html": {
+        "fr": "<p>Voyons ça dans une session de coding dojo</p>"
+      },
+      "image": "https://cibul.s3.amazonaws.com/9c3729cce33140c5a011056c8168ec5b.base.image.jpg",
+      "thumbnail": "https://cibul.s3.amazonaws.com/9c3729cce33140c5a011056c8168ec5b.thumb.image.jpg",
+      "originalImage": "https://cibul.s3.amazonaws.com/9c3729cce33140c5a011056c8168ec5b.full.image.jpg",
+      "age": {
+        "min": 6,
+        "max": 99
+      },
+      "accessibility": [],
+      "updatedAt": "2019-11-26T14:12:06.000Z",
+      "createdAt": "2019-11-26T14:12:06.000Z",
+      "range": {
+        "fr": "25 novembre - 1 décembre",
+        "en": "25 November - 1 December"
+      },
+      "imageCredits": "moi",
+      "origin": {
+        "uid": 36758196,
+        "title": "Test Laurent",
+        "url": "https://www.afi-sa.net",
+        "slug": "test-laurent",
+        "oaUrl": "https://openagenda.com/agendas/36758196"
+      },
+      "conditions": {
+        "fr": "être geek"
+      },
+      "registrationUrl": "llaffont@afi-sa.fr",
+      "locationName": "San pedro chez HDL",
+      "locationUid": 91517332,
+      "address": "San-Pédro",
+      "postalCode": null,
+      "city": null,
+      "district": null,
+      "department": "San Pedro",
+      "region": "Bas-Sassandra",
+      "latitude": 5.014152,
+      "longitude": -6.940224,
+      "timings": [
+        {
+          "start": "2019-11-25T09:30:00.000Z",
+          "end": "2019-11-25T11:30:00.000Z"
+        },
+        {
+          "start": "2019-11-29T09:00:00.000Z",
+          "end": "2019-11-29T11:00:00.000Z"
+        },
+        {
+          "start": "2019-12-01T09:30:00.000Z",
+          "end": "2019-12-01T10:30:00.000Z"
+        }
+      ],
+      "location": {
+        "uid": 91517332,
+        "name": "San pedro chez HDL",
+        "slug": "san-pedro-chez-hdl",
+        "address": "San-Pédro",
+        "image": null,
+        "imageCredits": null,
+        "postalCode": null,
+        "city": null,
+        "district": null,
+        "department": "San Pedro",
+        "region": "Bas-Sassandra",
+        "latitude": 5.014152,
+        "longitude": -6.940224,
+        "description": null,
+        "access": null,
+        "countryCode": "ci",
+        "website": null,
+        "email": null,
+        "links": [],
+        "insee": null,
+        "phone": null,
+        "tags": null,
+        "timezone": "Africa/Abidjan",
+        "updatedAt": "2019-11-26T14:11:55.000Z",
+        "extId": null,
+        "country": {
+          "en": "Cote D'Ivoire",
+          "fr": "Cote D'Ivoire",
+          "de": "Elfenbeinküste",
+          "es": "Costa de Marfil",
+          "code": "CI"
+        }
+      },
+      "registration": [
+        {
+          "value": "llaffont@afi-sa.fr",
+          "type": "email",
+          "prefix": "mailto:"
+        }
+      ],
+      "firstDate": "2019-11-25",
+      "firstTimeStart": "09:30",
+      "firstTimeEnd": "11:30",
+      "lastDate": "2019-12-01",
+      "lastTimeStart": "09:30",
+      "lastTimeEnd": "10:30",
+      "featured": 0,
+      "hasPrivateCustomFields": false,
+      "custom": null,
+      "contributor": null,
+      "category": null,
+      "tags": [],
+      "tagGroups": [],
+      "linkedEvents": []
+    }
+  ]
+}
diff --git a/tests/scenarios/ExternalAgendas/open-agenda-2.json b/tests/scenarios/ExternalAgendas/open-agenda-2.json
new file mode 100644
index 00000000000..e01201dfb43
--- /dev/null
+++ b/tests/scenarios/ExternalAgendas/open-agenda-2.json
@@ -0,0 +1,374 @@
+{
+  "readme": "Results are paginated. See: https://openagenda.zendesk.com/hc/fr/articles/203034982-L-export-JSON-d-un-agenda",
+  "total": 4,
+  "offset": 1,
+  "limit": 2,
+  "events": [
+    {
+      "uid": 84528178,
+      "slug": "planif-bokeh",
+      "canonicalUrl": "https://openagenda.com/test-laurent/event/planif-bokeh",
+      "title": {
+        "fr": "Planif Bokeh"
+      },
+      "description": {
+        "fr": "il faut planifier"
+      },
+      "longDescription": {
+        "fr": "coucou"
+      },
+      "keywords": null,
+      "html": {
+        "fr": "<p>coucou</p>"
+      },
+      "image": "https://cibul.s3.amazonaws.com/47bdaf7b4fab4b08b933e53a38534dba.base.image.jpg",
+      "thumbnail": "https://cibul.s3.amazonaws.com/47bdaf7b4fab4b08b933e53a38534dba.thumb.image.jpg",
+      "originalImage": "https://cibul.s3.amazonaws.com/47bdaf7b4fab4b08b933e53a38534dba.full.image.jpg",
+      "age": null,
+      "accessibility": [],
+      "updatedAt": "2019-11-25T10:29:51.000Z",
+      "createdAt": "2019-11-25T10:25:06.000Z",
+      "range": {
+        "fr": "25 novembre 2019 - 27 janvier 2020, les lundis",
+        "en": "25 November 2019 - 27 January 2020, on mondays"
+      },
+      "imageCredits": null,
+      "origin": {
+        "uid": 36758196,
+        "title": "Test Laurent",
+        "url": "https://www.afi-sa.net",
+        "slug": "test-laurent",
+        "oaUrl": "https://openagenda.com/agendas/36758196"
+      },
+      "conditions": null,
+      "registrationUrl": null,
+      "locationName": "AFI Annecy",
+      "locationUid": 51796356,
+      "address": "11 boulevard du fier, 74000 Annecy",
+      "postalCode": "74000",
+      "city": "Annecy",
+      "district": "Annecy",
+      "department": "Haute-Savoie",
+      "region": "Auvergne-Rhône-Alpes",
+      "latitude": 45.913614,
+      "longitude": 6.114212,
+      "timings": [
+        {
+          "start": "2019-11-25T10:00:00.000Z",
+          "end": "2019-11-25T10:30:00.000Z"
+        },
+        {
+          "start": "2019-12-02T10:00:00.000Z",
+          "end": "2019-12-02T10:30:00.000Z"
+        },
+        {
+          "start": "2019-12-09T10:00:00.000Z",
+          "end": "2019-12-09T10:30:00.000Z"
+        },
+        {
+          "start": "2019-12-16T10:00:00.000Z",
+          "end": "2019-12-16T10:30:00.000Z"
+        },
+        {
+          "start": "2019-12-23T10:00:00.000Z",
+          "end": "2019-12-23T10:30:00.000Z"
+        },
+        {
+          "start": "2019-12-30T10:00:00.000Z",
+          "end": "2019-12-30T10:30:00.000Z"
+        },
+        {
+          "start": "2020-01-06T10:00:00.000Z",
+          "end": "2020-01-06T10:30:00.000Z"
+        },
+        {
+          "start": "2020-01-13T10:00:00.000Z",
+          "end": "2020-01-13T10:30:00.000Z"
+        },
+        {
+          "start": "2020-01-20T10:00:00.000Z",
+          "end": "2020-01-20T10:30:00.000Z"
+        },
+        {
+          "start": "2020-01-27T10:00:00.000Z",
+          "end": "2020-01-27T10:30:00.000Z"
+        }
+      ],
+      "location": {
+        "uid": 51796356,
+        "name": "AFI Annecy",
+        "slug": "afi-annecy",
+        "address": "11 boulevard du fier, 74000 Annecy",
+        "image": null,
+        "imageCredits": null,
+        "postalCode": "74000",
+        "city": "Annecy",
+        "district": "Annecy",
+        "department": "Haute-Savoie",
+        "region": "Auvergne-Rhône-Alpes",
+        "latitude": 45.913614,
+        "longitude": 6.114212,
+        "description": null,
+        "access": null,
+        "countryCode": "fr",
+        "website": "https://www.afi-sa.net",
+        "email": "bokeh@afi-sa.net",
+        "links": [],
+        "insee": "74010",
+        "phone": "0123456789",
+        "tags": null,
+        "timezone": "Europe/Paris",
+        "updatedAt": "2019-11-25T10:24:36.000Z",
+        "extId": null,
+        "country": {
+          "en": "France (Metropolitan)",
+          "fr": "France (Métropole)",
+          "de": "Frankreich (Metropolitan)",
+          "es": "Francia (Metropolitana)",
+          "code": "FR"
+        }
+      },
+      "registration": [],
+      "firstDate": "2019-11-25",
+      "firstTimeStart": "11:00",
+      "firstTimeEnd": "11:30",
+      "lastDate": "2020-01-27",
+      "lastTimeStart": "11:00",
+      "lastTimeEnd": "11:30",
+      "featured": 0,
+      "hasPrivateCustomFields": false,
+      "custom": null,
+      "contributor": null,
+      "category": null,
+      "tags": [],
+      "tagGroups": [],
+      "linkedEvents": []
+    },
+    {
+      "uid": 51788120,
+      "slug": "bokehcon",
+      "canonicalUrl": "https://openagenda.com/test-laurent/event/bokehcon",
+      "title": {
+        "en": "BokehCon"
+      },
+      "description": {
+        "en": "International Bokeh Conference"
+      },
+      "longDescription": null,
+      "keywords": {
+        "en": [
+          "bokeh"
+        ]
+      },
+      "html": {
+        "en": null
+      },
+      "image": false,
+      "thumbnail": false,
+      "originalImage": false,
+      "age": null,
+      "accessibility": [],
+      "updatedAt": "2019-11-27T09:10:30.000Z",
+      "createdAt": "2019-11-27T09:09:22.000Z",
+      "range": {
+        "fr": "16 et 17 décembre",
+        "en": "16 and 17 December"
+      },
+      "origin": {
+        "uid": 36758196,
+        "title": "Test Laurent",
+        "url": "https://www.afi-sa.net",
+        "slug": "test-laurent",
+        "oaUrl": "https://openagenda.com/agendas/36758196"
+      },
+      "conditions": null,
+      "registrationUrl": null,
+      "locationName": "AFI Annecy",
+      "locationUid": 51796356,
+      "address": "11 boulevard du fier, 74000 Annecy",
+      "postalCode": "74000",
+      "city": "Annecy",
+      "district": "Annecy",
+      "department": "Haute-Savoie",
+      "region": "Auvergne-Rhône-Alpes",
+      "latitude": 45.913614,
+      "longitude": 6.114212,
+      "timings": [
+        {
+          "start": "2019-12-16T08:00:00.000Z",
+          "end": "2019-12-16T16:00:00.000Z"
+        },
+        {
+          "start": "2019-12-17T08:00:00.000Z",
+          "end": "2019-12-17T16:00:00.000Z"
+        }
+      ],
+      "location": {
+        "uid": 51796356,
+        "name": "AFI Annecy",
+        "slug": "afi-annecy",
+        "address": "11 boulevard du fier, 74000 Annecy",
+        "image": null,
+        "imageCredits": null,
+        "postalCode": "74000",
+        "city": "Annecy",
+        "district": "Annecy",
+        "department": "Haute-Savoie",
+        "region": "Auvergne-Rhône-Alpes",
+        "latitude": 45.913614,
+        "longitude": 6.114212,
+        "description": null,
+        "access": null,
+        "countryCode": "fr",
+        "website": null,
+        "email": null,
+        "links": [],
+        "insee": "74010",
+        "phone": null,
+        "tags": null,
+        "timezone": "Europe/Paris",
+        "updatedAt": "2019-11-25T10:24:36.000Z",
+        "extId": null,
+        "country": {
+          "en": "France (Metropolitan)",
+          "fr": "France (Métropole)",
+          "de": "Frankreich (Metropolitan)",
+          "es": "Francia (Metropolitana)",
+          "code": "FR"
+        }
+      },
+      "registration": [],
+      "firstDate": "2019-12-16",
+      "firstTimeStart": "09:00",
+      "firstTimeEnd": "17:00",
+      "lastDate": "2019-12-17",
+      "lastTimeStart": "09:00",
+      "lastTimeEnd": "17:00",
+      "featured": 0,
+      "hasPrivateCustomFields": false,
+      "custom": null,
+      "contributor": null,
+      "category": null,
+      "tags": [],
+      "tagGroups": [],
+      "linkedEvents": []
+    },
+    {
+      "uid": 32710419,
+      "slug": "atelier-de-traduction",
+      "canonicalUrl": "https://openagenda.com/test-laurent/event/atelier-de-traduction",
+      "title": {
+        "en": "Translation workshop",
+        "fr": "Atelier de traduction"
+      },
+      "description": {
+        "en": "Come and translate Bokeh",
+        "fr": "Venez traduire Bokeh"
+      },
+      "longDescription": {
+        "en": "Bokeh with your language",
+        "fr": "Bokeh dans votre langue"
+      },
+      "keywords": {
+        "en": [
+          "i18n"
+        ],
+        "fr": [
+          "i18n"
+        ]
+      },
+      "html": {
+        "en": "<p>Bokeh with your language</p>",
+        "fr": "<p>Bokeh dans votre langue</p>"
+      },
+      "image": false,
+      "thumbnail": false,
+      "originalImage": false,
+      "age": null,
+      "accessibility": [],
+      "updatedAt": "2019-11-27T09:12:50.000Z",
+      "createdAt": "2019-11-27T09:12:50.000Z",
+      "range": {
+        "fr": "Lundi 22 juin 2020, 11h00",
+        "en": "Monday 22 June 2020, 11:00"
+      },
+      "origin": {
+        "uid": 36758196,
+        "title": "Test Laurent",
+        "url": "https://www.afi-sa.net",
+        "slug": "test-laurent",
+        "oaUrl": "https://openagenda.com/agendas/36758196"
+      },
+      "conditions": {
+        "en": "free entrance",
+        "fr": "entrée libre"
+      },
+      "registrationUrl": null,
+      "locationName": "San pedro chez HDL",
+      "locationUid": 91517332,
+      "address": "San-Pédro",
+      "postalCode": null,
+      "city": null,
+      "district": null,
+      "department": "San Pedro",
+      "region": "Bas-Sassandra",
+      "latitude": 5.014152,
+      "longitude": -6.940224,
+      "timings": [
+        {
+          "start": "2020-06-22T11:00:00.000Z",
+          "end": "2020-06-22T14:00:00.000Z"
+        }
+      ],
+      "location": {
+        "uid": 91517332,
+        "name": "San pedro chez HDL",
+        "slug": "san-pedro-chez-hdl",
+        "address": "San-Pédro",
+        "image": null,
+        "imageCredits": null,
+        "postalCode": null,
+        "city": null,
+        "district": null,
+        "department": "San Pedro",
+        "region": "Bas-Sassandra",
+        "latitude": 5.014152,
+        "longitude": -6.940224,
+        "description": null,
+        "access": null,
+        "countryCode": "ci",
+        "website": null,
+        "email": null,
+        "links": [],
+        "insee": null,
+        "phone": null,
+        "tags": null,
+        "timezone": "Africa/Abidjan",
+        "updatedAt": "2019-11-26T14:11:55.000Z",
+        "extId": null,
+        "country": {
+          "en": "Cote D'Ivoire",
+          "fr": "Cote D'Ivoire",
+          "de": "Elfenbeinküste",
+          "es": "Costa de Marfil",
+          "code": "CI"
+        }
+      },
+      "registration": [],
+      "firstDate": "2020-06-22",
+      "firstTimeStart": "11:00",
+      "firstTimeEnd": "14:00",
+      "lastDate": "2020-06-22",
+      "lastTimeStart": "11:00",
+      "lastTimeEnd": "14:00",
+      "featured": 0,
+      "hasPrivateCustomFields": false,
+      "custom": null,
+      "contributor": null,
+      "category": null,
+      "tags": [],
+      "tagGroups": [],
+      "linkedEvents": []
+    }
+  ]
+}
diff --git a/tests/scenarios/ExternalAgendas/open-agenda-3.json b/tests/scenarios/ExternalAgendas/open-agenda-3.json
new file mode 100644
index 00000000000..d257f8f21e3
--- /dev/null
+++ b/tests/scenarios/ExternalAgendas/open-agenda-3.json
@@ -0,0 +1,7 @@
+{
+  "readme": "Results are paginated. See: https://openagenda.zendesk.com/hc/fr/articles/203034982-L-export-JSON-d-un-agenda",
+  "total": 4,
+  "offset": 2,
+  "limit": 1,
+  "events": []
+}
-- 
GitLab