diff --git a/tests/test_dsc.py b/tests/test_dsc.py new file mode 100644 index 0000000..cb74018 --- /dev/null +++ b/tests/test_dsc.py @@ -0,0 +1,467 @@ +"""Tests for DSC (Digital Selective Calling) utilities.""" + +import json +import pytest +from datetime import datetime + + +class TestDSCParser: + """Tests for DSC parser utilities.""" + + def test_get_country_from_mmsi_ship_station(self): + """Test country lookup for standard ship MMSI.""" + from utils.dsc.parser import get_country_from_mmsi + + # UK ships start with 232-235 + assert get_country_from_mmsi('232123456') == 'United Kingdom' + assert get_country_from_mmsi('235987654') == 'United Kingdom' + + # US ships start with 303, 338, 366-369 + assert get_country_from_mmsi('366123456') == 'USA' + assert get_country_from_mmsi('369000001') == 'USA' + + # Panama (common flag of convenience) + assert get_country_from_mmsi('351234567') == 'Panama' + assert get_country_from_mmsi('370000001') == 'Panama' + + # Norway + assert get_country_from_mmsi('257123456') == 'Norway' + + # Germany + assert get_country_from_mmsi('211000001') == 'Germany' + + def test_get_country_from_mmsi_coast_station(self): + """Test country lookup for coast station MMSI (starts with 00).""" + from utils.dsc.parser import get_country_from_mmsi + + # Coast stations: 00 + MID + assert get_country_from_mmsi('002320001') == 'United Kingdom' + assert get_country_from_mmsi('003660001') == 'USA' + + def test_get_country_from_mmsi_group_station(self): + """Test country lookup for group station MMSI (starts with 0).""" + from utils.dsc.parser import get_country_from_mmsi + + # Group call: 0 + MID + assert get_country_from_mmsi('023200001') == 'United Kingdom' + assert get_country_from_mmsi('036600001') == 'USA' + + def test_get_country_from_mmsi_unknown(self): + """Test country lookup returns None for unknown MID.""" + from utils.dsc.parser import get_country_from_mmsi + + assert get_country_from_mmsi('999999999') is None + assert get_country_from_mmsi('100000000') is None + + def test_get_country_from_mmsi_invalid(self): + """Test country lookup handles invalid input.""" + from utils.dsc.parser import get_country_from_mmsi + + assert get_country_from_mmsi('') is None + assert get_country_from_mmsi(None) is None + assert get_country_from_mmsi('12') is None + + def test_get_distress_nature_text(self): + """Test distress nature code to text conversion.""" + from utils.dsc.parser import get_distress_nature_text + + assert get_distress_nature_text(100) == 'UNDESIGNATED' + assert get_distress_nature_text(101) == 'FIRE' + assert get_distress_nature_text(102) == 'FLOODING' + assert get_distress_nature_text(103) == 'COLLISION' + assert get_distress_nature_text(106) == 'SINKING' + assert get_distress_nature_text(109) == 'PIRACY' + assert get_distress_nature_text(110) == 'MOB' # Man overboard + + def test_get_distress_nature_text_unknown(self): + """Test distress nature returns formatted unknown for invalid codes.""" + from utils.dsc.parser import get_distress_nature_text + + assert 'UNKNOWN' in get_distress_nature_text(999) + assert '999' in get_distress_nature_text(999) + + def test_get_distress_nature_text_string_input(self): + """Test distress nature accepts string input.""" + from utils.dsc.parser import get_distress_nature_text + + assert get_distress_nature_text('101') == 'FIRE' + assert get_distress_nature_text('invalid') == 'invalid' + + def test_get_format_text(self): + """Test format code to text conversion.""" + from utils.dsc.parser import get_format_text + + assert get_format_text(100) == 'DISTRESS' + assert get_format_text(102) == 'ALL_SHIPS' + assert get_format_text(106) == 'DISTRESS_ACK' + assert get_format_text(108) == 'DISTRESS_RELAY' + assert get_format_text(112) == 'INDIVIDUAL' + assert get_format_text(116) == 'ROUTINE' + assert get_format_text(118) == 'SAFETY' + assert get_format_text(120) == 'URGENCY' + + def test_get_format_text_unknown(self): + """Test format code returns unknown for invalid codes.""" + from utils.dsc.parser import get_format_text + + result = get_format_text(999) + assert 'UNKNOWN' in result + + def test_get_telecommand_text(self): + """Test telecommand code to text conversion.""" + from utils.dsc.parser import get_telecommand_text + + assert get_telecommand_text(100) == 'F3E_G3E_ALL' + assert get_telecommand_text(105) == 'DATA' + assert get_telecommand_text(107) == 'DISTRESS_ACK' + assert get_telecommand_text(111) == 'TEST' + + def test_get_category_priority(self): + """Test category priority values.""" + from utils.dsc.parser import get_category_priority + + # Distress has highest priority (0) + assert get_category_priority('DISTRESS') == 0 + assert get_category_priority('distress') == 0 + + # Urgency is lower + assert get_category_priority('URGENCY') == 3 + + # Safety is lower still + assert get_category_priority('SAFETY') == 4 + + # Routine is lowest + assert get_category_priority('ROUTINE') == 5 + + # Unknown gets default high number + assert get_category_priority('UNKNOWN') == 10 + + def test_validate_mmsi_valid(self): + """Test MMSI validation with valid numbers.""" + from utils.dsc.parser import validate_mmsi + + assert validate_mmsi('232123456') is True + assert validate_mmsi('366000001') is True + assert validate_mmsi('002320001') is True # Coast station + assert validate_mmsi('023200001') is True # Group station + + def test_validate_mmsi_invalid(self): + """Test MMSI validation rejects invalid numbers.""" + from utils.dsc.parser import validate_mmsi + + assert validate_mmsi('') is False + assert validate_mmsi(None) is False + assert validate_mmsi('12345678') is False # Too short + assert validate_mmsi('1234567890') is False # Too long + assert validate_mmsi('abcdefghi') is False # Not digits + assert validate_mmsi('000000000') is False # All zeros + + def test_classify_mmsi(self): + """Test MMSI classification.""" + from utils.dsc.parser import classify_mmsi + + # Ship stations (start with 2-7) + assert classify_mmsi('232123456') == 'ship' + assert classify_mmsi('366000001') == 'ship' + assert classify_mmsi('503000001') == 'ship' + + # Coast stations (start with 00) + assert classify_mmsi('002320001') == 'coast' + + # Group stations (start with 0, not 00) + assert classify_mmsi('023200001') == 'group' + + # SAR aircraft (start with 111) + assert classify_mmsi('111232001') == 'sar' + + # Aids to Navigation (start with 99) + assert classify_mmsi('992321001') == 'aton' + + # Unknown + assert classify_mmsi('invalid') == 'unknown' + assert classify_mmsi('812345678') == 'unknown' + + def test_parse_dsc_message_distress(self): + """Test parsing a distress message.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 100, + 'source_mmsi': '232123456', + 'dest_mmsi': '000000000', + 'category': 'DISTRESS', + 'nature': 101, + 'position': {'lat': 51.5, 'lon': -0.1}, + 'telecommand1': 100, + 'timestamp': '2025-01-15T12:00:00Z' + }) + + msg = parse_dsc_message(raw) + + assert msg is not None + assert msg['type'] == 'dsc_message' + assert msg['source_mmsi'] == '232123456' + assert msg['category'] == 'DISTRESS' + assert msg['source_country'] == 'United Kingdom' + assert msg['nature_of_distress'] == 'FIRE' + assert msg['latitude'] == 51.5 + assert msg['longitude'] == -0.1 + assert msg['is_critical'] is True + assert msg['priority'] == 0 + + def test_parse_dsc_message_routine(self): + """Test parsing a routine message.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 116, + 'source_mmsi': '366000001', + 'category': 'ROUTINE', + 'timestamp': '2025-01-15T12:00:00Z' + }) + + msg = parse_dsc_message(raw) + + assert msg is not None + assert msg['category'] == 'ROUTINE' + assert msg['source_country'] == 'USA' + assert msg['is_critical'] is False + assert msg['priority'] == 5 + + def test_parse_dsc_message_invalid_json(self): + """Test parsing rejects invalid JSON.""" + from utils.dsc.parser import parse_dsc_message + + assert parse_dsc_message('not json') is None + assert parse_dsc_message('{invalid}') is None + + def test_parse_dsc_message_missing_type(self): + """Test parsing rejects messages without correct type.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({'source_mmsi': '232123456'}) + assert parse_dsc_message(raw) is None + + raw = json.dumps({'type': 'other', 'source_mmsi': '232123456'}) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_missing_mmsi(self): + """Test parsing rejects messages without source MMSI.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({'type': 'dsc'}) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_empty(self): + """Test parsing handles empty input.""" + from utils.dsc.parser import parse_dsc_message + + assert parse_dsc_message('') is None + assert parse_dsc_message(None) is None + assert parse_dsc_message(' ') is None + + def test_format_dsc_for_display(self): + """Test message formatting for display.""" + from utils.dsc.parser import format_dsc_for_display + + msg = { + 'category': 'DISTRESS', + 'source_mmsi': '232123456', + 'source_country': 'United Kingdom', + 'dest_mmsi': '002320001', + 'nature_of_distress': 'FIRE', + 'latitude': 51.5074, + 'longitude': -0.1278, + 'telecommand1_text': 'F3E_G3E_ALL', + 'channel': 16, + 'timestamp': '2025-01-15T12:00:00Z' + } + + output = format_dsc_for_display(msg) + + assert 'DISTRESS' in output + assert '232123456' in output + assert 'United Kingdom' in output + assert 'FIRE' in output + assert '51.5074' in output + assert 'Channel: 16' in output + + +class TestDSCDecoder: + """Tests for DSC decoder utilities.""" + + @pytest.fixture + def decoder(self): + """Create a DSC decoder instance.""" + # Skip if scipy not available + pytest.importorskip('scipy') + pytest.importorskip('numpy') + from utils.dsc.decoder import DSCDecoder + return DSCDecoder() + + def test_decode_mmsi_valid(self, decoder): + """Test MMSI decoding from symbols.""" + # Each symbol is 2 BCD digits + # To encode MMSI 232123456, we need: + # 02-32-12-34-56 -> symbols [2, 32, 12, 34, 56] + symbols = [2, 32, 12, 34, 56] + result = decoder._decode_mmsi(symbols) + assert result == '232123456' + + def test_decode_mmsi_with_leading_zeros(self, decoder): + """Test MMSI decoding handles leading zeros.""" + # Coast station: 002320001 + # 00-23-20-00-01 -> [0, 23, 20, 0, 1] + symbols = [0, 23, 20, 0, 1] + result = decoder._decode_mmsi(symbols) + assert result == '002320001' + + def test_decode_mmsi_short_symbols(self, decoder): + """Test MMSI decoding handles short symbol list.""" + result = decoder._decode_mmsi([1, 2, 3]) + assert result == '000000000' + + def test_decode_mmsi_invalid_symbols(self, decoder): + """Test MMSI decoding handles invalid symbol values.""" + # Symbols > 99 should be treated as 0 + symbols = [100, 32, 12, 34, 56] + result = decoder._decode_mmsi(symbols) + # First symbol becomes 00 + assert result == '003212345'[-9:] + + def test_decode_position_northeast(self, decoder): + """Test position decoding for NE quadrant.""" + # Quadrant 10 = NE (lat+, lon+) + # Position: 51°30'N, 0°10'E + symbols = [10, 51, 30, 0, 10, 0, 0, 0, 0, 0] + result = decoder._decode_position(symbols) + + assert result is not None + assert result['lat'] == pytest.approx(51.5, rel=0.01) + assert result['lon'] == pytest.approx(0.1667, rel=0.01) + + def test_decode_position_northwest(self, decoder): + """Test position decoding for NW quadrant.""" + # Quadrant 11 = NW (lat+, lon-) + # Position: 40°42'N, 74°00'W (NYC area) + symbols = [11, 40, 42, 0, 74, 0, 0, 0, 0, 0] + result = decoder._decode_position(symbols) + + assert result is not None + assert result['lat'] > 0 # North + assert result['lon'] < 0 # West + + def test_decode_position_southeast(self, decoder): + """Test position decoding for SE quadrant.""" + # Quadrant 0 = SE (lat-, lon+) + symbols = [0, 33, 51, 1, 51, 12, 0, 0, 0, 0] + result = decoder._decode_position(symbols) + + assert result is not None + assert result['lat'] < 0 # South + assert result['lon'] > 0 # East + + def test_decode_position_short_symbols(self, decoder): + """Test position decoding handles short symbol list.""" + result = decoder._decode_position([10, 51, 30]) + assert result is None + + def test_decode_position_invalid_values(self, decoder): + """Test position decoding handles invalid values gracefully.""" + # Latitude > 90 should be treated as 0 + symbols = [10, 95, 30, 0, 10, 0, 0, 0, 0, 0] + result = decoder._decode_position(symbols) + assert result is not None + assert result['lat'] == pytest.approx(0.5, rel=0.01) # 0 deg + 30 min + + def test_bits_to_symbol(self, decoder): + """Test bit to symbol conversion.""" + # Symbol value is first 7 bits (LSB first) + # Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x] + bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0] + result = decoder._bits_to_symbol(bits) + assert result == 100 + + def test_bits_to_symbol_wrong_length(self, decoder): + """Test bit to symbol returns -1 for wrong length.""" + result = decoder._bits_to_symbol([0, 1, 0, 1, 0]) + assert result == -1 + + def test_detect_dot_pattern(self, decoder): + """Test dot pattern detection.""" + # Dot pattern is alternating 1010101... + decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits + assert decoder._detect_dot_pattern() is True + + def test_detect_dot_pattern_insufficient(self, decoder): + """Test dot pattern not detected with insufficient alternations.""" + decoder.bit_buffer = [1, 0] * 5 # Only 10 bits + assert decoder._detect_dot_pattern() is False + + def test_detect_dot_pattern_not_alternating(self, decoder): + """Test dot pattern not detected without alternation.""" + decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5 + assert decoder._detect_dot_pattern() is False + + +class TestDSCConstants: + """Tests for DSC constants.""" + + def test_format_codes_completeness(self): + """Test that all standard format codes are defined.""" + from utils.dsc.constants import FORMAT_CODES + + # ITU-R M.493 format codes + assert 100 in FORMAT_CODES # DISTRESS + assert 102 in FORMAT_CODES # ALL_SHIPS + assert 106 in FORMAT_CODES # DISTRESS_ACK + assert 112 in FORMAT_CODES # INDIVIDUAL + assert 116 in FORMAT_CODES # ROUTINE + assert 118 in FORMAT_CODES # SAFETY + assert 120 in FORMAT_CODES # URGENCY + + def test_distress_nature_codes_completeness(self): + """Test that all distress nature codes are defined.""" + from utils.dsc.constants import DISTRESS_NATURE_CODES + + # ITU-R M.493 distress nature codes + assert 100 in DISTRESS_NATURE_CODES # UNDESIGNATED + assert 101 in DISTRESS_NATURE_CODES # FIRE + assert 102 in DISTRESS_NATURE_CODES # FLOODING + assert 103 in DISTRESS_NATURE_CODES # COLLISION + assert 106 in DISTRESS_NATURE_CODES # SINKING + assert 109 in DISTRESS_NATURE_CODES # PIRACY + assert 110 in DISTRESS_NATURE_CODES # MOB + + def test_mid_country_map_completeness(self): + """Test that common MID codes are defined.""" + from utils.dsc.constants import MID_COUNTRY_MAP + + # Verify some key maritime nations + assert '232' in MID_COUNTRY_MAP # UK + assert '366' in MID_COUNTRY_MAP # USA + assert '351' in MID_COUNTRY_MAP # Panama + assert '257' in MID_COUNTRY_MAP # Norway + assert '211' in MID_COUNTRY_MAP # Germany + assert '503' in MID_COUNTRY_MAP # Australia + assert '431' in MID_COUNTRY_MAP # Japan + + def test_vhf_channel_70_frequency(self): + """Test DSC Channel 70 frequency constant.""" + from utils.dsc.constants import VHF_CHANNELS + + assert VHF_CHANNELS[70] == 156.525 + + def test_dsc_modulation_parameters(self): + """Test DSC modulation constants.""" + from utils.dsc.constants import ( + DSC_BAUD_RATE, + DSC_MARK_FREQ, + DSC_SPACE_FREQ, + ) + + assert DSC_BAUD_RATE == 100 + assert DSC_MARK_FREQ == 1800 + assert DSC_SPACE_FREQ == 1200 diff --git a/tests/test_dsc_database.py b/tests/test_dsc_database.py new file mode 100644 index 0000000..f58aab0 --- /dev/null +++ b/tests/test_dsc_database.py @@ -0,0 +1,422 @@ +"""Tests for DSC database operations.""" + +import tempfile +import pytest +from pathlib import Path +from unittest.mock import patch + + +@pytest.fixture(autouse=True) +def temp_db(): + """Use a temporary database for each test.""" + with tempfile.TemporaryDirectory() as tmpdir: + test_db_path = Path(tmpdir) / 'test_intercept.db' + test_db_dir = Path(tmpdir) + + with patch('utils.database.DB_PATH', test_db_path), \ + patch('utils.database.DB_DIR', test_db_dir): + from utils.database import init_db, close_db + + init_db() + yield test_db_path + close_db() + + +class TestDSCAlertsCRUD: + """Tests for DSC alerts CRUD operations.""" + + def test_store_and_get_dsc_alert(self, temp_db): + """Test storing and retrieving a DSC alert.""" + from utils.database import store_dsc_alert, get_dsc_alert + + alert_id = store_dsc_alert( + source_mmsi='232123456', + format_code='100', + category='DISTRESS', + source_name='MV Test Ship', + nature_of_distress='FIRE', + latitude=51.5, + longitude=-0.1 + ) + + assert alert_id is not None + assert alert_id > 0 + + alert = get_dsc_alert(alert_id) + + assert alert is not None + assert alert['source_mmsi'] == '232123456' + assert alert['format_code'] == '100' + assert alert['category'] == 'DISTRESS' + assert alert['source_name'] == 'MV Test Ship' + assert alert['nature_of_distress'] == 'FIRE' + assert alert['latitude'] == 51.5 + assert alert['longitude'] == -0.1 + assert alert['acknowledged'] is False + + def test_store_minimal_alert(self, temp_db): + """Test storing alert with only required fields.""" + from utils.database import store_dsc_alert, get_dsc_alert + + alert_id = store_dsc_alert( + source_mmsi='366000001', + format_code='116', + category='ROUTINE' + ) + + alert = get_dsc_alert(alert_id) + + assert alert is not None + assert alert['source_mmsi'] == '366000001' + assert alert['category'] == 'ROUTINE' + assert alert['latitude'] is None + assert alert['longitude'] is None + + def test_get_nonexistent_alert(self, temp_db): + """Test getting an alert that doesn't exist.""" + from utils.database import get_dsc_alert + + alert = get_dsc_alert(99999) + assert alert is None + + def test_get_dsc_alerts_all(self, temp_db): + """Test getting all alerts.""" + from utils.database import store_dsc_alert, get_dsc_alerts + + store_dsc_alert('232123456', '100', 'DISTRESS') + store_dsc_alert('366000001', '120', 'URGENCY') + store_dsc_alert('351234567', '116', 'ROUTINE') + + alerts = get_dsc_alerts() + + assert len(alerts) == 3 + + def test_get_dsc_alerts_by_category(self, temp_db): + """Test filtering alerts by category.""" + from utils.database import store_dsc_alert, get_dsc_alerts + + store_dsc_alert('232123456', '100', 'DISTRESS') + store_dsc_alert('232123457', '100', 'DISTRESS') + store_dsc_alert('366000001', '120', 'URGENCY') + store_dsc_alert('351234567', '116', 'ROUTINE') + + distress_alerts = get_dsc_alerts(category='DISTRESS') + urgency_alerts = get_dsc_alerts(category='URGENCY') + + assert len(distress_alerts) == 2 + assert len(urgency_alerts) == 1 + + def test_get_dsc_alerts_by_acknowledged(self, temp_db): + """Test filtering alerts by acknowledgement status.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alerts, + acknowledge_dsc_alert + ) + + id1 = store_dsc_alert('232123456', '100', 'DISTRESS') + id2 = store_dsc_alert('366000001', '100', 'DISTRESS') + store_dsc_alert('351234567', '100', 'DISTRESS') + + acknowledge_dsc_alert(id1) + acknowledge_dsc_alert(id2) + + unacked = get_dsc_alerts(acknowledged=False) + acked = get_dsc_alerts(acknowledged=True) + + assert len(unacked) == 1 + assert len(acked) == 2 + + def test_get_dsc_alerts_by_mmsi(self, temp_db): + """Test filtering alerts by source MMSI.""" + from utils.database import store_dsc_alert, get_dsc_alerts + + store_dsc_alert('232123456', '100', 'DISTRESS') + store_dsc_alert('232123456', '120', 'URGENCY') + store_dsc_alert('366000001', '100', 'DISTRESS') + + alerts = get_dsc_alerts(source_mmsi='232123456') + + assert len(alerts) == 2 + for alert in alerts: + assert alert['source_mmsi'] == '232123456' + + def test_get_dsc_alerts_pagination(self, temp_db): + """Test alert pagination.""" + from utils.database import store_dsc_alert, get_dsc_alerts + + # Create 10 alerts + for i in range(10): + store_dsc_alert(f'23212345{i}', '100', 'DISTRESS') + + # Get first page + page1 = get_dsc_alerts(limit=5, offset=0) + assert len(page1) == 5 + + # Get second page + page2 = get_dsc_alerts(limit=5, offset=5) + assert len(page2) == 5 + + # Ensure no overlap + page1_ids = {a['id'] for a in page1} + page2_ids = {a['id'] for a in page2} + assert page1_ids.isdisjoint(page2_ids) + + def test_get_dsc_alerts_order(self, temp_db): + """Test alerts are returned in reverse chronological order.""" + from utils.database import store_dsc_alert, get_dsc_alerts + + id1 = store_dsc_alert('232123456', '100', 'DISTRESS') + id2 = store_dsc_alert('366000001', '100', 'DISTRESS') + id3 = store_dsc_alert('351234567', '100', 'DISTRESS') + + alerts = get_dsc_alerts() + + # ORDER BY received_at DESC, so most recent first + # When timestamps are identical, higher IDs are more recent + # The actual order depends on the DB implementation + # We just verify all 3 are present and it's a list + assert len(alerts) == 3 + alert_ids = {a['id'] for a in alerts} + assert alert_ids == {id1, id2, id3} + + def test_acknowledge_dsc_alert(self, temp_db): + """Test acknowledging a DSC alert.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alert, + acknowledge_dsc_alert + ) + + alert_id = store_dsc_alert('232123456', '100', 'DISTRESS') + + # Initially not acknowledged + alert = get_dsc_alert(alert_id) + assert alert['acknowledged'] is False + + # Acknowledge it + result = acknowledge_dsc_alert(alert_id) + assert result is True + + # Now acknowledged + alert = get_dsc_alert(alert_id) + assert alert['acknowledged'] is True + + def test_acknowledge_dsc_alert_with_notes(self, temp_db): + """Test acknowledging with notes.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alert, + acknowledge_dsc_alert + ) + + alert_id = store_dsc_alert('232123456', '100', 'DISTRESS') + + acknowledge_dsc_alert(alert_id, notes='Vessel located, rescue underway') + + alert = get_dsc_alert(alert_id) + assert alert['acknowledged'] is True + assert alert['notes'] == 'Vessel located, rescue underway' + + def test_acknowledge_nonexistent_alert(self, temp_db): + """Test acknowledging an alert that doesn't exist.""" + from utils.database import acknowledge_dsc_alert + + result = acknowledge_dsc_alert(99999) + assert result is False + + def test_get_dsc_alert_summary(self, temp_db): + """Test getting alert summary counts.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alert_summary, + acknowledge_dsc_alert + ) + + # Create various alerts + store_dsc_alert('232123456', '100', 'DISTRESS') + store_dsc_alert('232123457', '100', 'DISTRESS') + store_dsc_alert('366000001', '120', 'URGENCY') + store_dsc_alert('351234567', '118', 'SAFETY') + acked_id = store_dsc_alert('257000001', '100', 'DISTRESS') + + # Acknowledge one distress + acknowledge_dsc_alert(acked_id) + + summary = get_dsc_alert_summary() + + assert summary['distress'] == 2 # 3 - 1 acknowledged + assert summary['urgency'] == 1 + assert summary['safety'] == 1 + assert summary['total'] == 4 + + def test_get_dsc_alert_summary_empty(self, temp_db): + """Test alert summary with no alerts.""" + from utils.database import get_dsc_alert_summary + + summary = get_dsc_alert_summary() + + assert summary['distress'] == 0 + assert summary['urgency'] == 0 + assert summary['safety'] == 0 + assert summary['routine'] == 0 + assert summary['total'] == 0 + + def test_cleanup_old_dsc_alerts(self, temp_db): + """Test cleanup function behavior.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alerts, + acknowledge_dsc_alert, + cleanup_old_dsc_alerts + ) + + # Create and acknowledge some alerts + id1 = store_dsc_alert('232123456', '100', 'DISTRESS') + id2 = store_dsc_alert('366000001', '100', 'DISTRESS') + id3 = store_dsc_alert('351234567', '100', 'DISTRESS') # Unacknowledged + + acknowledge_dsc_alert(id1) + acknowledge_dsc_alert(id2) + + # Cleanup with large max_age shouldn't delete recent records + deleted = cleanup_old_dsc_alerts(max_age_days=30) + assert deleted == 0 # Nothing old enough to delete + + # All 3 should still be present + alerts = get_dsc_alerts() + assert len(alerts) == 3 + + # Verify unacknowledged one is still unacknowledged + unacked = get_dsc_alerts(acknowledged=False) + assert len(unacked) == 1 + assert unacked[0]['id'] == id3 + + def test_cleanup_preserves_unacknowledged(self, temp_db): + """Test cleanup preserves unacknowledged alerts regardless of age.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alerts, + cleanup_old_dsc_alerts + ) + + # Create unacknowledged alerts + store_dsc_alert('232123456', '100', 'DISTRESS') + store_dsc_alert('366000001', '100', 'DISTRESS') + + # Cleanup with 0 days + deleted = cleanup_old_dsc_alerts(max_age_days=0) + + # All should remain (none were acknowledged) + alerts = get_dsc_alerts() + assert len(alerts) == 2 + assert deleted == 0 + + def test_store_alert_with_raw_message(self, temp_db): + """Test storing alert with raw message data.""" + from utils.database import store_dsc_alert, get_dsc_alert + + raw = '100023212345603660000110010010000000000127' + + alert_id = store_dsc_alert( + source_mmsi='232123456', + format_code='100', + category='DISTRESS', + raw_message=raw + ) + + alert = get_dsc_alert(alert_id) + assert alert['raw_message'] == raw + + def test_store_alert_with_destination(self, temp_db): + """Test storing alert with destination MMSI.""" + from utils.database import store_dsc_alert, get_dsc_alert + + alert_id = store_dsc_alert( + source_mmsi='232123456', + format_code='112', + category='INDIVIDUAL', + dest_mmsi='366000001' + ) + + alert = get_dsc_alert(alert_id) + assert alert['dest_mmsi'] == '366000001' + + +class TestDSCDatabaseIntegration: + """Integration tests for DSC database operations.""" + + def test_full_alert_lifecycle(self, temp_db): + """Test complete lifecycle of a DSC alert.""" + from utils.database import ( + store_dsc_alert, + get_dsc_alert, + get_dsc_alerts, + acknowledge_dsc_alert, + get_dsc_alert_summary + ) + + # 1. Store a distress alert + alert_id = store_dsc_alert( + source_mmsi='232123456', + format_code='100', + category='DISTRESS', + source_name='MV Mayday', + nature_of_distress='SINKING', + latitude=50.0, + longitude=-5.0 + ) + + # 2. Verify it appears in summary + summary = get_dsc_alert_summary() + assert summary['distress'] == 1 + assert summary['total'] == 1 + + # 3. Verify it appears in unacknowledged list + unacked = get_dsc_alerts(acknowledged=False) + assert len(unacked) == 1 + assert unacked[0]['source_mmsi'] == '232123456' + + # 4. Acknowledge with notes + acknowledge_dsc_alert(alert_id, 'Rescue helicopter dispatched') + + # 5. Verify it's now acknowledged + alert = get_dsc_alert(alert_id) + assert alert['acknowledged'] is True + assert alert['notes'] == 'Rescue helicopter dispatched' + + # 6. Verify summary updated + summary = get_dsc_alert_summary() + assert summary['distress'] == 0 + assert summary['total'] == 0 + + # 7. Verify it appears in acknowledged list + acked = get_dsc_alerts(acknowledged=True) + assert len(acked) == 1 + + def test_multiple_vessel_alerts(self, temp_db): + """Test handling alerts from multiple vessels.""" + from utils.database import store_dsc_alert, get_dsc_alerts + + # Simulate multiple vessels in distress + vessels = [ + ('232123456', 'United Kingdom', 'FIRE'), + ('366000001', 'USA', 'FLOODING'), + ('351234567', 'Panama', 'COLLISION'), + ] + + for mmsi, country, nature in vessels: + store_dsc_alert( + source_mmsi=mmsi, + format_code='100', + category='DISTRESS', + nature_of_distress=nature + ) + + # Verify all alerts stored + alerts = get_dsc_alerts(category='DISTRESS') + assert len(alerts) == 3 + + # Verify each has correct nature + natures = {a['nature_of_distress'] for a in alerts} + assert natures == {'FIRE', 'FLOODING', 'COLLISION'}