É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>
164 lines
6.3 KiB
Python
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")
|