Files
gomag-vending/api/tests/test_order_items_overwrite.py
Claude Agent 32974e3b85 fix(orders): preserve order_items on mark_order_deleted_in_roa
Detail view for DELETED_IN_ROA orders showed "Niciun articol" because
the soft-delete helper hard-deleted order_items. Now items stay in
SQLite so the detail page displays the original GoMag order alongside
"Comanda stearsa din ROA". On 'Reimporta', add_order_items already
replaces them via DELETE+INSERT inside _safe_upsert_order_items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:06:13 +00:00

203 lines
7.0 KiB
Python

"""
Order Items Overwrite Regression Tests
========================================
Re-import must replace SQLite order_items (not INSERT OR IGNORE) so quantity
changes in GoMag propagate to the dashboard. Regression for VELA CAFE #484669620.
Also: soft-delete (mark_order_deleted_in_roa) must purge stale items.
Run:
cd api && python -m pytest tests/test_order_items_overwrite.py -v
"""
import os
import sys
import tempfile
import pytest
pytestmark = pytest.mark.unit
# --- Set env vars BEFORE any app import ---
_tmpdir = tempfile.mkdtemp()
_sqlite_path = os.path.join(_tmpdir, "test_items.db")
os.environ.setdefault("FORCE_THIN_MODE", "true")
os.environ.setdefault("SQLITE_DB_PATH", _sqlite_path)
os.environ.setdefault("ORACLE_DSN", "dummy")
os.environ.setdefault("ORACLE_USER", "dummy")
os.environ.setdefault("ORACLE_PASSWORD", "dummy")
os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir)
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _api_dir not in sys.path:
sys.path.insert(0, _api_dir)
from app import database
from app.services import sqlite_service
from app.constants import OrderStatus
@pytest.fixture(autouse=True)
async def _init_db():
database.init_sqlite()
# Clean state before each test
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM order_items")
await db.execute("DELETE FROM sync_run_orders")
await db.execute("DELETE FROM orders")
await db.execute("DELETE FROM sync_runs")
await db.commit()
finally:
await db.close()
yield
def _item(sku="SKU1", qty=1.0, price=10.0):
return {
"sku": sku, "product_name": f"Product {sku}",
"quantity": qty, "price": price, "baseprice": price,
"vat": 19, "mapping_status": "direct", "codmat": None,
"id_articol": None, "cantitate_roa": None,
}
async def _seed_order(order_number="TEST-001"):
"""Create an orders row so FK constraints (if any) pass."""
await sqlite_service.upsert_order(
sync_run_id="test-run",
order_number=order_number,
order_date="2026-01-01",
customer_name="Test",
status=OrderStatus.IMPORTED.value,
)
async def _items_for(order_number):
return await sqlite_service.get_order_items(order_number)
# ===========================================================================
# add_order_items — replace semantics
# ===========================================================================
@pytest.mark.asyncio
async def test_add_order_items_deletes_before_insert():
"""Re-import with changed quantities must overwrite, not preserve old rows."""
await _seed_order("ORD-A")
# Initial import: 3 items
await sqlite_service.add_order_items("ORD-A", [
_item("SKU1", qty=5), _item("SKU2", qty=10), _item("SKU3", qty=2),
])
rows = await _items_for("ORD-A")
assert len(rows) == 3
# Re-import: only 2 items, different quantities (simulates user edit in GoMag)
await sqlite_service.add_order_items("ORD-A", [
_item("SKU1", qty=99), _item("SKU4", qty=1),
])
rows = await _items_for("ORD-A")
skus = {r["sku"]: r["quantity"] for r in rows}
assert skus == {"SKU1": 99, "SKU4": 1}, f"old rows leaked: {skus}"
@pytest.mark.asyncio
async def test_add_order_items_empty_list_no_delete():
"""Empty list is a no-op — existing items must remain (early return)."""
await _seed_order("ORD-B")
await sqlite_service.add_order_items("ORD-B", [_item("SKU1", qty=5)])
await sqlite_service.add_order_items("ORD-B", []) # should not wipe
rows = await _items_for("ORD-B")
assert len(rows) == 1
assert rows[0]["sku"] == "SKU1"
@pytest.mark.asyncio
async def test_add_order_items_isolation_between_orders():
"""add_order_items on ORD-A must not affect ORD-B items."""
await _seed_order("ORD-A")
await _seed_order("ORD-B")
await sqlite_service.add_order_items("ORD-A", [_item("SKU1", qty=5)])
await sqlite_service.add_order_items("ORD-B", [_item("SKU2", qty=7)])
# Re-import A
await sqlite_service.add_order_items("ORD-A", [_item("SKU1", qty=99)])
rows_a = await _items_for("ORD-A")
rows_b = await _items_for("ORD-B")
assert len(rows_a) == 1 and rows_a[0]["quantity"] == 99
assert len(rows_b) == 1 and rows_b[0]["quantity"] == 7
# ===========================================================================
# save_orders_batch — replace semantics for batch flow
# ===========================================================================
@pytest.mark.asyncio
async def test_save_orders_batch_overwrite():
"""save_orders_batch must also replace existing items for re-run order numbers."""
await _seed_order("ORD-BATCH")
await sqlite_service.add_order_items("ORD-BATCH", [
_item("SKU_OLD", qty=1),
])
assert len(await _items_for("ORD-BATCH")) == 1
batch = [{
"sync_run_id": "run-1",
"order_number": "ORD-BATCH",
"status_at_run": "PENDING",
"order_date": "2026-01-02",
"customer_name": "Batch",
"status": "PENDING",
"items": [_item("SKU_NEW_1", qty=3), _item("SKU_NEW_2", qty=4)],
}]
# save_orders_batch requires sync_runs row first
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT OR IGNORE INTO sync_runs (run_id, started_at, status) VALUES (?, datetime('now'), 'running')",
("run-1",),
)
await db.commit()
finally:
await db.close()
await sqlite_service.save_orders_batch(batch)
rows = await _items_for("ORD-BATCH")
skus = {r["sku"] for r in rows}
assert skus == {"SKU_NEW_1", "SKU_NEW_2"}, f"old items leaked: {skus}"
# ===========================================================================
# mark_order_deleted_in_roa — preserves items so detail view stays useful
# ===========================================================================
@pytest.mark.asyncio
async def test_mark_order_deleted_preserves_items():
"""Soft-delete keeps order_items so the detail view shows what was ordered.
On 'Reimporta', add_order_items replaces them (DELETE+INSERT inside _safe_upsert_order_items).
"""
await _seed_order("ORD-DEL")
await sqlite_service.add_order_items("ORD-DEL", [
_item("SKU1", qty=5), _item("SKU2", qty=3),
])
assert len(await _items_for("ORD-DEL")) == 2
await sqlite_service.mark_order_deleted_in_roa("ORD-DEL")
# Items preserved — detail view can still display them alongside "Comanda stearsa din ROA"
items = await _items_for("ORD-DEL")
assert len(items) == 2
assert {i["sku"] for i in items} == {"SKU1", "SKU2"}
# Orders row still present with DELETED_IN_ROA status (not hard-deleted)
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute("SELECT status, id_comanda FROM orders WHERE order_number = ?", ("ORD-DEL",))
row = await cur.fetchone()
finally:
await db.close()
assert row is not None
assert row["status"] == OrderStatus.DELETED_IN_ROA.value
assert row["id_comanda"] is None