Compare commits
2 Commits
tests-sqli
...
api-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a27f282b69 | |||
| e9f73c7562 |
@@ -24,6 +24,15 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def _enable_sqlite_foreign_keys(dbapi_connection, connection_record):
|
||||
"""Active les contraintes de clés étrangères sur chaque connexion SQLite."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _migrate_db(app):
|
||||
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
|
||||
|
||||
@@ -182,6 +191,9 @@ def create_app(
|
||||
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
|
||||
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
if db.engine.dialect.name == "sqlite":
|
||||
sa.event.listen(db.engine, "connect", _enable_sqlite_foreign_keys)
|
||||
app.jinja_env.filters["date_fr"] = _date_fr
|
||||
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
||||
|
||||
|
||||
227
app/business/presence_service.py
Normal file
227
app/business/presence_service.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Enregistrement métier des événements de présence Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, time
|
||||
from typing import Any, Callable, Literal, Mapping
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
|
||||
|
||||
EventType = Literal["arrival", "departure"]
|
||||
SlotState = Literal["open", "closed"]
|
||||
|
||||
|
||||
class PresenceServiceError(ValueError):
|
||||
"""Erreur métier prévisible lors de l'enregistrement d'une présence."""
|
||||
|
||||
|
||||
class InvalidPresenceEventError(PresenceServiceError):
|
||||
"""Les données de l'événement ne respectent pas le contrat métier."""
|
||||
|
||||
|
||||
class IdempotencyConflictError(PresenceServiceError):
|
||||
"""La clé est déjà utilisée par un événement différent."""
|
||||
|
||||
|
||||
class ArrivalAlreadyOpenError(PresenceServiceError):
|
||||
"""Une arrivée est déjà ouverte, quelle que soit sa journée."""
|
||||
|
||||
|
||||
class DepartureWithoutArrivalError(PresenceServiceError):
|
||||
"""Aucune arrivée ouverte ne peut être fermée."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceResult:
|
||||
"""Résultat sérialisable par la future route API."""
|
||||
|
||||
event_id: int
|
||||
entry_id: int
|
||||
time_slot_id: int | None
|
||||
replayed: bool
|
||||
slot_state: SlotState
|
||||
|
||||
@property
|
||||
def event_created(self) -> bool:
|
||||
return not self.replayed
|
||||
|
||||
|
||||
def _parse_occurred_at(value: str, timezone: ZoneInfo) -> tuple[datetime, datetime, date, time]:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InvalidPresenceEventError("occurred_at doit être un ISO 8601 valide") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise InvalidPresenceEventError("occurred_at doit comporter un offset explicite")
|
||||
local = parsed.astimezone(timezone)
|
||||
wall_time = local.replace(tzinfo=None)
|
||||
return wall_time, local, local.date(), local.time()
|
||||
|
||||
|
||||
def _received_at(value: datetime | None, clock: Callable[[], datetime] | None) -> datetime:
|
||||
received = value if value is not None else (clock() if clock else datetime.now(UTC))
|
||||
if received.tzinfo is None or received.utcoffset() is None:
|
||||
return received
|
||||
return received.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _validate_config(config: Mapping[str, Any]) -> tuple[ZoneInfo, str, str, str]:
|
||||
try:
|
||||
timezone_name = config["timezone"]
|
||||
defaults = (
|
||||
config["default_day_type"],
|
||||
config["default_journey_profile_id"],
|
||||
config["default_motor_vehicle_id"],
|
||||
)
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except (KeyError, TypeError, ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise InvalidPresenceEventError("Configuration Home Assistant invalide") from exc
|
||||
if not isinstance(timezone_name, str) or not all(isinstance(item, str) for item in defaults):
|
||||
raise InvalidPresenceEventError("Configuration Home Assistant invalide")
|
||||
return timezone, defaults[0], defaults[1], defaults[2]
|
||||
|
||||
|
||||
def _result(event: WorkplacePresenceEvent, replayed: bool) -> PresenceResult:
|
||||
return PresenceResult(
|
||||
event_id=event.id,
|
||||
entry_id=event.entry_id,
|
||||
time_slot_id=event.time_slot_id,
|
||||
replayed=replayed,
|
||||
slot_state="closed" if event.time_slot_id is not None else "open",
|
||||
)
|
||||
|
||||
|
||||
def record_presence_event(
|
||||
session: Session,
|
||||
config: Mapping[str, Any],
|
||||
event_type: str,
|
||||
occurred_at: str,
|
||||
idempotency_key: str,
|
||||
*,
|
||||
received_at: datetime | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> PresenceResult:
|
||||
"""Enregistre une arrivée ou un départ sans valider la transaction SQLAlchemy."""
|
||||
if not hasattr(session, "in_transaction"):
|
||||
session = session()
|
||||
if event_type not in ("arrival", "departure"):
|
||||
raise InvalidPresenceEventError("event_type doit valoir arrival ou departure")
|
||||
if not isinstance(idempotency_key, str) or not idempotency_key or len(idempotency_key) > 255:
|
||||
raise InvalidPresenceEventError(
|
||||
"idempotency_key doit être non vide et limitée à 255 caractères"
|
||||
)
|
||||
|
||||
timezone, day_type, journey_id, vehicle_id = _validate_config(config)
|
||||
occurred_wall, occurred_local, local_date, wall_time = _parse_occurred_at(occurred_at, timezone)
|
||||
received_wall = _received_at(received_at, clock)
|
||||
|
||||
# Le SELECT démarre explicitement la transaction racine. Aucun contexte ne
|
||||
# valide cette transaction : la route appelante garde la décision finale.
|
||||
session.execute(select(1))
|
||||
try:
|
||||
if session:
|
||||
existing = session.scalar(
|
||||
select(WorkplacePresenceEvent).where(
|
||||
WorkplacePresenceEvent.idempotency_key == idempotency_key
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.event_type != event_type or existing.occurred_at != occurred_wall:
|
||||
raise IdempotencyConflictError("La clé d'idempotence est déjà utilisée")
|
||||
return _result(existing, replayed=True)
|
||||
|
||||
if event_type == "arrival":
|
||||
# Une seule arrivée peut être ouverte dans toute l'application :
|
||||
# le prochain départ doit toujours avoir un rattachement unique.
|
||||
open_arrival = session.scalar(
|
||||
select(WorkplacePresenceEvent).where(
|
||||
WorkplacePresenceEvent.event_type == "arrival",
|
||||
WorkplacePresenceEvent.time_slot_id.is_(None),
|
||||
)
|
||||
)
|
||||
if open_arrival is not None:
|
||||
raise ArrivalAlreadyOpenError("Une arrivée est déjà ouverte")
|
||||
entry = session.scalar(select(WorkEntry).where(WorkEntry.date == local_date))
|
||||
if entry is None:
|
||||
entry = WorkEntry(
|
||||
date=local_date,
|
||||
day_type=day_type,
|
||||
journey_profile_id=journey_id,
|
||||
motor_vehicle_id=vehicle_id,
|
||||
)
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
event = WorkplacePresenceEvent(
|
||||
idempotency_key=idempotency_key,
|
||||
event_type="arrival",
|
||||
received_at=received_wall,
|
||||
occurred_at=occurred_wall,
|
||||
local_date=local_date,
|
||||
entry=entry,
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
return _result(event, replayed=False)
|
||||
|
||||
open_arrivals = session.scalars(
|
||||
select(WorkplacePresenceEvent)
|
||||
.where(
|
||||
WorkplacePresenceEvent.event_type == "arrival",
|
||||
WorkplacePresenceEvent.time_slot_id.is_(None),
|
||||
)
|
||||
.order_by(WorkplacePresenceEvent.occurred_at, WorkplacePresenceEvent.id)
|
||||
).all()
|
||||
if not open_arrivals:
|
||||
raise DepartureWithoutArrivalError("Aucune arrivée ouverte")
|
||||
if len(open_arrivals) > 1:
|
||||
raise ArrivalAlreadyOpenError("Plusieurs arrivées sont ouvertes")
|
||||
arrival = open_arrivals[0]
|
||||
arrival_local = arrival.occurred_at.replace(tzinfo=timezone)
|
||||
if occurred_local <= arrival_local:
|
||||
raise InvalidPresenceEventError(
|
||||
"L'instant du départ doit être postérieur à celui de l'arrivée"
|
||||
)
|
||||
slot = TimeSlot(
|
||||
entry_id=arrival.entry_id, start_time=arrival.occurred_at.time(), end_time=wall_time
|
||||
)
|
||||
session.add(slot)
|
||||
departure = WorkplacePresenceEvent(
|
||||
idempotency_key=idempotency_key,
|
||||
event_type="departure",
|
||||
received_at=received_wall,
|
||||
occurred_at=occurred_wall,
|
||||
local_date=local_date,
|
||||
entry_id=arrival.entry_id,
|
||||
time_slot=slot,
|
||||
processed_at=received_wall,
|
||||
)
|
||||
session.add(departure)
|
||||
session.flush()
|
||||
arrival.time_slot = slot
|
||||
arrival.processed_at = received_wall
|
||||
session.flush()
|
||||
return _result(departure, replayed=False)
|
||||
except IntegrityError as exc:
|
||||
# Une autre requête peut avoir gagné la clé entre le contrôle et le flush.
|
||||
session.rollback()
|
||||
existing = session.scalar(
|
||||
select(WorkplacePresenceEvent).where(
|
||||
WorkplacePresenceEvent.idempotency_key == idempotency_key
|
||||
)
|
||||
)
|
||||
if (
|
||||
existing is not None
|
||||
and existing.event_type == event_type
|
||||
and existing.occurred_at == occurred_wall
|
||||
):
|
||||
return _result(existing, replayed=True)
|
||||
raise IdempotencyConflictError("Conflit d'unicité lors de l'enregistrement") from exc
|
||||
|
||||
|
||||
record_home_assistant_event = record_presence_event
|
||||
@@ -37,6 +37,9 @@ class WorkEntry(db.Model):
|
||||
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
||||
back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time"
|
||||
)
|
||||
presence_events: so.Mapped[list["WorkplacePresenceEvent"]] = so.relationship(
|
||||
back_populates="entry", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def total_minutes(self) -> int:
|
||||
"""
|
||||
@@ -86,6 +89,50 @@ class TimeSlot(db.Model):
|
||||
end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False)
|
||||
|
||||
entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="time_slots")
|
||||
presence_events: so.Mapped[list["WorkplacePresenceEvent"]] = so.relationship(
|
||||
back_populates="time_slot", passive_deletes=True
|
||||
)
|
||||
|
||||
|
||||
class WorkplacePresenceEvent(db.Model):
|
||||
"""Événement de présence reçu de Home Assistant.
|
||||
|
||||
``received_at`` est un instant normalisé en UTC, stocké naïf selon la convention
|
||||
actuelle de l'application. À l'inverse, ``occurred_at`` est l'heure murale naïve
|
||||
dans ``Europe/Paris`` et ``local_date`` est le jour local dérivé de cette heure.
|
||||
Cette distinction est volontaire : elle sera utilisée par le service métier futur
|
||||
pour rattacher les arrivées et départs aux journées, notamment autour de minuit.
|
||||
"""
|
||||
|
||||
__tablename__ = "workplace_presence_events"
|
||||
__table_args__ = (
|
||||
sa.CheckConstraint("event_type IN ('arrival', 'departure')", name="ck_presence_event_type"),
|
||||
sa.Index("ix_presence_events_local_date", "local_date"),
|
||||
sa.Index("ix_presence_events_entry_id", "entry_id"),
|
||||
)
|
||||
|
||||
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
||||
idempotency_key: so.Mapped[str] = so.mapped_column(sa.String(255), unique=True, nullable=False)
|
||||
event_type: so.Mapped[str] = so.mapped_column(sa.String(9), nullable=False)
|
||||
received_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, nullable=False)
|
||||
# Heure locale Europe/Paris, sans fuseau : ne pas la traiter comme un instant UTC.
|
||||
occurred_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, nullable=False)
|
||||
local_date: so.Mapped[date] = so.mapped_column(sa.Date, nullable=False)
|
||||
entry_id: so.Mapped[int] = so.mapped_column(
|
||||
sa.ForeignKey("work_entries.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
time_slot_id: so.Mapped[int | None] = so.mapped_column(
|
||||
sa.ForeignKey("time_slots.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: so.Mapped[datetime] = so.mapped_column(
|
||||
sa.DateTime, default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
processed_at: so.Mapped[datetime | None] = so.mapped_column(sa.DateTime, nullable=True)
|
||||
|
||||
entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="presence_events")
|
||||
time_slot: so.Mapped["TimeSlot | None"] = so.relationship(
|
||||
back_populates="presence_events", passive_deletes=True
|
||||
)
|
||||
|
||||
|
||||
class LeaveBalance(db.Model):
|
||||
|
||||
@@ -120,6 +120,14 @@ La base de données stocke l'activité saisie par l'utilisateur :
|
||||
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
||||
- **`time_slots`** : Les plages horaires travaillées associées à une journée (heure de début, heure de fin, ID de l'entrée).
|
||||
- **`leave_balance`** : Les quotas annuels de congés et de RTT (année, total congés, total RTT).
|
||||
- **`workplace_presence_events`** : Les événements reçus de Home Assistant, avec une clé d'idempotence unique et leurs liens vers une journée et, facultativement, une plage horaire.
|
||||
|
||||
Les événements de présence stockent `received_at` comme un timestamp UTC naïf,
|
||||
conformément à la convention existante des métadonnées. `occurred_at` est différent :
|
||||
il représente une heure locale Europe/Paris naïve, et `local_date` est le jour local
|
||||
qui en est dérivé. Une arrivée non encore rattachée à une plage est identifiée par
|
||||
`event_type = "arrival"` et `time_slot_id IS NULL`; la signification de
|
||||
`processed_at` sera précisée par le service métier de l'étape suivante.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,3 +9,8 @@ def test_app_fixture_uses_one_in_memory_database_connection(app):
|
||||
assert str(db.engine.url) == "sqlite:///:memory:"
|
||||
assert isinstance(db.engine.pool, StaticPool)
|
||||
assert sa.inspect(db.engine).has_table("work_entries")
|
||||
|
||||
|
||||
def test_sqlite_foreign_keys_are_enabled(app):
|
||||
with app.app_context():
|
||||
assert db.session.scalar(sa.text("PRAGMA foreign_keys")) == 1
|
||||
|
||||
111
tests/test_models.py
Normal file
111
tests/test_models.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from datetime import date, datetime, time
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app import db
|
||||
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
|
||||
|
||||
|
||||
def make_entry() -> WorkEntry:
|
||||
return WorkEntry(date=date(2026, 8, 13), day_type="WORK")
|
||||
|
||||
|
||||
def make_event(entry: WorkEntry, key: str = "ha-arrival-1") -> WorkplacePresenceEvent:
|
||||
return WorkplacePresenceEvent(
|
||||
idempotency_key=key,
|
||||
event_type="arrival",
|
||||
received_at=datetime(2026, 8, 13, 6, 23, 10),
|
||||
occurred_at=datetime(2026, 8, 13, 8, 23, 10),
|
||||
local_date=date(2026, 8, 13),
|
||||
entry=entry,
|
||||
)
|
||||
|
||||
|
||||
def test_presence_event_creation_and_relations(app):
|
||||
with app.app_context():
|
||||
entry = make_entry()
|
||||
slot = TimeSlot(start_time=time(8), end_time=time(12), entry=entry)
|
||||
event = make_event(entry)
|
||||
event.time_slot = slot
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
assert event.entry is entry
|
||||
assert event in entry.presence_events
|
||||
assert event.time_slot is slot
|
||||
assert event in slot.presence_events
|
||||
assert event.processed_at is None
|
||||
|
||||
|
||||
def test_idempotency_key_is_unique(app):
|
||||
with app.app_context():
|
||||
entry = make_entry()
|
||||
db.session.add_all([entry, make_event(entry), make_event(entry, "ha-arrival-1")])
|
||||
with pytest.raises(IntegrityError):
|
||||
db.session.commit()
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def test_event_type_check_constraint(app):
|
||||
with app.app_context():
|
||||
entry = make_entry()
|
||||
event = make_event(entry)
|
||||
event.event_type = "unknown"
|
||||
db.session.add_all([entry, event])
|
||||
with pytest.raises(IntegrityError):
|
||||
db.session.commit()
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def test_time_slot_link_is_nullable_and_set_null_on_slot_delete(app):
|
||||
with app.app_context():
|
||||
entry = make_entry()
|
||||
slot = TimeSlot(start_time=time(8), end_time=time(12), entry=entry)
|
||||
event = make_event(entry)
|
||||
event.time_slot = slot
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
db.session.delete(slot)
|
||||
db.session.commit()
|
||||
|
||||
assert db.session.get(WorkplacePresenceEvent, event.id).time_slot_id is None
|
||||
|
||||
|
||||
def test_events_cascade_when_work_entry_is_deleted(app):
|
||||
with app.app_context():
|
||||
entry = make_entry()
|
||||
db.session.add(make_event(entry))
|
||||
db.session.commit()
|
||||
event_id = entry.presence_events[0].id
|
||||
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
|
||||
assert db.session.get(WorkplacePresenceEvent, event_id) is None
|
||||
|
||||
|
||||
def test_create_all_adds_presence_table_without_losing_existing_entries(app):
|
||||
with app.app_context():
|
||||
db.session.add(make_entry())
|
||||
db.session.commit()
|
||||
db.session.execute(sa.text("DROP TABLE workplace_presence_events"))
|
||||
db.session.commit()
|
||||
|
||||
db.create_all()
|
||||
|
||||
assert db.session.scalar(sa.select(sa.func.count()).select_from(WorkEntry)) == 1
|
||||
assert sa.inspect(db.engine).has_table("workplace_presence_events")
|
||||
|
||||
|
||||
def test_create_all_creates_all_tables_on_empty_sqlite_database(app):
|
||||
with app.app_context():
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
|
||||
inspector = sa.inspect(db.engine)
|
||||
assert inspector.has_table("work_entries")
|
||||
assert inspector.has_table("time_slots")
|
||||
assert inspector.has_table("workplace_presence_events")
|
||||
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