@@ -130,6 +130,26 @@ def test_retrieve_matches_product_key(self, mock_all_prices):
130130 self .assertEqual (response .data ['name' ], 'AI Academy' )
131131 self .assertEqual (response .data ['stripe_product_id' ], 'prod_ai' )
132132
133+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
134+ def test_retrieve_matches_uuid (self , mock_all_prices ):
135+ mock_all_prices .return_value = self .prices_by_lookup_key
136+
137+ url = reverse ('api:v1:academy-products-detail' , kwargs = {'pk' : str (self .ai_academy .uuid )})
138+ response = self .client .get (url )
139+
140+ self .assertEqual (response .status_code , status .HTTP_200_OK )
141+ self .assertEqual (response .data ['name' ], 'AI Academy' )
142+
143+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
144+ def test_retrieve_matches_stripe_product_id (self , mock_all_prices ):
145+ mock_all_prices .return_value = self .prices_by_lookup_key
146+
147+ url = reverse ('api:v1:academy-products-detail' , kwargs = {'pk' : 'prod_ai' })
148+ response = self .client .get (url )
149+
150+ self .assertEqual (response .status_code , status .HTTP_200_OK )
151+ self .assertEqual (response .data ['name' ], 'AI Academy' )
152+
133153 @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
134154 def test_retrieve_finds_inactive_academy_by_slug (self , mock_all_prices ):
135155 mock_all_prices .return_value = self .prices_by_lookup_key
@@ -286,6 +306,48 @@ def test_list_can_include_inactive(self, mock_all_prices):
286306 self .assertEqual (response .status_code , status .HTTP_200_OK )
287307 self .assertEqual (response .data ['count' ], 2 )
288308
309+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
310+ def test_list_filters_by_product_key (self , mock_all_prices ):
311+ mock_all_prices .return_value = self .prices_by_lookup_key
312+
313+ url = reverse ('api:v1:academy-products-list' )
314+ response = self .client .get (url , {'product_key' : 'academy_ai' })
315+
316+ self .assertEqual (response .status_code , status .HTTP_200_OK )
317+ self .assertEqual (response .data ['count' ], 1 )
318+ self .assertEqual (response .data ['results' ][0 ]['name' ], 'AI Academy' )
319+
320+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
321+ def test_list_filters_by_valid_academy_uuid (self , mock_all_prices ):
322+ mock_all_prices .return_value = self .prices_by_lookup_key
323+
324+ url = reverse ('api:v1:academy-products-list' )
325+ response = self .client .get (url , {'academy_uuid' : str (self .ai_academy .uuid )})
326+
327+ self .assertEqual (response .status_code , status .HTTP_200_OK )
328+ self .assertEqual (response .data ['count' ], 1 )
329+ self .assertEqual (response .data ['results' ][0 ]['name' ], 'AI Academy' )
330+
331+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
332+ def test_list_returns_empty_payload_when_no_active_academies (self , mock_all_prices ):
333+ mock_all_prices .return_value = self .prices_by_lookup_key
334+ self .ai_academy .is_active = False
335+ self .ai_academy .save (update_fields = ['is_active' ])
336+
337+ url = reverse ('api:v1:academy-products-list' )
338+ response = self .client .get (url )
339+
340+ self .assertEqual (response .status_code , status .HTTP_200_OK )
341+ self .assertEqual (response .data ['count' ], 0 )
342+ self .assertEqual (response .data ['results' ], [])
343+
344+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
345+ def test_helper_safe_get_all_stripe_prices_handles_stripe_error (self , mock_all_prices ):
346+ mock_all_prices .side_effect = stripe .error .APIError ('stripe api down' )
347+ view = AcademyProductsViewSet ()
348+
349+ self .assertEqual (view ._safe_get_all_stripe_prices (), {})
350+
289351 @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.get_all_stripe_prices' )
290352 def test_list_returns_502_if_price_lookup_fails (self , mock_all_prices ):
291353 mock_all_prices .side_effect = RuntimeError ('pricing unavailable' )
@@ -392,6 +454,26 @@ def test_build_normalized_academy_payload_applies_mapping_and_overrides(self):
392454 self .assertEqual (payload ['stripe_product_id' ], 'prod_override' )
393455 self .assertTrue (payload ['stripe_price_lookup_key' ])
394456
457+ @override_settings (
458+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_ACADEMY_UUID = {'abc' : '456' },
459+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_NAME = {'Data Academy' : '457' },
460+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_NAME = {
461+ 'Data Academy' : {
462+ 'catalog_query_id' : '789' ,
463+ }
464+ },
465+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_ACADEMY_UUID = {
466+ 'abc' : {
467+ 'catalog_query_id' : '999' ,
468+ }
469+ },
470+ )
471+ def test_build_normalized_academy_payload_prefers_uuid_override_over_name_override (self ):
472+ view = AcademyProductsViewSet ()
473+ payload = view ._build_normalized_academy_payload ({'uuid' : 'abc' , 'name' : 'Data Academy' }, 123 )
474+
475+ self .assertEqual (payload ['catalog_query_id' ], 999 )
476+
395477 def test_extract_results_helper_variants (self ):
396478 view = AcademyProductsViewSet ()
397479
@@ -413,6 +495,190 @@ def test_sync_essential_academies_skips_catalog_lookup_without_enterprise_custom
413495
414496 mock_client_instance .get_catalogs .assert_not_called ()
415497
498+ @override_settings (
499+ ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID = 'customer-uuid' ,
500+ ESSENTIAL_ACADEMY_UUID_ALLOWLIST = ['keep-uuid' ],
501+ ESSENTIAL_ACADEMY_NAME_ALLOWLIST = [],
502+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_ACADEMY_UUID = {},
503+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_NAME = {},
504+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_NAME = {},
505+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_ACADEMY_UUID = {},
506+ )
507+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseCatalogApiV1Client' )
508+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseAcademy.objects' )
509+ def test_sync_essential_academies_filters_by_uuid_allowlist (self , mock_academy_objects , mock_catalog_client_cls ):
510+ view = AcademyProductsViewSet ()
511+
512+ mock_client_instance = mock .Mock ()
513+ mock_client_instance .get_academies .return_value = {
514+ 'results' : [
515+ {'uuid' : 'keep-uuid' , 'name' : 'Allowed Academy' },
516+ {'uuid' : 'drop-uuid' , 'name' : 'Filtered Academy' },
517+ ]
518+ }
519+ mock_client_instance .get_catalogs .return_value = {'results' : []}
520+ mock_catalog_client_cls .return_value = mock_client_instance
521+
522+ filtered_by_name_queryset = mock .Mock ()
523+ filtered_by_name_queryset .first .return_value = None
524+ active_queryset = mock .Mock ()
525+ active_queryset .exclude .return_value .update .return_value = 0
526+
527+ def filter_side_effect (* args , ** kwargs ):
528+ if kwargs .get ('name__iexact' ):
529+ return filtered_by_name_queryset
530+ if kwargs .get ('is_active' ) is True :
531+ return active_queryset
532+ return mock .Mock ()
533+
534+ mock_academy_objects .filter .side_effect = filter_side_effect
535+
536+ view ._sync_essential_academies ()
537+
538+ created_payload = mock_academy_objects .create .call_args .kwargs
539+ self .assertEqual (created_payload ['name' ], 'Allowed Academy' )
540+ active_queryset .exclude .assert_called_once_with (name__in = {'Allowed Academy' })
541+
542+ @override_settings (
543+ ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID = 'customer-uuid' ,
544+ ESSENTIAL_ACADEMY_UUID_ALLOWLIST = [],
545+ ESSENTIAL_ACADEMY_NAME_ALLOWLIST = [],
546+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_ACADEMY_UUID = {},
547+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_NAME = {},
548+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_NAME = {},
549+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_ACADEMY_UUID = {},
550+ )
551+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseCatalogApiV1Client' )
552+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseAcademy.objects' )
553+ def test_sync_essential_academies_passes_default_catalog_query_id_to_builder (
554+ self ,
555+ mock_academy_objects ,
556+ mock_catalog_client_cls ,
557+ ):
558+ view = AcademyProductsViewSet ()
559+
560+ mock_client_instance = mock .Mock ()
561+ mock_client_instance .get_academies .return_value = {'results' : [{'uuid' : 'u-1' , 'name' : 'Data Academy' }]}
562+ mock_client_instance .get_catalogs .return_value = {
563+ 'results' : [
564+ {'catalog_query_id' : 'invalid' },
565+ {'catalog_query_id' : '777' },
566+ ]
567+ }
568+ mock_catalog_client_cls .return_value = mock_client_instance
569+
570+ filtered_by_name_queryset = mock .Mock ()
571+ filtered_by_name_queryset .first .return_value = None
572+ active_queryset = mock .Mock ()
573+ active_queryset .exclude .return_value .update .return_value = 0
574+
575+ def filter_side_effect (* args , ** kwargs ):
576+ if kwargs .get ('name__iexact' ):
577+ return filtered_by_name_queryset
578+ if kwargs .get ('is_active' ) is True :
579+ return active_queryset
580+ return mock .Mock ()
581+
582+ mock_academy_objects .filter .side_effect = filter_side_effect
583+
584+ with mock .patch .object (
585+ view ,
586+ '_build_normalized_academy_payload' ,
587+ wraps = view ._build_normalized_academy_payload ,
588+ ) as wrapped_builder :
589+ view ._sync_essential_academies ()
590+
591+ wrapped_builder .assert_called_once_with ({'uuid' : 'u-1' , 'name' : 'Data Academy' }, 777 )
592+
593+ @override_settings (
594+ ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID = 'customer-uuid' ,
595+ ESSENTIAL_ACADEMY_UUID_ALLOWLIST = [],
596+ ESSENTIAL_ACADEMY_NAME_ALLOWLIST = [],
597+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_ACADEMY_UUID = {},
598+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_NAME = {},
599+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_NAME = {},
600+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_ACADEMY_UUID = {},
601+ )
602+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseCatalogApiV1Client' )
603+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseAcademy.objects' )
604+ def test_sync_essential_academies_skips_deactivation_when_no_valid_normalized_items (
605+ self ,
606+ mock_academy_objects ,
607+ mock_catalog_client_cls ,
608+ ):
609+ view = AcademyProductsViewSet ()
610+
611+ mock_client_instance = mock .Mock ()
612+ mock_client_instance .get_academies .return_value = {'results' : [{'uuid' : 'u-1' }]}
613+ mock_client_instance .get_catalogs .return_value = {'results' : []}
614+ mock_catalog_client_cls .return_value = mock_client_instance
615+
616+ view ._sync_essential_academies ()
617+
618+ mock_academy_objects .create .assert_not_called ()
619+ mock_academy_objects .filter .assert_not_called ()
620+
621+ @override_settings (
622+ ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID = 'customer-uuid' ,
623+ ESSENTIAL_ACADEMY_UUID_ALLOWLIST = [],
624+ ESSENTIAL_ACADEMY_NAME_ALLOWLIST = [],
625+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_ACADEMY_UUID = {},
626+ ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_NAME = {},
627+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_NAME = {},
628+ ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_ACADEMY_UUID = {},
629+ )
630+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseCatalogApiV1Client' )
631+ @mock .patch ('enterprise_access.apps.api.v1.views.customer_billing.EnterpriseAcademy.objects' )
632+ def test_sync_essential_academies_does_not_save_when_existing_record_unchanged (
633+ self ,
634+ mock_academy_objects ,
635+ mock_catalog_client_cls ,
636+ ):
637+ view = AcademyProductsViewSet ()
638+
639+ mock_client_instance = mock .Mock ()
640+ mock_client_instance .get_academies .return_value = {
641+ 'results' : [{'uuid' : 'u-1' , 'name' : 'Data Academy' , 'catalog_query_id' : '200' }]
642+ }
643+ mock_client_instance .get_catalogs .return_value = {'results' : []}
644+ mock_catalog_client_cls .return_value = mock_client_instance
645+
646+ existing = SimpleNamespace (
647+ name = 'Data Academy' ,
648+ long_name = 'Data Academy' ,
649+ description = '' ,
650+ marketing_url = '' ,
651+ thumbnail_url = '' ,
652+ tags = [],
653+ stripe_product_id = '' ,
654+ stripe_price_lookup_key = 'data-academy-u-1-price' ,
655+ catalog_query_id = 111 ,
656+ product_key = 'data-academy' ,
657+ slug = 'data-academy' ,
658+ is_active = True ,
659+ display_order = 0 ,
660+ save = mock .Mock (),
661+ )
662+
663+ filtered_by_name_queryset = mock .Mock ()
664+ filtered_by_name_queryset .first .return_value = existing
665+ active_queryset = mock .Mock ()
666+ active_queryset .exclude .return_value .update .return_value = 0
667+
668+ def filter_side_effect (* args , ** kwargs ):
669+ if kwargs .get ('name__iexact' ):
670+ return filtered_by_name_queryset
671+ if kwargs .get ('is_active' ) is True :
672+ return active_queryset
673+ return mock .Mock ()
674+
675+ mock_academy_objects .filter .side_effect = filter_side_effect
676+
677+ view ._sync_essential_academies ()
678+
679+ mock_academy_objects .create .assert_not_called ()
680+ existing .save .assert_not_called ()
681+
416682 @override_settings (
417683 ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID = 'customer-uuid' ,
418684 ESSENTIAL_ACADEMY_UUID_ALLOWLIST = [],
0 commit comments