Files
site/WESP_REL/tests/test_feed_mill_sync.py
T
2026-07-17 12:57:18 +03:00

385 lines
13 KiB
Python

"""Sync кормоцеха: feed_dispenser type=mill и рецепты без period_recipes."""
from __future__ import annotations
import os
import tempfile
import unittest
import uuid
from datetime import datetime
from sqlalchemy import select
from app import create_app, db
from app.models import (
Component,
FeedDispenser,
Ingredient,
Recipe,
SyncClient,
SyncEngineState,
SyncQueue,
WESP_SUPPRESS_SYNC_ENQUEUE,
)
from app.services.recipe_update_service import update_recipe_from_payload
from app.services.sync_manager import SyncManager, apply_sync_change, enqueue_sync_queue_task
from app.services.sync_record_data import get_record_data_for_sync
from config import TestingConfig
from tests.helpers.mill_recipe_fixtures import (
create_recipe_with_children,
ingredient_payload_from_row,
recipe_update_payload,
)
class FeedMillSyncConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-feed-mill-sync-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "mill-sync-admin"
AUTH_PASSWORD = "mill-sync-secret"
class FeedMillSyncTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(FeedMillSyncConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
self.node_id = "mill-sync-client-1"
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _register_and_bootstrap(self) -> None:
resp = self.client.post(
"/api/sync/register",
json={"client_id": self.node_id, "client_name": "Mill sync client"},
)
self.assertEqual(resp.status_code, 200)
state = db.session.get(SyncEngineState, 1)
if state is None:
state = SyncEngineState(id=1)
db.session.add(state)
state.universal_bootstrap_completed_at = datetime.now()
state.universal_bootstrap_cursor = 999
sc = db.session.execute(
select(SyncClient).where(SyncClient.node_id == self.node_id)
).scalar_one()
sc.personal_snapshot_cursor = 999
sc.personal_snapshot_completed_at = datetime.now()
db.session.commit()
def test_apply_sync_feed_dispenser_mill_type(self) -> None:
mill_id = str(uuid.uuid4())
data = {
"id": mill_id,
"name": "Цех sync",
"farm": "Ферма",
"operator": "Оператор",
"type": "mill",
"is_active": True,
"version": 1,
"content_hash": "",
"created_by": "system",
"updated_by": "system",
"is_deleted": False,
}
res = apply_sync_change("feed_dispenser", mill_id, "create", data)
self.assertTrue(res.get("success"), msg=res.get("error"))
db.session.commit()
row = db.session.get(FeedDispenser, mill_id)
self.assertIsNotNone(row)
self.assertEqual(row.type, "mill")
def test_sync_payload_includes_mill_type_and_target_component(self) -> None:
comp_id = str(uuid.uuid4())
mill_id = str(uuid.uuid4())
recipe_id = str(uuid.uuid4())
db.session.add_all(
[
Component(
id=comp_id,
name="Компонент sync",
type="grain",
dry_matter=40.0,
protein=0.0,
energy=0.0,
price=0.0,
),
FeedDispenser(
id=mill_id,
name="Цех",
farm="F",
operator="O",
type="mill",
content_hash="",
),
Recipe(
id=recipe_id,
name="Рецепт sync",
heads_per_trip=7,
mixing_time=5,
trip_percent=100.0,
target_component_id=comp_id,
content_hash="",
),
]
)
db.session.commit()
mill_data = get_record_data_for_sync("feed_dispenser", mill_id)
recipe_data = get_record_data_for_sync("recipe", recipe_id)
self.assertIsNotNone(mill_data)
self.assertIsNotNone(recipe_data)
self.assertEqual(mill_data.get("type"), "mill")
self.assertEqual(recipe_data.get("target_component_id"), comp_id)
def test_pull_delivers_mill_dispenser_to_client(self) -> None:
self._register_and_bootstrap()
mill_id = str(uuid.uuid4())
db.session.add(
FeedDispenser(
id=mill_id,
name="Pull mill",
farm="F",
operator="O",
type="mill",
content_hash="",
)
)
enqueue_sync_queue_task(
"feed_dispenser", mill_id, "create", priority=3, target_node_id=None
)
db.session.commit()
res = SyncManager.process_pull(client_id=self.node_id, limit=20)
self.assertEqual(res["status_code"], 200)
changes = res["payload"].get("changes") or []
mill_changes = [
c for c in changes if c.get("table_name") == "feed_dispenser" and c.get("record_id") == mill_id
]
self.assertTrue(mill_changes)
self.assertEqual(mill_changes[0]["data"].get("type"), "mill")
def test_pull_delivers_orphan_recipe_without_period_recipes(self) -> None:
self._register_and_bootstrap()
recipe_id = str(uuid.uuid4())
db.session.add(
Recipe(
id=recipe_id,
name="Orphan pull",
heads_per_trip=3,
mixing_time=2,
trip_percent=100.0,
content_hash="",
)
)
enqueue_sync_queue_task("recipe", recipe_id, "create", priority=2, target_node_id=None)
db.session.commit()
res = SyncManager.process_pull(client_id=self.node_id, limit=50)
self.assertEqual(res["status_code"], 200)
changes = res["payload"].get("changes") or []
recipe_changes = [
c for c in changes if c.get("table_name") == "recipe" and c.get("record_id") == recipe_id
]
self.assertTrue(recipe_changes)
period_changes = [c for c in changes if c.get("table_name") == "period_recipes"]
self.assertEqual(period_changes, [])
def test_push_orphan_recipe_create_roundtrip(self) -> None:
self._register_and_bootstrap()
recipe_id = str(uuid.uuid4())
comp_id = str(uuid.uuid4())
db.session.add(
Component(
id=comp_id,
name="C",
type="grain",
dry_matter=1.0,
protein=0.0,
energy=0.0,
price=0.0,
)
)
db.session.commit()
payload = {
"id": recipe_id,
"name": "Mill push recipe",
"heads_per_trip": 9,
"mixing_time": 4,
"trip_percent": 100.0,
"target_component_id": comp_id,
"dry_matter_locked": False,
"unloading_link_broken": False,
"version": 1,
"content_hash": "",
"created_by": "system",
"updated_by": "system",
"is_deleted": False,
"ingredients": [],
"unloading_groups": [],
}
push = self.client.post(
"/api/sync/push",
json={
"client_id": self.node_id,
"changes": [
{
"table_name": "recipe",
"record_id": recipe_id,
"action": "create",
"data": payload,
}
],
},
)
self.assertEqual(push.status_code, 200)
self.assertTrue(push.get_json().get("success"))
saved = db.session.get(Recipe, recipe_id)
self.assertIsNotNone(saved)
self.assertEqual(saved.name, "Mill push recipe")
self.assertEqual(saved.target_component_id, comp_id)
def test_pull_recipe_update_after_ingredient_delete(self) -> None:
self._register_and_bootstrap()
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
recipe = db.session.get(Recipe, recipe_id)
remove_id, keep_id = ing_ids[0], ing_ids[1]
keep_row = db.session.get(Ingredient, keep_id)
update_recipe_from_payload(
recipe_id,
recipe_update_payload(
recipe,
deleted_ingredient_ids=[remove_id],
ingredients=[ingredient_payload_from_row(keep_row, order=1)],
groups=[],
),
)
db.session.commit()
res = SyncManager.process_pull(client_id=self.node_id, limit=50)
self.assertEqual(res["status_code"], 200)
changes = res["payload"].get("changes") or []
ing_delete = [
c
for c in changes
if c.get("table_name") == "ingredient"
and c.get("record_id") == remove_id
and c.get("action") == "delete"
]
recipe_update = [
c
for c in changes
if c.get("table_name") == "recipe"
and c.get("record_id") == recipe_id
and c.get("action") == "update"
]
self.assertTrue(ing_delete, changes)
self.assertTrue(recipe_update, changes)
deleted_data = get_record_data_for_sync("ingredient", remove_id)
self.assertIsNotNone(deleted_data)
self.assertTrue(deleted_data.get("is_deleted"))
def test_apply_ingredient_delete_on_client_db(self) -> None:
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
remove_id = ing_ids[0]
data = get_record_data_for_sync("ingredient", remove_id)
self.assertIsNotNone(data)
data = dict(data)
data["is_deleted"] = True
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
res = apply_sync_change("ingredient", remove_id, "delete", data)
self.assertTrue(res.get("success"), res.get("error"))
db.session.commit()
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
active = db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalars().all()
self.assertEqual(len(active), 1)
self.assertTrue(db.session.get(Ingredient, remove_id).is_deleted)
def test_pull_recipe_with_nested_ingredients_after_update(self) -> None:
self._register_and_bootstrap()
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
recipe = db.session.get(Recipe, recipe_id)
keep = db.session.get(Ingredient, ing_ids[1])
update_recipe_from_payload(
recipe_id,
recipe_update_payload(
recipe,
deleted_ingredient_ids=[ing_ids[0]],
ingredients=[ingredient_payload_from_row(keep, order=1)],
groups=[],
),
)
db.session.commit()
keep_data = get_record_data_for_sync("ingredient", str(keep.id))
self.assertIsNotNone(keep_data)
self.assertFalse(keep_data.get("is_deleted"))
self.assertEqual(str(keep_data.get("component_id")), str(keep.component_id))
res = SyncManager.process_pull(client_id=self.node_id, limit=50)
changes = res["payload"].get("changes") or []
recipe_changes = [c for c in changes if c.get("table_name") == "recipe" and c.get("record_id") == recipe_id]
ing_updates = [
c
for c in changes
if c.get("table_name") == "ingredient" and c.get("record_id") == str(keep.id)
]
self.assertTrue(recipe_changes)
self.assertTrue(ing_updates)
def test_mill_recipe_delete_enqueued_on_api_delete(self) -> None:
self._register_and_bootstrap()
login = self.client.post(
"/api/auth/login",
json={"login": "mill-sync-admin", "password": "mill-sync-secret"},
)
self.assertEqual(login.status_code, 200)
recipe_id = str(uuid.uuid4())
db.session.add(
Recipe(
id=recipe_id,
name="To delete",
heads_per_trip=1,
mixing_time=1,
content_hash="",
)
)
db.session.commit()
delete = self.client.delete(f"/api/recipes/{recipe_id}")
self.assertEqual(delete.status_code, 200)
task = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "recipe",
SyncQueue.record_id == recipe_id,
SyncQueue.action == "delete",
)
).scalar_one_or_none()
self.assertIsNotNone(task)
if __name__ == "__main__":
unittest.main()