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>
This commit is contained in:
163
tests/test_presence_service.py
Normal file
163
tests/test_presence_service.py
Normal file
@@ -0,0 +1,163 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user