from datetime import UTC, date, datetime import pytest from app import db from app.business.presence_service import ( ArrivalAlreadyOpenError, DepartureWithoutArrivalError, IdempotencyConflictError, InvalidPresenceEventError, record_presence_event, ) from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent CONFIG = { "timezone": "Europe/Paris", "default_day_type": "WORK", "default_journey_profile_id": "moteur_seul", "default_motor_vehicle_id": "citadine", } RECEIVED = datetime(2026, 8, 13, 7, 0, tzinfo=UTC) def call(session, event_type, occurred_at, key, **kwargs): return record_presence_event(session, CONFIG, event_type, occurred_at, key, **kwargs) def test_arrival_creates_entry_with_defaults_without_commit(app): with app.app_context(): result = call( db.session, "arrival", "2026-08-13T08:23:10+02:00", "a-1", received_at=RECEIVED ) entry = db.session.get(WorkEntry, result.entry_id) assert result.replayed is False assert result.slot_state == "open" assert (entry.day_type, entry.journey_profile_id, entry.motor_vehicle_id) == ( "WORK", "moteur_seul", "citadine", ) assert db.session.query(WorkplacePresenceEvent).count() == 1 db.session.rollback() assert db.session.query(WorkplacePresenceEvent).count() == 0 def test_departure_completes_both_events(app): with app.app_context(): arrival = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1") departure = call(db.session, "departure", "2026-08-13T17:00:00+02:00", "d-1") events = db.session.scalars( db.select(WorkplacePresenceEvent).order_by(WorkplacePresenceEvent.id) ).all() assert departure.time_slot_id is not None assert departure.entry_id == arrival.entry_id assert all(event.time_slot_id == departure.time_slot_id for event in events) assert all(event.processed_at is not None for event in events) assert db.session.get(TimeSlot, departure.time_slot_id).start_time.hour == 8 def test_second_pair_same_day_is_supported(app): with app.app_context(): first = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1") call(db.session, "departure", "2026-08-13T12:00:00+02:00", "d-1") call(db.session, "arrival", "2026-08-13T13:00:00+02:00", "a-2") second = call(db.session, "departure", "2026-08-13T17:00:00+02:00", "d-2") assert second.entry_id == first.entry_id assert db.session.query(TimeSlot).count() == 2 def test_arrival_is_global_across_days_and_does_not_create_entry(app): with app.app_context(): call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1") with pytest.raises(ArrivalAlreadyOpenError): call(db.session, "arrival", "2026-08-14T08:00:00+02:00", "a-2") assert db.session.query(WorkEntry).count() == 1 assert db.session.query(WorkplacePresenceEvent).count() == 1 assert db.session.query(TimeSlot).count() == 0 @pytest.mark.parametrize( "departure_at", ["2026-08-13T08:00:00+02:00", "2026-08-13T07:59:59+02:00"], ids=["equal", "before"], ) def test_invalid_departure_does_not_create_slot_or_event(app, departure_at): with app.app_context(): call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1") with pytest.raises(InvalidPresenceEventError): call(db.session, "departure", departure_at, "d-1") assert db.session.query(TimeSlot).count() == 0 assert db.session.query(WorkplacePresenceEvent).count() == 1 assert ( db.session.query(WorkplacePresenceEvent).filter_by(event_type="departure").count() == 0 ) @pytest.mark.parametrize("day_type", ["FORMATION", "GARDE"]) def test_existing_special_day_is_not_modified(app, day_type): with app.app_context(): entry = WorkEntry( date=date(2026, 8, 13), day_type=day_type, journey_profile_id="other-journey", motor_vehicle_id="other-car", ) db.session.add(entry) db.session.flush() result = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1") db.session.refresh(entry) assert result.entry_id == entry.id assert (entry.day_type, entry.journey_profile_id, entry.motor_vehicle_id) == ( day_type, "other-journey", "other-car", ) def test_invalid_transitions_and_payloads(app): with app.app_context(): with pytest.raises(ArrivalAlreadyOpenError): call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1") call(db.session, "arrival", "2026-08-13T09:00:00+02:00", "a-2") db.session.rollback() with pytest.raises(DepartureWithoutArrivalError): call(db.session, "departure", "2026-08-13T09:00:00+02:00", "d-1") for event_type, occurred_at, key in ( ("other", "2026-08-13T09:00:00+02:00", "x"), ("arrival", "2026-08-13T09:00:00", "x"), ("arrival", "not-a-date", "x"), ("arrival", "2026-08-13T09:00:00+02:00", ""), ): with pytest.raises(InvalidPresenceEventError): call(db.session, event_type, occurred_at, key) def test_departure_after_midnight_is_accepted_as_a_later_instant(app): with app.app_context(): arrival = call(db.session, "arrival", "2026-08-13T23:30:00+00:00", "a-1") departure = call(db.session, "departure", "2026-08-14T00:30:00+00:00", "d-1") event = db.session.get(WorkplacePresenceEvent, arrival.event_id) slot = db.session.get(TimeSlot, departure.time_slot_id) assert event.occurred_at == datetime(2026, 8, 14, 1, 30) assert event.local_date == date(2026, 8, 14) assert (slot.start_time.hour, slot.end_time.hour) == (1, 2) def test_replay_and_key_conflict(app): with app.app_context(): first = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "same") replay = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "same") assert replay.replayed is True assert replay.event_id == first.event_id with pytest.raises(IdempotencyConflictError): call(db.session, "arrival", "2026-08-13T08:01:00+02:00", "same")