Skip to content

Commit a753efe

Browse files
Merge pull request #108 from microsoft/psl-backend-unit-test-part-II
feat: backend unit test part II
2 parents 774f61d + 9aa6a13 commit a753efe

26 files changed

Lines changed: 1737 additions & 88 deletions

.coveragerc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[run]
2+
omit =
3+
src/tests/backend/*
4+
*/test_*.py
5+
*/*_test.py
6+
*/__init__.py
7+
8+
[report]
9+
exclude_lines =
10+
pragma: no cover
11+
if __name__ == "__main__":

.github/workflows/test.yml

Lines changed: 12 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
name: Test Workflow with Coverage - Code-Gen
22

33
on:
4+
workflow_dispatch:
45
push:
56
branches:
67
- main
78
- dev
89
- demo
10+
- hotfix
911
pull_request:
1012
types:
1113
- opened
@@ -16,52 +18,11 @@ on:
1618
- main
1719
- dev
1820
- demo
21+
- hotfix
1922

2023
jobs:
21-
# frontend_tests:
22-
# runs-on: ubuntu-latest
23-
24-
# steps:
25-
# - name: Checkout code
26-
# uses: actions/checkout@v3
27-
28-
# - name: Set up Node.js
29-
# uses: actions/setup-node@v3
30-
# with:
31-
# node-version: '20'
32-
33-
# - name: Check if Frontend Test Files Exist
34-
# id: check_frontend_tests
35-
# run: |
36-
# if [ -z "$(find src/tests/frontend -type f -name '*.test.js' -o -name '*.test.ts' -o -name '*.test.tsx')" ]; then
37-
# echo "No frontend test files found, skipping frontend tests."
38-
# echo "skip_frontend_tests=true" >> $GITHUB_ENV
39-
# else
40-
# echo "Frontend test files found, running tests."
41-
# echo "skip_frontend_tests=false" >> $GITHUB_ENV
42-
# fi
43-
44-
# - name: Install Frontend Dependencies
45-
# if: env.skip_frontend_tests == 'false'
46-
# run: |
47-
# cd src/frontend
48-
# npm install
49-
50-
# - name: Run Frontend Tests with Coverage
51-
# if: env.skip_frontend_tests == 'false'
52-
# run: |
53-
# cd src/tests/frontend
54-
# npm run test -- --coverage
55-
56-
# - name: Skip Frontend Tests
57-
# if: env.skip_frontend_tests == 'true'
58-
# run: |
59-
# echo "Skipping frontend tests because no test files were found."
60-
6124
backend_tests:
6225
runs-on: ubuntu-latest
63-
64-
6526
steps:
6627
- name: Checkout code
6728
uses: actions/checkout@v3
@@ -71,36 +32,22 @@ jobs:
7132
with:
7233
python-version: '3.11'
7334

74-
- name: Install Backend Dependencies
35+
- name: Install Dependencies
7536
run: |
7637
python -m pip install --upgrade pip
7738
pip install -r src/backend/requirements.txt
7839
pip install -r src/frontend/requirements.txt
79-
pip install pytest-cov
80-
pip install pytest-asyncio
40+
pip install pytest-cov pytest-asyncio
41+
8142
- name: Set PYTHONPATH
8243
run: echo "PYTHONPATH=$PWD/src/backend" >> $GITHUB_ENV
8344

84-
- name: Check if Backend Test Files Exist
85-
id: check_backend_tests
86-
run: |
87-
if [ -z "$(find src/tests/backend -type f -name '*_test.py')" ]; then
88-
echo "No backend test files found, skipping backend tests."
89-
echo "skip_backend_tests=true" >> $GITHUB_ENV
90-
else
91-
echo "Backend test files found, running tests."
92-
echo "skip_backend_tests=false" >> $GITHUB_ENV
93-
fi
94-
9545
- name: Run Backend Tests with Coverage
96-
if: env.skip_backend_tests == 'false'
9746
run: |
9847
cd src
99-
pytest --cov=. --cov-report=term-missing --cov-report=xml
100-
101-
102-
103-
- name: Skip Backend Tests
104-
if: env.skip_backend_tests == 'true'
105-
run: |
106-
echo "Skipping backend tests because no test files were found."
48+
# only measure coverage for src/backend, omit tests via .coveragerc
49+
pytest \
50+
--cov=backend \
51+
--cov-report=term-missing \
52+
--cov-report=xml \
53+
--cov-config=../.coveragerc

src/backend/common/storage/blob_base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def upload_file(
2525
Returns:
2626
Dict containing upload details (url, size, etc.)
2727
"""
28-
pass
28+
pass # pragma: no cover
2929

3030
@abstractmethod
3131
async def get_file(self, blob_path: str) -> BinaryIO:
@@ -38,7 +38,7 @@ async def get_file(self, blob_path: str) -> BinaryIO:
3838
Returns:
3939
File content as a binary stream
4040
"""
41-
pass
41+
pass # pragma: no cover
4242

4343
@abstractmethod
4444
async def delete_file(self, blob_path: str) -> bool:
@@ -51,7 +51,7 @@ async def delete_file(self, blob_path: str) -> bool:
5151
Returns:
5252
True if deletion was successful
5353
"""
54-
pass
54+
pass # pragma: no cover
5555

5656
@abstractmethod
5757
async def list_files(self, prefix: Optional[str] = None) -> list[Dict[str, Any]]:
@@ -64,4 +64,4 @@ async def list_files(self, prefix: Optional[str] = None) -> list[Dict[str, Any]]
6464
Returns:
6565
List of blob details
6666
"""
67-
pass
67+
pass # pragma: no cover

src/backend/sql_agents/convert_script.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
logger.setLevel(logging.DEBUG)
3535

3636

37-
async def convert_script(
37+
async def convert_script( # pragma: no cover
3838
source_script,
3939
file: FileRecord,
4040
batch_service: BatchService,

src/tests/backend/api/auth/__init__.py

Whitespace-only changes.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import base64
2+
import json
3+
from unittest.mock import MagicMock
4+
5+
from api.auth.auth_utils import UserDetails, get_authenticated_user, get_tenant_id
6+
7+
from fastapi import HTTPException, Request
8+
9+
import pytest
10+
11+
12+
def test_get_tenant_id_valid():
13+
payload = {"tid": "tenant123"}
14+
encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
15+
16+
result = get_tenant_id(encoded)
17+
assert result == "tenant123"
18+
19+
20+
def test_get_tenant_id_invalid():
21+
invalid_b64 = "invalid_base64_string"
22+
result = get_tenant_id(invalid_b64)
23+
assert result == ""
24+
25+
26+
def test_user_details_initialization_with_tenant():
27+
payload = {"tid": "tenant456"}
28+
encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
29+
30+
user_data = {
31+
"user_principal_id": "user1",
32+
"user_name": "John Doe",
33+
"auth_provider": "aad",
34+
"auth_token": "fake_token",
35+
"client_principal_b64": encoded,
36+
}
37+
38+
user = UserDetails(user_data)
39+
assert user.user_principal_id == "user1"
40+
assert user.user_name == "John Doe"
41+
assert user.tenant_id == "tenant456"
42+
43+
44+
def test_user_details_initialization_without_tenant():
45+
user_data = {
46+
"user_principal_id": "user2",
47+
"user_name": "Jane Doe",
48+
"auth_provider": "aad",
49+
"auth_token": "fake_token",
50+
"client_principal_b64": "your_base_64_encoded_token",
51+
}
52+
53+
user = UserDetails(user_data)
54+
assert user.tenant_id is None
55+
56+
57+
def test_get_authenticated_user_valid():
58+
headers = {
59+
"x-ms-client-principal-id": "user3",
60+
}
61+
62+
mock_request = MagicMock(spec=Request)
63+
mock_request.headers = headers
64+
65+
user = get_authenticated_user(mock_request)
66+
assert isinstance(user, UserDetails)
67+
assert user.user_principal_id == "user3"
68+
69+
70+
def test_get_authenticated_user_raises_http_exception(monkeypatch):
71+
# Mocking a development environment with no user principal in sample_user
72+
sample_user_mock = {"some-header": "some-value"}
73+
74+
monkeypatch.setattr("api.auth.auth_utils.sample_user", sample_user_mock)
75+
76+
mock_request = MagicMock(spec=Request)
77+
mock_request.headers = {}
78+
79+
with pytest.raises(HTTPException) as exc_info:
80+
get_authenticated_user(mock_request)
81+
82+
assert exc_info.value.status_code == 401
83+
assert exc_info.value.detail == "User not authenticated"
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import asyncio
2+
import uuid
3+
from unittest.mock import AsyncMock, patch
4+
5+
from api import status_updates
6+
7+
from common.models.api import AgentType, FileProcessUpdate, FileResult, ProcessStatus
8+
9+
import pytest
10+
11+
12+
@pytest.fixture
13+
def file_process_update():
14+
return FileProcessUpdate(
15+
batch_id=uuid.uuid4(),
16+
file_id=uuid.uuid4(),
17+
process_status=ProcessStatus.IN_PROGRESS,
18+
agent_type=AgentType.MIGRATOR,
19+
agent_message="Processing in progress",
20+
file_result=FileResult.INFO
21+
)
22+
23+
24+
@pytest.fixture
25+
def mock_websocket():
26+
return AsyncMock()
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_send_status_update_async_success(file_process_update):
31+
mock_websocket = AsyncMock()
32+
status_updates.app_connection_manager.add_connection(file_process_update.batch_id, mock_websocket)
33+
34+
with patch("api.status_updates.json.dumps", return_value='{"batch_id": "test_batch", "status": "Processing", "progress": 50}'):
35+
await status_updates.send_status_update_async(file_process_update)
36+
37+
mock_websocket.send_text.assert_awaited_once()
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_send_status_update_async_no_connection(file_process_update):
42+
# No connection added
43+
with patch("api.status_updates.logger") as mock_logger:
44+
await status_updates.send_status_update_async(file_process_update)
45+
mock_logger.warning.assert_called_once_with(
46+
"No connection found for batch ID: %s", file_process_update.batch_id
47+
)
48+
49+
50+
def test_send_status_update_success(file_process_update):
51+
mock_websocket = AsyncMock()
52+
loop = asyncio.new_event_loop()
53+
54+
with patch("api.status_updates.asyncio.get_event_loop", return_value=loop):
55+
with patch("api.status_updates.asyncio.run_coroutine_threadsafe") as mock_run:
56+
status_updates.app_connection_manager.add_connection(str(file_process_update.batch_id), mock_websocket)
57+
58+
with patch("api.status_updates.json.dumps", return_value='{}'):
59+
status_updates.send_status_update(file_process_update)
60+
61+
mock_run.assert_called_once()
62+
63+
64+
def test_send_status_update_no_connection(file_process_update):
65+
with patch("api.status_updates.logger") as mock_logger:
66+
status_updates.send_status_update(file_process_update)
67+
68+
mock_logger.warning.assert_called()
69+
args, kwargs = mock_logger.warning.call_args
70+
assert "No connection found for batch ID" in args[0]
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_close_connection_success(file_process_update, mock_websocket):
75+
status_updates.app_connection_manager.add_connection(file_process_update.batch_id, mock_websocket)
76+
loop = asyncio.new_event_loop()
77+
78+
with patch("api.status_updates.asyncio.get_event_loop", return_value=loop):
79+
with patch("api.status_updates.asyncio.run_coroutine_threadsafe") as mock_run:
80+
with patch("api.status_updates.logger") as mock_logger:
81+
await status_updates.close_connection(file_process_update.batch_id)
82+
83+
mock_run.assert_called_once()
84+
mock_logger.info.assert_any_call("Connection closed for batch ID: %s", file_process_update.batch_id)
85+
mock_logger.info.assert_any_call("Connection removed for batch ID: %s", file_process_update.batch_id)
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_close_connection_no_connection(file_process_update):
90+
with patch("api.status_updates.logger") as mock_logger:
91+
await status_updates.close_connection(file_process_update.batch_id)
92+
93+
mock_logger.warning.assert_called_once_with(
94+
"No connection found for batch ID: %s", file_process_update.batch_id
95+
)
96+
mock_logger.info.assert_called_once_with(
97+
"Connection removed for batch ID: %s", file_process_update.batch_id
98+
)
99+
100+
101+
# Test the connection manager directly
102+
def test_connection_manager_methods():
103+
# Get the actual connection manager instance
104+
manager = status_updates.app_connection_manager
105+
106+
# Test the get_connection method
107+
batch_id = uuid.uuid4()
108+
assert manager.get_connection(batch_id) is None
109+
110+
# Test add_connection method
111+
mock_websocket = AsyncMock()
112+
manager.add_connection(batch_id, mock_websocket)
113+
assert manager.get_connection(batch_id) == mock_websocket
114+
115+
# Test overwriting an existing connection
116+
new_mock_websocket = AsyncMock()
117+
manager.add_connection(batch_id, new_mock_websocket)
118+
assert manager.get_connection(batch_id) == new_mock_websocket
119+
120+
# Test remove_connection method
121+
manager.remove_connection(batch_id)
122+
assert manager.get_connection(batch_id) is None
123+
124+
# Test removing a non-existent connection (should not raise an error)
125+
manager.remove_connection(uuid.uuid4())
126+
127+
128+
def test_send_status_update_exception(file_process_update):
129+
mock_websocket = AsyncMock()
130+
status_updates.app_connection_manager.add_connection(str(file_process_update.batch_id), mock_websocket)
131+
132+
with patch("api.status_updates.asyncio.get_event_loop") as mock_loop:
133+
mock_loop.return_value = asyncio.new_event_loop()
134+
with patch("api.status_updates.json.dumps", return_value='{}'):
135+
with patch("api.status_updates.asyncio.run_coroutine_threadsafe", side_effect=Exception("send error")):
136+
with patch("api.status_updates.logger") as mock_logger:
137+
status_updates.send_status_update(file_process_update)
138+
mock_logger.error.assert_called_once()
139+
assert "Failed to send message" in mock_logger.error.call_args[0][0]

0 commit comments

Comments
 (0)