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:
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user