/* * 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/book.h" #include "../include/library.h" #include "../include/manager.h" #include "../src/library_dumper.h" #include "testing_tools.h" namespace { using namespace kiwix; Book createBook() { Book book; book.setId("book-id"); book.setTitle("Some Title"); book.setDescription("Some Description"); book.setLanguage("eng"); book.setCreator("Some Creator"); book.setPublisher("Some Publisher"); book.setDate("2021-03-25"); book.setName("some_name"); book.setTags("tag1;tag2;_category:wikipedia"); book.setFlavour("nopic"); book.setArticleCount(42); book.setMediaCount(7); return book; } #define CORE_ENTRY_BODY \ " urn:uuid:book-id\n" \ " Some Title\n" \ " 2021-03-25T00:00:00Z\n" \ " Some Description\n" \ " eng\n" \ " some_name\n" \ " nopic\n" \ /* Book::getCategory() only reflects a "_category:" tag/attribute */ \ /* resolved during XML/OPDS parsing - it is not derived on the fly */ \ /* from setTags(), so a manually-constructed Book always reports an */ \ /* empty category here. */ \ " \n" \ " tag1;tag2;_category:wikipedia\n" \ " 42\n" \ " 7\n" TEST(FullEntryOpdsTest, rendersCoreBookFields) { const Book book = createBook(); // No url and no contentAccessUrl were set, so neither the acquisition nor // the content is rendered (see rendersAcquisitionLinkWhenUrlIsSet / // rendersUrlEncodedContentLinkWhenContentAccessUrlIsSet below for when // they are). EXPECT_EQ(fullEntryOpds(book, "http://root.location", /*contentAccessUrl=*/"", /*contentId=*/"book-id"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, omitsAcquisitionLinkWhenUrlIsEmpty) { const Book book = createBook(); // url left at its default (empty). EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-id"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersAcquisitionLinkWhenUrlIsSet) { Book book = createBook(); book.setUrl("http://download.kiwix.org/zim/book.zim"); book.setSize(123456); EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-id"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, omitsContentLinkWhenContentAccessUrlIsEmpty) { const Book book = createBook(); // contentAccessUrl left at its default (empty). EXPECT_EQ(fullEntryOpds(book, "http://root.location", /*contentAccessUrl=*/"", "book-id"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersUrlEncodedContentLinkWhenContentAccessUrlIsSet) { const Book book = createBook(); // '/' is one of urlEncode()'s "harmless" characters (paths may legitimately // contain it) and passes through untouched, while ' ' and '#' do not. EXPECT_EQ(fullEntryOpds(book, "http://root.location", "http://root.location/content", "a/b c#d"), " \n" CORE_ENTRY_BODY " \n" " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersThumbnailLinkForBookIllustration) { auto lib = Library::create(); Manager manager(lib); const char sampleXML[] = R"( )"; manager.readXml(sampleXML, /*readOnly=*/false, "", /*trustLibrary=*/true); const Book& book = lib->getBookById("book-with-icon"); EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-with-icon"), " \n" " urn:uuid:book-with-icon\n" " Book With Icon\n" " T00:00:00Z\n" " \n" " \n" " \n" " \n" " \n" " \n" " 0\n" " 0\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersBase64ThumbnailForEmbeddedOnlyIllustrationInFileDump) { auto lib = Library::create(); Manager manager(lib); const char sampleXML[] = R"( )"; manager.readXml(sampleXML, /*readOnly=*/false, "", /*trustLibrary=*/true); const Book& book = lib->getBookById("book-with-embedded-icon"); // isLiveCatalog=false (offline file dump): an illustration with no // external url is only available as embedded data, and there is no server // behind /catalog/v2/illustration there to serve it from, so it is // embedded directly as a data: URI in the thumbnail link's href instead // (OPDS 1.2 5.2.2). EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-with-embedded-icon", /*localPath=*/"", /*isLiveCatalog=*/false), " \n" " urn:uuid:book-with-embedded-icon\n" " Book With Embedded Icon\n" " T00:00:00Z\n" " \n" " \n" " \n" " \n" " \n" " \n" " 0\n" " 0\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersIllustrationUrlDirectlyInFileDump) { auto lib = Library::create(); Manager manager(lib); const char sampleXML[] = R"( )"; manager.readXml(sampleXML, /*readOnly=*/false, "", /*trustLibrary=*/true); const Book& book = lib->getBookById("book-with-icon-url"); // isLiveCatalog=false (offline file dump): with an external url available, // the thumbnail link points straight at that url instead of the live // /catalog/v2/illustration endpoint, which has no server behind it there. EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-with-icon-url", /*localPath=*/"", /*isLiveCatalog=*/false), " \n" " urn:uuid:book-with-icon-url\n" " Book With Icon Url\n" " T00:00:00Z\n" " \n" " \n" " \n" " \n" " \n" " \n" " 0\n" " 0\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, doesNotDownloadIllustrationDataForLinkBasedThumbnail) { auto lib = Library::create(); Manager manager(lib); const char sampleOpds[] = R"( book-with-remote-icon Book With Remote Icon )"; ASSERT_TRUE(manager.readOpds(sampleOpds, "http://root.location")); const Book& book = lib->getBookById("book-with-remote-icon"); ASSERT_EQ(book.getIllustrations().size(), 1U); kiwix::testing::CapturedStderr stderror; fullEntryOpds(book, "http://root.location", "", "book-with-remote-icon"); // XXX This is a fragile way of testing the sought behaviour relying on the fact // that Book::Illustration::getData() prints to stderr if it tries to // download the thumbnail data. A more robust test would start an HTTP // server and check that no request has been made to it. EXPECT_EQ(std::string(stderror), ""); EXPECT_TRUE(book.getIllustrations().at(0)->getData().empty()); } TEST(FullEntryOpdsTest, roundTripsBase64ThumbnailDataThroughOPDSReadback) { auto lib = Library::create(); Manager manager(lib); const char sampleXML[] = R"( )"; manager.readXml(sampleXML, /*readOnly=*/false, "", /*trustLibrary=*/true); const Book& book = lib->getBookById("book-with-roundtrip-icon"); ASSERT_EQ(book.getIllustrations().at(0)->getData(), "Hello, World!"); const std::string rendered = fullEntryOpds(book, "http://root.location", "", "book-with-roundtrip-icon", /*localPath=*/"", /*isLiveCatalog=*/false); // Guards against the class of bug where the template puts whitespace (e.g. // a newline+indentation) around the base64 payload: base64_decode() stops // at the first non-base64 character rather than skipping it, so such // whitespace silently turns the decoded data into an empty string. A plain // golden-string comparison wouldn't catch this if the expected string were // updated to match a broken rendering, so decode the actual rendered // output through the real OPDS reader (Manager::readOpds()) instead. auto roundTripLib = Library::create(); Manager roundTripManager(roundTripLib); ASSERT_TRUE(roundTripManager.readOpds("" + rendered + "", "http://root.location")); const Book& roundTrippedBook = roundTripLib->getBookById("book-with-roundtrip-icon"); ASSERT_EQ(roundTrippedBook.getIllustrations().size(), 1U); EXPECT_EQ(roundTrippedBook.getIllustrations().at(0)->getData(), "Hello, World!"); } TEST(FullEntryOpdsTest, rendersMultipleBase64Thumbnails) { auto lib = Library::create(); Manager manager(lib); const char sampleOpds[] = R"( multi-icon-book Book With Multiple Icons )"; ASSERT_TRUE(manager.readOpds(sampleOpds, "http://root.location")); const Book& book = lib->getBookById("multi-icon-book"); EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "multi-icon-book", /*localPath=*/"", /*isLiveCatalog=*/false), " \n" " urn:uuid:multi-icon-book\n" " Book With Multiple Icons\n" " T00:00:00Z\n" " \n" " \n" " \n" " \n" " \n" " \n" " 0\n" " 0\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersMixedLinkAndBase64ThumbnailsInFileDump) { auto lib = Library::create(); Manager manager(lib); const char sampleOpds[] = R"( mixed-icon-book Book With Mixed Icons )"; ASSERT_TRUE(manager.readOpds(sampleOpds, "http://root.location")); const Book& book = lib->getBookById("mixed-icon-book"); EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "mixed-icon-book", /*localPath=*/"", /*isLiveCatalog=*/false), " \n" " urn:uuid:mixed-icon-book\n" " Book With Mixed Icons\n" " T00:00:00Z\n" " \n" " \n" " \n" " \n" " \n" " \n" " 0\n" " 0\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, omitsDataUriThumbnailForEmbeddedOnlyIllustrationInLiveCatalog) { auto lib = Library::create(); Manager manager(lib); const char sampleXML[] = R"( )"; manager.readXml(sampleXML, /*readOnly=*/false, "", /*trustLibrary=*/true); const Book& book = lib->getBookById("book-with-embedded-icon"); EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-with-embedded-icon"), " \n" " urn:uuid:book-with-embedded-icon\n" " Book With Embedded Icon\n" " T00:00:00Z\n" " \n" " \n" " \n" " \n" " \n" " \n" " 0\n" " 0\n" " \n" " \n" " \n" " \n" " \n" " \n" " \n" " T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, omitsLocalPathAcquisitionLinkWhenEmpty) { const Book book = createBook(); // localPath left at its default ("") - mirrors how OPDSDumper calls this // function for the live HTTP catalog, which must never leak a local path. EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-id"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersLocalPathAcquisitionLinkWhenSet) { Book book = createBook(); book.setSize(123456); EXPECT_EQ(fullEntryOpds(book, /*rootLocation=*/"", "", "book-id", /*localPath=*/"/local/path/book.zim"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } TEST(FullEntryOpdsTest, rendersBothAcquisitionLinksWhenUrlAndLocalPathAreSet) { Book book = createBook(); book.setUrl("http://download.kiwix.org/zim/book.zim"); book.setSize(123456); EXPECT_EQ(fullEntryOpds(book, /*rootLocation=*/"", "", "book-id", /*localPath=*/"/local/path/book.zim"), " \n" CORE_ENTRY_BODY " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" " \n" ); } TEST(FullEntryOpdsTest, specialCharactersInBookMetadataAreEscaped) { Book book = createBook(); book.setTitle("Q&A \"Title\""); book.setDescription("Ideas & \"quotes\" "); EXPECT_EQ(fullEntryOpds(book, "http://root.location", "", "book-id"), " \n" " urn:uuid:book-id\n" " Q&A <Tricky> "Title"\n" " 2021-03-25T00:00:00Z\n" " Ideas & "quotes" <tags>\n" " eng\n" " some_name\n" " nopic\n" " \n" " tag1;tag2;_category:wikipedia\n" " 42\n" " 7\n" " \n" " Some Creator\n" " \n" " \n" " Some Publisher\n" " \n" " 2021-03-25T00:00:00Z\n" " \n" " \n" ); } #undef CORE_ENTRY_BODY } // unnamed namespace