Skip to content

Commit

Permalink
Merge pull request #474 from koukihai/koukihai/mock-requests
Browse files Browse the repository at this point in the history
tests: Mock requests et DB
  • Loading branch information
m4dm4rtig4n authored Jan 29, 2024
2 parents 5746c92 + e2736fe commit 94adc88
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 103 deletions.
41 changes: 41 additions & 0 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Run Pytests

on:
# Run workflow automatically whenever the workflow, app or tests are updated
push:
paths:
- .github/workflows/pytest.yaml # Assuming this file is stored in .github/workflows/pytest.yaml
- src/**
- tests/**

jobs:
pytest:
name: Run pytests
runs-on: ubuntu-latest
steps:
- name: generate FR locale
run: sudo locale-gen fr_FR.UTF-8
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
cache: 'pip'
- name: Lint with Ruff # Running an optional linter step
run: |
pip install ruff
ruff --output-format=github src/
continue-on-error: true
- name: Install application dependencies
run: |
python -m pip install --upgrade pip
pip install -r src/requirements.txt
- name: Test with pytest
run: |
pip install pytest pytest-cov pytest-mock requests-mock
pytest --cov=src/ --cov-report=xml
- name: Upload coverage reports to Codecov # Optional: Run codecov. Requires: secrets.CODECOV_TOKEN
uses: codecov/codecov-action@v3
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
continue-on-error: true
2 changes: 1 addition & 1 deletion src/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(self, usage_point_id=None):
self.usage_points = [self.db.get_usage_point(self.usage_point_id)]

def boot(self):
if ("DEV" in environ and str2bool(getenv("DEV"))) or ("DEBUG" in environ and str2bool(getenv("DEBUG"))):
if str2bool(getenv("DEV")) or str2bool(getenv("DEBUG")):
logging.warning("=> Import job disable")
else:
self.job_import_data()
Expand Down
16 changes: 8 additions & 8 deletions tests/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Pour executer les tests unitaires de ce projet, veuillez vous placer a la racine
suivante:

```commandline
pip install pytest pytest-cov pytest-mock
python -m pytest --cov=app/ --cov-report=xml
pip install pytest pytest-cov pytest-mock requests-mock
python -m pytest --cov=src/ --cov-report=xml
```

### Execution automatisée
Expand All @@ -20,7 +20,7 @@ on:
push:
paths:
- .github/workflows/pytest.yaml # Assuming this file is stored in .github/workflows/pytest.yaml
- app/**
- src/**
- tests/**
jobs:
Expand All @@ -34,21 +34,21 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
python-version: '3.12'
cache: 'pip'
- name: Lint with Ruff # Running an optional linter step
run: |
pip install ruff
ruff --output-format=github app/
ruff --output-format=github src/
continue-on-error: true
- name: Install application dependencies
run: |
python -m pip install --upgrade pip
pip install -r app/requirements.txt
pip install -r src/requirements.txt
- name: Test with pytest
run: |
pip install pytest pytest-cov pytest-mock
pytest --cov=app/ --cov-report=xml
pip install pytest pytest-cov pytest-mock requests-mock
pytest --cov=src/ --cov-report=xml
- name: Upload coverage reports to Codecov # Optional: Run codecov. Requires: secrets.CODECOV_TOKEN
uses: codecov/codecov-action@v3
env:
Expand Down
86 changes: 86 additions & 0 deletions tests/test_job_get_account_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import pytest

from db_schema import UsagePoints
from test_jobs import job


@pytest.mark.parametrize(
"status_response, status_code",
[
({"incomplete": "response"}, 200),
({"detail": "truthy response"}, 300),
({"detail": "falsy response"}, 500),
({
"consent_expiration_date": "2099-01-01T00:00:00",
"call_number": 42,
"quota_limit": 42,
"quota_reached": 42,
"quota_reset_at": "2099-01-01T00:00:00.000000",
"ban": False
}, 200)],
)
def test_get_account_status(mocker, job, caplog, status_response, status_code, requests_mock):
from config import URL

m_set_error_log = mocker.patch("models.database.Database.set_error_log")
m_usage_point_update = mocker.patch('models.database.Database.usage_point_update')
mocker.patch("models.jobs.Job.header_generate")
requests_mocks = list()

if job.usage_point_id:
rm = requests_mock.get(f"{URL}/valid_access/{job.usage_point_id}/cache", json=status_response,
status_code=status_code)
requests_mocks.append(rm)
expected_count = 1
# FIXME: If job has usage_point_id, get_account_status() expects
# job.usage_point_config.usage_point_id to be populated from a side effect
job.usage_point_config = UsagePoints(usage_point_id=job.usage_point_id)
enabled_usage_points = [job.usage_point_config]
else:
enabled_usage_points = [up for up in job.usage_points if up.enable]
for u in enabled_usage_points:
rm = requests_mock.get(f"{URL}/valid_access/{u.usage_point_id}/cache", json=status_response,
status_code=status_code)
requests_mocks.append(rm)
expected_count = len(enabled_usage_points)

res = job.get_account_status()

assert "INFO root:dependencies.py:88 [PDL1] RÉCUPÉRATION DES INFORMATIONS DU COMPTE :" in caplog.text
is_complete = {"consent_expiration_date", "call_number", "quota_limit", "quota_reached", "quota_reset_at",
"ban"}.issubset(set(status_response.keys()))
is_truthy_response = 200 <= status_code < 400

if is_truthy_response:
if status_code != 200:
# If the status code is truthy, but not 200, the contents of response['detail'] are logged
assert f'ERROR root:query_status.py:75 {status_response["detail"]}\n'

if not is_complete:
# If some fields are missing from a truthy response, an exception is thrown and an error message is displayed
assert "ERROR root:jobs.py:196 Erreur lors de la récupération des informations du compte" in caplog.text

# db.usage_point_update is not called
assert 0 == m_usage_point_update.call_count
# FIXME: db.set_error_log is not called, because returned errors are missing status_code and description.detail
assert 0 == m_set_error_log.call_count

if is_complete and status_code == 200:
# Successful case: db is updated & set_error_log is called with None
assert 1 == m_usage_point_update.call_count
assert expected_count == m_set_error_log.call_count
for u in enabled_usage_points:
m_set_error_log.assert_called_once_with(u.usage_point_id, None)

if not is_truthy_response:
# FIXME: If response(500), no error is displayed
assert "ERROR root:jobs.py:196 Erreur lors de la récupération des informations du compte" not in caplog.text
# db.set_error_log is called
assert expected_count == m_set_error_log.call_count
for u in enabled_usage_points:
m_set_error_log.assert_called_once_with(u.usage_point_id, f"{status_code} - {status_response['detail']}")

# Ensuring {URL}/valid_access/{usage_point_id} is called exactly as many times as enabled usage_points
# and only once per enabled usage_point
for rm in requests_mocks:
assert len(rm.request_history) == 1
25 changes: 25 additions & 0 deletions tests/test_job_get_gateway_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pytest
from test_jobs import job


@pytest.mark.parametrize("response, status_code", [
(None, 200),
(None, 500),
({"mock": "response"}, 200)
])
def test_get_gateway_status(job, caplog, requests_mock, response, status_code):
from config import URL
requests_mock.get(f"{URL}/ping", json=response, status_code=status_code)

job.get_gateway_status()

assert 'INFO root:dependencies.py:88 RÉCUPÉRATION DU STATUT DE LA PASSERELLE :\n' in caplog.text
if status_code != 200:
# FIXME: No error is displayed
assert 'ERROR root:jobs.py:170 Erreur lors de la récupération du statut de la passerelle :\n' not in caplog.text

if status_code == 200:
if response:
assert 'ERROR root:jobs.py:170 Erreur lors de la récupération du statut de la passerelle :\n' not in caplog.text
else:
assert 'ERROR root:jobs.py:170 Erreur lors de la récupération du statut de la passerelle :\n' in caplog.text
117 changes: 23 additions & 94 deletions tests/test_jobs.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import pytest

from db_schema import UsagePoints
from tests.conftest import setenv
from conftest import setenv

EXPORT_METHODS = ["export_influxdb", "export_home_assistant_ws", "export_home_assistant", "export_mqtt"]
PER_USAGE_POINT_METHODS = [
"get_account_status",
"get_contract",
"get_addresses",
"get_consumption",
"get_consumption_detail",
"get_production",
"get_production_detail",
"get_consumption_max_power",
"stat_price",
] + EXPORT_METHODS
"get_account_status",
"get_contract",
"get_addresses",
"get_consumption",
"get_consumption_detail",
"get_production",
"get_production_detail",
"get_consumption_max_power",
"stat_price",
] + EXPORT_METHODS
PER_JOB_METHODS = ["get_gateway_status", "get_tempo", "get_ecowatt"]


Expand All @@ -39,11 +38,10 @@ def test_boot(mocker, caplog, job, envvar_to_true):
res = job.boot()

assert res is None
if envvar_to_true in ["DEV"]:
if envvar_to_true:
assert 0 == m.call_count, "job_import_data should not be called"
assert "WARNING root:jobs.py:50 => Import job disable\n" == caplog.text
else:
# FIXME: job_import_data is called when DEBUG=true
assert "" == caplog.text
m.assert_called_once()

Expand All @@ -54,22 +52,20 @@ def test_job_import_data(mocker, job, caplog):
mockers[method] = mocker.patch(f"models.jobs.Job.{method}")

count_enabled_jobs = len([j for j in job.usage_points if j.enable])
expected_logs = ""

res = job.job_import_data(target=None)

# FIXME: Logline says 10s regardless of job.wait_job_start
expected_logs += "INFO root:dependencies.py:88 DÉMARRAGE DU JOB D'IMPORTATION DANS 10S\n"
assert "INFO root:dependencies.py:88 DÉMARRAGE DU JOB D'IMPORTATION DANS 10S\n" in caplog.text
assert res["status"] is True

for method, m in mockers.items():
if method in PER_JOB_METHODS:
assert m.call_count == 1
else:
assert m.call_count == count_enabled_jobs
m.reset_mock()

assert expected_logs in caplog.text


def test_header_generate(job, caplog):
from dependencies import get_version
Expand All @@ -78,81 +74,14 @@ def test_header_generate(job, caplog):
# FIXME: header_generate() assumes job.usage_point_config is populated from a side effect
for job.usage_point_config in job.usage_points:
assert {
"Authorization": job.usage_point_config.token,
"Content-Type": "application/json",
"call-service": "myelectricaldata",
"version": get_version(),
} == job.header_generate()
"Authorization": job.usage_point_config.token,
"Content-Type": "application/json",
"call-service": "myelectricaldata",
"version": get_version(),
} == job.header_generate()
assert expected_logs == caplog.text


@pytest.mark.parametrize("ping_side_effect", [None, Exception("Mocker: Ping failed")])
def test_get_gateway_status(job, caplog, ping_side_effect, mocker):
m_ping = mocker.patch("models.query_status.Status.ping")
m_ping.side_effect = ping_side_effect
m_ping.return_value = {"mocked": "true"}

job.get_gateway_status()

if ping_side_effect:
assert "ERROR root:jobs.py:170 Erreur lors de la récupération du statut de la passerelle :" in caplog.text
else:
assert "INFO root:dependencies.py:88 RÉCUPÉRATION DU STATUT DE LA PASSERELLE :" in caplog.text


@pytest.mark.parametrize(
"status_return_value, is_supported",
[
({}, True),
({"any_key": "any_value"}, True),
({"error": "only"}, False),
({"error": "with all fields", "status_code": "5xx", "description": {"detail": "proper error"}}, True),
],
)
@pytest.mark.parametrize("status_side_effect", [None, Exception("Mocker: Status failed")])
def test_get_account_status(mocker, job, caplog, status_side_effect, status_return_value, is_supported):
m_status = mocker.patch("models.query_status.Status.status")
m_set_error_log = mocker.patch("models.database.Database.set_error_log")
mocker.patch("models.jobs.Job.header_generate")

m_status.side_effect = status_side_effect
m_status.return_value = status_return_value

enabled_usage_points = [up for up in job.usage_points if up.enable]
if not job.usage_point_id:
expected_count = len(enabled_usage_points)
else:
expected_count = 1
# If job has usage_point_id, get_account_status() expects
# job.usage_point_config.usage_point_id to be populated from a side effect
job.usage_point_config = UsagePoints(usage_point_id=job.usage_point_id)

res = job.get_account_status()

assert "INFO root:dependencies.py:88 [PDL1] RÉCUPÉRATION DES INFORMATIONS DU COMPTE :" in caplog.text
if status_side_effect is None and is_supported:
assert expected_count == m_set_error_log.call_count
if status_return_value.get("error"):
m_set_error_log.assert_called_with("pdl1", "5xx - proper error")
elif status_side_effect:
assert "ERROR root:jobs.py:196 Erreur lors de la récupération des informations du compte" in caplog.text
assert f"ERROR root:jobs.py:197 {status_side_effect}" in caplog.text
# set_error_log is not called in case status() raises an exception
assert 0 == m_set_error_log.call_count
elif not is_supported:
assert "ERROR root:jobs.py:196 Erreur lors de la récupération des informations du compte" in caplog.text
assert "ERROR root:jobs.py:197 'status_code'" in caplog.text
# FIXME: set_error_log is not called in case status() returns
# a dict with an error key but no status_code or description.detail
assert 0 == m_set_error_log.call_count

# Ensuring status() is called exactly as many times as enabled usage_points
# and only once per enabled usage_point
assert expected_count == m_status.call_count
for j in enabled_usage_points:
m_status.assert_called_once_with(usage_point_id=j.usage_point_id)


@pytest.mark.parametrize(
"method, patch, details, line_no",
[
Expand All @@ -163,10 +92,10 @@ def test_get_account_status(mocker, job, caplog, status_side_effect, status_retu
("get_production", "models.query_daily.Daily.get", "Récupération de la production journalière", 318),
("get_production_detail", "models.query_detail.Detail.get", "Récupération de la production détaillée", 346),
(
"get_consumption_max_power",
"models.query_power.Power.get",
"Récupération de la puissance maximum journalière",
367,
"get_consumption_max_power",
"models.query_power.Power.get",
"Récupération de la puissance maximum journalière",
367,
),
],
)
Expand Down

0 comments on commit 94adc88

Please sign in to comment.