Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+971
View File
@@ -0,0 +1,971 @@
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()