"""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()