diff --git a/CHANGELOG.md b/CHANGELOG.md index bf56841..69d2919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ uses: qBraid/upload-course-action@v0.1.0-beta ### Added - Unit tests for JSend response handling in course creation and polling +- Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range or non-integer value (including a numeric string such as `"3"`) now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared - Unit tests for shared deploy payload/response handling in `deploy_common.py` - Action inputs to configure polling limits (`max-poll-attempts`, `poll-interval-seconds`, `max-consecutive-errors`) - Comprehensive pytest testing infrastructure with unit and E2E tests diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index 439eeea..f03f336 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -112,6 +112,11 @@ class Course(BaseModel): tags: List[str] content: List[Chapter] deployedTo: List[str] = Field(..., min_length=1) + # Author-declared course length in weeks, forwarded to the qBraid API, + # which enforces the same 1-52 integer range. Optional: courses without + # it keep the platform's chapter-count estimate. strict, because pydantic + # would otherwise coerce "3" to 3 and forward a value the API rejects. + durationWeeks: Optional[int] = Field(None, ge=1, le=52, strict=True) @field_validator("deployedTo") @classmethod @@ -192,7 +197,9 @@ def validate(self) -> None: logger.info("✅ course.json structure and file sizes are valid") - # Save course data for next steps + # Save course data for next steps. A course.json without + # durationWeeks serializes it as null; the qBraid API treats null + # the same as an absent field. try: with open(Config.COURSE_DATA_FILE_NAME, "w") as f: json.dump(course.model_dump(mode="json"), f) diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index 37f3670..f83bfc8 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -2,6 +2,7 @@ from unittest import mock import pytest +from pydantic import ValidationError from validate_course import ( Course, CourseValidator, @@ -172,6 +173,41 @@ def test_format_missing_kernel_error_limits_kernel_sample(self): assert message.count("kernel_") <= 10 assert "..." in message + def _duration_course_data(self, **extra): + data = { + "courseName": "Test Course", + "courseDescription": "Desc", + "visibility": "public", + "imageLink": {"darkLogo": "d.png", "lightLogo": "l.png"}, + "tags": ["tag"], + "content": [], + "deployedTo": ["qbraid.com"], + } + data.update(extra) + return data + + def test_duration_weeks_is_forwarded_in_payload(self): + """A declared durationWeeks must survive into the deploy payload.""" + course = Course(**self._duration_course_data(durationWeeks=2)) + + payload = course.model_dump(mode="json") + + assert payload["durationWeeks"] == 2 + + def test_absent_duration_weeks_serializes_as_null(self): + """No durationWeeks in course.json -> null in the payload, which the + qBraid API treats the same as an absent field.""" + course = Course(**self._duration_course_data()) + + payload = course.model_dump(mode="json") + + assert payload["durationWeeks"] is None + + @pytest.mark.parametrize("bad_value", [0, -3, 53, 2.5, "two", "3", True]) + def test_out_of_range_duration_weeks_fails_validation(self, bad_value): + with pytest.raises(ValidationError): + Course(**self._duration_course_data(durationWeeks=bad_value)) + @mock.patch("builtins.open", new_callable=mock.mock_open) @mock.patch("json.load") @mock.patch("validate_course.Path.exists")