Persist material consumption snapshots
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
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
|
||||
Reference in New Issue
Block a user