Files
tableau-de-bord/tests/test_presence_service.py
Antoine Van Elstraete a27f282b69 feat(presence): ajoute le service métier de présence et ses tests
Étape 3 validée du service métier de présence : implémentation de
app/business/presence_service.py et couverture par
tests/test_presence_service.py.

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-13 18:54:33 +02:00

164 lines
6.3 KiB
Python

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")