|
3 | 3 | This test suite verifies the functionality of the authorization models including: |
4 | 4 | - ExtendedCasbinRule model with metadata and relationships |
5 | 5 | - Cascade deletion behavior across model hierarchies |
| 6 | +- AuthzCourseAuthoringMigrationRun (migration tracking and uniqueness on RUNNING inserts) |
6 | 7 |
|
7 | 8 | These tests use the stub ContentLibrary model from openedx_authz.tests.stubs.models |
8 | 9 | instead of the real ContentLibrary model, allowing them to run without the full |
|
18 | 19 |
|
19 | 20 | from casbin_adapter.models import CasbinRule |
20 | 21 | from django.contrib.auth import get_user_model |
| 22 | +from django.db import IntegrityError |
21 | 23 | from django.test import TestCase |
22 | 24 | from opaque_keys.edx.keys import CourseKey |
23 | 25 | from opaque_keys.edx.locator import LibraryLocatorV2 |
24 | 26 |
|
25 | 27 | from openedx_authz.api.data import ContentLibraryData, CourseOverviewData, UserData |
26 | 28 | from openedx_authz.models import ExtendedCasbinRule, Scope, Subject |
| 29 | +from openedx_authz.models.authz_migration import ( |
| 30 | + AuthzCourseAuthoringMigrationRun, |
| 31 | + MigrationType, |
| 32 | + ScopeType, |
| 33 | + Status, |
| 34 | +) |
27 | 35 | from openedx_authz.models.engine import PolicyCacheControl |
28 | 36 | from openedx_authz.tests.stubs.models import ContentLibrary, CourseOverview |
29 | 37 |
|
@@ -311,3 +319,114 @@ def test_singleton_behavior(self): |
311 | 319 | instance1.save() |
312 | 320 | all_instances = PolicyCacheControl.objects.all() |
313 | 321 | self.assertEqual(all_instances.count(), 1) |
| 322 | + |
| 323 | + |
| 324 | +class TestAuthzCourseAuthoringMigrationRun(TestCase): |
| 325 | + """Tests for ``AuthzCourseAuthoringMigrationRun`` lifecycle and uniqueness rules.""" |
| 326 | + |
| 327 | + def setUp(self): |
| 328 | + self.scope_key = "course-v1:TestOrg+AuthzMigrationRun+2024" |
| 329 | + |
| 330 | + def test_create_running(self): |
| 331 | + """``create_running`` stores RUNNING and optional metadata.""" |
| 332 | + meta = {"batch": 1} |
| 333 | + |
| 334 | + run = AuthzCourseAuthoringMigrationRun.create_running( |
| 335 | + MigrationType.FORWARD, ScopeType.COURSE, self.scope_key, metadata=meta |
| 336 | + ) |
| 337 | + |
| 338 | + run.refresh_from_db() |
| 339 | + self.assertEqual(run.migration_type, MigrationType.FORWARD) |
| 340 | + self.assertEqual(run.scope_type, ScopeType.COURSE) |
| 341 | + self.assertEqual(run.scope_key, self.scope_key) |
| 342 | + self.assertEqual(run.status, Status.RUNNING) |
| 343 | + self.assertEqual(run.metadata, meta) |
| 344 | + |
| 345 | + def test_create_skipped_merges_skip_reason(self): |
| 346 | + """``create_skipped`` records SKIPPED and documents why.""" |
| 347 | + run = AuthzCourseAuthoringMigrationRun.create_skipped( |
| 348 | + MigrationType.ROLLBACK, ScopeType.ORG, "test_org", metadata={"note": "extra"} |
| 349 | + ) |
| 350 | + |
| 351 | + self.assertEqual(run.status, Status.SKIPPED) |
| 352 | + self.assertEqual(run.metadata["note"], "extra") |
| 353 | + self.assertIn("skip_reason", run.metadata) |
| 354 | + |
| 355 | + def test_str_representation(self): |
| 356 | + """``__str__`` includes id, migration type, scope, and status.""" |
| 357 | + run = AuthzCourseAuthoringMigrationRun.create_running(MigrationType.FORWARD, ScopeType.COURSE, self.scope_key) |
| 358 | + |
| 359 | + text = str(run) |
| 360 | + self.assertIn(str(run.id), text) |
| 361 | + self.assertIn(MigrationType.FORWARD.value, text) |
| 362 | + self.assertIn(self.scope_key, text) |
| 363 | + self.assertIn(Status.RUNNING.value, text) |
| 364 | + |
| 365 | + def test_second_running_insert_same_scope_raises_integrity_error(self): |
| 366 | + """At most one RUNNING row per (scope_type, scope_key) on insert.""" |
| 367 | + AuthzCourseAuthoringMigrationRun.create_running(MigrationType.FORWARD, ScopeType.COURSE, self.scope_key) |
| 368 | + |
| 369 | + with self.assertRaises(IntegrityError): |
| 370 | + AuthzCourseAuthoringMigrationRun.create_running(MigrationType.FORWARD, ScopeType.COURSE, self.scope_key) |
| 371 | + |
| 372 | + def test_running_allowed_after_previous_run_finished(self): |
| 373 | + """A new RUNNING row is allowed once the prior run for that scope is no longer RUNNING.""" |
| 374 | + first = AuthzCourseAuthoringMigrationRun.create_running(MigrationType.FORWARD, ScopeType.COURSE, self.scope_key) |
| 375 | + first.mark_completed() |
| 376 | + |
| 377 | + second = AuthzCourseAuthoringMigrationRun.create_running( |
| 378 | + MigrationType.FORWARD, ScopeType.COURSE, self.scope_key |
| 379 | + ) |
| 380 | + |
| 381 | + self.assertEqual(second.status, Status.RUNNING) |
| 382 | + self.assertNotEqual(first.pk, second.pk) |
| 383 | + |
| 384 | + def test_distinct_scope_type_allows_parallel_running_same_key_string(self): |
| 385 | + """RUNNING uniqueness is per (scope_type, scope_key), not key alone.""" |
| 386 | + key = "same-key-string" |
| 387 | + |
| 388 | + AuthzCourseAuthoringMigrationRun.create_running(MigrationType.FORWARD, ScopeType.COURSE, key) |
| 389 | + other = AuthzCourseAuthoringMigrationRun.create_running(MigrationType.FORWARD, ScopeType.ORG, key) |
| 390 | + |
| 391 | + self.assertEqual(other.status, Status.RUNNING) |
| 392 | + |
| 393 | + def test_mark_completed_and_partial_success_merge_metadata(self): |
| 394 | + """Finalizers set status, ``completed_at``, and merge metadata.""" |
| 395 | + run = AuthzCourseAuthoringMigrationRun.create_running( |
| 396 | + MigrationType.FORWARD, ScopeType.COURSE, self.scope_key, metadata={"a": 1} |
| 397 | + ) |
| 398 | + run.mark_completed(metadata_updates={"success_count": 3}) |
| 399 | + |
| 400 | + run.refresh_from_db() |
| 401 | + self.assertEqual(run.status, Status.COMPLETED) |
| 402 | + self.assertIsNotNone(run.completed_at) |
| 403 | + self.assertEqual(run.metadata["a"], 1) |
| 404 | + self.assertEqual(run.metadata["success_count"], 3) |
| 405 | + |
| 406 | + run2 = AuthzCourseAuthoringMigrationRun.create_running(MigrationType.ROLLBACK, ScopeType.ORG, "org_partial") |
| 407 | + run2.mark_partial_success(metadata_updates={"error_count": 2}) |
| 408 | + |
| 409 | + run2.refresh_from_db() |
| 410 | + self.assertEqual(run2.status, Status.PARTIAL_SUCCESS) |
| 411 | + self.assertEqual(run2.metadata["error_count"], 2) |
| 412 | + |
| 413 | + def test_mark_failed_with_and_without_exception(self): |
| 414 | + """``mark_failed`` records FAILED, optional exception string is stored when provided.""" |
| 415 | + run = AuthzCourseAuthoringMigrationRun.create_running( |
| 416 | + MigrationType.FORWARD, ScopeType.COURSE, f"{self.scope_key}_fail" |
| 417 | + ) |
| 418 | + run.mark_failed(exception=ValueError("boom")) |
| 419 | + |
| 420 | + run.refresh_from_db() |
| 421 | + self.assertEqual(run.status, Status.FAILED) |
| 422 | + self.assertEqual(run.metadata["error"], "boom") |
| 423 | + |
| 424 | + run2 = AuthzCourseAuthoringMigrationRun.create_running( |
| 425 | + MigrationType.FORWARD, ScopeType.COURSE, f"{self.scope_key}_fail2" |
| 426 | + ) |
| 427 | + prior_meta = dict(run2.metadata) |
| 428 | + run2.mark_failed() |
| 429 | + |
| 430 | + run2.refresh_from_db() |
| 431 | + self.assertEqual(run2.status, Status.FAILED) |
| 432 | + self.assertEqual(run2.metadata, prior_meta) |
0 commit comments