/* * Copyright 2026 Hamazasp Avetisyan * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but * is provided AS IS, WITHOUT ANY WARRANTY; without even the implied * warranty of MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, and * NON-INFRINGEMENT. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA * */ #include "gtest/gtest.h" #include #include #include "../include/library.h" #include "../include/manager.h" #include "../include/name_mapper.h" #include "../include/book.h" #include "../include/tools.h" #include "../src/opds_dumper.h" #include "../src/tools/otherTools.h" namespace { using namespace kiwix; // Two books, deliberately exercising the two different ways a category can // be attributed to a book (an explicit "category" attribute vs. a // "_category:" tag), and two different languages. const char sampleLibraryXML[] = R"( )"; // Returns a copy of 'text' where every line that fully matches 'pattern' // (preceded by optional whitespace) is replaced with 'replacement', // preserving the leading whitespace. Copied from test/library_server.cpp, // which uses the same technique for the same reason (masking timestamps // generated from the current wall-clock time). std::string replaceLines(const std::string& text, const std::string& pattern, const std::string& replacement) { std::regex regex("^ *" + pattern + "$"); std::ostringstream oss; std::istringstream iss(text); std::string line; while ( std::getline(iss, line) ) { if ( std::regex_match(line, regex) ) { for ( size_t i = 0; i < line.size() && line[i] == ' '; ++i ) oss << ' '; oss << replacement << "\n"; } else { oss << line << "\n"; } } return oss.str(); } // Masks every ... timestamp - both the feed-level one // (always "now", via gen_date_str()) and the per-entry/per-category/ // per-language ones used by categoriesOPDSFeed()/languagesOPDSFeed() (also // "now" - see LibraryDumper::getCategoryData()/getLanguageData()). Per-book // entries elsewhere carry a real, deterministic book date, but this matches // them too, same as library_server.cpp's CATALOG_ENTRY does for the live // HTTP catalog - the trade-off of a slightly less precise assertion in // exchange for a much simpler, more robust one. std::string maskUpdatedTimestamps(std::string s) { return replaceLines(s, R"(\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ)", "YYYY-MM-DDThh:mm:ssZ"); } // Compares 'actual' (an OPDS feed/entry string, possibly containing // wall-clock-generated timestamps) against 'expected' after // masking those timestamps out of 'actual', so tests don't have to spell // out maskUpdatedTimestamps(...) at every assertion. #define EXPECT_OPDS(actual, expected) \ EXPECT_EQ(maskUpdatedTimestamps(actual), (expected)) #define BOOK1_ENTRY \ " \n" \ " urn:uuid:book1-id\n" \ " First Book\n" \ " YYYY-MM-DDThh:mm:ssZ\n" \ " Description of First Book\n" \ " eng\n" \ " test_book1-id\n" \ " \n" \ " wikipedia\n" \ " tag1;tag2;_category:wikipedia\n" \ " 42\n" \ " 7\n" \ " \n" \ " \n" \ " First Book Creator\n" \ " \n" \ " \n" \ " First Book Publisher\n" \ " \n" \ " 2021-03-25T00:00:00Z\n" \ " \n" \ " \n" #define BOOK2_ENTRY \ " \n" \ " urn:uuid:book2-id\n" \ " Second Book\n" \ " YYYY-MM-DDThh:mm:ssZ\n" \ " Description of Second Book\n" \ " fra\n" \ " test_book2-id\n" \ " \n" \ " wiktionary\n" \ " tag3\n" \ " 10\n" \ " 1\n" \ " \n" \ " \n" \ " Second Book Creator\n" \ " \n" \ " \n" \ " Second Book Publisher\n" \ " \n" \ " 2021-03-26T00:00:00Z\n" \ " \n" \ " \n" #define BOOK1_PARTIAL_ENTRY \ " \n" \ " urn:uuid:book1-id\n" \ " First Book\n" \ " YYYY-MM-DDThh:mm:ssZ\n" \ " \n" \ " \n" #define BOOK2_PARTIAL_ENTRY \ " \n" \ " urn:uuid:book2-id\n" \ " Second Book\n" \ " YYYY-MM-DDThh:mm:ssZ\n" \ " \n" \ " \n" class OPDSDumperTest : public ::testing::Test { protected: void SetUp() override { lib = Library::create(); Manager manager(lib); manager.readXml(sampleLibraryXML, /*readOnly=*/false, "", /*trustLibrary=*/true); dumper.reset(new OPDSDumper(lib.get(), &nameMapper)); dumper->setRootLocation("http://root.location"); dumper->setLibraryId("test-library-id"); dumper->setContentAccessUrl("http://root.location/content"); dumper->setOpenSearchInfo(/*totalResults=*/2, /*startIndex=*/0, /*count=*/1); } LibraryPtr lib; IdNameMapper nameMapper; std::unique_ptr dumper; }; TEST_F(OPDSDumperTest, dumpOPDSFeedWithoutFilteringInfo) { EXPECT_OPDS(dumper->dumpOPDSFeed({"book1-id", "book2-id"}, ""), "\n" " " + gen_uuid("test-library-id/catalog/search?") + "\n" " All zims\n" " YYYY-MM-DDThh:mm:ssZ\n" "\n" " \n" " \n" BOOK1_ENTRY BOOK2_ENTRY "\n" ); } TEST_F(OPDSDumperTest, dumpOPDSFeedWithFilteringInfo) { EXPECT_OPDS(dumper->dumpOPDSFeed({"book1-id"}, "lang=eng"), "\n" " " + gen_uuid("test-library-id/catalog/search?lang=eng") + "\n" " Filtered zims (lang=eng)\n" " YYYY-MM-DDThh:mm:ssZ\n" " 2\n" " 0\n" " 1\n" " \n" " \n" BOOK1_ENTRY "\n" ); } TEST_F(OPDSDumperTest, dumpOPDSFeedV2FullEntries) { EXPECT_OPDS(dumper->dumpOPDSFeedV2({"book1-id", "book2-id"}, "", /*partial=*/false), "\n" "\n" " " + gen_uuid("test-library-id/entries?") + "\n" "\n" " \n" " \n" " \n" "\n" " All Entries\n" " YYYY-MM-DDThh:mm:ssZ\n" "\n" BOOK1_ENTRY BOOK2_ENTRY "\n" ); } TEST_F(OPDSDumperTest, dumpOPDSFeedV2PartialEntries) { EXPECT_OPDS(dumper->dumpOPDSFeedV2({"book1-id", "book2-id"}, "", /*partial=*/true), "\n" "\n" " " + gen_uuid("test-library-id/partial_entries?") + "\n" "\n" " \n" " \n" " \n" "\n" " All Entries\n" " YYYY-MM-DDThh:mm:ssZ\n" "\n" BOOK1_PARTIAL_ENTRY BOOK2_PARTIAL_ENTRY "\n" ); } TEST_F(OPDSDumperTest, dumpOPDSCompleteEntryProducesStandaloneEntryDocument) { EXPECT_OPDS(dumper->dumpOPDSCompleteEntry("book2-id"), "\n" BOOK2_ENTRY ); } TEST_F(OPDSDumperTest, dumpOPDSCompleteEntryThrowsForUnknownBookId) { EXPECT_THROW(dumper->dumpOPDSCompleteEntry("no-such-book"), std::out_of_range); } TEST_F(OPDSDumperTest, categoriesOPDSFeedListsDistinctSortedCategories) { // Categories are listed in sorted order: "wikipedia" (from the // "_category:" tag on book1) before "wiktionary" (from book2's explicit // "category" attribute). EXPECT_OPDS(dumper->categoriesOPDSFeed(), "\n" "\n" " " + gen_uuid("test-library-id/categories") + "\n" " \n" " \n" " List of categories\n" " YYYY-MM-DDThh:mm:ssZ\n" "\n" " \n" " wikipedia\n" " \n" " YYYY-MM-DDThh:mm:ssZ\n" " " + gen_uuid("test-library-id/categories/wikipedia") + "\n" " All entries with category of 'wikipedia'.\n" " \n" " \n" " wiktionary\n" " \n" " YYYY-MM-DDThh:mm:ssZ\n" " " + gen_uuid("test-library-id/categories/wiktionary") + "\n" " All entries with category of 'wiktionary'.\n" " \n" "\n" ); } TEST_F(OPDSDumperTest, languagesOPDSFeedListsLanguagesWithCounts) { // Languages are listed in sorted order of their ISO code: "eng" before "fra". EXPECT_OPDS(dumper->languagesOPDSFeed(), "\n" "\n" " " + gen_uuid("test-library-id/languages") + "\n" " \n" " \n" " List of languages\n" " YYYY-MM-DDThh:mm:ssZ\n" "\n" " \n" " " + getLanguageSelfName("eng") + "\n" " eng\n" " 1\n" " \n" " YYYY-MM-DDThh:mm:ssZ\n" " " + gen_uuid("test-library-id/languages/eng") + "\n" " \n" " \n" " " + getLanguageSelfName("fra") + "\n" " fra\n" " 1\n" " \n" " YYYY-MM-DDThh:mm:ssZ\n" " " + gen_uuid("test-library-id/languages/fra") + "\n" " \n" "\n" ); } #undef EXPECT_OPDS #undef BOOK1_ENTRY #undef BOOK2_ENTRY #undef BOOK2_PARTIAL_ENTRY #undef BOOK1_PARTIAL_ENTRY } // unnamed namespace