Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/scripts/validate_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions test/unit/test_validate_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest import mock

import pytest
from pydantic import ValidationError
from validate_course import (
Course,
CourseValidator,
Expand Down Expand Up @@ -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")
Expand Down
Loading