Skip to content

Commit e36a10d

Browse files
committed
feat: Add Import of FSE's
* Add new importer.py to handle imports * Add new dialog with states to go through the import process * Use a newly spawned thread to process the import async
1 parent e9bb3c5 commit e36a10d

File tree

20 files changed

+1305
-93
lines changed

20 files changed

+1305
-93
lines changed

backend/lcfs/db/dependencies.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from typing import AsyncGenerator
33

44
from fastapi import Request
5-
from redis import asyncio as aioredis
65
from sqlalchemy import text
76
from sqlalchemy.engine import make_url
87
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@@ -30,7 +29,9 @@ async def set_user_context(session: AsyncSession, username: str):
3029
raise e
3130

3231

33-
async def get_async_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
32+
async def get_async_db_session(
33+
request: Request,
34+
) -> AsyncGenerator[AsyncSession, None]:
3435
"""
3536
Create and get database session.
3637
:yield: database session.

backend/lcfs/services/clamav/client.py

-1
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,4 @@ def scan_file(self, file):
4848
result=result,
4949
)
5050
raise VirusScanException(f"Virus detected: {result}")
51-
5251
return result
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import pytest
2+
import asyncio
3+
from unittest.mock import MagicMock, AsyncMock, patch
4+
import concurrent.futures
5+
6+
from lcfs.web.api.final_supply_equipment.importer import FinalSupplyEquipmentImporter
7+
from lcfs.web.api.final_supply_equipment.repo import FinalSupplyEquipmentRepository
8+
from lcfs.web.api.final_supply_equipment.services import FinalSupplyEquipmentServices
9+
from lcfs.web.api.compliance_report.services import ComplianceReportServices
10+
from lcfs.services.clamav.client import ClamAVService
11+
from redis.asyncio import Redis
12+
13+
14+
@pytest.fixture
15+
def mock_repo() -> FinalSupplyEquipmentRepository:
16+
repo = MagicMock(spec=FinalSupplyEquipmentRepository)
17+
return repo
18+
19+
20+
@pytest.fixture
21+
def mock_fse_service() -> FinalSupplyEquipmentServices:
22+
service = MagicMock(spec=FinalSupplyEquipmentServices)
23+
return service
24+
25+
26+
@pytest.fixture
27+
def mock_compliance_service() -> ComplianceReportServices:
28+
service = MagicMock(spec=ComplianceReportServices)
29+
return service
30+
31+
32+
@pytest.fixture
33+
def mock_clamav() -> ClamAVService:
34+
clamav = MagicMock(spec=ClamAVService)
35+
return clamav
36+
37+
38+
@pytest.fixture
39+
def mock_redis() -> Redis:
40+
redis_client = MagicMock(spec=Redis)
41+
redis_client.set = AsyncMock()
42+
redis_client.get = AsyncMock()
43+
return redis_client
44+
45+
46+
@pytest.fixture
47+
def mock_executor():
48+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
49+
yield executor
50+
executor.shutdown(wait=False)
51+
52+
53+
@pytest.fixture
54+
def importer_instance(
55+
mock_repo,
56+
mock_fse_service,
57+
mock_compliance_service,
58+
mock_clamav,
59+
mock_redis,
60+
mock_executor,
61+
):
62+
"""
63+
Creates a FinalSupplyEquipmentImporter with mocked dependencies.
64+
"""
65+
return FinalSupplyEquipmentImporter(
66+
repo=mock_repo,
67+
fse_service=mock_fse_service,
68+
compliance_report_services=mock_compliance_service,
69+
clamav_service=mock_clamav,
70+
redis_client=mock_redis,
71+
executor=mock_executor,
72+
)
73+
74+
75+
@pytest.mark.anyio
76+
async def test_import_data_success(importer_instance, mock_redis):
77+
file_mock = MagicMock()
78+
file_mock.filename = "test.xlsx"
79+
file_mock.read = AsyncMock(return_value=b"fake-excel-contents")
80+
81+
user_mock = MagicMock()
82+
user_mock.organization.organization_code = "TEST-ORG"
83+
84+
with patch(
85+
"lcfs.web.api.final_supply_equipment.importer.import_async",
86+
new=AsyncMock(return_value=None),
87+
) as mock_import_task:
88+
89+
job_id = await importer_instance.import_data(
90+
compliance_report_id=123, user=user_mock, file=file_mock, overwrite=False
91+
)
92+
93+
assert isinstance(job_id, str)
94+
assert len(job_id) > 0
95+
96+
# Check Redis progress was initialized
97+
mock_redis.set.assert_called()
98+
# Check our background task was scheduled
99+
mock_import_task.assert_called()
100+
101+
102+
@pytest.mark.anyio
103+
async def test_import_data_with_clamav(importer_instance, mock_clamav, mock_redis):
104+
with patch("lcfs.settings.settings.clamav_enabled", True):
105+
file_mock = MagicMock()
106+
file_mock.filename = "test.xlsx"
107+
file_mock.read = AsyncMock(return_value=b"excel-data")
108+
user_mock = MagicMock()
109+
110+
with patch(
111+
"lcfs.web.api.final_supply_equipment.importer.import_async", new=AsyncMock()
112+
) as mock_import_task:
113+
job_id = await importer_instance.import_data(
114+
compliance_report_id=999, user=user_mock, file=file_mock, overwrite=True
115+
)
116+
117+
assert job_id
118+
mock_import_task.assert_called()
119+
120+
121+
@pytest.mark.anyio
122+
async def test_get_status_no_job_found(importer_instance, mock_redis):
123+
"""
124+
Tests that get_status returns a default response if redis has no record for job_id.
125+
"""
126+
mock_redis.get = AsyncMock(return_value=None)
127+
128+
result = await importer_instance.get_status("non-existent-id")
129+
130+
assert result["progress"] == 0
131+
assert "No job found" in result["status"]
132+
133+
134+
@pytest.mark.anyio
135+
async def test_get_status_invalid_json(importer_instance, mock_redis):
136+
"""
137+
Tests that get_status handles invalid JSON from redis gracefully.
138+
"""
139+
mock_redis.get = AsyncMock(return_value=b"not-valid-json")
140+
141+
result = await importer_instance.get_status("corrupt-id")
142+
assert result["progress"] == 0
143+
assert "Invalid status data found." in result["status"]

backend/lcfs/tests/final_supply_equipment/test_final_supply_equipment_services.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ def service(mock_request, mock_repo, mock_comp_report_repo):
5050
Instantiate the service class with mocked dependencies.
5151
"""
5252
return FinalSupplyEquipmentServices(
53-
request=mock_request,
5453
repo=mock_repo,
5554
compliance_report_repo=mock_comp_report_repo,
5655
)
@@ -90,7 +89,7 @@ async def test_get_fse_options_success(service, mock_repo, mock_request):
9089
["OrgA", "OrgB"],
9190
)
9291

93-
result = await service.get_fse_options()
92+
result = await service.get_fse_options(mock_request.user)
9493
assert "intended_use_types" in result
9594
assert "levels_of_equipment" in result
9695
assert "intended_user_types" in result
@@ -101,14 +100,14 @@ async def test_get_fse_options_success(service, mock_repo, mock_request):
101100

102101

103102
@pytest.mark.anyio
104-
async def test_get_fse_options_exception(service, mock_repo):
103+
async def test_get_fse_options_exception(service, mock_repo, mock_request):
105104
"""
106105
Test get_fse_options raises HTTP 400 if repo call fails.
107106
"""
108107
mock_repo.get_fse_options.side_effect = Exception("Repo Error")
109108

110109
with pytest.raises(HTTPException, match="Error retrieving FSE options") as exc:
111-
await service.get_fse_options()
110+
await service.get_fse_options(mock_request.user)
112111

113112
assert exc.value.status_code == 400
114113

@@ -235,6 +234,7 @@ async def test_update_final_supply_equipment_not_found(
235234
async def test_create_final_supply_equipment_success(
236235
service,
237236
mock_repo,
237+
mock_request,
238238
valid_final_supply_equipment_schema,
239239
valid_final_supply_equipment_create_schema,
240240
):
@@ -248,7 +248,7 @@ async def test_create_final_supply_equipment_success(
248248
mock_repo.increment_seq_by_org_and_postal_code.return_value = None
249249

250250
new_fse = await service.create_final_supply_equipment(
251-
valid_final_supply_equipment_create_schema
251+
valid_final_supply_equipment_create_schema, mock_request.user
252252
)
253253
assert new_fse is not None
254254
mock_repo.create_final_supply_equipment.assert_awaited_once()
@@ -272,24 +272,26 @@ async def test_generate_registration_number_success(service, mock_repo, mock_req
272272
Test generating a registration number with a valid postal code.
273273
"""
274274
mock_repo.get_current_seq_by_org_and_postal_code.return_value = 1
275-
reg_num = await service.generate_registration_number("A1A 1A1")
275+
reg_num = await service.generate_registration_number(mock_request.user, "A1A 1A1")
276276
# Format => ORGCODE-A1A1A1-002 (for next_number = 2)
277277
assert reg_num.startswith("TESTORG-A1A1A1-")
278278
seq_str = reg_num.split("-")[-1]
279279
assert seq_str == "002" # Because current seq was 1, next is 2
280280

281281

282282
@pytest.mark.anyio
283-
async def test_generate_registration_number_invalid_postal(service):
283+
async def test_generate_registration_number_invalid_postal(service, mock_request):
284284
"""
285285
Test invalid postal code raises HTTP 400.
286286
"""
287287
with pytest.raises(ValueError, match="Invalid Canadian postal code format"):
288-
await service.generate_registration_number("12345")
288+
await service.generate_registration_number(mock_request.user, "12345")
289289

290290

291291
@pytest.mark.anyio
292-
async def test_generate_registration_number_exceeds_limit(service, mock_repo):
292+
async def test_generate_registration_number_exceeds_limit(
293+
service, mock_repo, mock_request
294+
):
293295
"""
294296
Test exceeding maximum registration numbers raises ValueError.
295297
"""
@@ -299,7 +301,7 @@ async def test_generate_registration_number_exceeds_limit(service, mock_repo):
299301
ValueError,
300302
match="Exceeded maximum registration numbers for the given postal code",
301303
):
302-
await service.generate_registration_number("A1A 1A1")
304+
await service.generate_registration_number(mock_request.user, "A1A 1A1")
303305

304306

305307
@pytest.mark.anyio

backend/lcfs/utils/constants.py

+2
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,5 @@ class FILE_MEDIA_TYPE(Enum):
8787
}
8888

8989
default_ci = {"Gasoline": 93.67, "Diesel": 100.21, "Jet fuel": 88.83}
90+
91+
POSTAL_REGEX = r"^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$"

0 commit comments

Comments
 (0)