diff --git a/backend/lcfs/tests/charging_equipment/test_charging_equipment_exporter.py b/backend/lcfs/tests/charging_equipment/test_charging_equipment_exporter.py new file mode 100644 index 000000000..e90e2eb27 --- /dev/null +++ b/backend/lcfs/tests/charging_equipment/test_charging_equipment_exporter.py @@ -0,0 +1,841 @@ +""" +Comprehensive unit tests for ChargingEquipmentExporter (export.py). + +Coverage areas: + - Column definition contracts (CE_INDEX, CE_MANAGE, CE_EXPORT) — labels, order, count + - _get_column_index / _get_column_letter helpers + - _build_charging_site_formulas — keys and formula template correctness + - load_charging_equipment_data — field mapping, null-safety + - export_filtered — user scoping, row building, column selection, filename, + pagination loop, empty results, null/missing ORM fields + - _current_pacific_date — format contract +""" +import io +import re +from datetime import date, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import openpyxl +import pytest + +from lcfs.db.models.compliance.ChargingEquipment import ChargingEquipment, PortsEnum +from lcfs.db.models.compliance.ChargingEquipmentStatus import ChargingEquipmentStatus +from lcfs.db.models.compliance.ChargingSite import ChargingSite +from lcfs.db.models.compliance.LevelOfEquipment import LevelOfEquipment +from lcfs.db.models.organization.Organization import Organization +from lcfs.db.models.user.UserProfile import UserProfile +from lcfs.utils.constants import FILE_MEDIA_TYPE +from lcfs.web.api.base import PaginationRequestSchema +from lcfs.web.api.charging_equipment.export import ( + CE_EXPORT_COLUMNS, + CE_INDEX_EXPORT_COLUMNS, + CE_MANAGE_EXPORT_COLUMNS, + MANAGE_FSE_EXPORT_FILENAME, + FSE_INDEX_EXPORT_FILENAME, + FSE_FILTERED_EXPORT_SHEETNAME, + ChargingEquipmentExporter, +) + + +# --------------------------------------------------------------------------- +# Test Factories +# --------------------------------------------------------------------------- + +def _make_exporter( + equipment_list: list = None, + total_count: int = None, +) -> ChargingEquipmentExporter: + """Return an exporter with a stubbed repo.""" + equipment_list = equipment_list or [] + total_count = total_count if total_count is not None else len(equipment_list) + repo = AsyncMock() + repo.get_charging_equipment_list.return_value = (equipment_list, total_count) + repo.get_all_equipment_by_organization_id.return_value = equipment_list + exporter = ChargingEquipmentExporter.__new__(ChargingEquipmentExporter) + exporter.repo = repo + return exporter + + +def _make_user(is_government: bool, org_id: int = 1) -> UserProfile: + user = MagicMock(spec=UserProfile) + user.is_government = is_government + user.organization_id = None if is_government else org_id + return user + + +def _make_site( + org_name: str = "Supplier Co", + allocating_org_name: str | None = "Allocating Org", + site_code: str = "SITE1", + site_name: str = "Test Site", + latitude: float = 49.77, + longitude: float = -123.42, +) -> ChargingSite: + org = Organization(organization_id=1, name=org_name) + site = ChargingSite( + charging_site_id=1, + organization_id=1, + site_code=site_code, + site_name=site_name, + latitude=latitude, + longitude=longitude, + ) + site.organization = org + site.allocating_organization_name = allocating_org_name + return site + + +def _make_equipment( + *, + serial_number: str = "SN-001", + manufacturer: str = "Tesla", + model: str = "Supercharger V3", + ports: PortsEnum = PortsEnum.DUAL_PORT, + latitude: float = 49.77, + longitude: float = -123.42, + version: int = 1, + equipment_number: str = "001", + status_str: str = "Validated", + level_name: str = "Level 2", + intended_uses: list = None, + intended_users: list = None, + site: ChargingSite = None, + create_date: datetime = datetime(2024, 1, 1), + update_date: datetime = datetime(2024, 1, 2), + notes: str = "", +) -> ChargingEquipment: + status = ChargingEquipmentStatus(charging_equipment_status_id=1, status=status_str) + level = LevelOfEquipment(level_of_equipment_id=1, name=level_name) + + eq = ChargingEquipment( + charging_equipment_id=1, + charging_site_id=1, + status_id=1, + equipment_number=equipment_number, + serial_number=serial_number, + manufacturer=manufacturer, + model=model, + level_of_equipment_id=1, + ports=ports, + latitude=latitude, + longitude=longitude, + version=version, + notes=notes, + ) + eq.charging_site = site or _make_site() + eq.status = status + eq.level_of_equipment = level + eq.intended_uses = intended_uses or [] + eq.intended_users = intended_users or [] + eq.create_date = create_date + eq.update_date = update_date + return eq + + +def _labels(columns) -> list[str]: + return [col.label for col in columns] + + +async def _read_xlsx(response) -> tuple[list[str], list[list]]: + """Return (header_row, data_rows) from the first sheet of the streaming response. + + SpreadsheetBuilder pre-fills the sheet with up to 2000 empty rows, so we strip + trailing rows where every cell is None to get only the meaningful data rows. + """ + body = b"" + async for chunk in response.body_iterator: + body += chunk + wb = openpyxl.load_workbook(io.BytesIO(body)) + ws = wb.active + rows = [list(r) for r in ws.iter_rows(values_only=True)] + if not rows: + return [], [] + header = rows[0] + data_rows = [r for r in rows[1:] if any(cell is not None for cell in r)] + return header, data_rows + + +# --------------------------------------------------------------------------- +# 1. Column Definition Contracts +# --------------------------------------------------------------------------- + +class TestColumnDefinitions: + """The contract for each column list is locked — label names and ordering matter.""" + + def test_index_columns_complete_set(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + expected = [ + "Status", "Site name", "Organization", "Allocating organization", + "Registration #", "Version #", "Serial #", "Manufacturer", "Model", + "Level of equipment", "Ports", "Intended use", "Intended users", + "Latitude", "Longitude", "Created", "Last updated", + ] + assert labels == expected + + def test_manage_columns_complete_set(self): + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + expected = [ + "Status", "Site name", "Allocating organization", + "Registration #", "Version #", "Serial #", "Manufacturer", "Model", + "Level of equipment", "Ports", "Intended use", "Intended users", + "Latitude", "Longitude", "Created", "Last updated", + ] + assert labels == expected + + def test_export_columns_complete_set(self): + labels = _labels(CE_EXPORT_COLUMNS) + expected = [ + "Charging Site", "Serial Number", "Manufacturer", "Model", + "Level of Equipment", "Ports", "Intended Uses", "Intended Users", + "Notes", "Latitude", "Longitude", + ] + assert labels == expected + + # ---- positional sanity checks for index columns ---- + + def test_index_allocating_org_immediately_after_organization(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert labels.index("Allocating organization") == labels.index("Organization") + 1 + + def test_index_allocating_org_before_registration(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert labels.index("Allocating organization") < labels.index("Registration #") + + # ---- positional sanity checks for manage columns ---- + + def test_manage_allocating_org_immediately_after_site_name(self): + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + assert labels.index("Allocating organization") == labels.index("Site name") + 1 + + def test_manage_no_organization_column(self): + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + assert "Organization" not in labels + + def test_index_has_organization_column(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert "Organization" in labels + + +# --------------------------------------------------------------------------- +# 2. _get_column_index / _get_column_letter +# --------------------------------------------------------------------------- + +class TestColumnHelpers: + def setup_method(self): + self.exporter = _make_exporter() + + def test_get_column_index_known_label(self): + # CE_EXPORT_COLUMNS: "Charging Site" is index 1 + assert self.exporter._get_column_index("Charging Site") == 1 + + def test_get_column_index_last_label(self): + # "Longitude" is the 11th column + assert self.exporter._get_column_index("Longitude") == 11 + + def test_get_column_index_unknown_raises(self): + with pytest.raises(ValueError, match="Missing column label"): + self.exporter._get_column_index("Nonexistent Column") + + def test_get_column_letter_a(self): + assert self.exporter._get_column_letter("Charging Site") == "A" + + def test_get_column_letter_k(self): + # Column 11 → "K" + assert self.exporter._get_column_letter("Longitude") == "K" + + +# --------------------------------------------------------------------------- +# 3. _build_charging_site_formulas +# --------------------------------------------------------------------------- + +class TestBuildChargingSiteFormulas: + def setup_method(self): + self.exporter = _make_exporter() + + def test_returns_two_entries(self): + formulas = self.exporter._build_charging_site_formulas(5) + assert len(formulas) == 2 + + def test_keys_are_latitude_and_longitude_indices(self): + formulas = self.exporter._build_charging_site_formulas(5) + lat_idx = self.exporter._get_column_index("Latitude") + lng_idx = self.exporter._get_column_index("Longitude") + assert lat_idx in formulas + assert lng_idx in formulas + + def test_formulas_contain_row_placeholder(self): + formulas = self.exporter._build_charging_site_formulas(5) + for formula in formulas.values(): + assert "{row}" in formula + + def test_lookup_range_uses_count_plus_one(self): + formulas = self.exporter._build_charging_site_formulas(10) + for formula in formulas.values(): + # lookup_end = 10 + 1 = 11 + assert "$G$11" in formula + + def test_latitude_formula_uses_column_6_lookup(self): + formulas = self.exporter._build_charging_site_formulas(3) + lat_idx = self.exporter._get_column_index("Latitude") + assert ",6,FALSE" in formulas[lat_idx] + + def test_longitude_formula_uses_column_7_lookup(self): + formulas = self.exporter._build_charging_site_formulas(3) + lng_idx = self.exporter._get_column_index("Longitude") + assert ",7,FALSE" in formulas[lng_idx] + + +# --------------------------------------------------------------------------- +# 4. load_charging_equipment_data +# --------------------------------------------------------------------------- + +class TestLoadChargingEquipmentData: + @pytest.mark.anyio + async def test_maps_all_fields_correctly(self): + use = MagicMock() + use.type = "Commercial" + user = MagicMock() + user.type_name = "Fleet" + + eq = _make_equipment( + serial_number="ABC123", + manufacturer="ChargePoint", + model="Express 250", + ports=PortsEnum.SINGLE_PORT, + latitude=48.5, + longitude=-123.1, + intended_uses=[use], + intended_users=[user], + notes="Some note", + ) + eq.charging_site.site_name = "My Site" + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + + assert len(data) == 1 + row = data[0] + assert row[0] == "My Site" # Charging Site + assert row[1] == "ABC123" # Serial Number + assert row[2] == "ChargePoint" # Manufacturer + assert row[3] == "Express 250" # Model + assert row[4] == "Level 2" # Level of Equipment + assert row[5] == "Single port" # Ports + assert row[6] == "Commercial" # Intended Uses + assert row[7] == "Fleet" # Intended Users + assert row[8] == "Some note" # Notes + assert row[9] == 48.5 # Latitude + assert row[10] == -123.1 # Longitude + + @pytest.mark.anyio + async def test_multiple_intended_uses_joined_with_comma(self): + use1, use2 = MagicMock(), MagicMock() + use1.type, use2.type = "Commercial", "Fleet" + eq = _make_equipment(intended_uses=[use1, use2]) + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][6] == "Commercial, Fleet" + + @pytest.mark.anyio + async def test_null_charging_site_gives_empty_site_name(self): + eq = _make_equipment() + eq.charging_site = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][0] == "" + + @pytest.mark.anyio + async def test_null_level_of_equipment_gives_empty_string(self): + eq = _make_equipment() + eq.level_of_equipment = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][4] == "" + + @pytest.mark.anyio + async def test_null_ports_gives_empty_string(self): + eq = _make_equipment() + eq.ports = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][5] == "" + + @pytest.mark.anyio + async def test_null_latitude_longitude_give_empty_string(self): + eq = _make_equipment() + eq.latitude = None + eq.longitude = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][9] == "" + assert data[0][10] == "" + + @pytest.mark.anyio + async def test_empty_intended_uses_and_users(self): + eq = _make_equipment(intended_uses=[], intended_users=[]) + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][6] == "" + assert data[0][7] == "" + + @pytest.mark.anyio + async def test_returns_empty_list_when_no_equipment(self): + exporter = _make_exporter([]) + data = await exporter.load_charging_equipment_data(1) + assert data == [] + + +# --------------------------------------------------------------------------- +# 5. export_filtered — core behaviour +# --------------------------------------------------------------------------- + +class TestExportFilteredColumnSelection: + @pytest.mark.anyio + async def test_government_uses_index_columns(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, _ = await _read_xlsx(response) + + assert headers == _labels(CE_INDEX_EXPORT_COLUMNS) + + @pytest.mark.anyio + async def test_supplier_uses_manage_columns(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, _ = await _read_xlsx(response) + + assert headers == _labels(CE_MANAGE_EXPORT_COLUMNS) + + @pytest.mark.anyio + async def test_government_filename_starts_with_fse_index(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + cd = response.headers.get("content-disposition", "") + assert FSE_INDEX_EXPORT_FILENAME in cd + assert ".xlsx" in cd + + @pytest.mark.anyio + async def test_supplier_filename_starts_with_manage_fse(self): + exporter = _make_exporter() + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + cd = response.headers.get("content-disposition", "") + assert MANAGE_FSE_EXPORT_FILENAME in cd + + @pytest.mark.anyio + async def test_filename_includes_date(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + with patch.object( + ChargingEquipmentExporter, "_current_pacific_date", return_value="2025-06-15" + ): + response = await exporter.export_filtered(user=user, pagination=pagination) + + cd = response.headers.get("content-disposition", "") + assert "2025-06-15" in cd + + @pytest.mark.anyio + async def test_response_media_type_is_xlsx(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + assert response.media_type == FILE_MEDIA_TYPE["XLSX"].value + + @pytest.mark.anyio + async def test_sheet_name_is_fse(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + body = b"".join([chunk async for chunk in response.body_iterator]) + wb = openpyxl.load_workbook(io.BytesIO(body)) + assert FSE_FILTERED_EXPORT_SHEETNAME in wb.sheetnames + + +class TestExportFilteredOrganizationScoping: + @pytest.mark.anyio + async def test_government_with_org_id_uses_provided_org_id(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination, organization_id=42) + + exporter.repo.get_charging_equipment_list.assert_called_once() + call_org_id = exporter.repo.get_charging_equipment_list.call_args[0][0] + assert call_org_id == 42 + + @pytest.mark.anyio + async def test_government_without_org_id_uses_none(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination, organization_id=None) + + call_org_id = exporter.repo.get_charging_equipment_list.call_args[0][0] + assert call_org_id is None + + @pytest.mark.anyio + async def test_supplier_always_uses_own_org_id(self): + exporter = _make_exporter() + user = _make_user(is_government=False, org_id=7) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + # Even if an org_id is passed, supplier's own org should be used + await exporter.export_filtered(user=user, pagination=pagination, organization_id=99) + + call_org_id = exporter.repo.get_charging_equipment_list.call_args[0][0] + assert call_org_id == 7 + + @pytest.mark.anyio + async def test_government_excludes_drafts(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination) + + call_kwargs = exporter.repo.get_charging_equipment_list.call_args.kwargs + assert call_kwargs.get("exclude_draft") is True + + @pytest.mark.anyio + async def test_supplier_does_not_exclude_drafts(self): + exporter = _make_exporter() + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination) + + call_kwargs = exporter.repo.get_charging_equipment_list.call_args.kwargs + assert call_kwargs.get("exclude_draft") is False + + +class TestExportFilteredRowBuilding: + @pytest.mark.anyio + async def test_government_row_maps_all_fields(self): + use = MagicMock() + use.type = "Commercial" + end_user = MagicMock() + end_user.type_name = "Fleet" + eq = _make_equipment( + status_str="Validated", + manufacturer="Tesla", + model="V3", + serial_number="XY-99", + level_name="Level 2", + ports=PortsEnum.DUAL_PORT, + latitude=49.77, + longitude=-123.4, + version=3, + intended_uses=[use], + intended_users=[end_user], + create_date=datetime(2024, 3, 15), + update_date=datetime(2024, 4, 20), + site=_make_site( + org_name="OrgA", + allocating_org_name="OrgB", + site_code="ABC01", + site_name="Alpha Site", + ), + ) + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + row = data_rows[0] + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert row[labels.index("Status")] == "Validated" + assert row[labels.index("Site name")] == "Alpha Site" + assert row[labels.index("Organization")] == "OrgA" + assert row[labels.index("Allocating organization")] == "OrgB" + assert row[labels.index("Serial #")] == "XY-99" + assert row[labels.index("Manufacturer")] == "Tesla" + assert row[labels.index("Model")] == "V3" + assert row[labels.index("Level of equipment")] == "Level 2" + assert row[labels.index("Ports")] == "Dual port" + assert row[labels.index("Intended use")] == "Commercial" + assert row[labels.index("Intended users")] == "Fleet" + assert row[labels.index("Latitude")] == 49.77 + assert row[labels.index("Longitude")] == -123.4 + # openpyxl reads date cells back as datetime (midnight), not date + created = row[labels.index("Created")] + updated = row[labels.index("Last updated")] + assert getattr(created, "date", lambda: created)() == date(2024, 3, 15) + assert getattr(updated, "date", lambda: updated)() == date(2024, 4, 20) + + @pytest.mark.anyio + async def test_supplier_row_does_not_include_organization_field(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, data_rows = await _read_xlsx(response) + + assert "Organization" not in headers + assert len(data_rows[0]) == len(CE_MANAGE_EXPORT_COLUMNS) + + @pytest.mark.anyio + async def test_supplier_row_allocating_org_after_site_name(self): + eq = _make_equipment( + site=_make_site(allocating_org_name="FortisBC", site_name="My Site") + ) + exporter = _make_exporter([eq]) + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + row = data_rows[0] + assert row[labels.index("Site name")] == "My Site" + assert row[labels.index("Allocating organization")] == "FortisBC" + + @pytest.mark.anyio + async def test_null_allocating_org_writes_empty(self): + eq = _make_equipment( + site=_make_site(allocating_org_name=None) + ) + exporter = _make_exporter([eq]) + + for is_gov in (True, False): + user = _make_user(is_government=is_gov) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + cols = CE_INDEX_EXPORT_COLUMNS if is_gov else CE_MANAGE_EXPORT_COLUMNS + alloc_idx = _labels(cols).index("Allocating organization") + assert data_rows[0][alloc_idx] in (None, "") + + @pytest.mark.anyio + async def test_null_charging_site_gives_empty_site_and_org_fields(self): + eq = _make_equipment() + eq.charging_site = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + row = data_rows[0] + assert row[labels.index("Site name")] in (None, "") + assert row[labels.index("Organization")] in (None, "") + assert row[labels.index("Allocating organization")] in (None, "") + + @pytest.mark.anyio + async def test_null_status_gives_empty_status_field(self): + eq = _make_equipment() + eq.status = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Status")] in (None, "") + + @pytest.mark.anyio + async def test_null_level_gives_empty_level_field(self): + eq = _make_equipment() + eq.level_of_equipment = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Level of equipment")] in (None, "") + + @pytest.mark.anyio + async def test_null_ports_gives_empty_ports_field(self): + eq = _make_equipment() + eq.ports = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Ports")] in (None, "") + + @pytest.mark.anyio + async def test_null_latitude_longitude_give_empty_values(self): + eq = _make_equipment() + eq.latitude = None + eq.longitude = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Latitude")] in (None, "") + assert data_rows[0][labels.index("Longitude")] in (None, "") + + @pytest.mark.anyio + async def test_null_dates_write_empty(self): + eq = _make_equipment(create_date=None, update_date=None) + eq.create_date = None + eq.update_date = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Created")] is None + assert data_rows[0][labels.index("Last updated")] is None + + @pytest.mark.anyio + async def test_multiple_intended_uses_joined_with_comma(self): + u1, u2 = MagicMock(), MagicMock() + u1.type, u2.type = "Commercial", "Fleet" + eq = _make_equipment(intended_uses=[u1, u2]) + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Intended use")] == "Commercial, Fleet" + + @pytest.mark.anyio + async def test_empty_result_set_writes_header_only(self): + exporter = _make_exporter(equipment_list=[], total_count=0) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, data_rows = await _read_xlsx(response) + + assert headers == _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows == [] + + +class TestExportFilteredPagination: + @pytest.mark.anyio + async def test_fetches_all_pages_until_total_satisfied(self): + """When total_count > first page size, the loop should page through all results.""" + page1 = [_make_equipment(serial_number="SN-1")] + page2 = [_make_equipment(serial_number="SN-2")] + + repo = AsyncMock() + repo.get_charging_equipment_list.side_effect = [ + (page1, 2), # page 1: 1 row, total 2 + (page2, 2), # page 2: 1 row, total 2 — all collected now + ([], 2), # safety: should not reach here + ] + + exporter = ChargingEquipmentExporter.__new__(ChargingEquipmentExporter) + exporter.repo = repo + + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + assert len(data_rows) == 2 + assert repo.get_charging_equipment_list.call_count == 2 + + @pytest.mark.anyio + async def test_stops_when_empty_page_returned(self): + """If the repo returns an empty list before total is met, the loop must stop.""" + repo = AsyncMock() + repo.get_charging_equipment_list.side_effect = [ + ([_make_equipment()], 999), # large total, but next page is empty + ([], 999), + ] + + exporter = ChargingEquipmentExporter.__new__(ChargingEquipmentExporter) + exporter.repo = repo + + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + assert len(data_rows) == 1 + assert repo.get_charging_equipment_list.call_count == 2 + + @pytest.mark.anyio + async def test_pagination_uses_page_size_1000(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + # Pass a different size; exporter should override to 1000 + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination) + + # Second positional arg to get_charging_equipment_list is the pagination object + call_pagination = exporter.repo.get_charging_equipment_list.call_args.args[1] + assert call_pagination.size == 1000 + + +# --------------------------------------------------------------------------- +# 6. _current_pacific_date +# --------------------------------------------------------------------------- + +class TestCurrentPacificDate: + def test_returns_yyyy_mm_dd_format(self): + result = ChargingEquipmentExporter._current_pacific_date() + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", result), ( + f"Expected YYYY-MM-DD, got {result!r}" + ) + + def test_returns_string(self): + assert isinstance(ChargingEquipmentExporter._current_pacific_date(), str) diff --git a/backend/lcfs/web/api/charging_equipment/export.py b/backend/lcfs/web/api/charging_equipment/export.py index 09310ddcd..423e5cda1 100644 --- a/backend/lcfs/web/api/charging_equipment/export.py +++ b/backend/lcfs/web/api/charging_equipment/export.py @@ -40,6 +40,7 @@ SpreadsheetColumn("Status", "text"), SpreadsheetColumn("Site name", "text"), SpreadsheetColumn("Organization", "text"), + SpreadsheetColumn("Allocating organization", "text"), SpreadsheetColumn("Registration #", "text"), SpreadsheetColumn("Version #", "int"), SpreadsheetColumn("Serial #", "text"), @@ -58,6 +59,7 @@ CE_MANAGE_EXPORT_COLUMNS = [ SpreadsheetColumn("Status", "text"), SpreadsheetColumn("Site name", "text"), + SpreadsheetColumn("Allocating organization", "text"), SpreadsheetColumn("Registration #", "text"), SpreadsheetColumn("Version #", "int"), SpreadsheetColumn("Serial #", "text"), @@ -375,9 +377,15 @@ async def export_filtered( equipment.update_date.date() if getattr(equipment, "update_date", None) else None ) + allocating_organization_name = ( + equipment.charging_site.allocating_organization_name + if equipment.charging_site + else "" + ) or "" common_values = [ status, site_name, + allocating_organization_name, registration_number, equipment.version, equipment.serial_number, @@ -398,6 +406,7 @@ async def export_filtered( status, site_name, organization_name, + allocating_organization_name, registration_number, equipment.version, equipment.serial_number, diff --git a/backend/lcfs/web/api/charging_site/export.py b/backend/lcfs/web/api/charging_site/export.py index 61a7a9286..fe0322970 100644 --- a/backend/lcfs/web/api/charging_site/export.py +++ b/backend/lcfs/web/api/charging_site/export.py @@ -91,11 +91,16 @@ async def export( async def _create_validators(self, organization, builder): validators: List[DataValidation] = [] - # Get allocating organization options (from allocation agreements) - allocating_org_options = await self.repo.get_allocation_agreement_organizations( + all_names = await self.repo.get_allocating_organization_names( organization.organization_id ) - allocating_org_names = [org.name for org in allocating_org_options] + # Exclude the exporting organization's own name (own-org exclusion is also + # enforced at the ID level inside get_allocation_agreement_organizations) + user_org_name_lower = organization.name.lower() if organization.name else None + allocating_org_names = [ + n for n in all_names + if not user_org_name_lower or n.lower() != user_org_name_lower + ] # Site Name column - helpful prompt site_name_validator = DataValidation( diff --git a/backend/lcfs/web/api/charging_site/importer.py b/backend/lcfs/web/api/charging_site/importer.py index b6f2672df..5440ee5c5 100644 --- a/backend/lcfs/web/api/charging_site/importer.py +++ b/backend/lcfs/web/api/charging_site/importer.py @@ -56,6 +56,7 @@ async def import_data( org_code: str, file: UploadFile, overwrite: bool = False, + organization_name: str = "", ) -> str: """ Initiates the import job in a separate thread executor. @@ -94,7 +95,13 @@ async def import_data( # Start the import task without blocking asyncio.create_task( import_async( - organization_id, user, org_code, copied_file, job_id, overwrite + organization_id, + user, + org_code, + copied_file, + job_id, + overwrite, + organization_name, ) ) @@ -134,6 +141,7 @@ async def import_async( file: UploadFile, job_id: str, overwrite: bool = False, + organization_name: str = "", ): """ Performs the actual import in an async context. @@ -233,7 +241,9 @@ async def import_async( # row[8] is now allocating_organization_name (optional string) # Validate row - error = _validate_row(row, row_idx, valid_org_names) + error = _validate_row( + row, row_idx, valid_org_names, organization_name + ) if error: errors.append(error) rejected += 1 @@ -320,6 +330,7 @@ def _validate_row( row: tuple, row_idx: int, valid_org_names: set[str], + organization_name: str = "", ) -> str | None: """ Validates a single row of data and returns an error string if invalid. @@ -383,10 +394,17 @@ def _validate_row( except (ValueError, TypeError): return f"Row {row_idx}: Invalid longitude value '{longitude}'. Must be a valid number" - # Validate allocating organization (optional field) - # Validation disabled - any value is now accepted - # if allocating_org_name and allocating_org_name not in valid_org_names: - # return f"Row {row_idx}: Invalid allocating organization: {allocating_org_name}. Must be from your allocation agreements." + # Validate that the allocating organization is not the user's own organization + if ( + allocating_org_name + and organization_name + and str(allocating_org_name).strip().lower() + == organization_name.strip().lower() + ): + return ( + f"Row {row_idx}: You cannot select your own organization " + f"as the Allocating organization." + ) return None diff --git a/backend/lcfs/web/api/charging_site/repo.py b/backend/lcfs/web/api/charging_site/repo.py index 85e1e9f14..c5e801032 100644 --- a/backend/lcfs/web/api/charging_site/repo.py +++ b/backend/lcfs/web/api/charging_site/repo.py @@ -159,7 +159,10 @@ async def get_allocation_agreement_organizations( AllocationAgreement.compliance_report_id == ComplianceReport.compliance_report_id, ) - .where(ComplianceReport.organization_id == organization_id) + .where( + ComplianceReport.organization_id == organization_id, + Organization.organization_id != organization_id, + ) .distinct() .order_by(Organization.name) ) @@ -917,6 +920,31 @@ async def search_organizations_by_name( ) return result.scalars().all() + @repo_handler + async def get_allocating_organization_names( + self, organization_id: int + ) -> List[str]: + matched_orgs = await self.get_allocation_agreement_organizations( + organization_id + ) + transaction_partners = ( + await self.get_transaction_partners_from_allocation_agreements( + organization_id + ) + ) + historical_names = await self.get_distinct_allocating_organization_names( + organization_id + ) + + seen: dict[str, str] = {} + for org in matched_orgs: + seen[org.name.lower()] = org.name + for name in transaction_partners + historical_names: + if name.lower() not in seen: + seen[name.lower()] = name + + return sorted(seen.values(), key=str.lower) + @repo_handler async def get_transaction_partners_from_allocation_agreements( self, organization_id: int diff --git a/backend/lcfs/web/api/charging_site/services.py b/backend/lcfs/web/api/charging_site/services.py index a8ceeec9c..3573b7b8b 100644 --- a/backend/lcfs/web/api/charging_site/services.py +++ b/backend/lcfs/web/api/charging_site/services.py @@ -119,44 +119,38 @@ async def search_allocation_organizations( self, organization_id: int, query: str ) -> List[dict]: """ - Search for allocating organization suggestions. + Return allocating organization suggestions filtered by query, + excluding the user's own organization. """ try: query_lower = query.lower().strip() + user_org = self.request.user.organization + user_org_name_lower = user_org.name.lower() if user_org else None - # Use existing method to get matched organizations matched_orgs = await self.repo.get_allocation_agreement_organizations( organization_id ) - - # Get unmatched names from allocation agreements and charging sites - transaction_partners = ( - await self.repo.get_transaction_partners_from_allocation_agreements( - organization_id - ) - ) - historical_names = ( - await self.repo.get_distinct_allocating_organization_names( - organization_id - ) + all_names = await self.repo.get_allocating_organization_names( + organization_id ) - # Build suggestions dict - matched orgs take precedence - suggestions = {} - for org in matched_orgs: - if query_lower in org.name.lower(): - suggestions[org.name.lower()] = { - "organizationId": org.organization_id, - "name": org.name, - } + # Build a lookup so matched orgs (with IDs) take precedence + org_id_by_name = {org.name.lower(): org.organization_id for org in matched_orgs} - # Add unmatched names (transaction partners + historical) - for name in transaction_partners + historical_names: + suggestions = [] + for name in all_names: name_lower = name.lower() - if name_lower not in suggestions and query_lower in name_lower: - suggestions[name_lower] = {"organizationId": None, "name": name} + if user_org_name_lower and name_lower == user_org_name_lower: + continue + if query_lower in name_lower: + suggestions.append( + { + "organizationId": org_id_by_name.get(name_lower), + "name": name, + } + ) - return sorted(suggestions.values(), key=lambda x: x["name"].lower())[:50] + return suggestions[:50] except Exception as e: logger.error("Error searching allocation organizations", error=str(e)) raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/backend/lcfs/web/api/charging_site/validation.py b/backend/lcfs/web/api/charging_site/validation.py index 35b1577d7..6b6de51f0 100644 --- a/backend/lcfs/web/api/charging_site/validation.py +++ b/backend/lcfs/web/api/charging_site/validation.py @@ -57,6 +57,8 @@ async def charging_site_create_access( detail="Organization ID in URL and request body do not match", ) + self._validate_allocating_organization(organization_id, data) + return True async def charging_site_delete_update_access( @@ -104,8 +106,36 @@ async def charging_site_delete_update_access( detail="A charging site with this name already exists for your organization. Please use a unique site name.", ) + if data: + self._validate_allocating_organization(organization_id, data) + return True + def _validate_allocating_organization( + self, organization_id: int, data: ChargingSiteCreateSchema + ): + """ + Validates that the allocating organization is not the user's own organization. + """ + if ( + data.allocating_organization_id + and data.allocating_organization_id == organization_id + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot select your own organization as the Allocating organization.", + ) + user_org = self.request.user.organization + if user_org and data.allocating_organization_name: + if ( + data.allocating_organization_name.strip().lower() + == user_org.name.strip().lower() + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot select your own organization as the Allocating organization.", + ) + async def validate_organization_access(self, charging_site_id: int): """ Validates that the charging site exists and the user has access to it. diff --git a/backend/lcfs/web/api/charging_site/views.py b/backend/lcfs/web/api/charging_site/views.py index 41cabe8df..d2d91dee8 100644 --- a/backend/lcfs/web/api/charging_site/views.py +++ b/backend/lcfs/web/api/charging_site/views.py @@ -411,6 +411,7 @@ async def import_charging_sites( organization.organization_code, file, overwrite, + organization_name=organization.name or "", ) return JSONResponse(content={"jobId": job_id}) diff --git a/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx b/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx index 40fa5a566..6983c5275 100644 --- a/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx +++ b/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { chargingSiteColDefs, chargingEquipmentColDefs, @@ -7,18 +7,17 @@ import { indexDefaultColDef } from '../../components/_schema' +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key) => key - }) + useTranslation: () => ({ t: (key) => key }) })) -// Fix the i18n mock to include a default export vi.mock('@/i18n', () => ({ - default: { - t: (key) => key - }, - t: (key) => key // Also export t directly in case it's used as named export + default: { t: (key) => key }, + t: (key) => key })) vi.mock('@/hooks/useChargingSite', () => ({ @@ -26,131 +25,529 @@ vi.mock('@/hooks/useChargingSite', () => ({ useChargingSiteStatuses: vi.fn() })) -// Mock any other dependencies that might be needed -vi.mock( - '@/components/BCDataGrid/FloatingFilters/BCSelectFloatingFilter', - () => ({ - default: vi.fn() - }) -) - -describe('_schema', () => { - const mockErrors = {} - const mockWarnings = {} - const mockT = (key) => key - const mockAllocationOrganizations = [ - { organization_id: 1, name: 'Org 1' }, - { organization_id: 2, name: 'Org 2' } - ] - - describe('chargingSiteColDefs', () => { - it('returns column definitions array', () => { - const colDefs = chargingSiteColDefs( - mockAllocationOrganizations, - mockErrors, - mockWarnings, - true - ) +vi.mock('@/components/BCDataGrid/FloatingFilters/BCSelectFloatingFilter', () => ({ + default: vi.fn() +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- - expect(Array.isArray(colDefs)).toBe(true) - expect(colDefs.length).toBeGreaterThan(0) +const mockT = (key) => key +const mockErrors = {} +const mockWarnings = {} + +/** Extract field names from a column-def array, skipping cols with no field. */ +const fields = (colDefs) => colDefs.map((c) => c.field).filter(Boolean) + +// --------------------------------------------------------------------------- +// chargingSiteColDefs +// --------------------------------------------------------------------------- + +describe('chargingSiteColDefs', () => { + const colDefs = () => chargingSiteColDefs(mockErrors, mockWarnings, true) + + it('returns a non-empty array', () => { + expect(Array.isArray(colDefs())).toBe(true) + expect(colDefs().length).toBeGreaterThan(0) + }) + + describe('required fields', () => { + it.each([ + 'siteName', + 'streetAddress', + 'city', + 'postalCode', + 'latitude', + 'longitude', + 'allocatingOrganization', + 'notes' + ])('includes field "%s"', (field) => { + expect(fields(colDefs())).toContain(field) }) + }) - it('includes required fields', () => { - const colDefs = chargingSiteColDefs( - mockAllocationOrganizations, - mockErrors, - mockWarnings, - true - ) + describe('hidden / utility columns', () => { + it('hides the id column', () => { + const col = colDefs().find((c) => c.field === 'id') + expect(col.hide).toBe(true) + }) - const fieldNames = colDefs.map((col) => col.field) - expect(fieldNames).toContain('siteName') - expect(fieldNames).toContain('streetAddress') - expect(fieldNames).toContain('city') - expect(fieldNames).toContain('postalCode') - expect(fieldNames).toContain('latitude') - expect(fieldNames).toContain('longitude') - }) - - it('configures editable fields correctly', () => { - const colDefs = chargingSiteColDefs( - mockAllocationOrganizations, - mockErrors, - mockWarnings, - true - ) + it('hides the chargingSiteId column', () => { + const col = colDefs().find((c) => c.field === 'chargingSiteId') + expect(col.hide).toBe(true) + }) + }) + + describe('editable fields', () => { + it.each(['siteName', 'streetAddress', 'city', 'postalCode', 'latitude', 'longitude', 'allocatingOrganization', 'notes'])( + 'marks "%s" as editable', + (field) => { + const col = colDefs().find((c) => c.field === field) + expect(col?.editable).toBe(true) + } + ) + }) + + describe('postalCode valueSetter', () => { + it('converts input to uppercase', () => { + const col = colDefs().find((c) => c.field === 'postalCode') + const data = {} + col.valueSetter({ newValue: 'v5k 1a1', data, colDef: { field: 'postalCode' } }) + expect(data.postalCode).toBe('V5K 1A1') + }) + + it('returns true (AG-Grid expects truthy to accept the change)', () => { + const col = colDefs().find((c) => c.field === 'postalCode') + const result = col.valueSetter({ newValue: 'a1b2c3', data: {}, colDef: { field: 'postalCode' } }) + expect(result).toBe(true) + }) + }) + + describe('streetAddress valueSetter', () => { + let col, data + + beforeEach(() => { + col = colDefs().find((c) => c.field === 'streetAddress') + data = {} + }) + + it('clears all address fields when newValue is empty string', () => { + data = { city: 'Victoria', postalCode: 'V8V1A1', latitude: 48.4, longitude: -123.3 } + col.valueSetter({ newValue: '', data }) + expect(data.streetAddress).toBe('') + expect(data.city).toBe('') + expect(data.postalCode).toBe('') + expect(data.latitude).toBe('') + expect(data.longitude).toBe('') + }) + + it('sets only streetAddress when newValue is a plain string', () => { + col.valueSetter({ newValue: '123 Main St', data }) + expect(data.streetAddress).toBe('123 Main St') + // city / postal should not be overwritten by a raw string + expect(data.city).toBeUndefined() + }) + + it('populates all fields from autocomplete object with fullAddress', () => { + col.valueSetter({ + newValue: { + fullAddress: '123 Main St, Victoria, BC V8V1A1', + streetAddress: '123 Main St', + city: 'Victoria', + postalCode: 'V8V 1A1', + latitude: 48.4, + longitude: -123.3 + }, + data + }) + expect(data.streetAddress).toBe('123 Main St') + expect(data.city).toBe('Victoria') + expect(data.postalCode).toBe('V8V 1A1') + expect(data.latitude).toBe(48.4) + expect(data.longitude).toBe(-123.3) + }) + + it('returns true to accept the change', async () => { + const result = await col.valueSetter({ newValue: '123 Main', data }) + expect(result).toBe(true) + }) + }) + + describe('allocatingOrganization valueGetter and valueSetter', () => { + let col + + beforeEach(() => { + col = colDefs().find((c) => c.field === 'allocatingOrganization') + }) + + it('valueGetter returns allocatingOrganizationName', () => { + expect(col.valueGetter({ data: { allocatingOrganizationName: 'BC Hydro' } })).toBe('BC Hydro') + }) + + it('valueGetter returns empty string when field is absent', () => { + expect(col.valueGetter({ data: {} })).toBe('') + }) - const siteNameCol = colDefs.find((col) => col.field === 'siteName') - expect(siteNameCol.editable).toBe(true) + it('valueSetter with org object sets id and name', () => { + const data = {} + col.valueSetter({ newValue: { organizationId: 5, name: 'FortisBC' }, data }) + expect(data.allocatingOrganizationId).toBe(5) + expect(data.allocatingOrganizationName).toBe('FortisBC') + }) + + it('valueSetter with plain string sets only name, clears id', () => { + const data = { allocatingOrganizationId: 99 } + col.valueSetter({ newValue: 'Custom Org Name', data }) + expect(data.allocatingOrganizationId).toBeNull() + expect(data.allocatingOrganizationName).toBe('Custom Org Name') + }) - const idCol = colDefs.find((col) => col.field === 'id') - expect(idCol.hide).toBe(true) + it('valueSetter returns true', () => { + const result = col.valueSetter({ newValue: 'test', data: {} }) + expect(result).toBe(true) }) }) +}) - describe('chargingEquipmentColDefs', () => { - it('returns equipment column definitions', () => { - const colDefs = chargingEquipmentColDefs(mockT, false) +// --------------------------------------------------------------------------- +// chargingEquipmentColDefs +// --------------------------------------------------------------------------- - expect(Array.isArray(colDefs)).toBe(true) - expect(colDefs.length).toBeGreaterThan(0) +describe('chargingEquipmentColDefs', () => { + describe('core fields always present', () => { + it.each(['status', 'siteName', 'registrationNumber', 'version', 'serialNumber', 'manufacturer', 'model', 'levelOfEquipment', 'ports', 'allocatingOrganizationName'])( + 'always includes "%s"', + (field) => { + expect(fields(chargingEquipmentColDefs(mockT))).toContain(field) + } + ) + }) + + describe('showOrganizationColumn option', () => { + it('excludes organizationName when false (BCeID)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: false })) + expect(f).not.toContain('organizationName') }) + it('includes organizationName when true (IDIR)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: true })) + expect(f).toContain('organizationName') + }) + }) + describe('allocatingOrganizationName column positioning', () => { + it('immediately follows organizationName for IDIR users', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: true })) + expect(f.indexOf('allocatingOrganizationName')).toBe(f.indexOf('organizationName') + 1) + }) - it('includes equipment fields', () => { - const colDefs = chargingEquipmentColDefs(mockT, true) + it('immediately follows siteName for BCeID users', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: false })) + expect(f.indexOf('allocatingOrganizationName')).toBe(f.indexOf('siteName') + 1) + }) - const fieldNames = colDefs.map((col) => col.field) - expect(fieldNames).toContain('status') - expect(fieldNames).toContain('registrationNumber') - expect(fieldNames).toContain('manufacturer') - expect(fieldNames).toContain('model') + it('appears before registrationNumber in both user modes', () => { + for (const showOrg of [true, false]) { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: showOrg })) + expect(f.indexOf('allocatingOrganizationName')).toBeLessThan(f.indexOf('registrationNumber')) + } }) }) - describe('indexChargingSitesColDefs', () => { - const mockOrgIdToName = { 1: 'Organization 1', 2: 'Organization 2' } + describe('allocatingOrganizationName valueGetter', () => { + const allocCol = () => + chargingEquipmentColDefs(mockT).find((c) => c.field === 'allocatingOrganizationName') + + it('prefers chargingSite.allocatingOrganizationName', () => { + const result = allocCol().valueGetter({ + data: { + chargingSite: { allocatingOrganizationName: 'Site Level Org' }, + allocatingOrganizationName: 'Direct Org' + } + }) + expect(result).toBe('Site Level Org') + }) + + it('falls back to direct allocatingOrganizationName', () => { + const result = allocCol().valueGetter({ + data: { allocatingOrganizationName: 'Direct Org' } + }) + expect(result).toBe('Direct Org') + }) + + it('returns empty string when neither field is present', () => { + expect(allocCol().valueGetter({ data: {} })).toBe('') + }) + }) - it('returns index column definitions', () => { - const colDefs = indexChargingSitesColDefs(false, mockOrgIdToName) + describe('enableSelection option', () => { + it('adds a leading __select__ checkbox column', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { enableSelection: true })) + expect(f[0]).toBe('__select__') + }) - expect(Array.isArray(colDefs)).toBe(true) - expect(colDefs.length).toBeGreaterThan(0) + it('does not add checkbox column when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { enableSelection: false })) + expect(f).not.toContain('__select__') }) + }) - it('hides organization column for non-IDIR users', () => { - const colDefs = indexChargingSitesColDefs(false, mockOrgIdToName) + describe('showDateColumns option', () => { + it('adds createdDate and updatedDate when true', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showDateColumns: true })) + expect(f).toContain('createdDate') + expect(f).toContain('updatedDate') + }) - const orgCol = colDefs.find((col) => col.field === 'organization') - expect(orgCol.hide).toBe(true) + it('omits date columns when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showDateColumns: false })) + expect(f).not.toContain('createdDate') + expect(f).not.toContain('updatedDate') }) + }) - it('shows organization column for IDIR users', () => { - const colDefs = indexChargingSitesColDefs(true, mockOrgIdToName) + describe('showIntendedUsers option', () => { + it('includes intendedUsers column when true', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showIntendedUsers: true })) + expect(f).toContain('intendedUsers') + }) - const orgCol = colDefs.find((col) => col.field === 'organization') - expect(orgCol.hide).toBe(false) + it('excludes intendedUsers column when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showIntendedUsers: false })) + expect(f).not.toContain('intendedUsers') }) }) - describe('defaultColDef', () => { - it('has correct default properties', () => { - expect(defaultColDef.editable).toBe(false) - expect(defaultColDef.resizable).toBe(true) - expect(defaultColDef.filter).toBe(false) - expect(defaultColDef.sortable).toBe(false) - expect(defaultColDef.singleClickEdit).toBe(true) + describe('showLocationFields option', () => { + it('includes latitude and longitude when true (default)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showLocationFields: true })) + expect(f).toContain('latitude') + expect(f).toContain('longitude') + }) + + it('excludes latitude and longitude when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showLocationFields: false })) + expect(f).not.toContain('latitude') + expect(f).not.toContain('longitude') }) }) - describe('indexDefaultColDef', () => { - it('has correct index default properties', () => { - expect(indexDefaultColDef.editable).toBe(false) - expect(indexDefaultColDef.resizable).toBe(true) - expect(indexDefaultColDef.floatingFilter).toBe(true) - expect(indexDefaultColDef.suppressFloatingFilterButton).toBe(true) + describe('showNotes option', () => { + it('includes notes column when true', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showNotes: true })) + expect(f).toContain('notes') + }) + + it('excludes notes column when false (default)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showNotes: false })) + expect(f).not.toContain('notes') }) }) + + describe('status column valueGetter', () => { + const statusCol = () => chargingEquipmentColDefs(mockT).find((c) => c.field === 'status') + + it('reads nested status.status', () => { + expect(statusCol().valueGetter({ data: { status: { status: 'Validated' } } })).toBe('Validated') + }) + + it('reads flat status string', () => { + expect(statusCol().valueGetter({ data: { status: 'Draft' } })).toBe('Draft') + }) + + it('returns empty string when status is absent', () => { + expect(statusCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('siteName column valueGetter', () => { + const siteCol = () => chargingEquipmentColDefs(mockT).find((c) => c.field === 'siteName') + + it('prefers chargingSite.siteName', () => { + expect(siteCol().valueGetter({ + data: { chargingSite: { siteName: 'Alpha' }, siteName: 'Beta' } + })).toBe('Alpha') + }) + + it('falls back to direct siteName', () => { + expect(siteCol().valueGetter({ data: { siteName: 'Beta' } })).toBe('Beta') + }) + + it('returns empty string when absent', () => { + expect(siteCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('levelOfEquipment column valueGetter', () => { + const levelCol = () => chargingEquipmentColDefs(mockT).find((c) => c.field === 'levelOfEquipment') + + it('reads nested levelOfEquipment.name', () => { + expect(levelCol().valueGetter({ data: { levelOfEquipment: { name: 'Level 2' } } })).toBe('Level 2') + }) + + it('falls back to flat levelOfEquipmentName', () => { + expect(levelCol().valueGetter({ data: { levelOfEquipmentName: 'Level 1' } })).toBe('Level 1') + }) + + it('returns empty string when absent', () => { + expect(levelCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('intendedUse column valueGetter and valueFormatter', () => { + const intendedCol = () => + chargingEquipmentColDefs(mockT, false, { showIntendedUsers: true }).find( + (c) => c.field === 'intendedUse' + ) + + it('maps array of intendedUseTypes to type strings', () => { + const col = intendedCol() + const result = col.valueGetter({ + data: { intendedUseTypes: [{ type: 'Commercial' }, { type: 'Fleet' }] } + }) + // valueGetter extracts .type, returning an array of strings + expect(result).toEqual(['Commercial', 'Fleet']) + }) + + it('falls back to intendedUses array and extracts type strings', () => { + const col = intendedCol() + const result = col.valueGetter({ + data: { intendedUses: [{ type: 'Public' }] } + }) + expect(result).toEqual(['Public']) + }) + + it('valueFormatter joins type strings with ", "', () => { + const col = intendedCol() + const formatted = col.valueFormatter({ + value: ['Commercial', 'Fleet'] + }) + expect(formatted).toBe('Commercial, Fleet') + }) + + it('valueFormatter returns empty string for non-array', () => { + const col = intendedCol() + expect(col.valueFormatter({ value: null })).toBe('') + expect(col.valueFormatter({ value: undefined })).toBe('') + }) + }) + + describe('intendedUsers column valueGetter', () => { + const intendedUsersCol = () => + chargingEquipmentColDefs(mockT, false, { showIntendedUsers: true }).find( + (c) => c.field === 'intendedUsers' + ) + + it('maps intendedUsers array to typeName values', () => { + const result = intendedUsersCol().valueGetter({ + data: { intendedUsers: [{ typeName: 'Fleet' }, { typeName: 'Public' }] } + }) + expect(result).toEqual(['Fleet', 'Public']) + }) + + it('falls back to intendedUserTypes', () => { + const result = intendedUsersCol().valueGetter({ + data: { intendedUserTypes: [{ typeName: 'Multi-unit residential' }] } + }) + expect(result).toEqual(['Multi-unit residential']) + }) + + it('valueFormatter joins with ", "', () => { + const col = intendedUsersCol() + expect(col.valueFormatter({ value: ['Fleet', 'Public'] })).toBe('Fleet, Public') + }) + }) +}) + +// --------------------------------------------------------------------------- +// indexChargingSitesColDefs +// --------------------------------------------------------------------------- + +describe('indexChargingSitesColDefs', () => { + const orgMap = { 1: 'Org One', 2: 'Org Two' } + + describe('required fields', () => { + it.each([ + 'status', 'organization', 'siteName', 'siteCode', + 'streetAddress', 'city', 'postalCode', 'allocatingOrganization', 'notes' + ])('includes field "%s"', (field) => { + expect(fields(indexChargingSitesColDefs(false, orgMap))).toContain(field) + }) + }) + + describe('organization column visibility', () => { + it('is hidden for BCeID (isIDIR=false)', () => { + const col = indexChargingSitesColDefs(false, orgMap).find((c) => c.field === 'organization') + expect(col.hide).toBe(true) + }) + + it('is visible for IDIR (isIDIR=true)', () => { + const col = indexChargingSitesColDefs(true, orgMap).find((c) => c.field === 'organization') + expect(col.hide).toBe(false) + }) + }) + + describe('organization column valueGetter', () => { + const orgCol = (isIDIR = false) => + indexChargingSitesColDefs(isIDIR, orgMap).find((c) => c.field === 'organization') + + it('prefers organization.name from nested object', () => { + expect(orgCol().valueGetter({ + data: { organization: { name: 'From Object' }, organizationId: 1 } + })).toBe('From Object') + }) + + it('falls back to orgIdToName lookup', () => { + expect(orgCol().valueGetter({ + data: { organizationId: 2 } + })).toBe('Org Two') + }) + + it('returns empty string when neither source is available', () => { + expect(orgCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('allocatingOrganization column valueGetter', () => { + const allocCol = () => + indexChargingSitesColDefs(false, orgMap).find((c) => c.field === 'allocatingOrganization') + + it('prefers allocatingOrganization.name from nested object', () => { + expect(allocCol().valueGetter({ + data: { + allocatingOrganization: { name: 'BC Hydro' }, + allocatingOrganizationName: 'FortisBC' + } + })).toBe('BC Hydro') + }) + + it('falls back to allocatingOrganizationName text field', () => { + expect(allocCol().valueGetter({ + data: { allocatingOrganizationName: 'FortisBC' } + })).toBe('FortisBC') + }) + + it('returns empty string when neither is present', () => { + expect(allocCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('column properties', () => { + it('allocatingOrganization column supports filter and sort', () => { + const col = indexChargingSitesColDefs(false, orgMap).find((c) => c.field === 'allocatingOrganization') + expect(col.filter).toBe(true) + expect(col.sortable).toBe(true) + }) + + it('organization column supports filter and sort', () => { + const col = indexChargingSitesColDefs(true, orgMap).find((c) => c.field === 'organization') + expect(col.filter).toBe(true) + expect(col.sortable).toBe(true) + }) + }) +}) + +// --------------------------------------------------------------------------- +// defaultColDef +// --------------------------------------------------------------------------- + +describe('defaultColDef', () => { + it('is not editable', () => expect(defaultColDef.editable).toBe(false)) + it('is resizable', () => expect(defaultColDef.resizable).toBe(true)) + it('has no filter', () => expect(defaultColDef.filter).toBe(false)) + it('is not sortable', () => expect(defaultColDef.sortable).toBe(false)) + it('uses single-click edit', () => expect(defaultColDef.singleClickEdit).toBe(true)) + it('has no floating filter', () => expect(defaultColDef.floatingFilter).toBe(false)) +}) + +// --------------------------------------------------------------------------- +// indexDefaultColDef +// --------------------------------------------------------------------------- + +describe('indexDefaultColDef', () => { + it('is not editable', () => expect(indexDefaultColDef.editable).toBe(false)) + it('is resizable', () => expect(indexDefaultColDef.resizable).toBe(true)) + it('enables floating filter', () => expect(indexDefaultColDef.floatingFilter).toBe(true)) + it('suppresses floating filter button', () => expect(indexDefaultColDef.suppressFloatingFilterButton).toBe(true)) }) diff --git a/frontend/src/views/ChargingSite/components/_schema.jsx b/frontend/src/views/ChargingSite/components/_schema.jsx index c52a3be5f..7a8b7f117 100644 --- a/frontend/src/views/ChargingSite/components/_schema.jsx +++ b/frontend/src/views/ChargingSite/components/_schema.jsx @@ -354,6 +354,16 @@ export const chargingEquipmentColDefs = (t, isIDIR = false, options = {}) => { minWidth: 200 }) } + cols.push({ + field: 'allocatingOrganizationName', + headerName: t('chargingSite:fseColumnLabels.allocatingOrg'), + minWidth: 250, + sortable: false, + valueGetter: (params) => + params.data.chargingSite?.allocatingOrganizationName || + params.data.allocatingOrganizationName || + '' + }) // Registration Number cols.push({ @@ -412,16 +422,6 @@ export const chargingEquipmentColDefs = (t, isIDIR = false, options = {}) => { minWidth: 160, sortable: false }) - cols.push({ - field: 'allocatingOrganizationName', - headerName: t('chargingSite:fseColumnLabels.allocatingOrg'), - minWidth: 250, - sortable: false, - valueGetter: (params) => - params.data.chargingSite?.allocatingOrganizationName || - params.data.allocatingOrganizationName || - '' - }) // Intended Uses cols.push({