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
13 changes: 13 additions & 0 deletions campus_python/api/v1/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ def submit(self) -> None:
resp.raise_for_status()
return None

def unsubmit(self) -> None:
"""Clear this submission's submitted_at timestamp.

The server only clears submitted_at for an explicit null in
the PATCH body; update() cannot express that because None
means "omit the field" there.
"""
resp = self.client.patch(
self.make_path(), json={"submitted_at": None}
)
resp.raise_for_status()
return None

class Responses(Resource):
"""Campus API Submission Responses resource."""
path = "responses"
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/test_submissions_unsubmit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Regression tests for the submissions unsubmit contract (issue #74).

The server clears a submission's submitted_at only when the PATCH body
carries an explicit null (campus/api/resources/submission.py), which is
how campus-classroom submits its unsubmit. update() cannot express
that — its None means "omit the field" — so Submissions.unsubmit()
sends the explicit-null body itself.
"""

import unittest
from unittest.mock import Mock

from campus_python.api.v1 import ApiRoot


def make_api() -> tuple[ApiRoot, Mock]:
"""Create an ApiRoot backed by a mock JSON client."""
client = Mock()
return ApiRoot(json_client=client), client


class TestSubmissionsUnsubmit(unittest.TestCase):
"""unsubmit() must PATCH an explicit submitted_at null."""

def setUp(self):
self.api, self.client = make_api()

def test_unsubmit_patches_explicit_null(self):
self.api.submissions["sub-1"].unsubmit()
self.client.patch.assert_called_once_with(
"/api/v1/submissions/sub-1/",
json={"submitted_at": None},
)

def test_update_still_requires_a_field(self):
"""update() keeps its "at least one field" contract."""
with self.assertRaises(ValueError):
self.api.submissions["sub-1"].update()
self.client.patch.assert_not_called()

def test_update_still_sets_timestamp(self):
self.api.submissions["sub-1"].update(
submitted_at="2026-10-04T01:02:03Z"
)
self.client.patch.assert_called_once_with(
"/api/v1/submissions/sub-1/",
json={"submitted_at": "2026-10-04T01:02:03Z"},
)


if __name__ == "__main__":
unittest.main()
Loading