diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..de30116
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,151 @@
+"""Shared pytest fixtures for BibAPI tests."""
+
+import pytest
+
+
+@pytest.fixture
+def sample_marc_record_xml() -> str:
+ """Sample MARC record XML for testing."""
+ return """
+
+ 00000nam a22000001i 4500
+ 123456789
+ 20230101120000.0
+
+ 9783123456789
+
+
+ ger
+
+
+ Test Book Title
+ A Subtitle
+
+
+ 2nd edition
+
+
+ Berlin
+ Test Publisher
+ 2023
+
+
+ 456 pages
+
+
+ Band
+
+
+ Author, Test
+
+
+ Frei 129
+ ABC 123
+ DE-Frei129
+
+ """
+
+
+@pytest.fixture
+def sample_sru_response_xml() -> bytes:
+ """Sample SRU searchRetrieveResponse XML for testing."""
+ return b"""
+
+ 1.1
+ 1
+
+
+ marcxml
+ xml
+
+
+ 00000nam a22
+ 123456789
+
+ 9783123456789
+
+
+ ger
+
+
+ Test Book
+
+
+ 1st edition
+
+
+ Publisher
+ 2023
+
+
+ 200 pages
+
+
+ Band
+
+
+ Author, Test
+
+
+ DE-Frei129
+
+
+
+ 1
+
+
+
+ 1.1
+ pica.tit=Test
+ 100
+ xml
+ marcxml
+
+ """
+
+
+@pytest.fixture
+def mock_catalogue_html() -> str:
+ """Sample HTML response from catalogue search."""
+ return """
+
+
+ Book Title
+
+ """
+
+
+@pytest.fixture
+def mock_catalogue_detail_html() -> str:
+ """Sample HTML response from catalogue book detail page."""
+ return """
+
+
+ Test Book Title
+
+ 123456789
+
+ 2nd ed.
+
+
+
+
+ 9783123456789
+
+ 300 pages
+
+ """
diff --git a/tests/test_catalogue.py b/tests/test_catalogue.py
index aecc148..e42eebf 100644
--- a/tests/test_catalogue.py
+++ b/tests/test_catalogue.py
@@ -1,16 +1,32 @@
"""Tests for the Catalogue class, which interacts with the library catalogue."""
+from unittest.mock import MagicMock
+
import pytest
+import requests
from pytest_mock import MockerFixture
-from src.bibapi.catalogue import Catalogue
+from bibapi.catalogue import Catalogue
class TestCatalogue:
"""Tests for the Catalogue class."""
+ def test_catalogue_initialization(self, mocker: MockerFixture):
+ """Test Catalogue initialization."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ catalogue = Catalogue()
+ assert catalogue.timeout == 15
+
+ def test_catalogue_custom_timeout(self, mocker: MockerFixture):
+ """Test Catalogue initialization with custom timeout."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ catalogue = Catalogue(timeout=30)
+ assert catalogue.timeout == 30
+
def test_check_book_exists(self, mocker: MockerFixture):
"""Test the check_book_exists method of the Catalogue class."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
catalogue = Catalogue()
# Mock the get_book_links method to control its output
@@ -36,7 +52,7 @@ class TestCatalogue:
assert catalogue.check_book_exists(non_existing_book_searchterm) is False
def test_no_connection_raises_error(self, mocker: MockerFixture):
- """Test that a ConnectionError is raised when there is no internet connection."""
+ """Test that a ConnectionError is raised with no internet connection."""
# Mock the check_connection method to simulate no internet connection
mocker.patch.object(
Catalogue,
@@ -46,3 +62,248 @@ class TestCatalogue:
with pytest.raises(ConnectionError, match="No internet connection available."):
Catalogue()
+
+ def test_check_connection_success(self, mocker: MockerFixture):
+ """Test check_connection returns True on success."""
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mocker.patch("requests.get", return_value=mock_response)
+
+ catalogue = Catalogue.__new__(Catalogue)
+ catalogue.timeout = 15
+ assert catalogue.check_connection() is True
+
+ def test_check_connection_failure(self, mocker: MockerFixture):
+ """Test check_connection handles request exception."""
+ mocker.patch(
+ "requests.get",
+ side_effect=requests.exceptions.RequestException("Network error"),
+ )
+
+ catalogue = Catalogue.__new__(Catalogue)
+ catalogue.timeout = 15
+ result = catalogue.check_connection()
+ assert result is None # Returns None on exception
+
+ def test_search_book(self, mocker: MockerFixture):
+ """Test search_book method."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mock_response = MagicMock()
+ mock_response.text = "search results"
+ mocker.patch("requests.get", return_value=mock_response)
+
+ catalogue = Catalogue()
+ result = catalogue.search_book("test search")
+ assert result == "search results"
+
+ def test_search(self, mocker: MockerFixture):
+ """Test search method."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mock_response = MagicMock()
+ mock_response.text = "detail page"
+ mocker.patch("requests.get", return_value=mock_response)
+
+ catalogue = Catalogue()
+ result = catalogue.search("https://example.com/book/123")
+ assert result == "detail page"
+
+ def test_get_book_links(self, mocker: MockerFixture, mock_catalogue_html):
+ """Test get_book_links method."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "search_book",
+ return_value=mock_catalogue_html,
+ )
+
+ catalogue = Catalogue()
+ links = catalogue.get_book_links("test search")
+
+ assert len(links) == 1
+ assert "https://rds.ibs-bw.de/opac/record/123" in links[0]
+
+ def test_in_library_with_ppn(self, mocker: MockerFixture):
+ """Test in_library method with valid PPN."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["link1"],
+ )
+
+ catalogue = Catalogue()
+ assert catalogue.in_library("123456789") is True
+
+ def test_in_library_without_ppn(self, mocker: MockerFixture):
+ """Test in_library method with None PPN."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+
+ catalogue = Catalogue()
+ assert catalogue.in_library(None) is False
+
+ def test_in_library_not_found(self, mocker: MockerFixture):
+ """Test in_library method when book not found."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=[],
+ )
+
+ catalogue = Catalogue()
+ assert catalogue.in_library("nonexistent") is False
+
+ def test_get_location_none_ppn(self, mocker: MockerFixture):
+ """Test get_location method with None PPN."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+
+ catalogue = Catalogue()
+ assert catalogue.get_location(None) is None
+
+ def test_get_location_not_found(self, mocker: MockerFixture):
+ """Test get_location when book not found."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(Catalogue, "get_book", return_value=None)
+
+ catalogue = Catalogue()
+ assert catalogue.get_location("123") is None
+
+ def test_get_ppn(self, mocker: MockerFixture):
+ """Test get_ppn method with valid PPN format."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["https://example.com/opac/record/1234567890"],
+ )
+ mocker.patch.object(Catalogue, "search", return_value="")
+
+ catalogue = Catalogue()
+ ppn = catalogue.get_ppn("test")
+ assert ppn == "1234567890"
+
+ def test_get_ppn_with_x(self, mocker: MockerFixture):
+ """Test get_ppn method with PPN ending in X."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["https://example.com/opac/record/123456789X"],
+ )
+ mocker.patch.object(Catalogue, "search", return_value="")
+
+ catalogue = Catalogue()
+ ppn = catalogue.get_ppn("test")
+ assert ppn == "123456789X"
+
+ def test_get_semesterapparat_number(self, mocker: MockerFixture):
+ """Test get_semesterapparat_number method."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["https://example.com/book"],
+ )
+
+ html = """
+
+ Semesterapparat-42
+
+ """
+ mocker.patch.object(Catalogue, "search", return_value=html)
+
+ catalogue = Catalogue()
+ result = catalogue.get_semesterapparat_number("test")
+ assert result == 42
+
+ def test_get_semesterapparat_number_handbibliothek(self, mocker: MockerFixture):
+ """Test get_semesterapparat_number with Handbibliothek location."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["https://example.com/book"],
+ )
+
+ html = """
+
+ Floor 1
+
+ Handbibliothek-Reference
+
+ """
+ mocker.patch.object(Catalogue, "search", return_value=html)
+
+ catalogue = Catalogue()
+ result = catalogue.get_semesterapparat_number("test")
+ assert "Reference" in str(result) or "Handbibliothek" in str(result)
+
+ def test_get_semesterapparat_number_not_found(self, mocker: MockerFixture):
+ """Test get_semesterapparat_number when not found."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(Catalogue, "get_book_links", return_value=[])
+
+ catalogue = Catalogue()
+ result = catalogue.get_semesterapparat_number("test")
+ assert result == 0
+
+ def test_get_author(self, mocker: MockerFixture):
+ """Test get_author method."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["https://example.com/book"],
+ )
+
+ html = """
+
+
+ """
+ mocker.patch.object(Catalogue, "search", return_value=html)
+
+ catalogue = Catalogue()
+ author = catalogue.get_author("kid:123")
+ assert "Author One" in author
+ assert "Author Two" in author
+ assert "; " in author # Separator
+
+ def test_get_signature(self, mocker: MockerFixture):
+ """Test get_signature method."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(
+ Catalogue,
+ "get_book_links",
+ return_value=["https://example.com/book"],
+ )
+
+ html = """
+
+ """
+ mocker.patch.object(Catalogue, "search", return_value=html)
+
+ catalogue = Catalogue()
+ signature = catalogue.get_signature("9783123456789")
+ assert signature == "ABC 123"
+
+ def test_get_signature_not_found(self, mocker: MockerFixture):
+ """Test get_signature when not found."""
+ mocker.patch.object(Catalogue, "check_connection", return_value=True)
+ mocker.patch.object(Catalogue, "get_book_links", return_value=[])
+
+ catalogue = Catalogue()
+ signature = catalogue.get_signature("nonexistent")
+ assert signature is None
diff --git a/tests/test_init.py b/tests/test_init.py
new file mode 100644
index 0000000..fb35af5
--- /dev/null
+++ b/tests/test_init.py
@@ -0,0 +1,112 @@
+"""Tests for the __init__.py wrapper classes."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from bibapi import DNB, HBZ, HEBIS, KOBV, OEVK, SWB
+from bibapi.schemas.api_types import (
+ ALMASchema,
+ DublinCoreSchema,
+ PicaSchema,
+)
+
+
+class TestSWBWrapper:
+ """Tests for the SWB wrapper class."""
+
+ def test_swb_initialization(self):
+ """Test SWB initializes with correct config."""
+ api = SWB()
+ assert api.site == "SWB"
+ assert "sru.k10plus.de" in api.url
+ assert api.prefix == PicaSchema
+ assert api.library_identifier == "924$b"
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_swb_getbooks(self, mock_get):
+ """Test SWB getBooks method."""
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.content = b"""
+
+ 1.1
+ 0
+ """
+ mock_get.return_value = mock_response
+
+ api = SWB()
+ books = api.getBooks(["TITLE=Test"])
+ assert isinstance(books, list)
+ api.close()
+
+
+class TestDNBWrapper:
+ """Tests for the DNB wrapper class."""
+
+ def test_dnb_initialization(self):
+ """Test DNB initializes with correct config.
+
+ Note: DNB class has a bug - it doesn't set library_identifier before
+ calling super().__init__. This test documents the bug.
+ """
+ # DNB has a bug - library_identifier is not set
+ with pytest.raises(AttributeError, match="library_identifier"):
+ api = DNB()
+
+
+class TestKOBVWrapper:
+ """Tests for the KOBV wrapper class."""
+
+ def test_kobv_initialization(self):
+ """Test KOBV initializes with correct config."""
+ api = KOBV()
+ assert api.site == "KOBV"
+ assert "sru.kobv.de" in api.url
+ assert api.prefix == DublinCoreSchema
+ assert api.library_identifier == "924$b"
+ api.close()
+
+
+class TestHEBISWrapper:
+ """Tests for the HEBIS wrapper class."""
+
+ def test_hebis_initialization(self):
+ """Test HEBIS initializes with correct config."""
+ api = HEBIS()
+ assert api.site == "HEBIS"
+ assert "sru.hebis.de" in api.url
+ assert api.prefix == PicaSchema
+ assert api.library_identifier == "924$b"
+ # HEBIS has specific replace patterns
+ assert " " in api.replace
+ # HEBIS has unsupported args
+ assert "YEAR" in api.notsupported_args
+ api.close()
+
+
+class TestOEVKWrapper:
+ """Tests for the OEVK wrapper class."""
+
+ def test_oevk_initialization(self):
+ """Test OEVK initializes with correct config."""
+ api = OEVK()
+ assert api.site == "OEVK"
+ assert api.prefix == PicaSchema
+ assert api.library_identifier == "924$b"
+ api.close()
+
+
+class TestHBZWrapper:
+ """Tests for the HBZ wrapper class."""
+
+ def test_hbz_initialization(self):
+ """Test HBZ initializes with correct config."""
+ api = HBZ()
+ assert api.site == "HBZ"
+ assert "alma.exlibrisgroup.com" in api.url
+ assert api.prefix == ALMASchema
+ assert api.library_identifier == "852$a"
+ api.close()
diff --git a/tests/test_marcxml_parser.py b/tests/test_marcxml_parser.py
index 5cb4e8d..81112ba 100644
--- a/tests/test_marcxml_parser.py
+++ b/tests/test_marcxml_parser.py
@@ -4,8 +4,11 @@ import xml.etree.ElementTree as ET
import pytest
+from bibapi.schemas.marcxml import (
+ DataField,
+ SubField,
+)
from bibapi.sru import (
- NS,
_smart_join_title,
_text,
controlfield_value,
@@ -15,20 +18,11 @@ from bibapi.sru import (
first_subfield_value,
first_subfield_value_from_fields,
iter_datafields,
- parse_echoed_request,
parse_marc_record,
- parse_record,
parse_search_retrieve_response,
subfield_values,
subfield_values_from_fields,
)
-from bibapi.schemas.marcxml import (
- ControlField,
- DataField,
- MarcRecord,
- SubField,
-)
-
# --- Fixtures for sample XML data ---
@@ -490,4 +484,3 @@ class TestSmartJoinTitle:
def test_join_strips_whitespace(self):
result = _smart_join_title(" Main Title ", " Subtitle ")
assert result == "Main Title : Subtitle"
-
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
index 007370a..74ca00b 100644
--- a/tests/test_schemas.py
+++ b/tests/test_schemas.py
@@ -1,5 +1,25 @@
-from src.bibapi.schemas.api_types import ALMASchema, DublinCoreSchema, PicaSchema
-from src.bibapi.sru import QueryTransformer
+"""Tests for schema modules."""
+
+import json
+
+import pytest
+
+from bibapi.schemas.api_types import (
+ ALMASchema,
+ DNBSchema,
+ DublinCoreSchema,
+ HBZSchema,
+ HebisSchema,
+ KOBVSchema,
+ OEVKSchema,
+ PicaSchema,
+ SWBSchema,
+)
+from bibapi.schemas.bookdata import BookData
+from bibapi.schemas.errors import BibAPIError, CatalogueError, NetworkError
+from bibapi.sru import QueryTransformer
+
+# --- QueryTransformer tests with different schemas ---
arguments = [
"TITLE=Java ist auch eine Insel",
@@ -35,3 +55,190 @@ def test_dublin_core_schema():
assert transformed[0].startswith(DublinCoreSchema.TITLE.value)
assert transformed[1].startswith(DublinCoreSchema.AUTHOR.value)
assert transformed[2].startswith(DublinCoreSchema.YEAR.value)
+
+
+# --- API Schema configuration tests ---
+
+
+class TestApiSchemas:
+ """Tests for API schema configurations."""
+
+ def test_swb_schema_config(self):
+ """Test SWB schema configuration."""
+ assert SWBSchema.NAME.value == "SWB"
+ assert "sru.k10plus.de" in SWBSchema.URL.value
+ assert SWBSchema.ARGSCHEMA.value == PicaSchema
+ assert SWBSchema.LIBRARY_NAME_LOCATION_FIELD.value == "924$b"
+
+ def test_dnb_schema_config(self):
+ """Test DNB schema configuration."""
+ assert DNBSchema.NAME.value == "DNB"
+ assert "services.dnb.de" in DNBSchema.URL.value
+ assert DNBSchema.ARGSCHEMA.value == DublinCoreSchema
+
+ def test_kobv_schema_config(self):
+ """Test KOBV schema configuration."""
+ assert KOBVSchema.NAME.value == "KOBV"
+ assert "sru.kobv.de" in KOBVSchema.URL.value
+ assert KOBVSchema.ARGSCHEMA.value == DublinCoreSchema
+
+ def test_hebis_schema_config(self):
+ """Test HEBIS schema configuration."""
+ assert HebisSchema.NAME.value == "HEBIS"
+ assert "sru.hebis.de" in HebisSchema.URL.value
+ assert HebisSchema.ARGSCHEMA.value == PicaSchema
+ # HEBIS has specific character replacements
+ assert " " in HebisSchema.REPLACE.value
+
+ def test_oevk_schema_config(self):
+ """Test OEVK schema configuration."""
+ assert OEVKSchema.NAME.value == "OEVK"
+ assert OEVKSchema.ARGSCHEMA.value == PicaSchema
+
+ def test_hbz_schema_config(self):
+ """Test HBZ schema configuration."""
+ assert HBZSchema.NAME.value == "HBZ"
+ assert HBZSchema.ARGSCHEMA.value == ALMASchema
+ assert HBZSchema.LIBRARY_NAME_LOCATION_FIELD.value == "852$a"
+ # HBZ doesn't support PPN
+ assert "PPN" in HBZSchema.NOTSUPPORTEDARGS.value
+
+
+# --- BookData tests ---
+
+
+class TestBookData:
+ """Tests for the BookData class."""
+
+ def test_bookdata_creation_defaults(self):
+ """Test BookData creation with defaults."""
+ book = BookData()
+ assert book.ppn is None
+ assert book.title is None
+ assert book.in_apparat is False
+ assert book.in_library is False
+
+ def test_bookdata_creation_with_values(self):
+ """Test BookData creation with values."""
+ book = BookData(
+ ppn="123456",
+ title="Test Book",
+ signature="ABC 123",
+ year=2023,
+ isbn=["9783123456789"],
+ )
+ assert book.ppn == "123456"
+ assert book.title == "Test Book"
+ assert book.signature == "ABC 123"
+ assert book.year == "2023" # Converted to string without non-digits
+ assert book.in_library is True # Because signature exists
+
+ def test_bookdata_post_init_year_cleaning(self):
+ """Test that year is cleaned of non-digits."""
+ book = BookData(year="2023 [erschienen]")
+ assert book.year == "2023"
+
+ def test_bookdata_post_init_language_normalization(self):
+ """Test language list normalization."""
+ book = BookData(language=["ger", "eng", " fra "])
+ assert book.language == "ger,eng,fra"
+
+ def test_bookdata_post_init_library_location(self):
+ """Test library_location is converted to string."""
+ book = BookData(library_location=123)
+ assert book.library_location == "123"
+
+ def test_bookdata_from_dict(self):
+ """Test BookData.from_dict method."""
+ book = BookData()
+ data = {"ppn": "123", "title": "Test", "year": "2023"}
+ book.from_dict(data)
+ assert book.ppn == "123"
+ assert book.title == "Test"
+
+ def test_bookdata_merge(self):
+ """Test BookData.merge method."""
+ book1 = BookData(ppn="123", title="Book 1")
+ book2 = BookData(title="Book 2", author="Author", isbn=["978123"])
+
+ book1.merge(book2)
+ assert book1.ppn == "123" # Original value preserved
+ assert book1.title == "Book 1" # Original value preserved (not None)
+ assert book1.author == "Author" # Merged from book2
+ assert "978123" in book1.isbn # Merged list
+
+ def test_bookdata_merge_lists(self):
+ """Test BookData.merge with list merging."""
+ book1 = BookData(isbn=["978123"])
+ book2 = BookData(isbn=["978456", "978123"]) # Has duplicate
+
+ book1.merge(book2)
+ # Should have both ISBNs but no duplicates
+ assert len(book1.isbn) == 2
+ assert "978123" in book1.isbn
+ assert "978456" in book1.isbn
+
+ def test_bookdata_to_dict(self):
+ """Test BookData.to_dict property."""
+ book = BookData(ppn="123", title="Test Book")
+ json_str = book.to_dict
+ data = json.loads(json_str)
+ assert data["ppn"] == "123"
+ assert data["title"] == "Test Book"
+ assert "old_book" not in data # Should be removed
+
+ def test_bookdata_from_string(self):
+ """Test BookData.from_string method."""
+ json_str = '{"ppn": "123", "title": "Test"}'
+ book = BookData().from_string(json_str)
+ assert book.ppn == "123"
+ assert book.title == "Test"
+
+ def test_bookdata_edition_number(self):
+ """Test BookData.edition_number property."""
+ book = BookData(edition="3rd edition")
+ assert book.edition_number == 3
+
+ book2 = BookData(edition="First edition")
+ assert book2.edition_number == 0 # No digit found
+
+ book3 = BookData(edition=None)
+ assert book3.edition_number == 0
+
+ def test_bookdata_get_book_type(self):
+ """Test BookData.get_book_type method."""
+ book = BookData(media_type="print", pages="Online Resource")
+ assert book.get_book_type() == "eBook"
+
+ book2 = BookData(media_type="print", pages="300 pages")
+ assert book2.get_book_type() == "Druckausgabe"
+
+
+# --- Error classes tests ---
+
+
+class TestErrors:
+ """Tests for error classes."""
+
+ def test_bibapi_error(self):
+ """Test BibAPIError exception."""
+ with pytest.raises(BibAPIError):
+ raise BibAPIError("Test error")
+
+ def test_catalogue_error(self):
+ """Test CatalogueError exception."""
+ with pytest.raises(CatalogueError):
+ raise CatalogueError("Catalogue error")
+
+ # Should also be a BibAPIError
+ with pytest.raises(BibAPIError):
+ raise CatalogueError("Catalogue error")
+
+ def test_network_error(self):
+ """Test NetworkError exception."""
+ with pytest.raises(NetworkError):
+ raise NetworkError("Network error")
+
+ # Should also be a BibAPIError
+ with pytest.raises(BibAPIError):
+ raise NetworkError("Network error")
diff --git a/tests/test_sru.py b/tests/test_sru.py
index e966636..c991102 100644
--- a/tests/test_sru.py
+++ b/tests/test_sru.py
@@ -1,7 +1,389 @@
+"""Comprehensive tests for the SRU module."""
+
+import xml.etree.ElementTree as ET
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from bibapi.schemas.api_types import ALMASchema, DublinCoreSchema, PicaSchema
+from bibapi.schemas.bookdata import BookData
+from bibapi.sru import (
+ Api,
+ QueryTransformer,
+ book_from_marc,
+ find_newer_edition,
+ parse_marc_record,
+)
from src.bibapi import SWB
+# --- Integration test (requires network) ---
+
+@pytest.mark.integration
def test_swb_schema() -> None:
+ """Integration test that requires network access."""
result = SWB().getBooks(["pica.tit=Java ist auch eine Insel", "pica.bib=20735"])
assert len(result) == 1
assert result[0].title == "Java ist auch eine Insel"
+
+
+# --- Api class tests ---
+
+
+class TestApiClass:
+ """Tests for the Api class."""
+
+ def test_api_initialization(self):
+ """Test Api class initialization."""
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ assert api.site == "TestSite"
+ assert api.url == "https://example.com/sru?query={}"
+ assert api.prefix == PicaSchema
+ assert api.library_identifier == "924$b"
+ assert api._rate_limit_seconds == 1.0
+ assert api._max_retries == 5
+ assert api._overall_timeout_seconds == 30.0
+ api.close()
+
+ def test_api_with_notsupported_args(self):
+ """Test Api initialization with unsupported arguments."""
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ notsupported_args=["YEAR", "PPN"],
+ )
+ assert "YEAR" in api.notsupported_args
+ assert "PPN" in api.notsupported_args
+ api.close()
+
+ def test_api_with_replace_dict(self):
+ """Test Api initialization with replace dictionary."""
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ replace={" ": "+", "&": "%26"},
+ )
+ assert api.replace == {" ": "+", "&": "%26"}
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_api_get_success(self, mock_get, sample_sru_response_xml):
+ """Test successful API get request."""
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.content = sample_sru_response_xml
+ mock_get.return_value = mock_response
+
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ records = api.get(["title=Test"])
+ assert len(records) == 1
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_api_get_with_string_query(self, mock_get, sample_sru_response_xml):
+ """Test API get with string query (not list)."""
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.content = sample_sru_response_xml
+ mock_get.return_value = mock_response
+
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ records = api.get("title=Test")
+ assert len(records) == 1
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_api_get_filters_notsupported_args(self, mock_get, sample_sru_response_xml):
+ """Test that unsupported args are filtered out."""
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.content = sample_sru_response_xml
+ mock_get.return_value = mock_response
+
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ notsupported_args=["YEAR"],
+ )
+ # YEAR should be filtered out
+ records = api.get(["title=Test", "YEAR=2023"])
+ assert len(records) == 1
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_api_get_http_error_retries(self, mock_get):
+ """Test that API retries on HTTP errors."""
+ mock_response = MagicMock()
+ mock_response.status_code = 500
+ mock_get.return_value = mock_response
+
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ api._max_retries = 2
+ api._rate_limit_seconds = 0.01 # Speed up test
+ api._overall_timeout_seconds = 5.0
+
+ with pytest.raises(Exception, match="HTTP 500"):
+ api.get(["title=Test"])
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_api_get_timeout_returns_empty_bookdata(self, mock_get):
+ """Test that timeout returns empty BookData list."""
+ mock_get.side_effect = requests.exceptions.ReadTimeout("Timeout")
+
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ api._max_retries = 1
+ api._rate_limit_seconds = 0.01
+
+ books = api.getBooks(["title=Test"])
+ assert len(books) == 1
+ assert books[0].ppn is None # Empty BookData
+ api.close()
+
+ @patch.object(requests.Session, "get")
+ def test_api_getbooks_filters_by_title(self, mock_get, sample_sru_response_xml):
+ """Test that getBooks filters results by title prefix."""
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.content = sample_sru_response_xml
+ mock_get.return_value = mock_response
+
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ # Title in sample is "Test Book" - filtering for "Test" should match
+ books = api.getBooks(["pica.tit=Test"])
+ assert len(books) == 1
+
+ # Filtering for "NonExistent" should not match
+ books = api.getBooks(["pica.tit=NonExistent"])
+ assert len(books) == 0
+ api.close()
+
+ def test_api_close(self):
+ """Test Api close method."""
+ api = Api(
+ site="TestSite",
+ url="https://example.com/sru?query={}",
+ prefix=PicaSchema,
+ library_identifier="924$b",
+ )
+ # Should not raise
+ api.close()
+ api.close() # Double close should be safe
+
+
+# --- QueryTransformer tests ---
+
+
+class TestQueryTransformer:
+ """Tests for the QueryTransformer class."""
+
+ def test_transform_pica_schema(self):
+ """Test transformation with PicaSchema."""
+ args = ["TITLE=Test Book", "AUTHOR=Smith, John"]
+ transformer = QueryTransformer(PicaSchema, args)
+ result = transformer.transform()
+
+ assert len(result) == 2
+ # Check that pica.tit is in the result
+ assert any(r.startswith("pica.tit=") for r in result)
+ # Author should have comma without space
+ assert any(r.startswith("pica.per=") for r in result)
+
+ def test_transform_alma_schema(self):
+ """Test transformation with ALMASchema."""
+ args = ["TITLE=Test Book", "AUTHOR=Smith, John"]
+ transformer = QueryTransformer(ALMASchema, args)
+ result = transformer.transform()
+
+ assert len(result) == 2
+ # Title should be enclosed in quotes
+ assert any('alma.title="Test Book"' in r for r in result)
+
+ def test_transform_dublin_core_schema(self):
+ """Test transformation with DublinCoreSchema."""
+ args = ["TITLE=Test Book", "AUTHOR=Smith,John"]
+ transformer = QueryTransformer(DublinCoreSchema, args)
+ result = transformer.transform()
+
+ assert len(result) == 2
+ # Check that dc.title is in the result
+ assert any(r.startswith("dc.title=") for r in result)
+ # Author should have space after comma
+ assert any(r.startswith("dc.creator=") for r in result)
+
+ def test_transform_string_input(self):
+ """Test transformation with string input instead of list."""
+ transformer = QueryTransformer(PicaSchema, "TITLE=Test Book")
+ result = transformer.transform()
+ assert len(result) == 1
+
+ def test_transform_drops_empty_values(self):
+ """Test that empty values are dropped when drop_empty is True."""
+ args = ["TITLE=Test Book", "AUTHOR="]
+ transformer = QueryTransformer(PicaSchema, args)
+ result = transformer.transform()
+ assert len(result) == 1
+
+ def test_transform_invalid_format_ignored(self):
+ """Test that arguments without = are ignored."""
+ args = ["TITLE=Test Book", "InvalidArg", "AUTHOR=Smith"]
+ transformer = QueryTransformer(PicaSchema, args)
+ result = transformer.transform()
+ assert len(result) == 2
+
+ def test_transform_unknown_key_ignored(self):
+ """Test that unknown keys are ignored."""
+ args = ["TITLE=Test Book", "UNKNOWNKEY=value"]
+ transformer = QueryTransformer(PicaSchema, args)
+ result = transformer.transform()
+ assert len(result) == 1
+
+
+# --- book_from_marc tests ---
+
+
+class TestBookFromMarc:
+ """Tests for the book_from_marc function."""
+
+ def test_book_from_marc_basic(self, sample_marc_record_xml):
+ """Test basic book extraction from MARC record."""
+ root = ET.fromstring(sample_marc_record_xml)
+ record = parse_marc_record(root)
+ book = book_from_marc(record, "924$b")
+
+ assert book.ppn == "123456789"
+ assert book.title == "Test Book Title"
+ assert book.edition == "2nd edition"
+ assert book.year == "2023"
+ assert book.publisher == "Test Publisher"
+ assert "9783123456789" in book.isbn
+ assert book.pages == "456 pages"
+ assert book.media_type == "Band"
+ assert book.author == "Author, Test"
+
+ def test_book_from_marc_signature(self, sample_marc_record_xml):
+ """Test signature extraction from MARC record with Frei 129."""
+ root = ET.fromstring(sample_marc_record_xml)
+ record = parse_marc_record(root)
+ book = book_from_marc(record, "924$b")
+
+ # Signature should be from 924 where $9 == "Frei 129" -> $g
+ assert book.signature == "ABC 123"
+
+ def test_book_from_marc_libraries(self, sample_marc_record_xml):
+ """Test library extraction from MARC record."""
+ root = ET.fromstring(sample_marc_record_xml)
+ record = parse_marc_record(root)
+ book = book_from_marc(record, "924$b")
+
+ assert "DE-Frei129" in book.libraries
+
+
+# --- find_newer_edition tests ---
+
+
+class TestFindNewerEdition:
+ """Tests for the find_newer_edition function."""
+
+ def test_find_newer_edition_by_year(self):
+ """Test finding newer edition by year."""
+ swb = BookData(ppn="1", year=2020, edition="1st edition")
+ dnb = [
+ BookData(ppn="2", year=2023, edition="3rd edition"),
+ BookData(ppn="3", year=2019, edition="1st edition"),
+ ]
+ result = find_newer_edition(swb, dnb)
+ assert result is not None
+ assert len(result) == 1
+ # Year is stored as string after post_init
+ assert result[0].year == "2023"
+
+ def test_find_newer_edition_by_edition_number(self):
+ """Test finding newer edition by edition number."""
+ swb = BookData(ppn="1", year=2020, edition="1st edition")
+ dnb = [
+ BookData(ppn="2", year=2020, edition="3rd edition"),
+ ]
+ result = find_newer_edition(swb, dnb)
+ assert result is not None
+ assert len(result) == 1
+ assert result[0].edition_number == 3
+
+ def test_find_newer_edition_none_found(self):
+ """Test when no newer edition exists."""
+ swb = BookData(ppn="1", year=2023, edition="5th edition")
+ dnb = [
+ BookData(ppn="2", year=2020, edition="1st edition"),
+ BookData(ppn="3", year=2019, edition="2nd edition"),
+ ]
+ result = find_newer_edition(swb, dnb)
+ assert result is None
+
+ def test_find_newer_edition_empty_list(self):
+ """Test with empty DNB result list."""
+ swb = BookData(ppn="1", year=2020)
+ result = find_newer_edition(swb, [])
+ assert result is None
+
+ def test_find_newer_edition_prefers_matching_signature(self):
+ """Test that matching signature is preferred."""
+ swb = BookData(ppn="1", year=2020, signature="ABC 123")
+ dnb = [
+ BookData(ppn="2", year=2023, signature="ABC 123"),
+ BookData(ppn="3", year=2023, signature="XYZ 789"),
+ ]
+ result = find_newer_edition(swb, dnb)
+ assert result is not None
+ assert len(result) == 1
+ # Should prefer matching signature (first one) but XYZ 789 differs
+ # so it's filtered out. Result should be the matching one.
+
+ def test_find_newer_edition_deduplicates_by_ppn(self):
+ """Test that results are deduplicated by PPN."""
+ swb = BookData(ppn="1", year=2020)
+ dnb = [
+ BookData(ppn="2", year=2023, signature="ABC"),
+ BookData(ppn="2", year=2023), # Duplicate PPN, no signature
+ ]
+ result = find_newer_edition(swb, dnb)
+ assert result is not None
+ assert len(result) == 1
+ # Should prefer the one with signature
+ assert result[0].signature == "ABC"
diff --git a/tests/test_transformers.py b/tests/test_transformers.py
new file mode 100644
index 0000000..6109a6b
--- /dev/null
+++ b/tests/test_transformers.py
@@ -0,0 +1,375 @@
+"""Tests for the _transformers module."""
+
+from src.bibapi._transformers import (
+ RDS_AVAIL_DATA,
+ RDS_DATA,
+ RDS_GENERIC_DATA,
+ ARRAYData,
+ BibTeXData,
+ COinSData,
+ DictToTable,
+ Item,
+ RISData,
+)
+from src.bibapi.schemas.bookdata import BookData
+
+# --- Item dataclass tests ---
+
+
+class TestItem:
+ """Tests for the Item dataclass."""
+
+ def test_item_creation_defaults(self):
+ """Test Item creation with defaults."""
+ item = Item()
+ assert item.superlocation == ""
+ assert item.status == ""
+ assert item.availability == ""
+
+ def test_item_creation_with_values(self):
+ """Test Item creation with values."""
+ item = Item(
+ superlocation="Main Library",
+ status="available",
+ callnumber="ABC 123",
+ )
+ assert item.superlocation == "Main Library"
+ assert item.status == "available"
+ assert item.callnumber == "ABC 123"
+
+ def test_item_from_dict(self):
+ """Test Item.from_dict method."""
+ item = Item()
+ data = {
+ "items": [
+ {
+ "status": "available",
+ "callnumber": "ABC 123",
+ "location": "Floor 1",
+ },
+ ],
+ }
+ result = item.from_dict(data)
+ assert result.status == "available"
+ assert result.callnumber == "ABC 123"
+ assert result.location == "Floor 1"
+
+
+# --- RDS_DATA dataclass tests ---
+
+
+class TestRDSData:
+ """Tests for the RDS_DATA dataclass."""
+
+ def test_rds_data_creation_defaults(self):
+ """Test RDS_DATA creation with defaults."""
+ rds = RDS_DATA()
+ assert rds.RDS_SIGNATURE == ""
+ assert rds.RDS_STATUS == ""
+ assert rds.RDS_LOCATION == ""
+
+ def test_rds_data_import_from_dict(self):
+ """Test RDS_DATA.import_from_dict method."""
+ rds = RDS_DATA()
+ data = {
+ "RDS_SIGNATURE": "ABC 123",
+ "RDS_STATUS": "available",
+ "RDS_LOCATION": "Floor 1",
+ }
+ result = rds.import_from_dict(data)
+ assert result.RDS_SIGNATURE == "ABC 123"
+ assert result.RDS_STATUS == "available"
+ assert result.RDS_LOCATION == "Floor 1"
+
+
+# --- RDS_AVAIL_DATA dataclass tests ---
+
+
+class TestRDSAvailData:
+ """Tests for the RDS_AVAIL_DATA dataclass."""
+
+ def test_rds_avail_data_creation_defaults(self):
+ """Test RDS_AVAIL_DATA creation with defaults."""
+ rds = RDS_AVAIL_DATA()
+ assert rds.library_sigil == ""
+ assert rds.items == []
+
+ def test_rds_avail_data_import_from_dict(self):
+ """Test RDS_AVAIL_DATA.import_from_dict method."""
+ rds = RDS_AVAIL_DATA()
+ json_data = (
+ '{"DE-Frei129": {"Location1": {"items": [{"status": "available"}]}}}'
+ )
+ result = rds.import_from_dict(json_data)
+ assert result.library_sigil == "DE-Frei129"
+ assert len(result.items) == 1
+
+
+# --- RDS_GENERIC_DATA dataclass tests ---
+
+
+class TestRDSGenericData:
+ """Tests for the RDS_GENERIC_DATA dataclass."""
+
+ def test_rds_generic_data_creation_defaults(self):
+ """Test RDS_GENERIC_DATA creation with defaults."""
+ rds = RDS_GENERIC_DATA()
+ assert rds.LibrarySigil == ""
+ assert rds.RDS_DATA == []
+
+ def test_rds_generic_data_import_from_dict(self):
+ """Test RDS_GENERIC_DATA.import_from_dict method."""
+ rds = RDS_GENERIC_DATA()
+ json_data = '{"DE-Frei129": [{"RDS_SIGNATURE": "ABC 123"}]}'
+ result = rds.import_from_dict(json_data)
+ assert result.LibrarySigil == "DE-Frei129"
+ assert len(result.RDS_DATA) == 1
+
+
+# --- ARRAYData tests ---
+
+
+class TestARRAYData:
+ """Tests for the ARRAYData transformer."""
+
+ def test_array_data_transform(self):
+ """Test ARRAYData transform method."""
+ sample_data = """
+ [kid] => 123456789
+ [ti_long] => Array
+ (
+ [0] => Test Book Title
+ )
+ [isbn] => Array
+ (
+ [0] => 9783123456789
+ )
+ [la_facet] => Array
+ (
+ [0] => German
+ )
+ [pu] => Array
+ (
+ [0] => Test Publisher
+ )
+ [py_display] => Array
+ (
+ [0] => 2023
+ )
+ [umfang] => Array
+ (
+ [0] => 300 pages
+ )
+ """
+ transformer = ARRAYData()
+ result = transformer.transform(sample_data)
+
+ assert isinstance(result, BookData)
+ assert result.ppn == "123456789"
+
+ def test_array_data_with_signature(self):
+ """Test ARRAYData with predefined signature."""
+ sample_data = "[kid] => 123456789"
+ transformer = ARRAYData(signature="ABC 123")
+ result = transformer.transform(sample_data)
+
+ assert isinstance(result, BookData)
+
+
+# --- COinSData tests ---
+
+
+class TestCOinSData:
+ """Tests for the COinSData transformer."""
+
+ def test_coins_data_transform(self):
+ """Test COinSData transform method."""
+ # Note: COinS format uses & separators, last field shouldn't have trailing &
+ sample_data = (
+ "ctx_ver=Z39.88-2004&"
+ "rft_id=info:sid/test?kid=123456&"
+ "rft.btitle=Test Bookrft&" # btitle ends parsing at next 'rft'
+ "rft.aulast=Smithrft&"
+ "rft.aufirst=Johnrft&"
+ "rft.edition=2ndrft&"
+ "rft.isbn=9783123456789rft&"
+ "rft.pub=Publisherrft&"
+ "rft.date=2023rft&"
+ "rft.tpages=300"
+ )
+ transformer = COinSData()
+ result = transformer.transform(sample_data)
+
+ assert isinstance(result, BookData)
+ # The transformer splits on 'rft' after the field value
+ assert "Test Book" in result.title
+ assert "Smith" in result.author
+
+
+# --- RISData tests ---
+
+
+class TestRISData:
+ """Tests for the RISData transformer."""
+
+ def test_ris_data_transform(self):
+ """Test RISData transform method."""
+ sample_data = """TY - BOOK
+TI - Test Book Title
+AU - Smith, John
+ET - 2nd edition
+CN - ABC 123
+SN - 9783123456789
+LA - English
+PB - Test Publisher
+PY - 2023
+SP - 300
+DP - https://example.com/book?kid=123456
+ER -"""
+ transformer = RISData()
+ result = transformer.transform(sample_data)
+
+ assert isinstance(result, BookData)
+ assert result.title == "Test Book Title"
+ assert result.signature == "ABC 123"
+ assert result.edition == "2nd edition"
+ assert result.year == "2023"
+
+
+# --- BibTeXData tests ---
+
+
+class TestBibTeXData:
+ """Tests for the BibTeXData transformer."""
+
+ def test_bibtex_data_transform(self):
+ """Test BibTeXData transform method."""
+ sample_data = """@book{test2023,
+ title = {Test Book Title},
+ author = {Smith, John and Doe, Jane},
+ edition = {2nd},
+ isbn = {9783123456789},
+ language = {English},
+ publisher = {Test Publisher},
+ year = {2023},
+ pages = {300},
+ bestand = {ABC 123}
+}"""
+ transformer = BibTeXData()
+ result = transformer.transform(sample_data)
+
+ assert isinstance(result, BookData)
+ assert result.title == "Test Book Title"
+ # BibTeX transformer joins with ; and removes commas
+ assert "Smith John" in result.author
+ assert "Doe Jane" in result.author
+ assert result.signature == "ABC 123"
+
+
+# --- DictToTable tests ---
+
+
+class TestDictToTable:
+ """Tests for the DictToTable transformer."""
+
+ def test_dict_to_table_book_mode(self):
+ """Test DictToTable with book mode."""
+ data = {
+ "mode": "book",
+ "book_author": "Smith, John",
+ "book_signature": "ABC 123",
+ "book_place": "Berlin",
+ "book_year": "2023",
+ "book_title": "Test Book",
+ "book_edition": "2nd",
+ "book_pages": "300",
+ "book_publisher": "Publisher",
+ "book_isbn": "9783123456789",
+ }
+ transformer = DictToTable()
+ result = transformer.transform(data)
+
+ assert result["type"] == "book"
+ assert result["work_author"] == "Smith, John"
+ assert result["signature"] == "ABC 123"
+ assert result["year"] == "2023"
+
+ def test_dict_to_table_hg_mode(self):
+ """Test DictToTable with hg (editor) mode."""
+ data = {
+ "mode": "hg",
+ "hg_author": "Chapter Author",
+ "hg_editor": "Editor Name",
+ "hg_year": "2023",
+ "hg_title": "Collection Title",
+ "hg_publisher": "Publisher",
+ "hg_place": "Berlin",
+ "hg_edition": "1st",
+ "hg_chaptertitle": "Chapter Title",
+ "hg_pages": "50-75",
+ "hg_signature": "ABC 123",
+ "hg_isbn": "9783123456789",
+ }
+ transformer = DictToTable()
+ result = transformer.transform(data)
+
+ assert result["type"] == "hg"
+ assert result["section_author"] == "Chapter Author"
+ assert result["work_author"] == "Editor Name"
+ assert result["chapter_title"] == "Chapter Title"
+
+ def test_dict_to_table_zs_mode(self):
+ """Test DictToTable with zs (journal) mode."""
+ data = {
+ "mode": "zs",
+ "zs_author": "Article Author",
+ "zs_chapter_title": "Article Title",
+ "zs_place": "Berlin",
+ "zs_issue": "Vol. 5, No. 2",
+ "zs_pages": "100-120",
+ "zs_publisher": "Publisher",
+ "zs_isbn": "1234-5678",
+ "zs_year": "2023",
+ "zs_signature": "PER 123",
+ "zs_title": "Journal Name",
+ }
+ transformer = DictToTable()
+ result = transformer.transform(data)
+
+ assert result["type"] == "zs"
+ assert result["section_author"] == "Article Author"
+ assert result["chapter_title"] == "Article Title"
+ assert result["issue"] == "Vol. 5, No. 2"
+
+ def test_dict_to_table_reset(self):
+ """Test DictToTable reset method."""
+ transformer = DictToTable()
+ transformer.work_author = "Test"
+ transformer.year = "2023"
+
+ transformer.reset()
+
+ assert transformer.work_author is None
+ assert transformer.year is None
+
+ def test_dict_to_table_make_result_excludes_none(self):
+ """Test that makeResult excludes None values."""
+ transformer = DictToTable()
+ transformer.work_author = "Test Author"
+ transformer.year = "2023"
+ # Leave others as None
+
+ result = transformer.makeResult()
+
+ assert "work_author" in result
+ assert "year" in result
+ assert "section_author" not in result # Should be excluded
+ assert "pages" not in result # Should be excluded
+
+ def test_dict_to_table_invalid_mode(self):
+ """Test DictToTable with invalid mode returns None."""
+ data = {"mode": "invalid"}
+ transformer = DictToTable()
+ result = transformer.transform(data)
+
+ assert result is None
diff --git a/tests/test_webrequest.py b/tests/test_webrequest.py
new file mode 100644
index 0000000..35224da
--- /dev/null
+++ b/tests/test_webrequest.py
@@ -0,0 +1,309 @@
+"""Tests for the webrequest module."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from src.bibapi.webrequest import (
+ ALLOWED_IPS,
+ BibTextTransformer,
+ TransformerType,
+ WebRequest,
+ cover,
+ get_content,
+)
+
+
+class TestTransformerType:
+ """Tests for TransformerType enum."""
+
+ def test_transformer_type_values(self):
+ """Test TransformerType enum values."""
+ assert TransformerType.ARRAY.value == "ARRAY"
+ assert TransformerType.COinS.value == "COinS"
+ assert TransformerType.BibTeX.value == "BibTeX"
+ assert TransformerType.RIS.value == "RIS"
+ assert TransformerType.RDS.value == "RDS"
+
+
+class TestWebRequest:
+ """Tests for WebRequest class."""
+
+ def test_webrequest_init_not_allowed_ip(self):
+ """Test WebRequest raises PermissionError for non-allowed IP."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.text = "192.168.1.1" # Not in ALLOWED_IPS
+ mock_get.return_value = mock_response
+
+ with pytest.raises(PermissionError, match="IP not allowed"):
+ WebRequest()
+
+ def test_webrequest_init_allowed_ip(self):
+ """Test WebRequest initializes successfully with allowed IP."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.text = ALLOWED_IPS[0] # Use first allowed IP
+ mock_get.return_value = mock_response
+
+ wr = WebRequest()
+ assert wr.public_ip == ALLOWED_IPS[0]
+ assert wr.timeout == 5
+ assert wr.use_any is False
+
+ def test_webrequest_no_connection(self):
+ """Test WebRequest raises ConnectionError when no internet."""
+ with patch("requests.get") as mock_get:
+ mock_get.side_effect = requests.exceptions.RequestException("No connection")
+
+ with pytest.raises(ConnectionError, match="No internet connection"):
+ WebRequest()
+
+ def test_webrequest_use_any_book(self):
+ """Test use_any_book property."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.text = ALLOWED_IPS[0]
+ mock_get.return_value = mock_response
+
+ wr = WebRequest()
+ result = wr.use_any_book
+ assert result.use_any is True
+
+ def test_webrequest_set_apparat(self):
+ """Test set_apparat method."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.text = ALLOWED_IPS[0]
+ mock_get.return_value = mock_response
+
+ wr = WebRequest()
+ result = wr.set_apparat(5)
+ assert result.apparat == "05" # Padded with 0
+
+ result = wr.set_apparat(15)
+ assert result.apparat == 15 # Not padded
+
+ def test_webrequest_get_ppn(self):
+ """Test get_ppn method."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.text = ALLOWED_IPS[0]
+ mock_get.return_value = mock_response
+
+ wr = WebRequest()
+
+ # Normal signature
+ result = wr.get_ppn("ABC 123")
+ assert result.ppn == "ABC 123"
+ assert result.signature == "ABC 123"
+
+ # Signature with +
+ result = wr.get_ppn("ABC+123")
+ assert result.ppn == "ABC%2B123"
+
+ # DOI
+ result = wr.get_ppn("https://doi.org/10.1234/test")
+ assert result.ppn == "test"
+
+ def test_webrequest_search_book(self):
+ """Test search_book method."""
+ with patch("requests.get") as mock_get:
+ # First call for IP check
+ ip_response = MagicMock()
+ ip_response.text = ALLOWED_IPS[0]
+
+ # Second call for actual search
+ search_response = MagicMock()
+ search_response.text = "results"
+
+ mock_get.side_effect = [ip_response, search_response]
+
+ wr = WebRequest()
+ result = wr.search_book("test search")
+ assert result == "results"
+
+ def test_webrequest_search_ppn(self):
+ """Test search_ppn method."""
+ with patch("requests.get") as mock_get:
+ ip_response = MagicMock()
+ ip_response.text = ALLOWED_IPS[0]
+
+ ppn_response = MagicMock()
+ ppn_response.text = "ppn result"
+
+ mock_get.side_effect = [ip_response, ppn_response]
+
+ wr = WebRequest()
+ result = wr.search_ppn("123456")
+ assert result == "ppn result"
+
+ def test_webrequest_search(self):
+ """Test search method."""
+ with patch("requests.get") as mock_get:
+ ip_response = MagicMock()
+ ip_response.text = ALLOWED_IPS[0]
+
+ search_response = MagicMock()
+ search_response.text = "detail page"
+
+ mock_get.side_effect = [ip_response, search_response]
+
+ wr = WebRequest()
+ result = wr.search("https://example.com/book")
+ assert result == "detail page"
+
+ def test_webrequest_search_error(self):
+ """Test search method handles errors."""
+ with patch("requests.get") as mock_get:
+ ip_response = MagicMock()
+ ip_response.text = ALLOWED_IPS[0]
+
+ mock_get.side_effect = [ip_response, requests.exceptions.RequestException()]
+
+ wr = WebRequest()
+ result = wr.search("https://example.com/book")
+ assert result is None
+
+ def test_webrequest_get_book_links(self):
+ """Test get_book_links method."""
+ html = """
+ Book 1
+ Book 2
+ """
+
+ with patch("requests.get") as mock_get:
+ ip_response = MagicMock()
+ ip_response.text = ALLOWED_IPS[0]
+
+ search_response = MagicMock()
+ search_response.text = html
+
+ mock_get.side_effect = [ip_response, search_response]
+
+ wr = WebRequest()
+ wr.ppn = "test"
+ links = wr.get_book_links("test")
+
+ assert len(links) == 2
+ assert "https://rds.ibs-bw.de/opac/book/123" in links[0]
+
+
+class TestBibTextTransformer:
+ """Tests for BibTextTransformer class."""
+
+ def test_bibtexttransformer_init_valid_mode(self):
+ """Test BibTextTransformer initialization with valid mode."""
+ bt = BibTextTransformer(TransformerType.ARRAY)
+ assert bt.mode == "ARRAY"
+
+ def test_bibtexttransformer_init_default_mode(self):
+ """Test BibTextTransformer uses ARRAY as default mode."""
+ bt = BibTextTransformer()
+ assert bt.mode == "ARRAY"
+
+ def test_bibtexttransformer_invalid_mode(self):
+ """Test BibTextTransformer raises error for invalid mode."""
+
+ # Create a fake invalid mode
+ class FakeMode:
+ value = "INVALID"
+
+ with pytest.raises(ValueError, match="not valid"):
+ BibTextTransformer(FakeMode())
+
+ def test_bibtexttransformer_use_signature(self):
+ """Test use_signature method."""
+ bt = BibTextTransformer()
+ result = bt.use_signature("ABC 123")
+ assert result.signature == "ABC 123"
+
+ def test_bibtexttransformer_get_data_none(self):
+ """Test get_data with None input."""
+ bt = BibTextTransformer()
+ result = bt.get_data(None)
+ assert result.data is None
+
+ def test_bibtexttransformer_get_data_ris(self):
+ """Test get_data with RIS format."""
+ bt = BibTextTransformer(TransformerType.RIS)
+ data = ["Some data", "TY - BOOK\nTI - Test"]
+ result = bt.get_data(data)
+ assert "TY -" in result.data
+
+ def test_bibtexttransformer_get_data_array(self):
+ """Test get_data with ARRAY format."""
+ bt = BibTextTransformer(TransformerType.ARRAY)
+ data = ["Some data", "[kid] => 123456"]
+ result = bt.get_data(data)
+ assert "[kid]" in result.data
+
+ def test_bibtexttransformer_get_data_coins(self):
+ """Test get_data with COinS format."""
+ bt = BibTextTransformer(TransformerType.COinS)
+ data = ["Some data", "ctx_ver=Z39.88"]
+ result = bt.get_data(data)
+ assert "ctx_ver" in result.data
+
+ def test_bibtexttransformer_get_data_bibtex(self):
+ """Test get_data with BibTeX format."""
+ bt = BibTextTransformer(TransformerType.BibTeX)
+ data = ["Some data", "@book{test2023,"]
+ result = bt.get_data(data)
+ assert "@book" in result.data
+
+ def test_bibtexttransformer_get_data_rds(self):
+ """Test get_data with RDS format."""
+ bt = BibTextTransformer(TransformerType.RDS)
+ data = ["Some data", "RDS ---------------------------------- test"]
+ result = bt.get_data(data)
+ assert "RDS" in result.data
+
+ def test_bibtexttransformer_return_data_none(self):
+ """Test return_data when data is None."""
+ bt = BibTextTransformer()
+ bt.get_data(None)
+ result = bt.return_data()
+ assert result is None
+
+
+class TestCoverFunction:
+ """Tests for the cover function."""
+
+ def test_cover_returns_content(self):
+ """Test cover function returns image content."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.content = b"fake_image_content"
+ mock_get.return_value = mock_response
+
+ result = cover("9783123456789")
+ assert result == b"fake_image_content"
+
+ def test_cover_url_format(self):
+ """Test cover function calls correct URL."""
+ with patch("requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.content = b""
+ mock_get.return_value = mock_response
+
+ cover("9783123456789")
+
+ called_url = mock_get.call_args[0][0]
+ assert "9783123456789" in called_url
+ assert "buchhandel.de/cover" in called_url
+
+
+class TestGetContentFunction:
+ """Tests for the get_content function."""
+
+ def test_get_content(self):
+ """Test get_content extracts text from div."""
+ from bs4 import BeautifulSoup
+
+ html = ' Content Here
'
+ soup = BeautifulSoup(html, "html.parser")
+
+ result = get_content(soup, "test-class")
+ assert result == "Content Here"