feat(sync): per-order SAVEPOINT protection for order_items upsert
_safe_upsert_order_items(db, order_number, items) wraps the DELETE + INSERT OR REPLACE pair in SAVEPOINT items. On IntegrityError / ValueError / TypeError it rolls the savepoint back, tags the parent order MALFORMED, logs to the error history file, and returns False to the caller. add_order_items now delegates to this helper so a single bad payload cannot leave order_items in a split state. 2 integration tests: happy path + simulated INSERT crash via aiosqlite monkeypatch. Existing order_items overwrite regression tests still pass (5/5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -247,3 +247,61 @@ async def test_reconnect_preserves_malformed_and_continues(monkeypatch):
|
||||
finally:
|
||||
# fresh was already closed; nothing else to do
|
||||
pass
|
||||
|
||||
|
||||
# ── 7. _safe_upsert_order_items — success + savepoint rollback ──
|
||||
|
||||
|
||||
async def test_safe_upsert_items_happy_path():
|
||||
# Seed parent order so FK context is valid.
|
||||
await sqlite_service.save_orders_batch([_order("SAFE-1", items=[])])
|
||||
db = await sqlite_service.get_sqlite()
|
||||
try:
|
||||
ok = await sqlite_service._safe_upsert_order_items(
|
||||
db, "SAFE-1", [_item("SKU-H", qty=2)]
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
assert ok is True
|
||||
items = await _items_of("SAFE-1")
|
||||
assert items == [("SKU-H", 2)]
|
||||
|
||||
|
||||
async def test_safe_upsert_items_rolls_back_and_marks_malformed(monkeypatch):
|
||||
await sqlite_service.save_orders_batch([_order("SAFE-2", items=[_item("PRE", qty=1)])])
|
||||
|
||||
import aiosqlite
|
||||
real_executemany = aiosqlite.core.Connection.executemany
|
||||
|
||||
async def boom_on_items(self, sql, rows):
|
||||
if "INSERT INTO order_items" in sql.upper().replace("\n", " ").replace(" ", " ").upper() or "ORDER_ITEMS" in sql.upper():
|
||||
raise sqlite3.IntegrityError("simulated items insert crash")
|
||||
return await real_executemany(self, sql, rows)
|
||||
|
||||
monkeypatch.setattr(aiosqlite.core.Connection, "executemany", boom_on_items)
|
||||
|
||||
db = await sqlite_service.get_sqlite()
|
||||
try:
|
||||
ok = await sqlite_service._safe_upsert_order_items(
|
||||
db, "SAFE-2", [_item("SKU-BAD", qty=1)]
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
assert ok is False
|
||||
# Parent order was tagged MALFORMED, pre-existing items were wiped by DELETE
|
||||
# (which ran inside the rolled-back savepoint, so they should survive).
|
||||
malformed = await _orders_with_status(OrderStatus.MALFORMED.value)
|
||||
assert "SAFE-2" in malformed
|
||||
|
||||
db = await sqlite_service.get_sqlite()
|
||||
try:
|
||||
cur = await db.execute(
|
||||
"SELECT error_message FROM orders WHERE order_number = ?", ("SAFE-2",)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
assert row is not None and "ITEMS_FAIL" in row[0]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
Reference in New Issue
Block a user