|
| 1 | +""" |
| 2 | +Unit tests for parse_to_utc edge cases in time_utils. |
| 3 | +
|
| 4 | +Covers: invalid strings, ambiguous formats, timezone edge cases, non-UTC offsets, |
| 5 | +and inputs that previously caused crashes or wrong results. |
| 6 | +""" |
| 7 | + |
| 8 | +import pytest |
| 9 | +from datetime import datetime, timezone |
| 10 | +from backend.secuscan.time_utils import parse_to_utc |
| 11 | + |
| 12 | + |
| 13 | +class TestParseToUtcEdgeCases: |
| 14 | + """Edge case tests for parse_to_utc.""" |
| 15 | + |
| 16 | + # ── None / empty / non-string input ─────────────────────────────────────── |
| 17 | + |
| 18 | + def test_none_returns_none(self): |
| 19 | + """None is a no-op that returns None.""" |
| 20 | + assert parse_to_utc(None) is None |
| 21 | + |
| 22 | + def test_empty_string_returns_none(self): |
| 23 | + """Empty string returns None.""" |
| 24 | + assert parse_to_utc("") is None |
| 25 | + |
| 26 | + def test_whitespace_only_returns_none(self): |
| 27 | + """Whitespace-only string returns None.""" |
| 28 | + assert parse_to_utc(" ") is None |
| 29 | + |
| 30 | + # ── Unparseable strings → None ─────────────────────────────────────────── |
| 31 | + |
| 32 | + def test_broken_iso_format_returns_none(self): |
| 33 | + """Garbage string returns None.""" |
| 34 | + assert parse_to_utc("not-a-date") is None |
| 35 | + |
| 36 | + def test_incomplete_iso_returns_none(self): |
| 37 | + """Incomplete ISO fragment returns None.""" |
| 38 | + assert parse_to_utc("2024-01-01T") is None |
| 39 | + |
| 40 | + def test_pure_time_string_returns_none(self): |
| 41 | + """Time-only string is not a full date.""" |
| 42 | + assert parse_to_utc("12:34:56") is None |
| 43 | + |
| 44 | + def test_us_date_format_returns_none(self): |
| 45 | + """US MM/DD/YYYY is not ISO – returns None.""" |
| 46 | + assert parse_to_utc("01/15/2024") is None |
| 47 | + |
| 48 | + def test_eu_date_format_returns_none(self): |
| 49 | + """EU DD/MM/YYYY is not ISO – returns None.""" |
| 50 | + assert parse_to_utc("15/01/2024") is None |
| 51 | + |
| 52 | + def test_slash_datetime_returns_none(self): |
| 53 | + """Slash-separated datetime is not ISO.""" |
| 54 | + assert parse_to_utc("2024/01/15 12:00:00") is None |
| 55 | + |
| 56 | + def test_dot_separated_returns_none(self): |
| 57 | + """Dot-separated datetime is not ISO.""" |
| 58 | + assert parse_to_utc("2024.01.15T12:00:00") is None |
| 59 | + |
| 60 | + # ── Date-only is NOT parseable ────────────────────────────────────────── |
| 61 | + |
| 62 | + def test_dash_date_only_parsed_as_midnight_utc(self): |
| 63 | + """YYYY-MM-DD (date-only) is parsed as midnight UTC.""" |
| 64 | + result = parse_to_utc("2024-01-15") |
| 65 | + assert result == datetime(2024, 1, 15, 0, 0, 0, tzinfo=timezone.utc) |
| 66 | + |
| 67 | + # ── Unix timestamp (int / float) ───────────────────────────────────────── |
| 68 | + |
| 69 | + def test_unix_timestamp_int(self): |
| 70 | + """Integer is interpreted as Unix epoch seconds.""" |
| 71 | + result = parse_to_utc(1704067200) |
| 72 | + assert result == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) |
| 73 | + |
| 74 | + def test_unix_timestamp_float(self): |
| 75 | + """Float is interpreted as Unix epoch with sub-second precision.""" |
| 76 | + result = parse_to_utc(1704067200.123) |
| 77 | + assert result.microsecond == 123000 |
| 78 | + |
| 79 | + # ── "now" special keyword ───────────────────────────────────────────────── |
| 80 | + |
| 81 | + def test_now_returns_current_utc(self): |
| 82 | + """The literal 'now' returns a UTC-aware current time.""" |
| 83 | + import time |
| 84 | + before = datetime.now(timezone.utc) |
| 85 | + time.sleep(0.01) |
| 86 | + result = parse_to_utc("now") |
| 87 | + time.sleep(0.01) |
| 88 | + after = datetime.now(timezone.utc) |
| 89 | + assert before <= result <= after |
| 90 | + assert result.tzinfo == timezone.utc |
| 91 | + |
| 92 | + # ── Space separator (SQLite convention) ────────────────────────────────── |
| 93 | + |
| 94 | + def test_space_separator_converted_to_T(self): |
| 95 | + """Space-separated ISO (SQLite format) is handled.""" |
| 96 | + result = parse_to_utc("2024-06-15 14:30:00") |
| 97 | + assert result == datetime(2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc) |
| 98 | + |
| 99 | + # ── Z suffix ───────────────────────────────────────────────────────────── |
| 100 | + |
| 101 | + def test_z_suffix_handled(self): |
| 102 | + """Z suffix is accepted and converted to +00:00.""" |
| 103 | + result = parse_to_utc("2024-01-01T00:00:00Z") |
| 104 | + assert result == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) |
| 105 | + |
| 106 | + # ── Sub-second precision ────────────────────────────────────────────────── |
| 107 | + |
| 108 | + def test_microseconds_preserved(self): |
| 109 | + """Sub-second precision is preserved.""" |
| 110 | + result = parse_to_utc("2024-06-15T14:30:00.123456") |
| 111 | + assert result == datetime(2024, 6, 15, 14, 30, 0, 123456, tzinfo=timezone.utc) |
| 112 | + |
| 113 | + def test_subseconds_rounded_to_microseconds(self): |
| 114 | + """Nano-second precision is truncated to microseconds.""" |
| 115 | + result = parse_to_utc("2024-06-15T14:30:00.123456789") |
| 116 | + assert result.microsecond == 123456 |
| 117 | + |
| 118 | + # ── UTC offset handling ────────────────────────────────────────────────── |
| 119 | + |
| 120 | + def test_positive_offset_converted_to_utc(self): |
| 121 | + """Positive UTC+N offset is converted to UTC.""" |
| 122 | + result = parse_to_utc("2024-01-01T03:00:00+03:00") |
| 123 | + assert result == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) |
| 124 | + |
| 125 | + def test_negative_offset_converted_to_utc(self): |
| 126 | + """Negative UTC-N offset is converted to UTC.""" |
| 127 | + result = parse_to_utc("2024-01-01T03:00:00-05:00") |
| 128 | + assert result == datetime(2024, 1, 1, 8, 0, 0, tzinfo=timezone.utc) |
| 129 | + |
| 130 | + def test_offset_without_colon(self): |
| 131 | + """Offset without colon separator: +0300.""" |
| 132 | + result = parse_to_utc("2024-01-01T03:00:00+0300") |
| 133 | + assert result == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) |
| 134 | + |
| 135 | + def test_india_offset(self): |
| 136 | + """India +05:30 is correctly converted.""" |
| 137 | + result = parse_to_utc("2024-01-01T09:30:00+05:30") |
| 138 | + assert result == datetime(2024, 1, 1, 4, 0, 0, tzinfo=timezone.utc) |
| 139 | + |
| 140 | + def test_nz_negative_fractional_offset(self): |
| 141 | + """NZ -08:30 (Chatham Islands) is correctly converted.""" |
| 142 | + result = parse_to_utc("2024-01-01T12:00:00-08:30") |
| 143 | + assert result == datetime(2024, 1, 1, 20, 30, 0, tzinfo=timezone.utc) |
| 144 | + |
| 145 | + # ── Leap second ───────────────────────────────────────────────────────── |
| 146 | + |
| 147 | + def test_leap_second_returns_none(self): |
| 148 | + """Leap second (60s) is not supported – returns None.""" |
| 149 | + assert parse_to_utc("2024-01-01T00:00:60Z") is None |
| 150 | + |
| 151 | + # ── Year boundary ──────────────────────────────────────────────────────── |
| 152 | + |
| 153 | + def test_year_boundary_crossing(self): |
| 154 | + """Year rollover at UTC midnight is preserved.""" |
| 155 | + result = parse_to_utc("2023-12-31T23:59:59Z") |
| 156 | + assert result.year == 2023 |
| 157 | + assert result.month == 12 |
| 158 | + assert result.day == 31 |
| 159 | + assert result.hour == 23 |
| 160 | + |
| 161 | + # ── Naive datetime (no tzinfo) ────────────────────────────────────────── |
| 162 | + |
| 163 | + def test_naive_utc_interpreted_as_utc(self): |
| 164 | + """Naive datetime is treated as UTC (SQLite storage semantics).""" |
| 165 | + result = parse_to_utc("2024-01-01T12:00:00") |
| 166 | + assert result == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) |
| 167 | + |
| 168 | + # ── Already-aware datetime ────────────────────────────────────────────── |
| 169 | + |
| 170 | + def test_aware_utc_returns_utc_equivalent(self): |
| 171 | + """Already-UTC-aware datetime returns UTC equivalent.""" |
| 172 | + aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) |
| 173 | + result = parse_to_utc(aware) |
| 174 | + assert result == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) |
| 175 | + |
| 176 | + def test_non_utc_aware_converted_to_utc(self): |
| 177 | + """Non-UTC aware datetime is converted to UTC.""" |
| 178 | + import zoneinfo |
| 179 | + est = zoneinfo.ZoneInfo("America/New_York") |
| 180 | + aware = datetime(2024, 1, 1, 7, 0, 0, tzinfo=est) # 12:00 UTC |
| 181 | + result = parse_to_utc(aware) |
| 182 | + assert result == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) |
| 183 | + |
| 184 | + # ── Epoch boundaries ──────────────────────────────────────────────────── |
| 185 | + |
| 186 | + def test_epoch_start(self): |
| 187 | + """Unix epoch start is correctly parsed.""" |
| 188 | + result = parse_to_utc("1970-01-01T00:00:00Z") |
| 189 | + assert result == datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc) |
0 commit comments