import datetime import logging from typing import Any import pytest import pytest_mock from documents.plugins.date_parsing.base import DateParserConfig from documents.plugins.date_parsing.regex_parser import RegexDateParserPlugin @pytest.mark.date_parsing class TestParseString: """Tests for DateParser._parse_string method via RegexDateParser.""" @pytest.mark.parametrize( ("date_string", "date_order", "expected_year"), [ pytest.param("15/01/2024", "DMY", 2024, id="dmy_slash"), pytest.param("01/15/2024", "MDY", 2024, id="mdy_slash"), pytest.param("2024/01/15", "YMD", 2024, id="ymd_slash"), pytest.param("January 15, 2024", "DMY", 2024, id="month_name_comma"), pytest.param("15 Jan 2024", "DMY", 2024, id="day_abbr_month_year"), pytest.param("15.01.2024", "DMY", 2024, id="dmy_dot"), pytest.param("2024-01-15", "YMD", 2024, id="ymd_dash"), ], ) def test_parse_string_valid_formats( self, regex_parser: RegexDateParserPlugin, date_string: str, date_order: str, expected_year: int, ) -> None: """Should correctly parse various valid date formats.""" result = regex_parser._parse_string(date_string, date_order) assert result is not None assert result.year == expected_year @pytest.mark.parametrize( "invalid_string", [ pytest.param("not a date", id="plain_text"), pytest.param("32/13/2024", id="invalid_day_month"), pytest.param("", id="empty_string"), pytest.param("abc123xyz", id="alphanumeric_gibberish"), pytest.param("99/99/9999", id="out_of_range"), ], ) def test_parse_string_invalid_input( self, regex_parser: RegexDateParserPlugin, invalid_string: str, ) -> None: """Should return None for invalid date strings.""" result = regex_parser._parse_string(invalid_string, "DMY") assert result is None def test_parse_string_handles_exceptions( self, caplog: pytest.LogCaptureFixture, mocker: pytest_mock.MockerFixture, regex_parser: RegexDateParserPlugin, ) -> None: """Should handle and log exceptions from dateparser gracefully.""" with caplog.at_level( logging.ERROR, logger="documents.plugins.date_parsing.base", ): # We still need to mock dateparser.parse to force the exception mocker.patch( "documents.plugins.date_parsing.base.dateparser.parse", side_effect=ValueError( "Parsing error: 01/01/2024", ), ) # 1. Execute the function under test result = regex_parser._parse_string("01/01/2024", "DMY") assert result is None # Check if an error was logged assert len(caplog.records) == 1 assert caplog.records[0].levelname == "ERROR" # Check if the specific error message is present assert "Error while parsing date string" in caplog.text # Optional: Check for the exact exception message if it's included in the log assert "Parsing error: 01/01/2024" in caplog.text @pytest.mark.date_parsing class TestFilterDate: """Tests for DateParser._filter_date method via RegexDateParser.""" @pytest.mark.parametrize( ("date", "expected_output"), [ # Valid Dates pytest.param( datetime.datetime(2024, 1, 10, tzinfo=datetime.UTC), datetime.datetime(2024, 1, 10, tzinfo=datetime.UTC), id="valid_past_date", ), pytest.param( datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=datetime.UTC), datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=datetime.UTC), id="exactly_at_reference", ), pytest.param( datetime.datetime(1901, 1, 1, tzinfo=datetime.UTC), datetime.datetime(1901, 1, 1, tzinfo=datetime.UTC), id="year_1901_valid", ), # Date is > reference_time pytest.param( datetime.datetime(2024, 1, 16, tzinfo=datetime.UTC), None, id="future_date_day_after", ), # date.date() in ignore_dates pytest.param( datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC), None, id="ignored_date_midnight_jan1", ), pytest.param( datetime.datetime(2024, 1, 1, 10, 30, 0, tzinfo=datetime.UTC), None, id="ignored_date_midday_jan1", ), pytest.param( datetime.datetime(2024, 12, 25, 15, 0, 0, tzinfo=datetime.UTC), None, id="ignored_date_dec25_future", ), # date.year <= 1900 pytest.param( datetime.datetime(1899, 12, 31, tzinfo=datetime.UTC), None, id="year_1899", ), pytest.param( datetime.datetime(1900, 1, 1, tzinfo=datetime.UTC), None, id="year_1900_boundary", ), # date is None pytest.param(None, None, id="none_input"), ], ) def test_filter_date_validation_rules( self, config_with_ignore_dates: DateParserConfig, date: datetime.datetime | None, expected_output: datetime.datetime | None, ) -> None: """Should correctly validate dates against various rules.""" parser = RegexDateParserPlugin(config_with_ignore_dates) result = parser._filter_date(date) assert result == expected_output def test_filter_date_respects_ignore_dates( self, config_with_ignore_dates: DateParserConfig, ) -> None: """Should filter out dates in the ignore_dates set.""" parser = RegexDateParserPlugin(config_with_ignore_dates) ignored_date = datetime.datetime( 2024, 1, 1, 12, 0, tzinfo=datetime.UTC, ) another_ignored = datetime.datetime( 2024, 12, 25, 15, 30, tzinfo=datetime.UTC, ) allowed_date = datetime.datetime( 2024, 1, 2, 12, 0, tzinfo=datetime.UTC, ) assert parser._filter_date(ignored_date) is None assert parser._filter_date(another_ignored) is None assert parser._filter_date(allowed_date) == allowed_date def test_filter_date_timezone_aware( self, regex_parser: RegexDateParserPlugin, ) -> None: """Should work with timezone-aware datetimes.""" date_utc = datetime.datetime(2024, 1, 10, 12, 0, tzinfo=datetime.UTC) result = regex_parser._filter_date(date_utc) assert result is not None assert result.tzinfo is not None @pytest.mark.date_parsing class TestRegexDateParser: @pytest.mark.parametrize( ("filename", "content", "expected"), [ pytest.param( "report-2023-12-25.txt", "Event recorded on 25/12/2022.", [ datetime.datetime(2023, 12, 25, tzinfo=datetime.UTC), datetime.datetime(2022, 12, 25, tzinfo=datetime.UTC), ], id="filename-y-m-d_and_content-d-m-y", ), pytest.param( "img_2023.01.02.jpg", "Taken on 01/02/2023", [ datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC), datetime.datetime(2023, 2, 1, tzinfo=datetime.UTC), ], id="ambiguous-dates-respect-orders", ), pytest.param( "notes.txt", "bad date 99/99/9999 and 25/12/2022", [ datetime.datetime(2022, 12, 25, tzinfo=datetime.UTC), ], id="parse-exception-skips-bad-and-yields-good", ), ], ) def test_parse_returns_expected_dates( self, base_config: DateParserConfig, mocker: pytest_mock.MockerFixture, filename: str, content: str, expected: list[datetime.datetime], ) -> None: """ High-level tests that exercise RegexDateParser.parse only. dateparser.parse is mocked so tests are deterministic. """ parser = RegexDateParserPlugin(base_config) # Patch the dateparser.parse target = "documents.plugins.date_parsing.base.dateparser.parse" def fake_parse( date_string: str, settings: dict[str, Any] | None = None, locales: None = None, ) -> datetime.datetime | None: date_order = settings.get("DATE_ORDER") if settings else None # Filename-style YYYY-MM-DD / YYYY.MM.DD if ( "2023-12-25" in date_string or "2023.12.25" in date_string or "2023-12-25" in date_string ): return datetime.datetime(2023, 12, 25, tzinfo=datetime.UTC) # content DMY 25/12/2022 if "25/12/2022" in date_string or "25-12-2022" in date_string: return datetime.datetime(2022, 12, 25, tzinfo=datetime.UTC) # filename YMD 2023.01.02 if "2023.01.02" in date_string or "2023-01-02" in date_string: return datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC) # ambiguous 01/02/2023 -> respect DATE_ORDER setting if "01/02/2023" in date_string: if date_order == "DMY": return datetime.datetime(2023, 2, 1, tzinfo=datetime.UTC) if date_order == "YMD": return datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC) # fallback return datetime.datetime(2023, 2, 1, tzinfo=datetime.UTC) # simulate parse failure for malformed input if "99/99/9999" in date_string or "bad date" in date_string: raise Exception("parse failed for malformed date") return None mocker.patch(target, side_effect=fake_parse) results = list(parser.parse(filename, content)) assert results == expected for dt in results: assert dt.tzinfo is not None def test_parse_filters_future_and_ignored_dates( self, mocker: pytest_mock.MockerFixture, ) -> None: """ Ensure parser filters out: - dates after reference_time - dates whose .date() are in ignore_dates """ cfg = DateParserConfig( languages=["en"], timezone_str="UTC", ignore_dates={datetime.date(2023, 12, 10)}, reference_time=datetime.datetime( 2024, 1, 15, 12, 0, 0, tzinfo=datetime.UTC, ), filename_date_order="YMD", content_date_order="DMY", ) parser = RegexDateParserPlugin(cfg) target = "documents.plugins.date_parsing.base.dateparser.parse" def fake_parse( date_string: str, settings: dict[str, Any] | None = None, locales: None = None, ) -> datetime.datetime | None: if "10/12/2023" in date_string or "10-12-2023" in date_string: # ignored date return datetime.datetime(2023, 12, 10, tzinfo=datetime.UTC) if "01/02/2024" in date_string or "01-02-2024" in date_string: # future relative to reference_time -> filtered return datetime.datetime(2024, 2, 1, tzinfo=datetime.UTC) if "05/01/2023" in date_string or "05-01-2023" in date_string: # valid return datetime.datetime(2023, 1, 5, tzinfo=datetime.UTC) return None mocker.patch(target, side_effect=fake_parse) content = "Ignored: 10/12/2023, Future: 01/02/2024, Keep: 05/01/2023" results = list(parser.parse("whatever.txt", content)) assert results == [datetime.datetime(2023, 1, 5, tzinfo=datetime.UTC)] def test_parse_handles_no_matches_and_returns_empty_list( self, base_config: DateParserConfig, ) -> None: """ When there are no matching date-like substrings, parse should yield nothing. """ parser = RegexDateParserPlugin(base_config) results = list( parser.parse("no-dates.txt", "this has no dates whatsoever"), ) assert results == [] def test_parse_skips_filename_when_filename_date_order_none( self, mocker: pytest_mock.MockerFixture, ) -> None: """ When filename_date_order is None the parser must not attempt to parse the filename. Only dates found in the content should be passed to dateparser.parse. """ cfg = DateParserConfig( languages=["en"], timezone_str="UTC", ignore_dates=set(), reference_time=datetime.datetime( 2024, 1, 15, 12, 0, 0, tzinfo=datetime.UTC, ), filename_date_order=None, content_date_order="DMY", ) parser = RegexDateParserPlugin(cfg) # Patch the module's dateparser.parse so we can inspect calls target = "documents.plugins.date_parsing.base.dateparser.parse" def fake_parse( date_string: str, settings: dict[str, Any] | None = None, locales: None = None, ) -> datetime.datetime | None: # return distinct datetimes so we can tell which source was parsed if "25/12/2022" in date_string: return datetime.datetime(2022, 12, 25, tzinfo=datetime.UTC) if "2023-12-25" in date_string: return datetime.datetime(2023, 12, 25, tzinfo=datetime.UTC) return None mock = mocker.patch(target, side_effect=fake_parse) filename = "report-2023-12-25.txt" content = "Event recorded on 25/12/2022." results = list(parser.parse(filename, content)) # Only the content date should have been parsed -> one call assert mock.call_count == 1 # # first call, first positional arg called_date_string = mock.call_args_list[0][0][0] assert "25/12/2022" in called_date_string # And the parser should have yielded the corresponding datetime assert results == [ datetime.datetime(2022, 12, 25, tzinfo=datetime.UTC), ]