AI_Agro_Support/tests/test_db.py
Arsham Mirehvandi f77c0abebd Update database handling and configuration for advisory generation
- Enhanced `.env.example` to include `SQL_APP_DATABASE` for managing additional advisory-related tables.
- Refactored `config.yaml` to remove the `crops` block, shifting to a database-driven approach for job discovery.
- Updated `README.md` to clarify the new database structure and job discovery process, emphasizing the role of the `AI_agrosupport_an_pathogen_models` and `AI_agrosupport_agro_models` tables.
- Modified SQL queries across multiple files to accommodate the new database schema, ensuring correct table references and improving overall data handling.
- Adjusted `pipeline` logic to utilize the new configuration, enhancing the efficiency of the advisory generation process.
2026-10-14 10:57:45 +02:00

236 lines
9.6 KiB
Python

"""Unit tests for the application-database qualifier and ODBC server form."""
from __future__ import annotations
import ast
import unittest
from datetime import date, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
from pipeline.config import SqlSettings
from pipeline.db import _connection_string, app_table, qualify_app_sql
from pipeline.stages.advice import AdviceResult, insert_advice
from pipeline.stages.advice_context import load_last_advice, load_previous_incubation_register
from pipeline.stages.incubations import incubation_start_covered
from pipeline.stages.product_json import ProductJsonBuilder
from pipeline.stages.products import ProductIndex, ProductRow, last_treatment_interval_open
from pipeline.stages.treatments import TreatmentHistory
_ROOT = Path(__file__).resolve().parent.parent
def _sql(**overrides: str) -> SqlSettings:
values = {
"driver": "ODBC Driver 17 for SQL Server",
"server": "localhost",
"database": "DBMeteo",
"app_database": "DBMeteo",
"username": "user",
"password": "secret",
}
values.update(overrides)
return SqlSettings(**values)
class AppTableTests(unittest.TestCase):
def test_same_database_stays_bare(self) -> None:
self.assertEqual(app_table(_sql(), "advisories"), "advisories")
self.assertEqual(app_table(_sql(app_database="dbmeteo"), "products"), "products")
def test_different_database_is_three_part(self) -> None:
settings = _sql(app_database="Agrosupport")
self.assertEqual(app_table(settings, "advisories"), "[Agrosupport].dbo.[advisories]")
def test_qualify_leaves_placeholders(self) -> None:
sql = "SELECT product_id FROM {product_uses} WHERE product_id IN ({placeholders})"
qualified = qualify_app_sql(_sql(app_database="Agrosupport"), sql)
self.assertEqual(
qualified,
"SELECT product_id FROM [Agrosupport].dbo.[product_uses] "
"WHERE product_id IN ({placeholders})",
)
def test_unknown_table_rejected(self) -> None:
with self.assertRaises(ValueError):
app_table(_sql(), "AI_agrosupport_cmp_layers")
class ConnectionStringTests(unittest.TestCase):
def test_colon_port_becomes_comma(self) -> None:
text = _connection_string(_sql(server="192.168.220.50:1433"))
self.assertIn("SERVER=192.168.220.50,1433;", text)
def test_named_instance_unchanged(self) -> None:
text = _connection_string(_sql(server=r"localhost\SQLEXPRESS"))
self.assertIn(r"SERVER=localhost\SQLEXPRESS;", text)
def test_comma_port_unchanged(self) -> None:
text = _connection_string(_sql(server="192.168.220.50,1433"))
self.assertIn("SERVER=192.168.220.50,1433;", text)
def _product() -> ProductRow:
return ProductRow(
product_id="p1",
product_name="LIETO",
registration_number=None,
is_organic_certified=False,
systemicity="contact",
has_preventive_action=True,
has_curative_action=False,
has_eradicant_action=False,
target_crops=["grapevine"],
target_diseases=["downy mildew"],
active_ingredients=[],
frac_codes=[],
)
class QualifiedSqlTests(unittest.TestCase):
def setUp(self) -> None:
self.split = _sql(app_database="Agrosupport")
def test_product_index_qualifies_products_and_leaves_the_catalog(self) -> None:
with patch("pipeline.stages.products.fetch_all", side_effect=[[], []]) as fetch:
ProductIndex.load(MagicMock(), self.split)
products_sql, catalog_sql = (call.args[1] for call in fetch.call_args_list)
self.assertIn("[Agrosupport].dbo.[products]", products_sql)
self.assertIn("AI_agrosupport_products_catalog", catalog_sql)
self.assertNotIn("Agrosupport", catalog_sql)
def test_treatment_interval_qualifies_product_uses(self) -> None:
with patch("pipeline.stages.products.fetch_all", return_value=[]) as fetch:
last_treatment_interval_open(
MagicMock(),
ProductIndex([_product()]),
crop_english="grapevine",
disease_english="downy mildew",
latest_spray=(date(2026, 4, 6), ["LIETO"]),
as_of=date(2026, 4, 16),
sql=self.split,
)
self.assertIn("[Agrosupport].dbo.[product_uses]", fetch.call_args.args[1])
def test_incubation_coverage_qualifies_product_uses(self) -> None:
as_of = date(2026, 4, 16)
start = as_of - timedelta(days=2)
with patch("pipeline.stages.incubations.fetch_all", return_value=[]) as fetch:
incubation_start_covered(
MagicMock(),
ProductIndex([_product()]),
crop_english="grapevine",
disease_english="downy mildew",
start_date=start,
treatment_history=TreatmentHistory(as_of=as_of, by_day={start - timedelta(days=4): ["LIETO"]}),
sql=self.split,
)
self.assertIn("[Agrosupport].dbo.[product_uses]", fetch.call_args.args[1])
def test_product_json_qualifies_uses_and_chunks(self) -> None:
builder = ProductJsonBuilder(ProductIndex([_product()]), "grapevine", "downy mildew", self.split)
with patch("pipeline.stages.product_json.fetch_all", side_effect=[[], []]) as fetch:
builder.build(MagicMock(), "LIETO")
uses_sql, chunks_sql = (call.args[1] for call in fetch.call_args_list)
self.assertIn("[Agrosupport].dbo.[product_uses]", uses_sql)
self.assertIn("[Agrosupport].dbo.[label_chunks]", chunks_sql)
def test_advice_reads_qualify_advisories(self) -> None:
as_of = date(2026, 4, 16)
with patch("pipeline.stages.advice_context.fetch_one", return_value=None) as fetch:
load_last_advice(MagicMock(), 4012, "downy mildew", as_of, self.split)
load_previous_incubation_register(MagicMock(), 4012, "downy mildew", as_of, self.split)
for call in fetch.call_args_list:
self.assertIn("[Agrosupport].dbo.[advisories]", call.args[1])
def test_advice_write_qualifies_advisories(self) -> None:
result = AdviceResult(
full_advice="text",
advice_summary="summary",
is_treatment_recommended=False,
recommended_product_names=[],
recommended_doses=[],
recommended_application_date=None,
)
with (
patch("pipeline.stages.advice.transaction", MagicMock()),
patch("pipeline.stages.advice.execute") as execute,
):
insert_advice(
MagicMock(),
issuance_date=date(2026, 4, 16),
field_id=4012,
disease_english="downy mildew",
advice_input_json={},
result=result,
sql=self.split,
)
delete_sql, insert_sql = (call.args[1] for call in execute.call_args_list)
self.assertIn("DELETE FROM [Agrosupport].dbo.[advisories]", delete_sql)
self.assertIn("INSERT INTO [Agrosupport].dbo.[advisories]", insert_sql)
def _called_name(node: ast.Call) -> str | None:
func = node.func
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return None
def _passes_sql(node: ast.Call) -> bool:
values = list(node.args) + [keyword.value for keyword in node.keywords]
return any(isinstance(value, ast.Attribute) and value.attr == "sql" for value in values)
class CallSiteTests(unittest.TestCase):
def _calls(self, relative: str) -> list[ast.Call]:
tree = ast.parse((_ROOT / relative).read_text(encoding="utf-8"))
return [node for node in ast.walk(tree) if isinstance(node, ast.Call)]
def test_job_passes_sql_settings(self) -> None:
calls = self._calls("pipeline/job.py")
expected = {
"load_previous_incubation_register",
"last_treatment_interval_open",
"incubation_start_covered",
"load_last_advice",
"insert_advice",
}
found = {name for node in calls if (name := _called_name(node)) in expected}
self.assertEqual(found, expected)
for node in calls:
if _called_name(node) in expected:
self.assertTrue(_passes_sql(node), _called_name(node))
def test_resources_pass_sql_settings(self) -> None:
calls = self._calls("pipeline/resources.py")
load = [node for node in calls if _called_name(node) == "load"]
builder = [node for node in calls if _called_name(node) == "ProductJsonBuilder"]
self.assertEqual(len(load), 1)
self.assertEqual(len(builder), 1)
self.assertTrue(_passes_sql(load[0]))
self.assertTrue(_passes_sql(builder[0]))
def test_batch_existing_advice_uses_app_database(self) -> None:
calls = self._calls("pipeline/batch.py")
qualify = [node for node in calls if _called_name(node) == "qualify_app_sql"]
self.assertEqual(len(qualify), 1)
self.assertTrue(_passes_sql(qualify[0]))
module = ast.parse((_ROOT / "pipeline/batch.py").read_text(encoding="utf-8"))
constant = next(
node.value.value
for node in module.body
if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == "_EXISTING_ADVICE_SQL"
and isinstance(node.value, ast.Constant)
)
qualified = qualify_app_sql(_sql(app_database="Agrosupport"), constant)
self.assertIn("FROM [Agrosupport].dbo.[advisories]", qualified)
if __name__ == "__main__":
unittest.main()