fix(backend): sync push error handling + validation

- apply_push now uses PRAGMA table_info() to get valid column names per
  table and filters incoming data to only known columns, preventing
  "no such column" SQLite errors from frontend-only fields like oracle_id
- Wrap each operation in try/except so one bad op doesn't abort the
  whole batch; errors are returned in the conflicts list instead of 500
- Add server_default to all NOT NULL float/int columns so raw SQL
  INSERT OR REPLACE without those fields still succeeds
- Align DB models with frontend schema.js:
  - orders: add nr_comanda, client_nume, client_telefon, nr_auto,
    marca_denumire, model_denumire, created_by
  - order_lines: add norma_id, mecanic_id, ordine
  - vehicles: add serie_sasiu, client_cod_fiscal (keep vin, client_cui
    for REST API compat)
  - catalog_*: rename nume → denumire to match frontend schema
  - catalog_norme: align fields (cod, denumire, ore_normate)
  - invoices: add serie_factura, modalitate_plata, client_cod_fiscal,
    nr_auto, total_fara_tva, tva, total_general; keep total for compat
  - appointments: add client_nume, client_telefon, data_ora, durata_minute,
    status, order_id
  - mecanici: add user_id, prenume, activ
- Fix seed.py to use denumire= instead of nome= after catalog rename
- Add 3 new sync push tests covering order insert with frontend fields,
  unknown column filtering, and order_line with norma_id/mecanic_id/ordine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:45:31 +02:00
parent 9aef3d6933
commit 1e96db4d91
10 changed files with 321 additions and 58 deletions

View File

@@ -7,6 +7,11 @@ from httpx import ASGITransport, AsyncClient
from app.main import app
async def _get_tenant_id(client, auth_headers):
me = await client.get("/api/auth/me", headers=auth_headers)
return me.json()["tenant_id"]
@pytest.mark.asyncio
async def test_full_sync_returns_all_tables(client, auth_headers):
r = await client.get("/api/sync/full", headers=auth_headers)
@@ -126,3 +131,200 @@ async def test_sync_push_rejects_wrong_tenant(client, auth_headers):
assert r.status_code == 200
# Wrong tenant_id is rejected (skipped)
assert r.json()["applied"] == 0
@pytest.mark.asyncio
async def test_sync_push_insert_order_with_frontend_fields(client, auth_headers):
"""Frontend sends nr_comanda, client_nume, nr_auto, marca_denumire, model_denumire.
Backend must accept these without 500."""
tenant_id = await _get_tenant_id(client, auth_headers)
# First insert a vehicle
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "vehicles",
"id": vid,
"operation": "INSERT",
"data": {
"id": vid,
"tenant_id": tenant_id,
"nr_inmatriculare": "B01TST",
"client_nume": "Ion Popescu",
"client_telefon": "0722000000",
"client_cod_fiscal": "RO12345",
"serie_sasiu": "WBA1234567890",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
# Now insert an order like the frontend does
oid = str(uuid.uuid4())
r = await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "orders",
"id": oid,
"operation": "INSERT",
"data": {
"id": oid,
"tenant_id": tenant_id,
"nr_comanda": "CMD-ABC123",
"data_comanda": now,
"vehicle_id": vid,
"tip_deviz_id": None,
"status": "DRAFT",
"km_intrare": 50000,
"observatii": "Test order",
"client_nume": "Ion Popescu",
"client_telefon": "0722000000",
"nr_auto": "B01TST",
"marca_denumire": "Dacia",
"model_denumire": "Logan",
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
result = r.json()
assert result["applied"] == 1
assert result["conflicts"] == []
# Verify the order appears in full sync
full = await client.get("/api/sync/full", headers=auth_headers)
orders = full.json()["tables"]["orders"]
assert any(o["id"] == oid for o in orders)
order = next(o for o in orders if o["id"] == oid)
assert order["nr_comanda"] == "CMD-ABC123"
assert order["client_nume"] == "Ion Popescu"
assert order["marca_denumire"] == "Dacia"
@pytest.mark.asyncio
async def test_sync_push_unknown_columns_ignored(client, auth_headers):
"""If frontend sends extra fields not in the DB schema, they must be silently
ignored (not cause a 500 error)."""
tenant_id = await _get_tenant_id(client, auth_headers)
vid = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
r = await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "vehicles",
"id": vid,
"operation": "INSERT",
"data": {
"id": vid,
"tenant_id": tenant_id,
"nr_inmatriculare": "CT99XYZ",
"created_at": now,
"updated_at": now,
"oracle_id": 12345, # frontend-only field
"nonexistent_column": "boom", # completely unknown field
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
result = r.json()
assert result["applied"] == 1
assert result["conflicts"] == []
@pytest.mark.asyncio
async def test_sync_push_insert_order_line_with_frontend_fields(client, auth_headers):
"""Frontend sends norma_id, mecanic_id, ordine in order_line — must not cause 500."""
tenant_id = await _get_tenant_id(client, auth_headers)
now = datetime.now(UTC).isoformat()
# Create order first
vid = str(uuid.uuid4())
oid = str(uuid.uuid4())
lid = str(uuid.uuid4())
await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "vehicles",
"id": vid,
"operation": "INSERT",
"data": {"id": vid, "tenant_id": tenant_id, "nr_inmatriculare": "B02TST", "created_at": now, "updated_at": now},
"timestamp": now,
},
{
"table": "orders",
"id": oid,
"operation": "INSERT",
"data": {
"id": oid, "tenant_id": tenant_id,
"nr_comanda": "CMD-XYZ", "status": "DRAFT",
"vehicle_id": vid, "created_at": now, "updated_at": now,
},
"timestamp": now,
},
]
},
)
r = await client.post(
"/api/sync/push",
headers=auth_headers,
json={
"operations": [
{
"table": "order_lines",
"id": lid,
"operation": "INSERT",
"data": {
"id": lid,
"order_id": oid,
"tenant_id": tenant_id,
"tip": "manopera",
"descriere": "Schimb ulei",
"norma_id": None,
"ore": 1.5,
"pret_ora": 150.0,
"um": "ora",
"cantitate": 0,
"pret_unitar": 0,
"total": 225.0,
"mecanic_id": None,
"ordine": 1,
"created_at": now,
"updated_at": now,
},
"timestamp": now,
}
]
},
)
assert r.status_code == 200
result = r.json()
assert result["applied"] == 1
assert result["conflicts"] == []