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

972 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()