diff --git a/Framework/DataHandling/CMakeLists.txt b/Framework/DataHandling/CMakeLists.txt
index 0de48b0bcaba3811e81fabd30699de4295974dfd..6f54eb66fda726a0fae59c1f7f1dfc64f6f22de7 100644
--- a/Framework/DataHandling/CMakeLists.txt
+++ b/Framework/DataHandling/CMakeLists.txt
@@ -29,6 +29,7 @@ set(SRC_FILES
     src/GroupDetectors2.cpp
     src/H5Util.cpp
     src/ISISDataArchive.cpp
+    src/ISISJournal.cpp
     src/ISISRunLogs.cpp
     src/JoinISISPolarizationEfficiencies.cpp
     src/Load.cpp
@@ -235,6 +236,7 @@ set(INC_FILES
     inc/MantidDataHandling/GroupDetectors2.h
     inc/MantidDataHandling/H5Util.h
     inc/MantidDataHandling/ISISDataArchive.h
+    inc/MantidDataHandling/ISISJournal.h
     inc/MantidDataHandling/ISISRunLogs.h
     inc/MantidDataHandling/JoinISISPolarizationEfficiencies.h
     inc/MantidDataHandling/Load.h
@@ -440,6 +442,7 @@ set(TEST_FILES
     GroupDetectorsTest.h
     H5UtilTest.h
     ISISDataArchiveTest.h
+    ISISJournalTest.h
     InstrumentRayTracerTest.h
     JoinISISPolarizationEfficienciesTest.h
     LoadAscii2Test.h
diff --git a/Framework/DataHandling/inc/MantidDataHandling/ISISJournal.h b/Framework/DataHandling/inc/MantidDataHandling/ISISJournal.h
new file mode 100644
index 0000000000000000000000000000000000000000..010873195fe3b13635b66c32fd5b62dffac5e4b5
--- /dev/null
+++ b/Framework/DataHandling/inc/MantidDataHandling/ISISJournal.h
@@ -0,0 +1,65 @@
+// Mantid Repository : https://github.com/mantidproject/mantid
+//
+// Copyright © 2020 ISIS Rutherford Appleton Laboratory UKRI,
+//   NScD Oak Ridge National Laboratory, European Spallation Source,
+//   Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
+// SPDX - License - Identifier: GPL - 3.0 +
+#pragma once
+
+#include "MantidKernel/System.h"
+
+#include <Poco/AutoPtr.h>
+#include <map>
+#include <memory>
+#include <string>
+#include <vector>
+
+namespace Poco::XML {
+class Document;
+}
+
+namespace Mantid {
+namespace Kernel {
+class InternetHelper;
+}
+
+namespace DataHandling {
+namespace ISISJournal {
+/**
+ * ISISJournal: Helper class to aid in fetching ISIS specific run information
+ * from journal files
+ */
+
+class DLLExport ISISJournal {
+public:
+  using RunData = std::map<std::string, std::string>;
+
+  ISISJournal(std::string const &instrument, std::string const &cycle,
+              std::unique_ptr<Kernel::InternetHelper> internetHelper =
+                  std::make_unique<Kernel::InternetHelper>());
+  virtual ~ISISJournal();
+
+  ISISJournal(ISISJournal const &rhs) = delete;
+  ISISJournal(ISISJournal &&rhs);
+  ISISJournal const &operator=(ISISJournal const &rhs) = delete;
+  ISISJournal &operator=(ISISJournal &&rhs);
+
+  /// Get the list of cycle names
+  std::vector<std::string> getCycleNames();
+  /// Get data for runs that match the given filters
+  std::vector<RunData>
+  getRuns(std::vector<std::string> const &valuesToLookup = {},
+          RunData const &filters = RunData());
+
+private:
+  std::unique_ptr<Kernel::InternetHelper> m_internetHelper;
+  std::string m_runsFileURL;
+  std::string m_indexFileURL;
+  Poco::AutoPtr<Poco::XML::Document> m_runsDocument;
+  Poco::AutoPtr<Poco::XML::Document> m_indexDocument;
+
+  std::string getURLContents(std::string const &url);
+};
+} // namespace ISISJournal
+} // namespace DataHandling
+} // namespace Mantid
diff --git a/Framework/DataHandling/src/ISISJournal.cpp b/Framework/DataHandling/src/ISISJournal.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..69cc30270cdee3f3bebd7c6f2d9e41625f9054a2
--- /dev/null
+++ b/Framework/DataHandling/src/ISISJournal.cpp
@@ -0,0 +1,319 @@
+// Mantid Repository : https://github.com/mantidproject/mantid
+//
+// Copyright &copy; 2020 ISIS Rutherford Appleton Laboratory UKRI,
+//   NScD Oak Ridge National Laboratory, European Spallation Source,
+//   Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
+// SPDX - License - Identifier: GPL - 3.0 +
+// Includes
+#include "MantidDataHandling/ISISJournal.h"
+#include "MantidKernel/Exception.h"
+#include "MantidKernel/InternetHelper.h"
+
+#include "Poco/SAX/SAXException.h"
+#include <Poco/DOM/DOMParser.h>
+#include <Poco/DOM/Document.h>
+#include <Poco/DOM/NodeFilter.h>
+#include <Poco/DOM/TreeWalker.h>
+#include <Poco/Net/HTTPResponse.h>
+
+#include <boost/algorithm/string.hpp>
+#include <boost/regex.hpp>
+#include <sstream>
+
+namespace Mantid {
+namespace DataHandling {
+namespace ISISJournal {
+
+using Kernel::InternetHelper;
+using Poco::XML::Document;
+using Poco::XML::DOMParser;
+using Poco::XML::Element;
+using Poco::XML::Node;
+using Poco::XML::NodeFilter;
+using Poco::XML::TreeWalker;
+
+namespace {
+static constexpr const char *URL_PREFIX =
+    "http://data.isis.rl.ac.uk/journals/ndx";
+static constexpr const char *INDEX_FILE_NAME = "main";
+static constexpr const char *JOURNAL_PREFIX = "/journal_";
+static constexpr const char *JOURNAL_EXT = ".xml";
+static constexpr const char *NXROOT_TAG = "NXroot";
+static constexpr const char *NXENTRY_TAG = "NXentry";
+static constexpr const char *JOURNAL_TAG = "journal";
+static constexpr const char *FILE_TAG = "file";
+
+/** Construct the URL for a journal file containing run data for a particular
+ * instrument and cycle, e.g.
+ *   http://data.isis.rl.ac.uk/journals/ndxinter/journal_19_4.xml
+ * @param instrument : the ISIS instrument name (case insensitive)
+ * @param name : the file name excluding the "journal_" prefix and extension,
+ * e.g. "19_4" for "journal_19_4.xml"
+ * @returns : the URL as a string
+ */
+std::string constructURL(std::string instrument, std::string const &name) {
+  boost::algorithm::to_lower(instrument);
+  std::ostringstream url;
+  url << URL_PREFIX << instrument << JOURNAL_PREFIX << name << JOURNAL_EXT;
+  return url.str();
+}
+
+/** Parse a given XML string into a Document object
+ * @param xmlString : the XML file contents as a string
+ * @returns : the XML Document
+ * @throws : if there was an error parsing the file
+ */
+Poco::AutoPtr<Document> parse(std::string const &xmlString) {
+  DOMParser domParser;
+  Poco::AutoPtr<Document> xmldoc;
+  try {
+    xmldoc = domParser.parseString(xmlString);
+  } catch (Poco::XML::SAXParseException const &ex) {
+    std::ostringstream msg;
+    msg << "ISISJournal: Error parsing file: " << ex.what();
+    throw std::runtime_error(msg.str());
+  }
+  return xmldoc;
+}
+
+/** Check the given element is valid.
+ * @param expectedName : the node name that the element is expected to have
+ * @throws : if the element is null or does not match the expected name
+ */
+void throwIfNotValid(Element *element, const char *expectedName) {
+  if (!element) {
+    std::ostringstream msg;
+    msg << "ISISJournal::throwIfNotValid() - invalid element for '"
+        << expectedName << "'\n";
+    throw std::invalid_argument(msg.str());
+  }
+
+  if (element->nodeName() != expectedName) {
+    std::ostringstream msg;
+    msg << "ISISJournal::throwIfNotValid() - Element name does not match '"
+        << expectedName << "'. Found " << element->nodeName() << "\n";
+    throw std::invalid_argument(msg.str());
+  }
+}
+
+/** Get the text contained in a node.
+ * @param node : the node
+ * @returns : the value contained in the child #text node, or an empty string if
+ * it does not contain a text value.
+ */
+std::string getTextValue(Node *node) {
+  if (!node)
+    return std::string();
+
+  auto child = node->firstChild();
+  if (child && child->nodeName() == "#text") {
+    auto value = child->nodeValue();
+    boost::algorithm::trim(value);
+    return value;
+  }
+
+  return std::string();
+}
+
+/** Check if an element matches a set of filter criteria.
+ * @param element : the element to check
+ * @param filters : a map of names and values to check against
+ * @returns : true if, for all filters, the element has a child node with
+ * the filter name that contains text matching the filter value
+ */
+bool matchesAllFilters(Element *element, ISISJournal::RunData const &filters) {
+  for (auto const &filterKvp : filters) {
+    auto const childElement = element->getChildElement(filterKvp.first);
+    if (getTextValue(childElement) != filterKvp.second)
+      return false;
+  }
+  return true;
+}
+
+/** Utility function to create the run data for an element.
+ * @param element : the element to get run data for
+ * @returns : a map of name->value pairs initialised with the mandatory data
+ * that is returned for all runs (currently just the name attribute, e.g.
+ * "name": "INTER00013460")
+ */
+ISISJournal::RunData createRunDataForElement(Element *element) {
+  return ISISJournal::RunData{{"name", element->getAttribute("name")}};
+}
+
+/** Extract a list of named values for the given element.
+ * @param element : the element to extract values for
+ * @param valuesToLookup : a list of values to extract (which may be empty if
+ * nothing is requested)
+ * @param result : a map of names to values to which we add key-value pairs for
+ * each requested value
+ */
+void addValuesForElement(Element *element,
+                         std::vector<std::string> const &valuesToLookup,
+                         ISISJournal::RunData &result) {
+  for (auto &name : valuesToLookup)
+    result[name] = getTextValue(element->getChildElement(name));
+}
+
+/** Extract a list of named values for all child nodes in the given parent
+ * element.
+ * @param parentElement : the element containing all child nodes we want to
+ * check
+ * @param valuesToLookup : the names of the values to extract from within the
+ * child nodes
+ * @param filters : a map of names and values to filter the results by
+ * @returns : a map of name->value pairs containing mandatory run data as well
+ * as the values that were requested
+ */
+std::vector<ISISJournal::RunData>
+getValuesForAllElements(Element *parentElement,
+                        std::vector<std::string> const &valuesToLookup,
+                        ISISJournal::RunData const &filters) {
+  auto results = std::vector<ISISJournal::RunData>{};
+
+  auto nodeIter = TreeWalker(parentElement, NodeFilter::SHOW_ELEMENT);
+  for (auto node = nodeIter.nextNode(); node; node = nodeIter.nextSibling()) {
+    auto element = dynamic_cast<Element *>(node);
+    throwIfNotValid(element, NXENTRY_TAG);
+
+    if (matchesAllFilters(element, filters)) {
+      auto result = createRunDataForElement(element);
+      addValuesForElement(element, valuesToLookup, result);
+      results.emplace_back(std::move(result));
+    }
+  }
+
+  return results;
+}
+
+/** Extract an attribute value for all direct children of a given element.
+ * @param parentElement : the element containing the child nodes to check
+ * @param childElementName : the name that the child elements should have
+ * @returns : the attribute values for all of the child elements
+ * @throws : if any of the child elements does not have the expected name
+ */
+std::vector<std::string>
+getAttributeForAllChildElements(Element *parentElement,
+                                const char *childElementName,
+                                const char *attributeName) {
+  auto results = std::vector<std::string>{};
+
+  auto nodeIter = TreeWalker(parentElement, NodeFilter::SHOW_ELEMENT);
+  for (auto node = nodeIter.nextNode(); node; node = nodeIter.nextSibling()) {
+    auto element = dynamic_cast<Element *>(node);
+    throwIfNotValid(element, childElementName);
+    results.emplace_back(element->getAttribute(attributeName));
+  }
+
+  return results;
+}
+
+/** Convert a cycle filename to the cycle name
+ * @param filename : a filename e.g. journal_19_4.xml
+ * @return the cycle name e.g. 19_4, or an empty string if the name
+ * does not match the required pattern
+ */
+std::string convertFilenameToCycleName(std::string const &filename) {
+  boost::regex pattern("[0-9]+_[0-9]+");
+  boost::smatch matches;
+  boost::regex_search(filename, matches, pattern);
+
+  if (matches.size() == 1)
+    return matches[0];
+
+  return std::string();
+}
+
+/** Convert a list of cycle filenames to a list of cycle names.
+ * @param filenames : the list of filenames e.g. journal_main.xml,
+ * journal_19_4.xml
+ * @returns : the list of cycle names for all valid journal files e.g. 19_4
+ * (note that this excludes files that do not match the journal-file pattern).
+ */
+std::vector<std::string>
+convertFilenamesToCycleNames(std::vector<std::string> const &filenames) {
+  auto cycles = std::vector<std::string>();
+  cycles.reserve(filenames.size());
+  for (const auto &filename : filenames) {
+    auto cycle = convertFilenameToCycleName(filename);
+    if (!cycle.empty())
+      cycles.emplace_back(std::move(cycle));
+  }
+  return cycles;
+}
+} // namespace
+
+/** Construct the journal class for a specific instrument and cycle
+ * @param instrument : the ISIS instrument name to request data for e.g. "INTER"
+ * (case insensitive)
+ * @param cycle : the ISIS cycle the required data is from e.g. "19_4"
+ * @param internetHelper : class for sending internet requests
+ */
+ISISJournal::ISISJournal(std::string const &instrument,
+                         std::string const &cycle,
+                         std::unique_ptr<InternetHelper> internetHelper)
+    : m_internetHelper(std::move(internetHelper)),
+      m_runsFileURL(constructURL(instrument, cycle)),
+      m_indexFileURL(constructURL(instrument, INDEX_FILE_NAME)) {}
+
+ISISJournal::~ISISJournal() = default;
+
+ISISJournal::ISISJournal(ISISJournal &&rhs) = default;
+
+ISISJournal &ISISJournal::operator=(ISISJournal &&rhs) = default;
+
+/** Get the cycle names
+ *
+ * @returns : a list of all ISIS cycle names for the instrument
+ * @throws : if there was an error fetching the runs
+ */
+std::vector<std::string> ISISJournal::getCycleNames() {
+  if (!m_indexDocument) {
+    auto xmlString = getURLContents(m_indexFileURL);
+    m_indexDocument = parse(xmlString);
+  }
+  auto rootElement = m_indexDocument->documentElement();
+  throwIfNotValid(rootElement, JOURNAL_TAG);
+  auto filenames =
+      getAttributeForAllChildElements(rootElement, FILE_TAG, "name");
+  return convertFilenamesToCycleNames(filenames);
+}
+
+/** Get run names and other specified data for all runs that match the given
+ * filter criteria.
+ *
+ * @param valuesToLookup : optional list of additional values to be returned
+ * e.g. "run_number", "title"
+ * @param filters : optional element names and values to filter the results by
+ * @throws : if there was an error fetching the runs
+ */
+std::vector<ISISJournal::RunData>
+ISISJournal::getRuns(std::vector<std::string> const &valuesToLookup,
+                     ISISJournal::RunData const &filters) {
+  if (!m_runsDocument) {
+    auto xmlString = getURLContents(m_runsFileURL);
+    m_runsDocument = parse(xmlString);
+  }
+  auto rootElement = m_runsDocument->documentElement();
+  throwIfNotValid(rootElement, NXROOT_TAG);
+  return getValuesForAllElements(rootElement, valuesToLookup, filters);
+}
+
+/** Get the contents of a file at a given URL
+ * @param url : the URL to fetch
+ * @returns : the contents of the file as a string
+ * @throws : if there was an error fetching the file
+ */
+std::string ISISJournal::getURLContents(std::string const &url) {
+  std::ostringstream serverReply;
+  auto const statusCode = m_internetHelper->sendRequest(url, serverReply);
+  if (statusCode != Poco::Net::HTTPResponse::HTTP_OK) {
+    throw Kernel::Exception::InternetError(
+        std::string("Failed to access journal file: HTTP Code: ") +
+        std::to_string(statusCode));
+  }
+  return serverReply.str();
+}
+
+} // namespace ISISJournal
+} // namespace DataHandling
+} // namespace Mantid
diff --git a/Framework/DataHandling/test/CMakeLists.txt b/Framework/DataHandling/test/CMakeLists.txt
index 75f98634a22221d34ffad2469c2c323e8ec7fb05..7bffa2e35f9c7f2e3d4920b7ea96de0ddbc52fa7 100644
--- a/Framework/DataHandling/test/CMakeLists.txt
+++ b/Framework/DataHandling/test/CMakeLists.txt
@@ -33,7 +33,8 @@ if(CXXTEST_FOUND)
                         HistogramData
                         ${NEXUS_LIBRARIES}
                         ${HDF5_LIBRARIES}
-                        ${HDF5_HL_LIBRARIES})
+                        ${HDF5_HL_LIBRARIES}
+                        gmock)
   add_dependencies(DataHandlingTest Algorithms MDAlgorithms)
   add_dependencies(FrameworkTests DataHandlingTest)
   # Test data
diff --git a/Framework/DataHandling/test/ISISJournalTest.h b/Framework/DataHandling/test/ISISJournalTest.h
new file mode 100644
index 0000000000000000000000000000000000000000..f1036130c74f8b3f4c05e01e5d8b0f02f9d36706
--- /dev/null
+++ b/Framework/DataHandling/test/ISISJournalTest.h
@@ -0,0 +1,302 @@
+// Mantid Repository : https://github.com/mantidproject/mantid
+//
+// Copyright &copy; 2020 ISIS Rutherford Appleton Laboratory UKRI,
+//   NScD Oak Ridge National Laboratory, European Spallation Source,
+//   Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS
+// SPDX - License - Identifier: GPL - 3.0 +
+#pragma once
+
+#include "MantidDataHandling/ISISJournal.h"
+#include "MantidKernel/Exception.h"
+#include "MantidKernel/InternetHelper.h"
+#include <Poco/Net/HTTPResponse.h>
+
+#include <cxxtest/TestSuite.h>
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+using namespace Mantid::DataHandling::ISISJournal;
+using Mantid::Kernel::InternetHelper;
+using Mantid::Kernel::Exception::InternetError;
+using testing::_;
+using testing::NiceMock;
+using testing::Return;
+
+namespace {
+static constexpr const char *emptyFile = "";
+
+static constexpr const char *badFile = "<NXroot";
+
+static constexpr const char *emptyJournalFile = "\
+<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
+  <NXroot NeXus_version=\"4.3.0\" XML_version=\"mxml\"></NXroot>";
+
+static constexpr const char *emptyIndexFile = "<journal></journal>";
+
+static constexpr const char *invalidIndexFile = "<journal><badtag/></journal>";
+
+static constexpr const char *indexFile = "\
+<journal>\
+  <file name=\"journal.xml\" />\
+  <file name=\"journal_17_1.xml\" />\
+  <file name=\"journal_18_1.xml\" />\
+  <file name=\"journal_19_1.xml\" />\
+  <file name=\"journal_19_2.xml\" />\
+</journal>";
+
+static constexpr const char *journalFile = "\
+  <NXroot>\
+    <NXentry name=\"INTER22345\">\
+      <title>Experiment 2 run 1</title>\
+      <experiment_id>200001</experiment_id>\
+      <run_number> 22345</run_number>\
+      <count> 5  </count>\
+    </NXentry>\
+    <NXentry name=\"INTER12345\">\
+      <title>Experiment 1 run 1</title>\
+      <experiment_id>100001</experiment_id>\
+      <run_number> 12345</run_number>\
+      <count> 3  </count>\
+    </NXentry>\
+    <NXentry name=\"INTER12346\">\
+      <title>Experiment 1 run 2</title>\
+      <experiment_id>100001</experiment_id>\
+      <run_number> 12346</run_number>\
+      <count> 5  </count>\
+    </NXentry>\
+  </NXroot>";
+} // namespace
+
+class MockInternetHelper : public InternetHelper {
+public:
+  // The constructor specifies the string that we want sendRequest to return
+  MockInternetHelper(std::string const &returnString)
+      : m_returnString(returnString){};
+
+  MOCK_METHOD2(sendRequestProxy, int(std::string const &, std::ostream &));
+
+  int sendRequest(std::string const &url, std::ostream &serverReply) override {
+    serverReply << m_returnString;
+    return sendRequestProxy(url, serverReply);
+  }
+
+private:
+  std::string m_returnString;
+};
+
+class ISISJournalTest : public CxxTest::TestSuite {
+public:
+  void test_getRuns_requests_correct_url() {
+    auto journal = makeJournal();
+    auto url = std::string(
+        "http://data.isis.rl.ac.uk/journals/ndxinter/journal_19_4.xml");
+    EXPECT_CALL(*m_internetHelper, sendRequestProxy(url, _)).Times(1);
+    journal.getRuns();
+    verifyAndClear();
+  }
+
+  void test_getCycleNames_requests_correct_url() {
+    auto journal = makeJournal(indexFile);
+    auto url = std::string(
+        "http://data.isis.rl.ac.uk/journals/ndxinter/journal_main.xml");
+    EXPECT_CALL(*m_internetHelper, sendRequestProxy(url, _)).Times(1);
+    journal.getCycleNames();
+    verifyAndClear();
+  }
+
+  void test_getRuns_when_server_returns_journalFile() {
+    auto journal = makeJournal(journalFile);
+    auto results = journal.getRuns();
+    auto const expected =
+        std::vector<ISISJournal::RunData>{{{"name", "INTER22345"}},
+                                          {{"name", "INTER12345"}},
+                                          {{"name", "INTER12346"}}};
+    TS_ASSERT_EQUALS(results, expected);
+    verifyAndClear();
+  }
+
+  void test_getCycleNames_when_server_returns_indexFile() {
+    auto journal = makeJournal(indexFile);
+    auto results = journal.getCycleNames();
+    auto const expected =
+        std::vector<std::string>{"17_1", "18_1", "19_1", "19_2"};
+    TS_ASSERT_EQUALS(results, expected);
+    verifyAndClear();
+  }
+
+  void test_getRuns_throws_if_url_not_found() {
+    auto journal = makeJournal();
+    expectURLNotFound();
+    TS_ASSERT_THROWS(journal.getRuns(), InternetError const &);
+    verifyAndClear();
+  }
+
+  void test_getCycleNames_throws_if_url_not_found() {
+    auto journal = makeJournal(indexFile);
+    expectURLNotFound();
+    TS_ASSERT_THROWS(journal.getCycleNames(), InternetError const &);
+    verifyAndClear();
+  }
+
+  void test_getRuns_with_empty_file_throws() {
+    auto journal = makeJournal(emptyFile);
+    TS_ASSERT_THROWS(journal.getRuns(), std::runtime_error const &);
+    verifyAndClear();
+  }
+
+  void test_getRuns_with_bad_xml_throws() {
+    auto journal = makeJournal(badFile);
+    TS_ASSERT_THROWS(journal.getRuns(), std::runtime_error const &);
+    verifyAndClear();
+  }
+
+  void test_getRuns_with_empty_xml_file_returns_empty_results() {
+    auto journal = makeJournal(emptyJournalFile);
+    auto results = journal.getRuns(valuesToLookup(), filters());
+    TS_ASSERT_EQUALS(results, std::vector<ISISJournal::RunData>{});
+    verifyAndClear();
+  }
+
+  void
+  test_getRuns_still_returns_run_names_when_requested_values_list_is_empty() {
+    auto journal = makeJournal();
+    auto results = journal.getRuns(emptyValueNames(), filters());
+    auto const expected = std::vector<ISISJournal::RunData>{
+        {{"name", "INTER12345"}}, {{"name", "INTER12346"}}};
+  }
+
+  void
+  test_getRuns_returns_all_run_names_when_values_list_and_filters_are_empty() {
+    auto journal = makeJournal();
+    auto results = journal.getRuns();
+    auto const expected =
+        std::vector<ISISJournal::RunData>{{{"name", "INTER22345"}},
+                                          {{"name", "INTER12345"}},
+                                          {{"name", "INTER12346"}}};
+    verifyAndClear();
+  }
+
+  void test_getRuns_returns_requested_values_filtered_by_one_filter() {
+    auto journal = makeJournal();
+    auto results = journal.getRuns(valuesToLookup(), filters());
+    auto const expected =
+        std::vector<ISISJournal::RunData>{{{"name", "INTER12345"},
+                                           {"run_number", "12345"},
+                                           {"title", "Experiment 1 run 1"}},
+                                          {{"name", "INTER12346"},
+                                           {"run_number", "12346"},
+                                           {"title", "Experiment 1 run 2"}}};
+    TS_ASSERT_EQUALS(results, expected);
+    verifyAndClear();
+  }
+
+  void test_getRuns_returns_requested_values_filtered_by_multiple_filters() {
+    auto journal = makeJournal();
+    auto results = journal.getRuns(valuesToLookup(), multipleFilters());
+    auto const expected =
+        std::vector<ISISJournal::RunData>{{{"name", "INTER12346"},
+                                           {"run_number", "12346"},
+                                           {"title", "Experiment 1 run 2"}}};
+    TS_ASSERT_EQUALS(results, expected);
+    verifyAndClear();
+  }
+
+  void
+  test_getRuns_returns_requested_values_for_all_entries_when_no_filter_is_set() {
+    auto journal = makeJournal();
+    auto results = journal.getRuns(valuesToLookup(), emptyFilters());
+    auto const expected =
+        std::vector<ISISJournal::RunData>{{{"name", "INTER22345"},
+                                           {"run_number", "22345"},
+                                           {"title", "Experiment 2 run 1"}},
+                                          {{"name", "INTER12345"},
+                                           {"run_number", "12345"},
+                                           {"title", "Experiment 1 run 1"}},
+                                          {{"name", "INTER12346"},
+                                           {"run_number", "12346"},
+                                           {"title", "Experiment 1 run 2"}}};
+    TS_ASSERT_EQUALS(results, expected);
+    verifyAndClear();
+  }
+
+  void test_getCycleList_with_empty_file_throws() {
+    auto journal = makeJournal(emptyFile);
+    TS_ASSERT_THROWS(journal.getCycleNames(), std::runtime_error const &);
+    verifyAndClear();
+  }
+
+  void test_getCycleList_with_bad_xml_throws() {
+    auto journal = makeJournal(badFile);
+    TS_ASSERT_THROWS(journal.getCycleNames(), std::runtime_error const &);
+    verifyAndClear();
+  }
+
+  void test_getCycleList_with_empty_xml_file_returns_empty_results() {
+    auto journal = makeJournal(emptyIndexFile);
+    auto results = journal.getCycleNames();
+    TS_ASSERT_EQUALS(results, std::vector<std::string>{});
+    verifyAndClear();
+  }
+
+  void test_getCycleList_throws_when_invalid_element_names() {
+    auto journal = makeJournal(invalidIndexFile);
+    TS_ASSERT_THROWS(journal.getCycleNames(), std::invalid_argument const &);
+    verifyAndClear();
+  }
+
+  void test_getCycleList_returns_all_valid_cycles() {
+    auto journal = makeJournal(indexFile);
+    auto results = journal.getCycleNames();
+    auto const expected =
+        std::vector<std::string>{"17_1", "18_1", "19_1", "19_2"};
+    TS_ASSERT_EQUALS(results, expected);
+    verifyAndClear();
+  }
+
+private:
+  NiceMock<MockInternetHelper> *m_internetHelper;
+
+  ISISJournal
+  makeJournal(std::string const &xmlContents = std::string(journalFile)) {
+    // Inject a mock internet helper. The journal takes ownership of the
+    // unique_ptr but we store a raw pointer to use in our test.
+    auto internetHelper =
+        std::make_unique<NiceMock<MockInternetHelper>>(xmlContents);
+    m_internetHelper = internetHelper.get();
+    auto journal = ISISJournal("INTER", "19_4", std::move(internetHelper));
+    // Ensure the internet helper returns an ok status by default.
+    auto status = Poco::Net::HTTPResponse::HTTP_OK;
+    ON_CALL(*m_internetHelper, sendRequestProxy(_, _))
+        .WillByDefault(Return(status));
+    return journal;
+  }
+
+  std::vector<std::string> valuesToLookup() {
+    return std::vector<std::string>{"run_number", "title"};
+  }
+
+  std::vector<std::string> emptyValueNames() {
+    return std::vector<std::string>{};
+  }
+
+  ISISJournal::RunData filters() {
+    return ISISJournal::RunData{{"experiment_id", "100001"}};
+  }
+
+  ISISJournal::RunData multipleFilters() {
+    return ISISJournal::RunData{{"experiment_id", "100001"}, {"count", "5"}};
+  }
+
+  ISISJournal::RunData emptyFilters() { return ISISJournal::RunData{}; }
+
+  void expectURLNotFound() {
+    auto status = Poco::Net::HTTPResponse::HTTP_NOT_FOUND;
+    EXPECT_CALL(*m_internetHelper, sendRequestProxy(_, _))
+        .Times(1)
+        .WillOnce(Return(status));
+  }
+
+  void verifyAndClear() {
+    TS_ASSERT(testing::Mock::VerifyAndClearExpectations(m_internetHelper));
+  }
+};