fix(M3): immuabilité et validation des modèles — correction des écarts FIXME_M3

Immuabilité :
- Collections des modèles frozen : list → tuple (Lesson, TheoreticalLesson,
  SchoolEvent, AgendaDiff, Homework, XmppMessage, ExternalInfo)
- Gel de HomeworkBlock et ExternalInfo (sous-modèles imbriqués dans contrats)
- Valeurs par défaut immuables : Field(default=(), ...)

Validation de domaine :
- TheoreticalLesson.day_of_week : Field(ge=0, le=6)
- Lesson/TheoreticalLesson : start < end (validateur de modèle)
- SchoolEvent : from_date <= to_date
- AgendaChange : cohérence type/payload (ADDED/MODIFIED → lesson,
  REMOVED → theoretical_lesson)
- CalDAVSyncResult : compteurs Field(ge=0) sur added/updated/removed

Tests : 66 tests (construction/sérialisation, immuabilité, invariants)
couvrant le gel, la mutabilité légitime, et les cas invalides par invariant.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 12:36:27 +02:00
parent 2f6184df0b
commit 292aef4f5b
9 changed files with 1155 additions and 27 deletions

View File

@@ -8,7 +8,7 @@ from datetime import time as _time
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class Status(StrEnum):
@@ -30,6 +30,8 @@ class LessonStatus(StrEnum):
class HomeworkBlock(BaseModel):
"""Représente un bloc de devoir extrait de la description d'un cours."""
model_config = ConfigDict(frozen=True)
kind: Literal["due", "assigned"] = Field(
..., description="Type de bloc (échéance ou attribution)"
)
@@ -47,16 +49,28 @@ class Lesson(BaseModel):
start: datetime = Field(..., description="Date/heure de début")
end: datetime = Field(..., description="Date/heure de fin")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: list[HomeworkBlock] = Field(
default_factory=list,
homework_blocks: tuple[HomeworkBlock, ...] = Field(
default=(),
description="Blocs de devoirs extraits de la description",
)
@model_validator(mode="after")
def _validate_time_order(self) -> Lesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: Lesson
:raises ValueError: Si ``end`` n'est pas postérieur à ``start``.
"""
if self.end <= self.start:
raise ValueError("end doit être postérieur à start")
return self
class SchoolEventKind(StrEnum):
"""Type d'événement scolaire."""
@@ -75,6 +89,18 @@ class SchoolEvent(BaseModel):
from_date: _date = Field(..., description="Date de début (inclusive)")
to_date: _date = Field(..., description="Date de fin (exclusive)")
@model_validator(mode="after")
def _validate_date_order(self) -> SchoolEvent:
"""Valide que la date de fin est postérieure ou égale à la date de début.
:return: L'instance validée.
:rtype: SchoolEvent
:raises ValueError: Si ``to_date`` est antérieur à ``from_date``.
"""
if self.to_date < self.from_date:
raise ValueError("to_date doit être postérieur ou égal à from_date")
return self
class TheoreticalLesson(BaseModel):
"""Représente un cours dans l'agenda théorique."""
@@ -82,9 +108,23 @@ class TheoreticalLesson(BaseModel):
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="Identifiant unique")
day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)")
day_of_week: int = Field(
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
)
start_time: _time = Field(..., description="Heure de début")
end_time: _time = Field(..., description="Heure de fin")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
@model_validator(mode="after")
def _validate_time_order(self) -> TheoreticalLesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: TheoreticalLesson
:raises ValueError: Si ``end_time`` n'est pas postérieur à ``start_time``.
"""
if self.end_time <= self.start_time:
raise ValueError("end_time doit être postérieur à start_time")
return self

View File

@@ -26,17 +26,22 @@ class BlogArticle(BaseModel):
class ExternalInfo(BaseModel):
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP."""
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP.
blog_articles: list[BlogArticle] = Field(
default_factory=list,
Modèle figé (``frozen``) : les instances sont immuables après création.
"""
model_config = ConfigDict(frozen=True)
blog_articles: tuple[BlogArticle, ...] = Field(
default=(),
description="Liste des nouveaux articles du blog",
)
pronote_messages: list[Message] = Field(
default_factory=list,
pronote_messages: tuple[Message, ...] = Field(
default=(),
description="Liste des messages Pronote",
)
other_info: list[str] = Field(
default_factory=list,
other_info: tuple[str, ...] = Field(
default=(),
description="Autres informations (extensible)",
)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import date
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
@@ -30,6 +30,22 @@ class AgendaChange(BaseModel):
)
details: str = Field(default="", description="Détails du changement")
@model_validator(mode="after")
def _validate_payload_consistency(self) -> AgendaChange:
"""Valide la cohérence entre le type de changement et le payload.
:return: L'instance validée.
:rtype: AgendaChange
:raises ValueError: Si le payload ne correspond pas au type de changement.
"""
if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED):
if self.lesson is None:
raise ValueError(f"lesson est requis pour le type {self.type!r}")
if self.type == AgendaChangeType.REMOVED:
if self.theoretical_lesson is None:
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
return self
class AgendaDiff(BaseModel):
"""Représente les différences entre l'agenda réel et l'agenda théorique."""
@@ -37,4 +53,4 @@ class AgendaDiff(BaseModel):
model_config = ConfigDict(frozen=True)
target_date: date = Field(..., description="Date cible de la comparaison")
changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements")
changes: tuple[AgendaChange, ...] = Field(default=(), description="Liste des changements")

View File

@@ -14,7 +14,7 @@ class Homework(BaseModel):
id: str = Field(..., description="ID stable (hachage)")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
assigned_on: date | None = Field(None, description="Date de distribution")
due_on: date = Field(..., description="Date d'échéance")
text: str = Field(..., description="Texte du devoir (brut)")

View File

@@ -36,7 +36,7 @@ class CalDAVSyncResult(BaseModel):
"""Résultat d'une synchronisation CalDAV (mutable)."""
status: CalDAVSyncStatus = Field(..., description="Statut global")
added: int = Field(0, description="Nombre d'événements ajoutés")
updated: int = Field(0, description="Nombre d'événements mis à jour")
removed: int = Field(0, description="Nombre d'événements supprimés")
added: int = Field(0, ge=0, description="Nombre d'événements ajoutés")
updated: int = Field(0, ge=0, description="Nombre d'événements mis à jour")
removed: int = Field(0, ge=0, description="Nombre d'événements supprimés")
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")

View File

@@ -19,13 +19,11 @@ class XmppMessage(BaseModel):
target_date: date = Field(..., description="Date cible")
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs")
changes: list[AgendaChange] = Field(
default_factory=list, description="Liste des changements d'agenda"
)
messages: list[Message] = Field(
default_factory=list, description="Liste des messages/informations"
homeworks: tuple[Homework, ...] = Field(default=(), description="Liste brute des devoirs")
changes: tuple[AgendaChange, ...] = Field(
default=(), description="Liste des changements d'agenda"
)
messages: tuple[Message, ...] = Field(default=(), description="Liste des messages/informations")
external_info: ExternalInfo | None = Field(
None, description="Informations externes (blog, messages Pronote)"
)

View File

@@ -0,0 +1,373 @@
"""Tests de construction et sérialisation JSON des modèles Pydantic.
Ce module vérifie que chaque modèle figé peut être sérialisé en JSON puis désérialisé
sans perte de données, garantissant la stabilité des contrats d'échange.
Les tests utilisent des données minimales valides extraites des définitions de modèles.
"""
from __future__ import annotations
from datetime import date, datetime, time
from typing import Any
import pytest
from pydantic import BaseModel
from pronote_sync.models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-001",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
"group": "Classe A",
"status": LessonStatus.NORMAL,
"content": "Algèbre linéaire",
"homework_blocks": (
HomeworkBlock(kind="due", date=date(2024, 9, 13), text="Exercice 42"),
),
},
),
(
TheoreticalLesson,
{
"id": "theo-lesson-001",
"day_of_week": 0, # Lundi
"start_time": time(8, 0, 0),
"end_time": time(9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
},
),
(
SchoolEvent,
{
"kind": SchoolEventKind.HOLIDAY,
"label": "Vacances de la Toussaint",
"from_date": date(2024, 10, 19),
"to_date": date(2024, 11, 3),
},
),
(
Homework,
{
"id": "hw-001",
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"assigned_on": date(2024, 9, 1),
"due_on": date(2024, 9, 15),
"text": "Exercice 42 à rendre",
"html": "<p>Exercice 42 à rendre</p>",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-002",
start=datetime(2024, 9, 7, 10, 0, 0),
end=datetime(2024, 9, 7, 11, 30, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("Labo 205",),
group=None,
content=None,
),
"theoretical_lesson": None,
"details": "Cours ajouté exceptionnellement",
},
),
(
AgendaDiff,
{
"target_date": date(2024, 9, 6),
"changes": (
AgendaChange(
type=AgendaChangeType.ADDED,
lesson=Lesson(
id="lesson-003",
start=datetime(2024, 9, 8, 14, 0, 0),
end=datetime(2024, 9, 8, 15, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Aucun changement majeur aujourd'hui.",
"homeworks": (
Homework(
id="hw-002",
subject="Physique",
teachers=("M. Martin",),
assigned_on=None,
due_on=date(2024, 9, 7),
text="TP à préparer",
),
),
"changes": (
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=Lesson(
id="lesson-004",
start=datetime(2024, 9, 6, 16, 0, 0),
end=datetime(2024, 9, 6, 17, 30, 0),
subject="SVT",
teachers=("M. Lefèvre",),
rooms=("Salle 302",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
"messages": (
Message(
id="msg-001",
type=MessageType.INFORMATION,
title="Rappel",
content="Réunion parents-professeurs demain",
author="Secrétariat",
date=datetime(2024, 9, 5, 10, 0, 0),
read=False,
),
),
"external_info": ExternalInfo(blog_articles=(), pronote_messages=(), other_info=()),
},
),
(
BlogArticle,
{
"id": "article-001",
"title": "Sortie scolaire à Paris",
"url": "https://college.fr/blog/sortie-paris",
"published_at": datetime(2024, 9, 5, 14, 30, 0),
"updated_at": datetime(2024, 9, 5, 16, 45, 0),
"category": "Actualités",
"author": "M. Dubois",
"content_html": "<p>Sortie prévue le 15 octobre</p>",
"content_text": "Sortie prévue le 15 octobre",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-002",
title="Nouveau règlement intérieur",
url="https://college.fr/blog/reglement",
published_at=datetime(2024, 9, 1, 9, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Mise à jour du règlement</p>",
content_text="Mise à jour du règlement",
),
),
"pronote_messages": (
Message(
id="msg-002",
type=MessageType.DISCUSSION,
title="Devoirs de maths",
content="Voir Pronote pour les détails",
author="Prof Maths",
date=datetime(2024, 9, 4, 8, 0, 0),
read=False,
),
),
"other_info": ("Info complémentaire 1", "Info complémentaire 2"),
},
),
(
Message,
{
"id": "msg-003",
"type": MessageType.SURVEY,
"title": "Enquête de satisfaction",
"content": "Merci de répondre au questionnaire",
"author": "Direction",
"date": datetime(2024, 9, 5, 11, 0, 0),
"read": False,
},
),
],
)
def test_model_json_roundtrip(model_class: type[BaseModel], kwargs: dict[str, Any]) -> None:
"""Vérifie qu'un modèle peut être sérialisé en JSON puis désérialisé sans perte.
:param model_class: Classe du modèle à tester.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
# Construction de l'instance
instance = model_class(**kwargs)
# Sérialisation JSON
json_str = instance.model_dump_json()
# Désérialisation depuis JSON
roundtrip_instance = model_class.model_validate_json(json_str)
# Vérification des champs clés (comparaison par valeur)
for field_name in kwargs:
if field_name in (
"start",
"end",
"start_time",
"end_time",
"published_at",
"updated_at",
"date",
"due_on",
"assigned_on",
"generated_at",
):
# Pour les dates/heures, comparer les objets directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
else:
# Pour les autres champs, comparer directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
def test_lesson_status_enum_values() -> None:
"""Vérifie que les valeurs de l'enum LessonStatus sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for status in [LessonStatus.NORMAL, LessonStatus.CANCELLED, LessonStatus.MOVED]:
assert isinstance(status.value, str)
Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
status=status,
group=None,
content=None,
)
def test_school_event_kind_enum_values() -> None:
"""Vérifie que les valeurs de l'enum SchoolEventKind sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for kind in [SchoolEventKind.HOLIDAY, SchoolEventKind.PUBLIC_HOLIDAY]:
assert isinstance(kind.value, str)
SchoolEvent(
kind=kind,
label="Test",
from_date=date(2024, 9, 1),
to_date=date(2024, 9, 2),
)
def test_message_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum MessageType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for msg_type in [MessageType.DISCUSSION, MessageType.INFORMATION, MessageType.SURVEY]:
assert isinstance(msg_type.value, str)
Message(
id="test",
type=msg_type,
title="Test",
content="Test",
author="Test",
date=datetime(2024, 9, 1, 8, 0),
read=False,
)
def test_agenda_change_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum AgendaChangeType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for change_type in [
AgendaChangeType.ADDED,
AgendaChangeType.REMOVED,
AgendaChangeType.MODIFIED,
]:
assert isinstance(change_type.value, str)
if change_type == AgendaChangeType.ADDED:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)
elif change_type == AgendaChangeType.REMOVED:
AgendaChange(
type=change_type,
lesson=None,
theoretical_lesson=TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 0, 0),
subject="Test",
),
)
else:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)

View File

@@ -0,0 +1,409 @@
"""Tests d'immuabilité des modèles Pydantic figés.
Ce module vérifie que les modèles marqués comme figés (frozen=True) ne peuvent pas
être modifiés après construction, et que leurs collections sont bien des tuples.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-immutability",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont", "Mme Martin"),
"rooms": ("Salle 101",),
"status": LessonStatus.NORMAL,
},
),
(
Homework,
{
"id": "hw-immutability",
"subject": "Physique",
"teachers": ("M. Martin",),
"due_on": date(2024, 9, 15),
"text": "Exercice à rendre",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-ref",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Test immuabilité",
},
),
(
HomeworkBlock,
{
"kind": "due",
"date": date(2024, 9, 15),
"text": "Devoir à faire",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-1",
title="Article 1",
url="https://example.com/1",
published_at=datetime(2024, 9, 1, 8, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
"pronote_messages": (
Message(
id="msg-1",
type=MessageType.INFORMATION,
title="Message 1",
content="Message",
author="Auteur",
date=datetime(2024, 9, 1, 8, 0, 0),
read=False,
),
),
},
),
],
)
def test_frozen_model_rejects_field_assignment(
model_class: type[BaseModel], kwargs: dict[str, Any]
) -> None:
"""Vérifie qu'un modèle figé rejette toute tentative d'assignation de champ.
:param model_class: Classe du modèle figé.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
instance: Any = model_class(**kwargs)
# Tentative d'assignation directe sur un champ
with pytest.raises(ValidationError) as exc_info:
if model_class == Lesson:
instance.subject = "Nouveau sujet"
elif model_class == Homework:
instance.subject = "Nouveau sujet"
elif model_class == AgendaChange:
instance.details = "Nouveaux détails"
elif model_class == XmppMessage:
instance.synthesis = "Nouvelle synthèse"
elif model_class == HomeworkBlock:
instance.text = "Nouveau texte"
elif model_class == ExternalInfo:
instance.other_info = ("Nouvelle info",)
error_msg = str(exc_info.value).lower()
assert "instance is frozen" in error_msg or "immutable" in error_msg
@pytest.mark.parametrize(
"model_class, kwargs, field_name, expected_type",
[
(
Lesson,
{
"id": "lesson-tuple",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"teachers",
tuple,
),
(
Lesson,
{
"id": "lesson-tuple2",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"rooms",
tuple,
),
(
Lesson,
{
"id": "lesson-tuple3",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
"homework_blocks": (),
},
"homework_blocks",
tuple,
),
(
Homework,
{
"id": "hw-tuple",
"subject": "Test",
"teachers": ("Prof 1",),
"due_on": date(2024, 9, 15),
"text": "Devoir",
},
"teachers",
tuple,
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="ref",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
},
"details",
str,
),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "homeworks", tuple),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "changes", tuple),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "messages", tuple),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"blog_articles",
tuple,
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"pronote_messages",
tuple,
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"other_info",
tuple,
),
],
)
def test_collection_fields_are_tuples(
model_class: type[BaseModel],
kwargs: dict[str, Any],
field_name: str,
expected_type: type[Any],
) -> None:
"""Vérifie que les champs de collection sont bien des tuples et non des listes.
:param model_class: Classe du modèle.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ de collection à vérifier.
:type field_name: str
:param expected_type: Type attendu (tuple).
:type expected_type: type
"""
instance = model_class(**kwargs)
field_value = getattr(instance, field_name)
assert isinstance(field_value, expected_type), (
f"Le champ {field_name} devrait être de type {expected_type.__name__}, "
f"got {type(field_value).__name__}"
)
@pytest.mark.parametrize(
"model_class, kwargs, field_name",
[
(
Lesson,
{
"id": "lesson-no-append",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"teachers",
),
(
Lesson,
{
"id": "lesson-no-append2",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"rooms",
),
(
Homework,
{
"id": "hw-no-append",
"subject": "Test",
"teachers": ("Prof 1",),
"due_on": date(2024, 9, 15),
"text": "Devoir",
},
"teachers",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"blog_articles",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"pronote_messages",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"other_info",
),
],
)
def test_collection_fields_reject_append(
model_class: type[BaseModel], kwargs: dict[str, Any], field_name: str
) -> None:
"""Vérifie que les champs de collection (tuples) rejettent la méthode append.
:param model_class: Classe du modèle.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ de collection.
:type field_name: str
"""
instance = model_class(**kwargs)
with pytest.raises(AttributeError) as exc_info:
collection = getattr(instance, field_name)
collection.append("new_item")
assert "'tuple' object has no attribute 'append'" in str(exc_info.value)
@pytest.mark.parametrize(
"model_class, kwargs, field_name, new_value",
[
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "added", 5),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "updated", 3),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "removed", 2),
(
CalDAVSyncResult,
{"status": CalDAVSyncStatus.FAILED},
"errors",
["Erreur 1", "Erreur 2"],
),
],
)
def test_mutable_models_accept_field_assignment(
model_class: type[BaseModel],
kwargs: dict[str, Any],
field_name: str,
new_value: Any,
) -> None:
"""Vérifie que les modèles mutables (non figés) acceptent l'assignation de champs.
:param model_class: Classe du modèle mutable.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ à assigner.
:type field_name: str
:param new_value: Nouvelle valeur à assigner.
:type new_value: Any
"""
instance = model_class(**kwargs)
# Assignation du champ
setattr(instance, field_name, new_value)
# Vérification que la valeur a bien été assignée
assert getattr(instance, field_name) == new_value, (
f"Le champ {field_name} n'a pas été correctement assigné"
)
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
PronoteData,
{
"target_date": date(2024, 9, 6),
"generated_at": datetime(2024, 9, 6, 8, 0, 0),
},
),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}),
],
)
def test_mutable_models_reject_frozen_assignment(
model_class: type[BaseModel], kwargs: dict[str, Any]
) -> None:
"""Vérifie que les modèles mutables peuvent être modifiés mais pas rendus figés.
Note: Les modèles mutables comme PronoteData et CalDAVSyncResult n'ont pas
ConfigDict(frozen=True), donc ils peuvent être modifiés.
:param model_class: Classe du modèle mutable.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
from pronote_sync.models.pronote import PronoteData
instance: Any = model_class(**kwargs)
# Assignation normale doit fonctionner
if model_class == PronoteData:
instance.lessons.append("dummy")
else:
instance.added = 10
# Vérification que l'instance n'est pas figée
assert not hasattr(instance.model_config, "frozen") or not instance.model_config.get(
"frozen", False
)

View File

@@ -0,0 +1,287 @@
"""Tests d'invariants et de validation des modèles Pydantic.
Ce module vérifie que les validateurs de modèle rejettent les données invalides
conformément aux spécifications (ex: ordre des dates, plages de valeurs).
"""
from __future__ import annotations
from datetime import date, datetime, time
import pytest
from pydantic import ValidationError
from pronote_sync.models.agenda import (
Lesson,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
class TestTheoreticalLessonDayOfWeek:
"""Tests de validation du champ day_of_week pour TheoreticalLesson."""
def test_day_of_week_7_invalid(self) -> None:
"""Vérifie que day_of_week=7 lève une ValidationError."""
with pytest.raises(ValidationError):
TheoreticalLesson(
id="test",
day_of_week=7,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
def test_day_of_week_minus_1_invalid(self) -> None:
"""Vérifie que day_of_week=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLesson(
id="test",
day_of_week=-1,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert any(
"greater than or equal to 0" in str(error).lower() or "le 6" in str(error).lower()
for error in exc_info.value.errors()
)
def test_day_of_week_0_valid(self) -> None:
"""Vérifie que day_of_week=0 (lundi) est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.day_of_week == 0
def test_day_of_week_6_valid(self) -> None:
"""Vérifie que day_of_week=6 (dimanche) est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=6,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.day_of_week == 6
class TestLessonTimeOrder:
"""Tests de validation de l'ordre temporel pour Lesson."""
def test_lesson_end_before_start_invalid(self) -> None:
"""Vérifie que end <= start lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
Lesson(
id="test",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 9, 0, 0), # end avant start
subject="Mathématiques",
group=None,
content=None,
)
assert any(
"end doit être postérieur à start" in str(error) for error in exc_info.value.errors()
)
def test_lesson_end_after_start_valid(self) -> None:
"""Vérifie que end > start est valide."""
instance = Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
assert instance.end > instance.start
class TestTheoreticalLessonTimeOrder:
"""Tests de validation de l'ordre temporel pour TheoreticalLesson."""
def test_theoretical_lesson_end_time_before_start_time_invalid(self) -> None:
"""Vérifie que end_time <= start_time lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(9, 0, 0),
end_time=time(8, 0, 0), # end_time avant start_time
subject="Mathématiques",
)
assert any(
"end_time doit être postérieur à start_time" in str(error)
for error in exc_info.value.errors()
)
def test_theoretical_lesson_end_time_after_start_time_valid(self) -> None:
"""Vérifie que end_time > start_time est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.end_time > instance.start_time
class TestSchoolEventDateOrder:
"""Tests de validation de l'ordre des dates pour SchoolEvent."""
def test_school_event_to_date_before_from_date_invalid(self) -> None:
"""Vérifie que to_date < from_date lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 19), # to_date avant from_date
)
assert any(
"to_date doit être postérieur ou égal à from_date" in str(error)
for error in exc_info.value.errors()
)
def test_school_event_to_date_equal_from_date_valid(self) -> None:
"""Vérifie que to_date == from_date est valide."""
instance = SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 20), # égal
)
assert instance.to_date >= instance.from_date
def test_school_event_to_date_after_from_date_valid(self) -> None:
"""Vérifie que to_date > from_date est valide."""
instance = SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 25),
)
assert instance.to_date >= instance.from_date
class TestAgendaChangeConsistency:
"""Tests de cohérence des changements d'agenda."""
def test_agenda_change_added_without_lesson_invalid(self) -> None:
"""Vérifie que type=ADDED avec lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(type=AgendaChangeType.ADDED, lesson=None, theoretical_lesson=None)
assert any(
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_removed_without_theoretical_lesson_invalid(self) -> None:
"""Vérifie que type=REMOVED avec theoretical_lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=None,
)
assert any(
"theoretical_lesson est requis pour le type" in str(error)
for error in exc_info.value.errors()
)
def test_agenda_change_modified_without_lesson_invalid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(type=AgendaChangeType.MODIFIED, lesson=None, theoretical_lesson=None)
assert any(
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_added_with_lesson_valid(self) -> None:
"""Vérifie que type=ADDED avec lesson=<valide> est valide."""
lesson = Lesson(
id="lesson-valid",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
instance = AgendaChange(
type=AgendaChangeType.ADDED,
lesson=lesson,
theoretical_lesson=None,
)
assert instance.type == AgendaChangeType.ADDED
assert instance.lesson is not None
def test_agenda_change_removed_with_theoretical_lesson_valid(self) -> None:
"""Vérifie que type=REMOVED avec theoretical_lesson=<valide> est valide."""
theoretical_lesson = TheoreticalLesson(
id="theo-lesson-valid",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
instance = AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical_lesson,
)
assert instance.type == AgendaChangeType.REMOVED
assert instance.theoretical_lesson is not None
def test_agenda_change_modified_with_lesson_valid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide."""
lesson = Lesson(
id="lesson-valid-mod",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Physique",
group=None,
content=None,
)
instance = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=None,
)
assert instance.type == AgendaChangeType.MODIFIED
assert instance.lesson is not None
class TestCalDAVSyncResultInvariants:
"""Tests de validation des champs numériques de CalDAVSyncResult."""
def test_caldav_sync_result_added_negative_invalid(self) -> None:
"""Vérifie que added=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=-1, updated=0, removed=0)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_updated_negative_invalid(self) -> None:
"""Vérifie que updated=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=-1, removed=0)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_removed_negative_invalid(self) -> None:
"""Vérifie que removed=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=-1)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_zero_values_valid(self) -> None:
"""Vérifie que les valeurs à zéro sont valides."""
instance = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
assert instance.added == 0
assert instance.updated == 0
assert instance.removed == 0