Skip to content

Commit 0a39ee4

Browse files
Merge pull request #4013 from bcgov/feat/hamed-charging-site-status-override-3793
Feat: Charging Site status override - 3793
2 parents 83535d3 + df50478 commit 0a39ee4

File tree

13 files changed

+713
-11
lines changed

13 files changed

+713
-11
lines changed

backend/lcfs/tests/charging_site/test_charging_site_services.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ChargingSiteCreateSchema,
1212
ChargingSiteSchema,
1313
ChargingSitesSchema,
14+
ChargingSiteManualStatusUpdateSchema,
1415
ChargingEquipmentStatusSchema,
1516
ChargingSiteStatusSchema,
1617
)
@@ -383,6 +384,162 @@ async def test_update_charging_site_success(self, charging_site_service, mock_re
383384
"Updated Site", 1, exclude_site_id=mock_existing_site.charging_site_id
384385
)
385386

387+
@pytest.mark.anyio
388+
async def test_update_charging_site_status_manual_government_submitted_to_validated(
389+
self, charging_site_service, mock_repo, mock_request
390+
):
391+
"""Test IDIR Analyst can set status from Submitted to Validated."""
392+
mock_request.user.organization = MagicMock()
393+
mock_request.user.organization.organization_id = 1
394+
395+
mock_site = MagicMock(spec=ChargingSite)
396+
mock_site.charging_site_id = 1
397+
mock_site.organization_id = 1
398+
mock_site.status = MagicMock()
399+
mock_site.status.status = "Submitted"
400+
401+
mock_validated_status = MagicMock(spec=ChargingSiteStatus)
402+
mock_validated_status.charging_site_status_id = 2
403+
mock_validated_status.status = "Validated"
404+
405+
# Second call returns a site that must validate as ChargingSiteSchema
406+
mock_org = MagicMock()
407+
mock_org.organization_id = 1
408+
mock_org.name = "Test Org"
409+
mock_allocating_org = MagicMock()
410+
mock_allocating_org.organization_id = 2
411+
mock_allocating_org.name = "Allocating Org"
412+
mock_updated_site = MagicMock(spec=ChargingSite)
413+
mock_updated_site.charging_site_id = 1
414+
mock_updated_site.group_uuid = "site-uuid-1"
415+
mock_updated_site.organization_id = 1
416+
mock_updated_site.organization = mock_org
417+
mock_updated_site.allocating_organization_id = 2
418+
mock_updated_site.allocating_organization = mock_allocating_org
419+
mock_updated_site.allocating_organization_name = "Allocating Org"
420+
mock_updated_site.status_id = 2
421+
mock_updated_site.status = mock_validated_status
422+
mock_updated_site.version = 1
423+
mock_updated_site.site_code = "SITE001"
424+
mock_updated_site.site_name = "Test Site"
425+
mock_updated_site.street_address = "123 Main St"
426+
mock_updated_site.city = "Vancouver"
427+
mock_updated_site.postal_code = "V6B 1A1"
428+
mock_updated_site.latitude = 49.2827
429+
mock_updated_site.longitude = -123.1207
430+
mock_updated_site.documents = []
431+
mock_updated_site.notes = "Test notes"
432+
mock_updated_site.create_date = None
433+
mock_updated_site.update_date = None
434+
mock_updated_site.create_user = "testuser"
435+
mock_updated_site.update_user = "testuser"
436+
437+
mock_repo.get_charging_site_by_id.return_value = mock_site
438+
mock_repo.get_charging_site_status_by_name.return_value = mock_validated_status
439+
mock_repo.update_charging_site_status.return_value = None
440+
mock_repo.get_charging_site_by_id.side_effect = [mock_site, mock_updated_site]
441+
442+
with patch(
443+
"lcfs.web.api.charging_site.services.user_has_roles"
444+
) as mock_has_roles:
445+
# Government and Analyst return True so IDIR Analyst path is taken
446+
mock_has_roles.side_effect = lambda user, roles: (
447+
RoleEnum.GOVERNMENT in roles or RoleEnum.ANALYST in roles
448+
)
449+
450+
body = ChargingSiteManualStatusUpdateSchema(new_status="Validated")
451+
result = await charging_site_service.update_charging_site_status_manual(
452+
1, body
453+
)
454+
455+
assert result.status.status == "Validated"
456+
mock_repo.update_charging_site_status.assert_called_once_with(1, 2)
457+
458+
@pytest.mark.anyio
459+
async def test_update_charging_site_status_manual_unauthorized(
460+
self, charging_site_service, mock_repo, mock_request
461+
):
462+
"""Test user without Government or BCeID roles gets 403."""
463+
with patch(
464+
"lcfs.web.api.charging_site.services.user_has_roles"
465+
) as mock_has_roles:
466+
mock_has_roles.return_value = False
467+
468+
body = ChargingSiteManualStatusUpdateSchema(new_status="Validated")
469+
with pytest.raises(HTTPException) as exc_info:
470+
await charging_site_service.update_charging_site_status_manual(
471+
1, body
472+
)
473+
474+
assert exc_info.value.status_code == 403
475+
mock_repo.get_charging_site_by_id.assert_not_called()
476+
477+
@pytest.mark.anyio
478+
async def test_update_charging_site_status_manual_government_not_analyst_forbidden(
479+
self, charging_site_service, mock_repo, mock_request
480+
):
481+
"""Test Government user without Analyst role cannot set status to Validated (403)."""
482+
mock_request.user.organization = MagicMock()
483+
mock_request.user.organization.organization_id = 1
484+
485+
mock_site = MagicMock(spec=ChargingSite)
486+
mock_site.charging_site_id = 1
487+
mock_site.organization_id = 1
488+
mock_site.status = MagicMock()
489+
mock_site.status.status = "Submitted"
490+
491+
mock_repo.get_charging_site_by_id.return_value = mock_site
492+
493+
with patch(
494+
"lcfs.web.api.charging_site.services.user_has_roles"
495+
) as mock_has_roles:
496+
# Government True, Analyst False (and not compliance/signing)
497+
mock_has_roles.side_effect = lambda user, roles: (
498+
RoleEnum.GOVERNMENT in roles and RoleEnum.ANALYST not in roles
499+
)
500+
501+
body = ChargingSiteManualStatusUpdateSchema(new_status="Validated")
502+
with pytest.raises(HTTPException) as exc_info:
503+
await charging_site_service.update_charging_site_status_manual(
504+
1, body
505+
)
506+
507+
assert exc_info.value.status_code == 403
508+
assert "Only IDIR Analyst" in str(exc_info.value.detail)
509+
mock_repo.update_charging_site_status.assert_not_called()
510+
511+
@pytest.mark.anyio
512+
async def test_update_charging_site_status_manual_invalid_transition_government(
513+
self, charging_site_service, mock_repo, mock_request
514+
):
515+
"""Test IDIR Analyst cannot set status to Validated when current is not Submitted."""
516+
mock_request.user.organization = MagicMock()
517+
mock_request.user.organization.organization_id = 1
518+
519+
mock_site = MagicMock(spec=ChargingSite)
520+
mock_site.charging_site_id = 1
521+
mock_site.organization_id = 1
522+
mock_site.status = MagicMock()
523+
mock_site.status.status = "Draft"
524+
525+
mock_repo.get_charging_site_by_id.return_value = mock_site
526+
527+
with patch(
528+
"lcfs.web.api.charging_site.services.user_has_roles"
529+
) as mock_has_roles:
530+
mock_has_roles.side_effect = lambda user, roles: (
531+
RoleEnum.GOVERNMENT in roles or RoleEnum.ANALYST in roles
532+
)
533+
534+
body = ChargingSiteManualStatusUpdateSchema(new_status="Validated")
535+
with pytest.raises(HTTPException) as exc_info:
536+
await charging_site_service.update_charging_site_status_manual(
537+
1, body
538+
)
539+
540+
assert exc_info.value.status_code == 400
541+
mock_repo.update_charging_site_status.assert_not_called()
542+
386543
@pytest.mark.anyio
387544
async def test_update_charging_site_validated_creates_new_version(
388545
self, charging_site_service, mock_repo

backend/lcfs/tests/charging_site/test_charging_site_views.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ChargingSiteSchema,
1313
ChargingSiteStatusSchema,
1414
ChargingSitesSchema,
15+
ChargingSiteManualStatusUpdateSchema,
1516
DeleteChargingSiteResponseSchema,
1617
ChargingEquipmentStatusSchema,
1718
ChargingEquipmentPaginatedSchema,
@@ -744,3 +745,88 @@ async def test_search_allocation_organizations_service_error(
744745
response = await client.get(url, params={"query": "test"})
745746

746747
assert response.status_code == 500
748+
749+
750+
# --- Manual charging site status (PATCH /charging-sites/{site_id}/status) ---
751+
752+
753+
@pytest.mark.anyio
754+
async def test_update_charging_site_status_success_government(
755+
client: AsyncClient, fastapi_app: FastAPI, set_mock_user, valid_charging_site_schema
756+
):
757+
"""Test IDIR Analyst can set charging site status to Validated (Submitted -> Validated)."""
758+
with patch(
759+
"lcfs.web.api.charging_site.services.ChargingSiteService.update_charging_site_status_manual"
760+
) as mock_update, patch(
761+
"lcfs.web.api.charging_site.validation.ChargingSiteValidation.validate_organization_access"
762+
) as mock_validate:
763+
mock_validate.return_value = None
764+
updated = valid_charging_site_schema.model_copy()
765+
updated.status = ChargingSiteStatusSchema(
766+
charging_site_status_id=2, status="Validated"
767+
)
768+
mock_update.return_value = updated
769+
770+
set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT, RoleEnum.ANALYST])
771+
url = fastapi_app.url_path_for("update_charging_site_status", site_id=1)
772+
response = await client.patch(url, json={"new_status": "Validated"})
773+
774+
assert response.status_code == 200
775+
assert response.json()["status"]["status"] == "Validated"
776+
mock_update.assert_called_once()
777+
assert mock_update.call_args[0][0] == 1
778+
assert mock_update.call_args[0][1].new_status == "Validated"
779+
780+
781+
@pytest.mark.anyio
782+
async def test_update_charging_site_status_success_bceid(
783+
client: AsyncClient, fastapi_app: FastAPI, set_mock_user, valid_charging_site_schema
784+
):
785+
"""Test BCeID Compliance Reporting user can set charging site status to Submitted (Draft/Updated -> Submitted)."""
786+
with patch(
787+
"lcfs.web.api.charging_site.services.ChargingSiteService.update_charging_site_status_manual"
788+
) as mock_update, patch(
789+
"lcfs.web.api.charging_site.validation.ChargingSiteValidation.validate_organization_access"
790+
) as mock_validate:
791+
mock_validate.return_value = None
792+
updated = valid_charging_site_schema.model_copy()
793+
updated.status = ChargingSiteStatusSchema(
794+
charging_site_status_id=3, status="Submitted"
795+
)
796+
mock_update.return_value = updated
797+
798+
user_details = {"organization_id": 3}
799+
set_mock_user(fastapi_app, [RoleEnum.COMPLIANCE_REPORTING], user_details)
800+
url = fastapi_app.url_path_for("update_charging_site_status", site_id=1)
801+
response = await client.patch(url, json={"new_status": "Submitted"})
802+
803+
assert response.status_code == 200
804+
assert response.json()["status"]["status"] == "Submitted"
805+
mock_update.assert_called_once()
806+
assert mock_update.call_args[0][0] == 1
807+
assert mock_update.call_args[0][1].new_status == "Submitted"
808+
809+
810+
@pytest.mark.anyio
811+
async def test_update_charging_site_status_unauthorized(
812+
client: AsyncClient, fastapi_app: FastAPI, set_mock_user
813+
):
814+
"""Test Supplier without Compliance Reporting/Signing Authority cannot update charging site status."""
815+
user_details = {"organization_id": 3}
816+
set_mock_user(fastapi_app, [RoleEnum.SUPPLIER], user_details)
817+
url = fastapi_app.url_path_for("update_charging_site_status", site_id=1)
818+
response = await client.patch(url, json={"new_status": "Validated"})
819+
820+
assert response.status_code == 403
821+
822+
823+
@pytest.mark.anyio
824+
async def test_update_charging_site_status_validation_error(
825+
client: AsyncClient, fastapi_app: FastAPI, set_mock_user
826+
):
827+
"""Test PATCH with invalid body returns 422."""
828+
set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT])
829+
url = fastapi_app.url_path_for("update_charging_site_status", site_id=1)
830+
response = await client.patch(url, json={})
831+
832+
assert response.status_code == 422

backend/lcfs/web/api/charging_site/schema.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,18 @@ class BulkEquipmentStatusUpdateSchema(BaseSchema):
178178
new_status: str
179179

180180

181-
class ChargingSiteStatusEnum:
181+
class ChargingSiteStatusEnum(str, Enum):
182+
182183
DRAFT = "Draft"
183184
SUBMITTED = "Submitted"
184185
VALIDATED = "Validated"
185186
UPDATED = "Updated"
186187

187188

189+
class ChargingSiteManualStatusUpdateSchema(BaseSchema):
190+
new_status: ChargingSiteStatusEnum
191+
192+
188193
class EquipmentStatusEnum:
189194
DRAFT = "Draft"
190195
SUBMITTED = "Submitted"

0 commit comments

Comments
 (0)