@@ -0,0 +1,384 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user