972 lines
34 KiB
Python
972 lines
34 KiB
Python
import os
|
||
import tempfile
|
||
import unittest
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from unittest.mock import patch
|
||
|
||
from sqlalchemy import func, select
|
||
|
||
from app import create_app, db
|
||
from app.models import (
|
||
Component,
|
||
FeedDispenser,
|
||
FeedingPeriod,
|
||
Ingredient,
|
||
LoadingReport,
|
||
LoadingReportComponent,
|
||
PeriodRecipe,
|
||
Recipe,
|
||
SyncClient,
|
||
SyncEngineState,
|
||
SyncDelivery,
|
||
SyncQueue,
|
||
WESP_SUPPRESS_SYNC_ENQUEUE,
|
||
)
|
||
from app.services.sync_manager import (
|
||
SNAPSHOT_MODELS,
|
||
SyncManager,
|
||
apply_sync_change,
|
||
enqueue_personal_snapshot,
|
||
enqueue_sync_queue_task,
|
||
requeue_stuck_processing,
|
||
)
|
||
from app.timeutil import utc_now_naive
|
||
from config import TestingConfig
|
||
|
||
|
||
class SyncTestConfig(TestingConfig):
|
||
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-sync-tests-")
|
||
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')}",
|
||
}
|
||
|
||
|
||
class SyncManagerTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.app = create_app(SyncTestConfig)
|
||
self.ctx = self.app.app_context()
|
||
self.ctx.push()
|
||
db.create_all()
|
||
|
||
def tearDown(self) -> None:
|
||
db.session.remove()
|
||
db.drop_all()
|
||
self.ctx.pop()
|
||
|
||
def test_apply_sync_change_strips_unknown_keys_from_get_object_data(self) -> None:
|
||
"""get_object_data добавляет legacy-ключи soft-delete без колонок в модели — create не должен падать."""
|
||
cid = str(uuid.uuid4())
|
||
data = {
|
||
"id": cid,
|
||
"name": "K1",
|
||
"type": "grain",
|
||
"is_active": True,
|
||
"dry_matter": 1.0,
|
||
"protein": 2.0,
|
||
"energy": 3.0,
|
||
"price": 4.0,
|
||
"version": 1,
|
||
"content_hash": "",
|
||
"created_by": "system",
|
||
"updated_by": "system",
|
||
"is_deleted": False,
|
||
"deleted_at": None,
|
||
"deleted_by": None,
|
||
"deleted_reason": None,
|
||
"restored_at": None,
|
||
"restored_by": None,
|
||
"restored_reason": None,
|
||
"delete_restore_count": 0,
|
||
}
|
||
res = apply_sync_change("component", cid, "create", data)
|
||
self.assertTrue(res.get("success"), msg=res.get("error"))
|
||
db.session.commit()
|
||
row = db.session.get(Component, cid)
|
||
self.assertIsNotNone(row)
|
||
self.assertEqual(row.name, "K1")
|
||
|
||
def test_apply_period_recipes_coerces_iso_datetime_strings(self) -> None:
|
||
"""После JSON pull даты приходят строками; ORM + SQLite DateTime ожидают datetime."""
|
||
d_id = str(uuid.uuid4())
|
||
fp_id = str(uuid.uuid4())
|
||
r_id = str(uuid.uuid4())
|
||
db.session.add_all(
|
||
[
|
||
FeedDispenser(
|
||
id=d_id,
|
||
name="D",
|
||
farm="F",
|
||
operator="O",
|
||
content_hash="",
|
||
),
|
||
FeedingPeriod(
|
||
id=fp_id,
|
||
name="Per",
|
||
dispenser_id=d_id,
|
||
content_hash="",
|
||
),
|
||
Recipe(id=r_id, name="Rec", content_hash=""),
|
||
]
|
||
)
|
||
db.session.commit()
|
||
|
||
rid_key = f"{fp_id}:{r_id}"
|
||
data = {
|
||
"period_id": fp_id,
|
||
"recipe_id": r_id,
|
||
"order": 0,
|
||
"version": 1,
|
||
"created_by": "system",
|
||
"updated_by": "system",
|
||
"sync_status": "pending",
|
||
"content_hash": "ab" * 32,
|
||
"created_at": "2026-04-20T01:48:57.123456",
|
||
"updated_at": "2026-04-21T02:00:00Z",
|
||
"is_deleted": False,
|
||
"deleted_at": None,
|
||
"deleted_by": None,
|
||
}
|
||
res = apply_sync_change("period_recipes", rid_key, "update", data)
|
||
self.assertTrue(res.get("success"), msg=res.get("error"))
|
||
db.session.commit()
|
||
row = db.session.execute(
|
||
select(PeriodRecipe).where(
|
||
PeriodRecipe.period_id == fp_id,
|
||
PeriodRecipe.recipe_id == r_id,
|
||
)
|
||
).scalar_one()
|
||
self.assertIsInstance(row.created_at, datetime)
|
||
self.assertIsInstance(row.updated_at, datetime)
|
||
|
||
def test_apply_sync_change_failed_flush_does_not_poison_session(self) -> None:
|
||
"""После ошибки flush одной записи следующая в той же сессии всё ещё коммитится (SAVEPOINT)."""
|
||
orig_flush = db.session.flush
|
||
boom = [0]
|
||
|
||
def side_effect(*args, **kwargs):
|
||
boom[0] += 1
|
||
if boom[0] == 1:
|
||
raise RuntimeError("simulated flush failure")
|
||
return orig_flush(*args, **kwargs)
|
||
|
||
def cmp_data(name: str) -> dict:
|
||
return {
|
||
"name": name,
|
||
"type": "grain",
|
||
"is_active": True,
|
||
"dry_matter": 1.0,
|
||
"protein": 0.0,
|
||
"energy": 0.0,
|
||
"price": 0.0,
|
||
"version": 1,
|
||
"content_hash": "",
|
||
"created_by": "system",
|
||
"updated_by": "system",
|
||
}
|
||
|
||
c1 = str(uuid.uuid4())
|
||
self.assertTrue(apply_sync_change("component", c1, "create", cmp_data("ok-first"))["success"])
|
||
db.session.commit()
|
||
|
||
c_bad = str(uuid.uuid4())
|
||
c_ok = str(uuid.uuid4())
|
||
with patch.object(db.session, "flush", side_effect=side_effect):
|
||
r_bad = apply_sync_change("component", c_bad, "create", cmp_data("bad"))
|
||
self.assertFalse(r_bad.get("success"))
|
||
r_ok = apply_sync_change("component", c_ok, "create", cmp_data("ok-after-fail"))
|
||
self.assertTrue(r_ok.get("success"), msg=r_ok.get("error"))
|
||
|
||
db.session.commit()
|
||
self.assertIsNotNone(db.session.get(Component, c1))
|
||
self.assertIsNone(db.session.get(Component, c_bad))
|
||
self.assertIsNotNone(db.session.get(Component, c_ok))
|
||
|
||
def test_remote_apply_suppress_flag_skips_sync_queue_enqueue(self) -> None:
|
||
"""Как на клиенте при pull: не плодить sync_queue на каждый применённый объект."""
|
||
cid = str(uuid.uuid4())
|
||
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
|
||
try:
|
||
r = apply_sync_change(
|
||
"component",
|
||
cid,
|
||
"create",
|
||
{
|
||
"name": "NoQueue",
|
||
"type": "grain",
|
||
"is_active": True,
|
||
"dry_matter": 1.0,
|
||
"protein": 0.0,
|
||
"energy": 0.0,
|
||
"price": 0.0,
|
||
"version": 1,
|
||
"content_hash": "",
|
||
"created_by": "system",
|
||
"updated_by": "system",
|
||
},
|
||
)
|
||
self.assertTrue(r.get("success"), r)
|
||
db.session.commit()
|
||
finally:
|
||
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
|
||
|
||
n = db.session.scalar(
|
||
select(func.count()).select_from(SyncQueue).where(
|
||
SyncQueue.table_name == "component",
|
||
SyncQueue.record_id == cid,
|
||
)
|
||
)
|
||
self.assertEqual(int(n or 0), 0)
|
||
|
||
def test_process_push_server_master_skips_stale_version_applies_rest(self) -> None:
|
||
"""Серверный master: несовпадение версии — пропуск строки без 409; остальные изменения применяются."""
|
||
existing = Component(id="cmp-1", name="server-name", version=2)
|
||
db.session.add(existing)
|
||
db.session.commit()
|
||
|
||
changes = [
|
||
{
|
||
"table_name": "component",
|
||
"record_id": "cmp-1",
|
||
"action": "update",
|
||
"data": {"id": "cmp-1", "name": "client-name", "version": 1},
|
||
},
|
||
{
|
||
"table_name": "component",
|
||
"record_id": "cmp-2",
|
||
"action": "create",
|
||
"data": {"id": "cmp-2", "name": "new-component"},
|
||
},
|
||
]
|
||
|
||
result = SyncManager.process_push(client_id="client-a", changes=changes)
|
||
|
||
self.assertEqual(result["status_code"], 200)
|
||
self.assertEqual(result["payload"]["total_applied"], 1)
|
||
|
||
cmp1 = db.session.get(Component, "cmp-1")
|
||
cmp2 = db.session.get(Component, "cmp-2")
|
||
self.assertIsNotNone(cmp1)
|
||
self.assertEqual(cmp1.name, "server-name")
|
||
self.assertIsNotNone(cmp2)
|
||
self.assertEqual(cmp2.name, "new-component")
|
||
|
||
def test_process_push_unknown_table_returns_409(self) -> None:
|
||
changes = [
|
||
{
|
||
"table_name": "unknown_table_xyz",
|
||
"record_id": "x-1",
|
||
"action": "update",
|
||
"data": {"id": "x-1"},
|
||
},
|
||
]
|
||
result = SyncManager.process_push(client_id="client-a", changes=changes)
|
||
self.assertEqual(result["status_code"], 409)
|
||
self.assertTrue(result["payload"]["error"])
|
||
|
||
def test_check_for_sync_conflict_detects_version_mismatch(self) -> None:
|
||
from app.services.sync_manager import _check_for_sync_conflict
|
||
|
||
cid = str(uuid.uuid4())
|
||
db.session.add(
|
||
Component(
|
||
id=cid,
|
||
name="Server",
|
||
type="grain",
|
||
dry_matter=50.0,
|
||
protein=0.0,
|
||
energy=0.0,
|
||
price=0.0,
|
||
version=3,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
conflict = _check_for_sync_conflict(
|
||
"component",
|
||
cid,
|
||
{"id": cid, "name": "Client", "version": 1},
|
||
)
|
||
self.assertIsNotNone(conflict)
|
||
self.assertEqual(conflict.get("conflict_type"), "version_mismatch")
|
||
|
||
def test_process_confirm_universal_task_completes_only_after_all_active_clients(self) -> None:
|
||
now = datetime.now()
|
||
client1 = SyncClient(node_id="node-1", client_name="client-1", status="active", is_enabled=True)
|
||
client2 = SyncClient(node_id="node-2", client_name="client-2", status="active", is_enabled=True)
|
||
db.session.add_all([client1, client2])
|
||
db.session.flush()
|
||
|
||
task = SyncQueue(
|
||
id="task-1",
|
||
table_name="component",
|
||
record_id="cmp-x",
|
||
action="update",
|
||
status="processing",
|
||
target_node_id=None,
|
||
created_at=now,
|
||
processed_at=now,
|
||
)
|
||
db.session.add(task)
|
||
# Delivered only to client1 for now
|
||
db.session.add(SyncDelivery(client_id=client1.id, task_id=task.id, delivered_at=now))
|
||
db.session.commit()
|
||
|
||
first = SyncManager.process_confirm(client_id="node-1", task_ids=[task.id])
|
||
self.assertEqual(first["status_code"], 200)
|
||
self.assertEqual(first["payload"]["updated"], 0)
|
||
self.assertEqual(db.session.get(SyncQueue, task.id).status, "processing")
|
||
|
||
# After delivery to all active clients, universal task can be completed
|
||
db.session.add(SyncDelivery(client_id=client2.id, task_id=task.id, delivered_at=now))
|
||
db.session.commit()
|
||
|
||
second = SyncManager.process_confirm(client_id="node-1", task_ids=[task.id])
|
||
self.assertEqual(second["status_code"], 200)
|
||
self.assertEqual(second["payload"]["updated"], 1)
|
||
self.assertEqual(db.session.get(SyncQueue, task.id).status, "completed")
|
||
|
||
def test_requeue_does_not_fire_on_fresh_utc_processed_at(self) -> None:
|
||
"""processed_at в UTC; requeue не должен срабатывать на свежих задачах (раньше ломалось с datetime.now())."""
|
||
now = utc_now_naive()
|
||
task = SyncQueue(
|
||
id="task-fresh",
|
||
table_name="component",
|
||
record_id="cmp-fresh",
|
||
action="update",
|
||
status="processing",
|
||
target_node_id=None,
|
||
created_at=now,
|
||
processed_at=now,
|
||
)
|
||
db.session.add(task)
|
||
db.session.commit()
|
||
|
||
n = requeue_stuck_processing(timeout_minutes=5)
|
||
self.assertEqual(n, 0)
|
||
self.assertEqual(db.session.get(SyncQueue, task.id).status, "processing")
|
||
|
||
def test_process_pull_delivers_processing_universal_task_to_second_client(self) -> None:
|
||
"""Второй клиент получает универсальную задачу в processing, если delivery ещё не было."""
|
||
now = utc_now_naive()
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
client1 = SyncClient(
|
||
node_id="node-a",
|
||
client_name="a",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_cursor=len(SNAPSHOT_MODELS),
|
||
personal_snapshot_completed_at=now,
|
||
)
|
||
client2 = SyncClient(
|
||
node_id="node-b",
|
||
client_name="b",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_cursor=len(SNAPSHOT_MODELS),
|
||
personal_snapshot_completed_at=now,
|
||
)
|
||
db.session.add_all([client1, client2])
|
||
db.session.flush()
|
||
|
||
cid = str(uuid.uuid4())
|
||
from app.models import WESP_SUPPRESS_SYNC_ENQUEUE
|
||
|
||
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
|
||
try:
|
||
db.session.add(
|
||
Component(
|
||
id=cid,
|
||
name="Shared",
|
||
type="grain",
|
||
dry_matter=1.0,
|
||
protein=1.0,
|
||
energy=1.0,
|
||
price=0.0,
|
||
content_hash="0" * 64,
|
||
)
|
||
)
|
||
db.session.flush()
|
||
finally:
|
||
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
|
||
task = SyncQueue(
|
||
id="task-shared",
|
||
table_name="component",
|
||
record_id=cid,
|
||
action="create",
|
||
status="processing",
|
||
target_node_id=None,
|
||
priority=3,
|
||
created_at=now,
|
||
processed_at=now,
|
||
)
|
||
db.session.add(task)
|
||
db.session.add(SyncDelivery(client_id=client1.id, task_id=task.id, delivered_at=now))
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_pull(client_id="node-b", limit=10)
|
||
self.assertEqual(res["status_code"], 200)
|
||
self.assertEqual(len(res["payload"]["changes"]), 1)
|
||
deliveries = db.session.execute(
|
||
select(func.count()).select_from(SyncDelivery).where(SyncDelivery.task_id == task.id)
|
||
).scalar()
|
||
self.assertEqual(int(deliveries or 0), 2)
|
||
|
||
def test_loading_report_component_insert_enqueues_sync(self) -> None:
|
||
rid = str(uuid.uuid4())
|
||
report = LoadingReport(
|
||
id=rid,
|
||
recipe_id=str(uuid.uuid4()),
|
||
recipe_name="R",
|
||
start_time=datetime.now(),
|
||
dispenser_type="dispenser",
|
||
)
|
||
db.session.add(report)
|
||
db.session.flush()
|
||
lrc_id = str(uuid.uuid4())
|
||
lrc = LoadingReportComponent(
|
||
id=lrc_id,
|
||
report_id=rid,
|
||
component_name="C",
|
||
target_weight=1.0,
|
||
actual_weight=1.0,
|
||
loading_order=1,
|
||
)
|
||
db.session.add(lrc)
|
||
db.session.commit()
|
||
|
||
row = db.session.execute(
|
||
select(SyncQueue).where(
|
||
SyncQueue.table_name == "loading_report_component",
|
||
SyncQueue.record_id == lrc_id,
|
||
SyncQueue.action == "create",
|
||
)
|
||
).scalar_one_or_none()
|
||
self.assertIsNotNone(row)
|
||
self.assertEqual(row.status, "pending")
|
||
|
||
def test_enqueue_personal_snapshot_includes_reports(self) -> None:
|
||
rid = str(uuid.uuid4())
|
||
report = LoadingReport(
|
||
id=rid,
|
||
recipe_id=str(uuid.uuid4()),
|
||
recipe_name="R2",
|
||
start_time=datetime.now(),
|
||
dispenser_type="dispenser",
|
||
)
|
||
db.session.add(report)
|
||
db.session.commit()
|
||
|
||
n = enqueue_personal_snapshot("new-node-1")
|
||
self.assertGreaterEqual(n, 1)
|
||
hit = db.session.execute(
|
||
select(SyncQueue).where(
|
||
SyncQueue.table_name == "loading_report",
|
||
SyncQueue.record_id == rid,
|
||
SyncQueue.target_node_id == "new-node-1",
|
||
)
|
||
).scalar_one_or_none()
|
||
self.assertIsNotNone(hit)
|
||
|
||
def test_process_pull_does_not_run_universal_step_when_flag_is_set(self) -> None:
|
||
now = datetime.now()
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-ready",
|
||
client_name="ready",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_completed_at=now,
|
||
personal_snapshot_cursor=len(SNAPSHOT_MODELS),
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
with patch("app.services.sync_manager._bootstrap_universal_step") as boot_step:
|
||
first = SyncManager.process_pull(client_id="node-ready", limit=10)
|
||
second = SyncManager.process_pull(client_id="node-ready", limit=10)
|
||
|
||
self.assertEqual(first["status_code"], 200)
|
||
self.assertEqual(second["status_code"], 200)
|
||
boot_step.assert_not_called()
|
||
|
||
def test_process_pull_reactivates_soft_deleted_sync_client(self) -> None:
|
||
"""Повторный pull после soft-delete в админке не должен падать по UNIQUE(node_id)."""
|
||
now = datetime.now()
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-sd",
|
||
client_name="sd",
|
||
status="disabled",
|
||
is_enabled=False,
|
||
is_deleted=True,
|
||
personal_snapshot_completed_at=now,
|
||
personal_snapshot_cursor=len(SNAPSHOT_MODELS),
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_pull(
|
||
client_id="node-sd", limit=10, client_name="back", client_ip="192.168.0.13"
|
||
)
|
||
self.assertEqual(res["status_code"], 200)
|
||
row = db.session.execute(
|
||
select(SyncClient).where(SyncClient.node_id == "node-sd")
|
||
).scalar_one()
|
||
self.assertFalse(bool(row.is_deleted))
|
||
self.assertTrue(row.is_enabled)
|
||
self.assertEqual(row.client_name, "back")
|
||
self.assertEqual(row.ip_address, "192.168.0.13")
|
||
|
||
def test_process_pull_personal_snapshot_runs_once_with_client_flag(self) -> None:
|
||
now = datetime.now()
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-new",
|
||
client_name="new",
|
||
status="active",
|
||
is_enabled=True,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
step_payload = {"done": True, "created": 0, "cursor": 0, "total": 1}
|
||
|
||
def _snapshot_once(client, *, max_models):
|
||
client.personal_snapshot_cursor = len(SNAPSHOT_MODELS)
|
||
return step_payload
|
||
|
||
with patch(
|
||
"app.services.sync_manager._enqueue_personal_snapshot_step",
|
||
side_effect=_snapshot_once,
|
||
) as snapshot_step:
|
||
first = SyncManager.process_pull(client_id="node-new", limit=10)
|
||
second = SyncManager.process_pull(client_id="node-new", limit=10)
|
||
|
||
self.assertEqual(first["status_code"], 200)
|
||
self.assertEqual(second["status_code"], 200)
|
||
self.assertEqual(snapshot_step.call_count, 1)
|
||
|
||
def test_enqueue_redelivery_clears_sync_delivery_for_completed_universal(self) -> None:
|
||
"""Повторный enqueue update после completed должен снова выдать задачу клиентам."""
|
||
old = utc_now_naive() - timedelta(minutes=5)
|
||
client = SyncClient(node_id="node-enq", client_name="c", status="active", is_enabled=True)
|
||
db.session.add(client)
|
||
db.session.flush()
|
||
task = SyncQueue(
|
||
id="task-enq",
|
||
table_name="period_recipes",
|
||
record_id="period-a:recipe-a",
|
||
action="update",
|
||
status="completed",
|
||
target_node_id=None,
|
||
created_at=old,
|
||
processed_at=old,
|
||
completed_at=old,
|
||
)
|
||
db.session.add(task)
|
||
db.session.add(SyncDelivery(client_id=client.id, task_id=task.id, delivered_at=old))
|
||
db.session.commit()
|
||
|
||
enqueue_sync_queue_task("period_recipes", "period-a:recipe-a", "update", priority=4)
|
||
db.session.commit()
|
||
|
||
self.assertEqual(db.session.get(SyncQueue, task.id).status, "pending")
|
||
d = db.session.execute(
|
||
select(SyncDelivery).where(SyncDelivery.task_id == task.id)
|
||
).scalar_one_or_none()
|
||
self.assertIsNone(d)
|
||
|
||
def test_requeue_stuck_clears_sync_delivery_for_universal_retry(self) -> None:
|
||
"""После requeue универсальная задача снова может быть выдана (иначе блокирует NOT EXISTS delivery)."""
|
||
old = utc_now_naive() - timedelta(minutes=60)
|
||
client = SyncClient(node_id="node-rq", client_name="c", status="active", is_enabled=True)
|
||
db.session.add(client)
|
||
db.session.flush()
|
||
task = SyncQueue(
|
||
id="task-rq",
|
||
table_name="component",
|
||
record_id="cmp-rq",
|
||
action="create",
|
||
status="processing",
|
||
target_node_id=None,
|
||
created_at=old,
|
||
processed_at=old,
|
||
)
|
||
db.session.add(task)
|
||
db.session.add(SyncDelivery(client_id=client.id, task_id=task.id, delivered_at=old))
|
||
db.session.commit()
|
||
|
||
n = requeue_stuck_processing(timeout_minutes=30)
|
||
self.assertGreaterEqual(n, 1)
|
||
self.assertEqual(db.session.get(SyncQueue, task.id).status, "pending")
|
||
d = db.session.execute(
|
||
select(SyncDelivery).where(SyncDelivery.task_id == task.id)
|
||
).scalar_one_or_none()
|
||
self.assertIsNone(d)
|
||
|
||
def test_process_pull_delivers_when_universal_bootstrap_incomplete_but_queue_has_tasks(self) -> None:
|
||
"""Живые задачи в очереди не должны ждать 202 от universal bootstrap."""
|
||
db.session.add(SyncEngineState(id=1))
|
||
cid = "cmp-ub-1"
|
||
db.session.add(
|
||
Component(
|
||
id=cid,
|
||
name="Ub",
|
||
type="grain",
|
||
dry_matter=1.0,
|
||
protein=1.0,
|
||
energy=1.0,
|
||
price=0.0,
|
||
content_hash="0" * 64,
|
||
)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-ub",
|
||
client_name="ub",
|
||
status="active",
|
||
is_enabled=True,
|
||
)
|
||
)
|
||
now = datetime.now()
|
||
db.session.add(
|
||
SyncQueue(
|
||
id="task-ub",
|
||
table_name="component",
|
||
record_id=cid,
|
||
action="create",
|
||
status="pending",
|
||
target_node_id=None,
|
||
priority=3,
|
||
created_at=now,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_pull(client_id="node-ub", limit=10)
|
||
self.assertEqual(res["status_code"], 200)
|
||
self.assertGreater(len(res["payload"]["changes"]), 0)
|
||
|
||
def test_initial_sync_active_while_personal_tasks_pending(self) -> None:
|
||
"""Enqueue завершён, но personal задачи ещё pending — initial_sync_active остаётся True."""
|
||
now = datetime.now()
|
||
total = len(SNAPSHOT_MODELS)
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-pending",
|
||
client_name="pending",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_cursor=total,
|
||
)
|
||
)
|
||
db.session.add(
|
||
SyncQueue(
|
||
id="task-pend",
|
||
table_name="component",
|
||
record_id=str(uuid.uuid4()),
|
||
action="create",
|
||
status="pending",
|
||
target_node_id="node-pending",
|
||
priority=2,
|
||
created_at=now,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_pull(client_id="node-pending", limit=10)
|
||
self.assertEqual(res["status_code"], 200)
|
||
self.assertTrue(res["payload"]["initial_sync_active"])
|
||
row = db.session.execute(
|
||
select(SyncClient).where(SyncClient.node_id == "node-pending")
|
||
).scalar_one()
|
||
self.assertIsNone(row.personal_snapshot_completed_at)
|
||
|
||
def test_confirm_all_personal_marks_snapshot_completed(self) -> None:
|
||
now = datetime.now()
|
||
total = len(SNAPSHOT_MODELS)
|
||
client = SyncClient(
|
||
node_id="node-done",
|
||
client_name="done",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_cursor=total,
|
||
)
|
||
db.session.add(client)
|
||
db.session.flush()
|
||
task = SyncQueue(
|
||
id="task-done",
|
||
table_name="component",
|
||
record_id=str(uuid.uuid4()),
|
||
action="create",
|
||
status="processing",
|
||
target_node_id="node-done",
|
||
priority=2,
|
||
created_at=now,
|
||
processed_at=now,
|
||
)
|
||
db.session.add(task)
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_confirm(client_id="node-done", task_ids=[task.id])
|
||
self.assertEqual(res["status_code"], 200)
|
||
self.assertFalse(res["payload"]["initial_sync_active"])
|
||
row = db.session.execute(
|
||
select(SyncClient).where(SyncClient.node_id == "node-done")
|
||
).scalar_one()
|
||
self.assertIsNotNone(row.personal_snapshot_completed_at)
|
||
|
||
def test_pull_initial_sync_orders_parent_before_child(self) -> None:
|
||
now = datetime.now()
|
||
total = len(SNAPSHOT_MODELS)
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-order",
|
||
client_name="order",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_cursor=total,
|
||
)
|
||
)
|
||
disp_id = str(uuid.uuid4())
|
||
period_id = str(uuid.uuid4())
|
||
db.session.add(
|
||
SyncQueue(
|
||
id="t-period",
|
||
table_name="feeding_period",
|
||
record_id=period_id,
|
||
action="create",
|
||
status="pending",
|
||
target_node_id="node-order",
|
||
priority=2,
|
||
created_at=now + timedelta(seconds=1),
|
||
)
|
||
)
|
||
db.session.add(
|
||
SyncQueue(
|
||
id="t-disp",
|
||
table_name="feed_dispenser",
|
||
record_id=disp_id,
|
||
action="create",
|
||
status="pending",
|
||
target_node_id="node-order",
|
||
priority=2,
|
||
created_at=now + timedelta(seconds=2),
|
||
)
|
||
)
|
||
db.session.add(FeedDispenser(id=disp_id, name="D", farm="F", operator="O", content_hash=""))
|
||
db.session.add(
|
||
FeedingPeriod(id=period_id, name="P", dispenser_id=disp_id, content_hash="")
|
||
)
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_pull(client_id="node-order", limit=10)
|
||
self.assertEqual(res["status_code"], 200)
|
||
tables = [c["table_name"] for c in res["payload"]["changes"]]
|
||
if len(tables) >= 2:
|
||
self.assertLess(
|
||
tables.index("feed_dispenser"),
|
||
tables.index("feeding_period"),
|
||
)
|
||
|
||
def test_cascade_feed_dispenser_marks_period_deleted_and_enqueues(self) -> None:
|
||
from app.services.sync_cascade import cascade_soft_delete
|
||
|
||
disp_id = str(uuid.uuid4())
|
||
period_id = str(uuid.uuid4())
|
||
db.session.add(FeedDispenser(id=disp_id, name="D", farm="F", operator="O", content_hash=""))
|
||
db.session.add(
|
||
FeedingPeriod(id=period_id, name="P", dispenser_id=disp_id, content_hash="")
|
||
)
|
||
db.session.commit()
|
||
|
||
cascade_soft_delete("feed_dispenser", disp_id, deleted_by="test")
|
||
db.session.commit()
|
||
|
||
period = db.session.get(FeedingPeriod, period_id)
|
||
self.assertIsNotNone(period)
|
||
self.assertTrue(period.is_deleted)
|
||
q = db.session.execute(
|
||
select(SyncQueue)
|
||
.where(
|
||
SyncQueue.table_name == "feeding_period",
|
||
SyncQueue.record_id == period_id,
|
||
SyncQueue.action == "update",
|
||
)
|
||
.order_by(SyncQueue.created_at.desc())
|
||
).scalars().first()
|
||
self.assertIsNotNone(q)
|
||
|
||
def test_one_completed_personal_task_does_not_finish_initial_sync_on_pull(self) -> None:
|
||
"""Reconnect: одна completed personal-задача не должна завершать initial sync."""
|
||
now = datetime.now()
|
||
total = len(SNAPSHOT_MODELS)
|
||
db.session.add(
|
||
SyncEngineState(id=1, universal_bootstrap_completed_at=now, universal_bootstrap_cursor=0)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-one-done",
|
||
client_name="one",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_cursor=total,
|
||
)
|
||
)
|
||
db.session.add(
|
||
SyncQueue(
|
||
id="task-done-one",
|
||
table_name="component",
|
||
record_id=str(uuid.uuid4()),
|
||
action="create",
|
||
status="completed",
|
||
target_node_id="node-one-done",
|
||
priority=2,
|
||
created_at=now,
|
||
completed_at=now,
|
||
)
|
||
)
|
||
pending_id = str(uuid.uuid4())
|
||
db.session.add(
|
||
SyncQueue(
|
||
id="task-still-pending",
|
||
table_name="component",
|
||
record_id=pending_id,
|
||
action="create",
|
||
status="pending",
|
||
target_node_id="node-one-done",
|
||
priority=2,
|
||
created_at=now,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
res = SyncManager.process_pull(client_id="node-one-done", limit=10)
|
||
self.assertEqual(res["status_code"], 200)
|
||
self.assertTrue(res["payload"]["initial_sync_active"])
|
||
row = db.session.execute(
|
||
select(SyncClient).where(SyncClient.node_id == "node-one-done")
|
||
).scalar_one()
|
||
self.assertIsNone(row.personal_snapshot_completed_at)
|
||
|
||
def test_initial_sync_header_uses_larger_snapshot_step(self) -> None:
|
||
now = datetime.now()
|
||
db.session.add(
|
||
SyncEngineState(
|
||
id=1,
|
||
universal_bootstrap_completed_at=now,
|
||
universal_bootstrap_cursor=0,
|
||
)
|
||
)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-hdr",
|
||
client_name="hdr",
|
||
status="active",
|
||
is_enabled=True,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
seen: list[int] = []
|
||
|
||
def _capture_step(client, *, max_models):
|
||
seen.append(max_models)
|
||
client.personal_snapshot_cursor = len(SNAPSHOT_MODELS)
|
||
return {"done": True, "created": 0, "cursor": len(SNAPSHOT_MODELS), "total": len(SNAPSHOT_MODELS)}
|
||
|
||
with patch(
|
||
"app.services.sync_manager._enqueue_personal_snapshot_step",
|
||
side_effect=_capture_step,
|
||
):
|
||
SyncManager.process_pull(
|
||
client_id="node-hdr", limit=10, initial_sync_header=True
|
||
)
|
||
self.assertTrue(seen)
|
||
self.assertGreaterEqual(seen[0], 5)
|
||
|
||
def test_cascade_recipe_via_soft_delete_enqueues_ingredients(self) -> None:
|
||
rid = str(uuid.uuid4())
|
||
ing_id = str(uuid.uuid4())
|
||
db.session.add(Recipe(id=rid, name="R", content_hash=""))
|
||
db.session.add(
|
||
Ingredient(
|
||
id=ing_id,
|
||
recipe_id=rid,
|
||
name="Ing",
|
||
amount=1.0,
|
||
dry_matter=1.0,
|
||
content_hash="",
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
recipe = db.session.get(Recipe, rid)
|
||
recipe.soft_delete(deleted_by_user="test")
|
||
db.session.commit()
|
||
|
||
ing = db.session.get(Ingredient, ing_id)
|
||
self.assertIsNotNone(ing)
|
||
self.assertTrue(ing.is_deleted)
|
||
q = db.session.execute(
|
||
select(SyncQueue)
|
||
.where(
|
||
SyncQueue.table_name == "ingredient",
|
||
SyncQueue.record_id == ing_id,
|
||
SyncQueue.action == "update",
|
||
)
|
||
.order_by(SyncQueue.created_at.desc())
|
||
).scalars().first()
|
||
self.assertIsNotNone(q)
|
||
|
||
def test_repair_legacy_sync_client_snapshot_cursor(self) -> None:
|
||
from alembic.operations import Operations
|
||
from alembic.runtime.migration import MigrationContext
|
||
|
||
from app.schema_bootstrap import repair_legacy_sync_client_snapshot_cursor
|
||
|
||
now = datetime.now()
|
||
total = len(SNAPSHOT_MODELS)
|
||
db.session.add(
|
||
SyncClient(
|
||
node_id="node-legacy-cursor",
|
||
client_name="legacy",
|
||
status="active",
|
||
is_enabled=True,
|
||
personal_snapshot_completed_at=now,
|
||
personal_snapshot_cursor=0,
|
||
)
|
||
)
|
||
db.session.commit()
|
||
|
||
with db.engine.begin() as conn:
|
||
ctx = MigrationContext.configure(conn)
|
||
repair_legacy_sync_client_snapshot_cursor(Operations(ctx))
|
||
|
||
row = db.session.execute(
|
||
select(SyncClient).where(SyncClient.node_id == "node-legacy-cursor")
|
||
).scalar_one()
|
||
self.assertEqual(int(row.personal_snapshot_cursor or 0), total)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|