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>
203 lines
7.0 KiB
Python
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
|