|
13 | 13 | from rest_framework import status |
14 | 14 | from rest_framework.reverse import reverse |
15 | 15 |
|
| 16 | +from enterprise_access.apps.api.serializers import LearnerCreditRequestDeclineSerializer |
16 | 17 | from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices |
17 | 18 | from enterprise_access.apps.content_assignments.models import LearnerContentAssignment |
18 | 19 | from enterprise_access.apps.content_assignments.tests.factories import ( |
@@ -2145,6 +2146,258 @@ def test_decline_reason_saved(self, mock_get_enterprise_uuid): |
2145 | 2146 | assert self.user_request_1.reviewer == self.user |
2146 | 2147 | assert self.user_request_1.decline_reason == 'Request outside program scope' |
2147 | 2148 |
|
| 2149 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2150 | + def test_decline_with_uuid_list_success(self, mock_get_enterprise_uuid): |
| 2151 | + """ |
| 2152 | + Decline accepts a list of UUIDs (``subsidy_request_uuids``) mirroring approve. |
| 2153 | + """ |
| 2154 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2155 | + self.set_jwt_cookie([{ |
| 2156 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2157 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2158 | + }]) |
| 2159 | + |
| 2160 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2161 | + uuids = [str(self.user_request_1.uuid), str(self.enterprise_request.uuid)] |
| 2162 | + data = { |
| 2163 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2164 | + 'subsidy_request_uuids': uuids, |
| 2165 | + 'policy_uuid': str(self.policy.uuid), |
| 2166 | + } |
| 2167 | + |
| 2168 | + response = self.client.post(url, data) |
| 2169 | + |
| 2170 | + assert response.status_code == status.HTTP_200_OK |
| 2171 | + assert set(response.json()['declined']) == set(uuids) |
| 2172 | + |
| 2173 | + for request_obj in (self.user_request_1, self.enterprise_request): |
| 2174 | + request_obj.refresh_from_db() |
| 2175 | + assert request_obj.state == SubsidyRequestStates.DECLINED |
| 2176 | + assert request_obj.reviewer == self.user |
| 2177 | + |
| 2178 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2179 | + def test_decline_requires_uuid_or_uuids(self, mock_get_enterprise_uuid): |
| 2180 | + """ |
| 2181 | + Decline returns 400 when neither ``subsidy_request_uuid`` nor ``subsidy_request_uuids`` is provided. |
| 2182 | + """ |
| 2183 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2184 | + self.set_jwt_cookie([{ |
| 2185 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2186 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2187 | + }]) |
| 2188 | + |
| 2189 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2190 | + response = self.client.post(url, {'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1)}) |
| 2191 | + |
| 2192 | + assert response.status_code == status.HTTP_400_BAD_REQUEST |
| 2193 | + assert 'Must provide subsidy_request_uuid' in str(response.data) |
| 2194 | + |
| 2195 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2196 | + def test_decline_rejects_both_uuid_and_uuids(self, mock_get_enterprise_uuid): |
| 2197 | + """ |
| 2198 | + Decline returns 400 when both single and list payloads are provided. |
| 2199 | + """ |
| 2200 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2201 | + self.set_jwt_cookie([{ |
| 2202 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2203 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2204 | + }]) |
| 2205 | + |
| 2206 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2207 | + data = { |
| 2208 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2209 | + 'subsidy_request_uuid': str(self.user_request_1.uuid), |
| 2210 | + 'subsidy_request_uuids': [str(self.enterprise_request.uuid)], |
| 2211 | + } |
| 2212 | + response = self.client.post(url, data) |
| 2213 | + |
| 2214 | + assert response.status_code == status.HTTP_400_BAD_REQUEST |
| 2215 | + assert 'not both' in str(response.data) |
| 2216 | + |
| 2217 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2218 | + def test_decline_uuid_list_with_non_declinable_request(self, mock_get_enterprise_uuid): |
| 2219 | + """ |
| 2220 | + Decline returns 400 when any UUID in the list points at a non-declinable request. |
| 2221 | + """ |
| 2222 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2223 | + self.set_jwt_cookie([{ |
| 2224 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2225 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2226 | + }]) |
| 2227 | + |
| 2228 | + self.enterprise_request.state = SubsidyRequestStates.APPROVED |
| 2229 | + self.enterprise_request.save() |
| 2230 | + |
| 2231 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2232 | + data = { |
| 2233 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2234 | + 'subsidy_request_uuids': [ |
| 2235 | + str(self.user_request_1.uuid), |
| 2236 | + str(self.enterprise_request.uuid), |
| 2237 | + ], |
| 2238 | + } |
| 2239 | + response = self.client.post(url, data) |
| 2240 | + |
| 2241 | + assert response.status_code == status.HTTP_400_BAD_REQUEST |
| 2242 | + assert 'cannot be declined' in str(response.data) |
| 2243 | + |
| 2244 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2245 | + def test_decline_uuid_list_with_unknown_uuid(self, mock_get_enterprise_uuid): |
| 2246 | + """ |
| 2247 | + Decline returns 400 when any UUID in the list does not exist. |
| 2248 | + """ |
| 2249 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2250 | + self.set_jwt_cookie([{ |
| 2251 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2252 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2253 | + }]) |
| 2254 | + |
| 2255 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2256 | + data = { |
| 2257 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2258 | + 'subsidy_request_uuids': [str(self.user_request_1.uuid), str(uuid4())], |
| 2259 | + } |
| 2260 | + response = self.client.post(url, data) |
| 2261 | + |
| 2262 | + assert response.status_code == status.HTTP_400_BAD_REQUEST |
| 2263 | + assert 'not found' in str(response.data) |
| 2264 | + |
| 2265 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2266 | + @mock.patch(BNR_VIEW_PATH + '.unlink_users_from_enterprise_task.delay') |
| 2267 | + def test_decline_uuid_list_with_disassociate_from_org(self, mock_unlink_task, mock_get_enterprise_uuid): |
| 2268 | + """ |
| 2269 | + Decline with ``disassociate_from_org=True`` unlinks each declined learner. |
| 2270 | + """ |
| 2271 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2272 | + self.set_jwt_cookie([{ |
| 2273 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2274 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2275 | + }]) |
| 2276 | + |
| 2277 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2278 | + data = { |
| 2279 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2280 | + 'subsidy_request_uuids': [ |
| 2281 | + str(self.user_request_1.uuid), |
| 2282 | + str(self.enterprise_request.uuid), |
| 2283 | + ], |
| 2284 | + 'disassociate_from_org': True, |
| 2285 | + } |
| 2286 | + response = self.client.post(url, data) |
| 2287 | + |
| 2288 | + assert response.status_code == status.HTTP_200_OK |
| 2289 | + mock_unlink_task.assert_called_once() |
| 2290 | + call_args = mock_unlink_task.call_args.args |
| 2291 | + assert call_args[0] == str(self.enterprise_customer_uuid_1) |
| 2292 | + assert set(call_args[1]) == { |
| 2293 | + self.user_request_1.user.lms_user_id, |
| 2294 | + self.enterprise_request.user.lms_user_id, |
| 2295 | + } |
| 2296 | + |
| 2297 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2298 | + def test_decline_send_notification_true_logs_per_declined_request(self, mock_get_enterprise_uuid): |
| 2299 | + """ |
| 2300 | + Decline with ``send_notification=True`` emits a per-request log line for each |
| 2301 | + declined request (one logger.info call per UUID in the input list). |
| 2302 | + """ |
| 2303 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2304 | + self.set_jwt_cookie([{ |
| 2305 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2306 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2307 | + }]) |
| 2308 | + |
| 2309 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2310 | + uuids = [str(self.user_request_1.uuid), str(self.enterprise_request.uuid)] |
| 2311 | + data = { |
| 2312 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2313 | + 'subsidy_request_uuids': uuids, |
| 2314 | + 'send_notification': True, |
| 2315 | + } |
| 2316 | + |
| 2317 | + with self.assertLogs(BNR_VIEW_PATH, level='INFO') as captured: |
| 2318 | + response = self.client.post(url, data) |
| 2319 | + |
| 2320 | + assert response.status_code == status.HTTP_200_OK |
| 2321 | + assert set(response.json()['declined']) == set(uuids) |
| 2322 | + |
| 2323 | + per_request_log_matches = [ |
| 2324 | + record for record in captured.output |
| 2325 | + if 'Decline notification already queued by api layer for request' in record |
| 2326 | + ] |
| 2327 | + assert len(per_request_log_matches) == 2 |
| 2328 | + for uuid_value in uuids: |
| 2329 | + assert any(uuid_value in record for record in per_request_log_matches) |
| 2330 | + |
| 2331 | + @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
| 2332 | + @mock.patch(BNR_VIEW_PATH + '.subsidy_request_api.decline_learner_credit_requests') |
| 2333 | + def test_decline_returns_422_when_api_reports_non_declinable( |
| 2334 | + self, mock_decline_api, mock_get_enterprise_uuid, |
| 2335 | + ): |
| 2336 | + """ |
| 2337 | + Decline returns 422 if the backend API reports any request as non-declinable |
| 2338 | + (e.g. a race where state changes between serializer validation and the |
| 2339 | + API call). The response surfaces both declined and non_declinable lists. |
| 2340 | + """ |
| 2341 | + mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1) |
| 2342 | + self.set_jwt_cookie([{ |
| 2343 | + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, |
| 2344 | + 'context': str(self.enterprise_customer_uuid_1) |
| 2345 | + }]) |
| 2346 | + |
| 2347 | + mock_decline_api.return_value = { |
| 2348 | + 'declined': [self.user_request_1], |
| 2349 | + 'non_declinable': [self.enterprise_request], |
| 2350 | + } |
| 2351 | + |
| 2352 | + url = reverse('api:v1:learner-credit-requests-decline') |
| 2353 | + data = { |
| 2354 | + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), |
| 2355 | + 'subsidy_request_uuids': [ |
| 2356 | + str(self.user_request_1.uuid), |
| 2357 | + str(self.enterprise_request.uuid), |
| 2358 | + ], |
| 2359 | + } |
| 2360 | + response = self.client.post(url, data) |
| 2361 | + |
| 2362 | + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY |
| 2363 | + body = response.json() |
| 2364 | + assert body['declined'] == [str(self.user_request_1.uuid)] |
| 2365 | + assert body['non_declinable'] == [str(self.enterprise_request.uuid)] |
| 2366 | + |
| 2367 | + def test_decline_serializer_legacy_accessor_returns_first_or_none(self): |
| 2368 | + """ |
| 2369 | + ``LearnerCreditRequestDeclineSerializer.get_learner_credit_request`` (legacy |
| 2370 | + singular accessor) returns ``None`` before validation and the first validated |
| 2371 | + request after a successful ``is_valid()``. |
| 2372 | + """ |
| 2373 | + serializer = LearnerCreditRequestDeclineSerializer( |
| 2374 | + data={'subsidy_request_uuid': str(self.user_request_1.uuid)}, |
| 2375 | + ) |
| 2376 | + # Before validation: no underlying requests have been resolved. |
| 2377 | + assert serializer.get_learner_credit_request() is None |
| 2378 | + assert serializer.get_learner_credit_requests() == [] |
| 2379 | + |
| 2380 | + assert serializer.is_valid(), serializer.errors |
| 2381 | + first = serializer.get_learner_credit_request() |
| 2382 | + assert first is not None |
| 2383 | + assert first.uuid == self.user_request_1.uuid |
| 2384 | + |
| 2385 | + def test_decline_serializer_create_and_update_raise_not_implemented(self): |
| 2386 | + """ |
| 2387 | + ``LearnerCreditRequestDeclineSerializer`` is validation-only; ``create`` and |
| 2388 | + ``update`` must raise ``NotImplementedError`` so callers cannot misuse it as |
| 2389 | + a ModelSerializer. |
| 2390 | + """ |
| 2391 | + serializer = LearnerCreditRequestDeclineSerializer( |
| 2392 | + data={'subsidy_request_uuid': str(self.user_request_1.uuid)}, |
| 2393 | + ) |
| 2394 | + assert serializer.is_valid(), serializer.errors |
| 2395 | + |
| 2396 | + with self.assertRaises(NotImplementedError): |
| 2397 | + serializer.create(serializer.validated_data) |
| 2398 | + with self.assertRaises(NotImplementedError): |
| 2399 | + serializer.update(self.user_request_1, serializer.validated_data) |
| 2400 | + |
2148 | 2401 | @mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data') |
2149 | 2402 | @mock.patch( |
2150 | 2403 | 'enterprise_access.apps.api_client.enterprise_catalog_client.' |
|
0 commit comments