import tempfile from datetime import timedelta import pytest from birds.importer import ( import_birding_csv, parse_bool, parse_coords, parse_duration, parse_int, parse_timestamp, ) from birds.models import Bird, BirdingLocation, BirdingCSVImport from scrobbles.models import Scrobble class TestParserHelpers: def test_parse_duration(self): assert parse_duration("9 minute(s)") == 9 assert parse_duration("120 minute(s)") == 120 assert parse_duration("") is None assert parse_duration(None) is None assert parse_duration("not a duration") is None def test_parse_coords(self): loc = "Some Place, US (44.384, -68.805)" lat, lon = parse_coords(loc) assert lat == 44.384 assert lon == -68.805 def test_parse_coords_no_match(self): loc = "Some Place, US" lat, lon = parse_coords(loc) assert lat is None assert lon is None def test_parse_timestamp(self): dt = parse_timestamp("May 10, 2026", "4:15 PM") assert dt is not None assert dt.year == 2026 assert dt.month == 5 assert dt.day == 10 assert dt.hour == 16 assert dt.minute == 15 def test_parse_timestamp_no_time(self): dt = parse_timestamp("May 10, 2026", "") assert dt is not None assert dt.year == 2026 def test_parse_timestamp_invalid(self): assert parse_timestamp("not a date", "") is None def test_parse_bool(self): assert parse_bool("true") is True assert parse_bool("True") is True assert parse_bool("yes") is True assert parse_bool("1") is True assert parse_bool("false") is False assert parse_bool("") is None assert parse_bool(None) is None def test_parse_int(self): assert parse_int("42") == 42 assert parse_int("") is None assert parse_int(None) is None assert parse_int("not a number") is None class TestImportBirdingCSV: def test_import_creates_birds(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) assert Bird.objects.filter(common_name="Canada Goose").exists() assert Bird.objects.filter(common_name="Northern Cardinal").exists() def test_import_creates_location(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) assert BirdingLocation.objects.filter(title="Test Park").exists() def test_import_creates_scrobble(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) assert Scrobble.objects.filter( source="Birding CSV Import" ).count() == 1 def test_import_logdata_fields(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() log = scrobble.log assert log["duration_minutes"] == 9 assert log["observation_type"] == "Stationary" assert log["party_size"] == 4 assert log["complete_checklist"] is True assert len(log["birds"]) == 2 def test_import_sighting_details(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() birds = scrobble.log["birds"] cardinal = next(b for b in birds if b["quantity"] == 2) assert cardinal["sighting_notes"] == "At the feeder" def test_import_idempotent(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) import_birding_csv(birding_csv_file, user.id) assert Scrobble.objects.filter( source="Birding CSV Import" ).count() == 1 def test_import_bird_quantities(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() birds = scrobble.log["birds"] goose = next(b for b in birds if b["quantity"] == 6) assert goose is not None def test_import_sets_stop_timestamp(self, user, birding_csv_file): import_birding_csv(birding_csv_file, user.id) scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() assert scrobble.stop_timestamp is not None expected = scrobble.timestamp + timedelta(minutes=9) assert scrobble.stop_timestamp == expected class TestBirdingCSVImportModel: def test_create_import_model(self, db, user): imp = BirdingCSVImport.objects.create(user=user) assert imp.uuid is not None assert imp.import_type == "Birding CSV" assert "Birding" in str(imp) def test_record_error(self, db, user): imp = BirdingCSVImport.objects.create(user=user) assert imp.error_log is None imp.record_error("test error") imp.refresh_from_db() assert imp.error_log is not None assert "test error" in imp.error_log def test_record_error_appends(self, db, user): imp = BirdingCSVImport.objects.create(user=user) imp.record_error("first error") imp.record_error("second error") imp.refresh_from_db() assert imp.error_log.count("\n") == 1 assert "first error" in imp.error_log assert "second error" in imp.error_log @pytest.mark.django_db(transaction=True) def test_process_via_model(self, user, birding_csv_file): imp = BirdingCSVImport.objects.create(user=user) with open(birding_csv_file, "rb") as f: imp.csv_file.save("test.csv", f, save=True) imp.process() imp.refresh_from_db() assert imp.process_count == 1 assert imp.processed_finished is not None def test_record_error_on_bad_csv(self, user, db): content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species, """ with tempfile.NamedTemporaryFile( mode="w", suffix=".csv", delete=False, encoding="utf-8" ) as f: f.write(content) file_path = f.name errors = [] scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append) assert len(scrobbles) == 0 assert len(errors) == 1 assert "Could not parse date/time" in errors[0] def test_record_error_on_bad_location(self, user, db): content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species, """ with tempfile.NamedTemporaryFile( mode="w", suffix=".csv", delete=False, encoding="utf-8" ) as f: f.write(content) file_path = f.name errors = [] scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append) assert len(scrobbles) == 0 assert len(errors) == 1 assert "Skipping rows with no location" in errors[0]