129 lines
5.6 KiB
Python
129 lines
5.6 KiB
Python
import io
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from production_analytics.calculations.material_consumption import MaterialIntegrationState
|
|
from production_analytics.enlyze.exploration import ConfigurationError
|
|
from production_analytics.enlyze.gateway import EnlyzeProductionRun
|
|
from production_analytics.service.material_polling import MaterialPollResult
|
|
from production_analytics.service.material_runner import MaterialPollingRunner
|
|
from production_analytics.service.postgres_material import (
|
|
PostgresMaterialSnapshotWriter,
|
|
PostgresSettings,
|
|
)
|
|
|
|
ENV = dict(POSTGRES_HOST='localhost', POSTGRES_PORT='5432', POSTGRES_DB='analytics',
|
|
POSTGRES_USER='writer', POSTGRES_PASSWORD='secret-password')
|
|
NOW = datetime(2026, 1, 1, tzinfo=UTC)
|
|
|
|
|
|
@pytest.mark.parametrize('key', ENV)
|
|
@pytest.mark.parametrize('value', [None, '', ' ', '\x00'])
|
|
def test_required_settings(key, value):
|
|
environment = dict(ENV)
|
|
if value is None:
|
|
del environment[key]
|
|
else:
|
|
environment[key] = value
|
|
with pytest.raises(ConfigurationError, match=key) as error:
|
|
PostgresSettings.from_environment(environment)
|
|
assert 'secret-password' not in str(error.value)
|
|
|
|
|
|
@pytest.mark.parametrize('port', ['0', '-1', '65536', '1.5', 'secret-password'])
|
|
def test_invalid_port(port):
|
|
with pytest.raises(ConfigurationError, match='POSTGRES_PORT') as error:
|
|
PostgresSettings.from_environment(dict(ENV, POSTGRES_PORT=port))
|
|
assert str(error.value) == 'POSTGRES_PORT must be an integer from 1 to 65535'
|
|
|
|
|
|
def test_valid_settings_and_password_repr():
|
|
settings = PostgresSettings.from_environment(ENV)
|
|
assert (settings.host, settings.port, settings.dbname, settings.user) == (
|
|
'localhost', 5432, 'analytics', 'writer',
|
|
)
|
|
assert settings.password == 'secret-password'
|
|
assert settings.password not in repr(settings)
|
|
|
|
|
|
def test_parameterized_insert_and_connection_lifecycle():
|
|
driver = MagicMock()
|
|
connection = driver.connect.return_value.__enter__.return_value
|
|
writer = PostgresMaterialSnapshotWriter(PostgresSettings.from_environment(ENV))
|
|
fields = dict(timestamp=NOW, calculation_id='calc', machine_id='machine',
|
|
production_order=" 00842'; DROP TABLE x; -- ", run_id='run', consumption_kg=12.5)
|
|
with patch.dict(sys.modules, psycopg=driver):
|
|
writer.write(**fields)
|
|
sql, parameters = connection.execute.call_args.args
|
|
assert sql.count('%s') == 6
|
|
assert fields['production_order'] not in sql
|
|
assert parameters == tuple(fields.values())
|
|
assert 'ON CONFLICT' in sql
|
|
connection.execute.assert_called_once()
|
|
driver.connect.assert_called_once_with(
|
|
host='localhost', port=5432, dbname='analytics', user='writer',
|
|
password='secret-password', connect_timeout=10, options='-c statement_timeout=10000',
|
|
)
|
|
driver.connect.return_value.__exit__.assert_called_once_with(None, None, None)
|
|
|
|
|
|
def test_writer_failure_propagates_and_next_write_reconnects():
|
|
driver = MagicMock()
|
|
driver.connect.return_value.__enter__.return_value.execute.side_effect = [
|
|
RuntimeError('secret'), None,
|
|
]
|
|
writer = PostgresMaterialSnapshotWriter(PostgresSettings.from_environment(ENV))
|
|
fields = dict(timestamp=NOW, calculation_id='calc', machine_id='machine',
|
|
production_order='order', run_id='run', consumption_kg=0)
|
|
with patch.dict(sys.modules, psycopg=driver):
|
|
with pytest.raises(RuntimeError):
|
|
writer.write(**fields)
|
|
writer.write(**fields)
|
|
assert driver.connect.call_count == 2
|
|
assert driver.connect.return_value.__exit__.call_count == 2
|
|
|
|
|
|
@pytest.mark.parametrize('consumption', [0, 12.5])
|
|
def test_runner_snapshot_fields_and_none(consumption):
|
|
result = MaterialPollResult(
|
|
EnlyzeProductionRun('run', 'machine', None, ' 00842 ', NOW, None),
|
|
MaterialIntegrationState(consumption, 20),
|
|
)
|
|
writer = Mock()
|
|
output = io.StringIO()
|
|
MaterialPollingRunner(
|
|
Mock(poll_once=Mock(side_effect=[None, result])), machine_id='machine',
|
|
calculation_id='configured-calculation', snapshot_writer=writer,
|
|
poll_interval_seconds=10, clock=lambda: NOW,
|
|
sleep=Mock(side_effect=[None, KeyboardInterrupt]), stdout=output,
|
|
).run()
|
|
writer.write.assert_called_once_with(
|
|
timestamp=NOW, calculation_id='configured-calculation', machine_id='machine',
|
|
production_order=' 00842 ', run_id='run', consumption_kg=consumption,
|
|
)
|
|
assert 'no open Production Run' in output.getvalue()
|
|
assert f'consumption_kg={consumption:.9f}' in output.getvalue()
|
|
|
|
|
|
def test_snapshot_failure_reports_safely_and_continues():
|
|
result = MaterialPollResult(
|
|
EnlyzeProductionRun('run', 'machine', None, 'order', NOW, None),
|
|
MaterialIntegrationState(12.5, 20),
|
|
)
|
|
service = Mock(poll_once=Mock(return_value=result))
|
|
writer = Mock(write=Mock(side_effect=[RuntimeError('secret-password arbitrary SQL'), None]))
|
|
output, errors = io.StringIO(), io.StringIO()
|
|
MaterialPollingRunner(
|
|
service, machine_id='machine', calculation_id='calc', snapshot_writer=writer,
|
|
poll_interval_seconds=10, clock=lambda: NOW,
|
|
sleep=Mock(side_effect=[None, KeyboardInterrupt]), stdout=output, stderr=errors,
|
|
).run()
|
|
assert service.poll_once.call_count == writer.write.call_count == 2
|
|
assert errors.getvalue().count('PostgreSQL snapshot write failed') == 1
|
|
assert 'secret-password' not in errors.getvalue()
|
|
assert 'arbitrary SQL' not in errors.getvalue()
|
|
assert output.getvalue().count('consumption_kg=12.500000000') == 2
|