Skip to content

Commit 96f916d

Browse files
committed
test: add unit test for handlers
1 parent ba868f4 commit 96f916d

3 files changed

Lines changed: 187 additions & 5 deletions

File tree

openedx_authz/settings/test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,4 @@ def plugin_settings(settings): # pylint: disable=unused-argument
8080
OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "stubs.CourseOverview"
8181

8282
# Migration settings
83-
ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False
83+
ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = True

openedx_authz/tests/stubs/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,22 @@ class CourseAccessRole(models.Model):
167167
# blank course_id implies org wide role
168168
course_id = CourseKeyField(max_length=255, db_index=True, blank=True)
169169
role = models.CharField(max_length=64, db_index=True)
170+
171+
172+
# Waffle flag models
173+
class WaffleFlagCourseOverrideModel(models.Model):
174+
"""Stub model representing a waffle flag course override for testing purposes."""
175+
176+
course_id = CourseKeyField(max_length=255, db_index=True)
177+
waffle_flag = models.CharField(max_length=255, db_index=True, default="")
178+
enabled = models.BooleanField(default=False)
179+
change_date = models.DateTimeField(auto_now_add=True)
180+
181+
182+
class WaffleFlagOrgOverrideModel(models.Model):
183+
"""Stub model representing a waffle flag org override for testing purposes."""
184+
185+
org = models.CharField(max_length=64, db_index=True)
186+
waffle_flag = models.CharField(max_length=255, db_index=True, default="")
187+
enabled = models.BooleanField(default=False)
188+
change_date = models.DateTimeField(auto_now_add=True)

openedx_authz/tests/test_handlers.py

Lines changed: 167 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
1-
"""Behavioral tests for the ExtendedCasbinRule deletion signal.
1+
"""Tests for ``openedx_authz.handlers``
22
33
Coverage confirms direct deletions, cascades, bulk operations, and resilience when foreign keys
4-
are missing so that the signal stays aligned with the cleanup guarantees in
5-
``openedx_authz.handlers``.
4+
are missing so that the signal stays aligned with the cleanup guarantees.
5+
6+
Also covers ``trigger_course_authoring_migration`` using stub waffle model classes (Open edX
7+
waffle models are not imported in the test environment).
68
"""
79

10+
from types import SimpleNamespace
811
from unittest.mock import patch
912

1013
from casbin_adapter.models import CasbinRule
11-
from django.test import TestCase
14+
from ddt import data, ddt, unpack
15+
from django.test import TestCase, override_settings
1216

17+
from openedx_authz.handlers import trigger_course_authoring_migration
18+
from openedx_authz.models.authz_migration import MigrationType, ScopeType
1319
from openedx_authz.models.core import ExtendedCasbinRule, Scope, Subject
20+
from openedx_authz.models.subjects import UserSubject
21+
from openedx_authz.tests.stubs.models import (
22+
CourseAccessRole,
23+
WaffleFlagCourseOverrideModel,
24+
WaffleFlagOrgOverrideModel,
25+
)
26+
27+
AUTHZ_COURSE_AUTHORING_FLAG_NAME = "authz.enable_course_authoring"
28+
OTHER_WAFFLE_FLAG_NAME = "some.other.flag"
1429

1530

1631
def create_casbin_rule_with_extended( # pylint: disable=too-many-positional-arguments
@@ -220,3 +235,151 @@ def test_cascade_deletion_with_scope_deletion(self):
220235
self.assertFalse(CasbinRule.objects.filter(id=casbin_rule_id).exists())
221236
self.assertFalse(Scope.objects.filter(id=scope_id).exists())
222237
self.assertTrue(Subject.objects.filter(id=subject_id).exists())
238+
239+
240+
@ddt
241+
@patch("openedx_authz.handlers.run_course_authoring_migration")
242+
@patch.multiple(
243+
"openedx_authz.handlers",
244+
AUTHZ_COURSE_AUTHORING_FLAG=SimpleNamespace(name=AUTHZ_COURSE_AUTHORING_FLAG_NAME),
245+
WaffleFlagCourseOverrideModel=WaffleFlagCourseOverrideModel,
246+
WaffleFlagOrgOverrideModel=WaffleFlagOrgOverrideModel,
247+
CourseAccessRole=CourseAccessRole,
248+
)
249+
class TestTriggerCourseAuthoringMigration(TestCase):
250+
"""
251+
Cover ``trigger_course_authoring_migration`` when Open edX waffle imports are absent.
252+
253+
The class-level ``patch.multiple`` injects stub models and a stand-in flag into
254+
``openedx_authz.handlers`` so ``isinstance`` checks and flag name resolution match production.
255+
Course and org overrides use the stub ORM (creates and queries) where the handler touches the
256+
database. A class-level ``patch`` replaces ``run_course_authoring_migration`` so no full
257+
migration runs; tests that also patch ``logger`` receive that mock before ``mock_run``.
258+
"""
259+
260+
@override_settings(ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION=False)
261+
def test_skips_when_automatic_migration_setting_disabled(self, mock_run):
262+
"""When the setting is off, the handler returns before scheduling work."""
263+
instance = WaffleFlagCourseOverrideModel(
264+
course_id="course-v1:org+course+run",
265+
waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG_NAME,
266+
enabled=True,
267+
)
268+
269+
trigger_course_authoring_migration(
270+
sender=WaffleFlagCourseOverrideModel,
271+
instance=instance,
272+
scope_key=str(instance.course_id),
273+
)
274+
275+
mock_run.assert_not_called()
276+
277+
@data(
278+
(
279+
WaffleFlagCourseOverrideModel,
280+
{
281+
"course_id": "course-v1:org+course+run_mm",
282+
"waffle_flag": OTHER_WAFFLE_FLAG_NAME,
283+
"enabled": True,
284+
},
285+
"course-v1:org+course+run_mm",
286+
),
287+
(
288+
WaffleFlagOrgOverrideModel,
289+
{
290+
"org": "test_org_waffle_mm",
291+
"waffle_flag": OTHER_WAFFLE_FLAG_NAME,
292+
"enabled": True,
293+
},
294+
"test_org_waffle_mm",
295+
),
296+
)
297+
@unpack
298+
def test_skips_when_waffle_flag_name_mismatch(self, sender_model, instance_kwargs, scope_key, mock_run):
299+
"""Only the authz course authoring flag triggers migration (course and org overrides)."""
300+
instance = sender_model(**instance_kwargs)
301+
302+
trigger_course_authoring_migration(sender_model, instance, scope_key)
303+
304+
mock_run.assert_not_called()
305+
306+
@patch("openedx_authz.handlers.logger")
307+
def test_logs_error_for_unsupported_instance_type(self, mock_logger, mock_run):
308+
"""Instances that are neither course nor org overrides are rejected."""
309+
unsupported = SimpleNamespace(waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG_NAME, enabled=True, id=9)
310+
311+
trigger_course_authoring_migration(WaffleFlagCourseOverrideModel, unsupported, "ignored")
312+
313+
mock_run.assert_not_called()
314+
mock_logger.error.assert_called_once()
315+
self.assertIn("Unsupported waffle flag instance", mock_logger.error.call_args[0][0])
316+
317+
@data(
318+
(True, MigrationType.FORWARD),
319+
(False, MigrationType.ROLLBACK),
320+
)
321+
@unpack
322+
def test_course_scope_migration_depends_on_enabled(self, enabled, expected_migration_type, mock_run):
323+
"""Course override runs forward migration when enabled and rollback when disabled."""
324+
course_key = f"course-v1:test_org+handlers_course+{'on' if enabled else 'off'}"
325+
instance = WaffleFlagCourseOverrideModel.objects.create(
326+
course_id=course_key,
327+
waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG_NAME,
328+
enabled=enabled,
329+
)
330+
331+
trigger_course_authoring_migration(WaffleFlagCourseOverrideModel, instance, course_key)
332+
333+
mock_run.assert_called_once_with(
334+
migration_type=expected_migration_type,
335+
scope_type=ScopeType.COURSE,
336+
scope_key=course_key,
337+
course_access_role_model=CourseAccessRole,
338+
user_subject_model=UserSubject,
339+
course_id_list=[course_key],
340+
org_id=None,
341+
)
342+
343+
@data(
344+
(True, MigrationType.FORWARD),
345+
(False, MigrationType.ROLLBACK),
346+
)
347+
@unpack
348+
def test_org_scope_migration_depends_on_enabled(self, enabled, expected_migration_type, mock_run):
349+
"""Org override runs forward migration when enabled and rollback when disabled."""
350+
org_name = f"test_org_handlers_{enabled}"
351+
instance = WaffleFlagOrgOverrideModel.objects.create(
352+
org=org_name,
353+
waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG_NAME,
354+
enabled=enabled,
355+
)
356+
357+
trigger_course_authoring_migration(WaffleFlagOrgOverrideModel, instance, org_name)
358+
359+
mock_run.assert_called_once_with(
360+
migration_type=expected_migration_type,
361+
scope_type=ScopeType.ORG,
362+
scope_key=org_name,
363+
course_access_role_model=CourseAccessRole,
364+
user_subject_model=UserSubject,
365+
course_id_list=None,
366+
org_id=org_name,
367+
)
368+
369+
def test_skips_when_previous_record_has_same_enabled_state(self, mock_run):
370+
"""Repeated saves with the same enabled value do not trigger migration."""
371+
course_id = "course-v1:test_org+tcam_noop+2024"
372+
WaffleFlagCourseOverrideModel.objects.create(
373+
course_id=course_id,
374+
waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG_NAME,
375+
enabled=True,
376+
)
377+
instance = WaffleFlagCourseOverrideModel.objects.create(
378+
course_id=course_id,
379+
waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG_NAME,
380+
enabled=True,
381+
)
382+
383+
trigger_course_authoring_migration(WaffleFlagCourseOverrideModel, instance, course_id)
384+
385+
mock_run.assert_not_called()

0 commit comments

Comments
 (0)