@@ -0,0 +1,236 @@
|
||||
"""Sync с двумя независимыми терминалами: pull → apply → confirm на обоих."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
FeedDispenser,
|
||||
FeedingPeriod,
|
||||
Ingredient,
|
||||
PeriodRecipe,
|
||||
Recipe,
|
||||
SyncQueue,
|
||||
)
|
||||
from app.services.recipe_update_service import update_recipe_from_payload
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
from tests.helpers.mill_recipe_fixtures import (
|
||||
create_recipe_with_children,
|
||||
ingredient_payload_from_row,
|
||||
recipe_update_payload,
|
||||
)
|
||||
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
|
||||
|
||||
|
||||
class SyncDualInstanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.harness = SyncDualInstanceHarness()
|
||||
self.harness.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.harness.stop()
|
||||
|
||||
def test_recipe_create_reaches_both_terminals(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
recipe_id, _, _ = create_recipe_with_children(name="Dual create")
|
||||
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
row = self.harness.recipe_on_terminal(term, recipe_id)
|
||||
self.assertIsNotNone(row, msg=f"terminal {term}")
|
||||
self.assertEqual(row.name, "Dual create")
|
||||
|
||||
def test_ingredient_delete_reaches_both_terminals(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
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()
|
||||
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
active = self.harness.active_ingredient_ids(term, recipe_id)
|
||||
self.assertEqual(active, [keep_id], msg=f"terminal {term}")
|
||||
with self.harness.terminal_ctx(term):
|
||||
deleted = db.session.get(Ingredient, remove_id)
|
||||
self.assertTrue(deleted.is_deleted)
|
||||
|
||||
def test_recipe_update_reaches_both_terminals(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
recipe_id, _, _ = create_recipe_with_children(name="Before update")
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
update_recipe_from_payload(
|
||||
recipe_id,
|
||||
recipe_update_payload(
|
||||
recipe,
|
||||
ingredients=[],
|
||||
groups=[],
|
||||
)
|
||||
| {"name": "After dual sync"},
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
row = self.harness.recipe_on_terminal(term, recipe_id)
|
||||
self.assertEqual(row.name, "After dual sync", msg=f"terminal {term}")
|
||||
|
||||
def test_mill_global_delete_reaches_both_terminals(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
|
||||
client = self.harness.server_test_client()
|
||||
resp = client.delete(f"/api/recipes/{recipe_id}")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
with self.harness.terminal_ctx(term):
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
self.assertIsNotNone(recipe)
|
||||
self.assertTrue(recipe.is_deleted)
|
||||
for ing_id in ing_ids:
|
||||
ing = db.session.get(Ingredient, ing_id)
|
||||
self.assertTrue(ing.is_deleted, msg=f"ing {ing_id} term {term}")
|
||||
|
||||
def test_period_recipes_reorder_reaches_both_terminals(self) -> None:
|
||||
disp_id = "dual-disp-1"
|
||||
period_id = "dual-period-1"
|
||||
r1, r2 = "dual-r1", "dual-r2"
|
||||
with self.harness.server_ctx():
|
||||
db.session.add_all(
|
||||
[
|
||||
FeedDispenser(
|
||||
id=disp_id,
|
||||
name="D",
|
||||
farm="F",
|
||||
operator="O",
|
||||
type="dispenser",
|
||||
content_hash="",
|
||||
),
|
||||
FeedingPeriod(id=period_id, name="P", dispenser_id=disp_id),
|
||||
Recipe(id=r1, name="R1", heads_per_trip=1, mixing_time=0, content_hash=""),
|
||||
Recipe(id=r2, name="R2", heads_per_trip=1, mixing_time=0, content_hash=""),
|
||||
]
|
||||
)
|
||||
db.session.flush()
|
||||
now = datetime.now()
|
||||
for rid, ord_ in ((r1, 1), (r2, 2)):
|
||||
db.session.add(
|
||||
PeriodRecipe(
|
||||
period_id=period_id,
|
||||
recipe_id=rid,
|
||||
order=ord_,
|
||||
created_at=now,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
for rid in (r1, r2):
|
||||
enqueue_sync_queue_task("recipe", rid, "create", priority=2, target_node_id=None)
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes", f"{period_id}:{r1}", "create", priority=2, target_node_id=None
|
||||
)
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes", f"{period_id}:{r2}", "create", priority=2, target_node_id=None
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
self.harness.drain_sync()
|
||||
|
||||
client = self.harness.server_test_client()
|
||||
move = client.put(
|
||||
f"/api/recipes/{r1}/move",
|
||||
json={"period_id": period_id, "from_index": 0, "to_index": 1},
|
||||
)
|
||||
self.assertEqual(move.status_code, 200, move.get_json())
|
||||
time.sleep(0.25)
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
order = self.harness.period_recipe_order(term, period_id)
|
||||
self.assertEqual(order, [r2, r1], msg=f"terminal {term}")
|
||||
|
||||
def test_confirm_waits_for_both_terminals(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
recipe_id, _, _ = create_recipe_with_children(name="Confirm gate")
|
||||
task = self.harness.latest_task("recipe", recipe_id, "create")
|
||||
self.assertIsNotNone(task)
|
||||
task_id = task.id
|
||||
|
||||
self.harness.sync_terminal("a")
|
||||
self.assertEqual(self.harness.task_status(task_id), "processing")
|
||||
|
||||
self.harness.sync_terminal("b")
|
||||
self.assertEqual(self.harness.task_status(task_id), "completed")
|
||||
|
||||
def test_late_join_terminal_catches_up(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
recipe_id, _, _ = create_recipe_with_children(name="Late join")
|
||||
|
||||
self.harness.sync_terminal("a")
|
||||
self.harness.sync_terminal("a")
|
||||
|
||||
row_b_before = self.harness.recipe_on_terminal("b", recipe_id)
|
||||
self.assertIsNone(row_b_before)
|
||||
|
||||
self.harness.sync_terminal("b")
|
||||
self.harness.sync_terminal("b")
|
||||
|
||||
row_b_after = self.harness.recipe_on_terminal("b", recipe_id)
|
||||
self.assertIsNotNone(row_b_after)
|
||||
self.assertEqual(row_b_after.name, "Late join")
|
||||
|
||||
def test_offline_terminal_catches_up_after_changes(self) -> None:
|
||||
with self.harness.server_ctx():
|
||||
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
|
||||
self.harness.sync_both(rounds=2)
|
||||
|
||||
with self.harness.server_ctx():
|
||||
remove_id = ing_ids[0]
|
||||
keep_row = db.session.get(Ingredient, ing_ids[1])
|
||||
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()
|
||||
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
self.assertEqual(
|
||||
len(self.harness.active_ingredient_ids(term, recipe_id)),
|
||||
1,
|
||||
msg=f"terminal {term}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user