feat(sqlite): refactor orders schema + dashboard period filter

Replace import_orders (insert-per-run) with orders table (one row per
order, upsert on conflict). Eliminates dedup CTE on every dashboard
query and prevents unbounded row growth at 4-500 orders/sync.

Key changes:
- orders table: PK order_number, upsert via ON CONFLICT DO UPDATE;
  COALESCE preserves id_comanda once set; times_skipped auto-increments
- sync_run_orders: lightweight junction (sync_run_id, order_number)
  replaces sync_run_id column on orders
- order_items: PK changed to (order_number, sku), INSERT OR IGNORE
- Auto-migration in init_sqlite(): import_orders → orders on first boot,
  old table renamed to import_orders_bak
- /api/dashboard/orders: period_days param (3/7/30/0=all, default 7)
- Dashboard: period selector buttons in orders card header
- start.sh: stop existing process on port 5003 before restart;
  remove --reload (broken on WSL2 /mnt/e/)
- Add invoice_service, E2E Playwright tests, Oracle package updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 16:18:57 +02:00
parent 650e98539e
commit 82196b9dc0
32 changed files with 4164 additions and 1192 deletions

82
api/tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Playwright E2E test fixtures.
Starts the FastAPI app on a random port with test SQLite, no Oracle.
"""
import os
import sys
import tempfile
import pytest
import subprocess
import time
import socket
def _free_port():
with socket.socket() as s:
s.bind(('', 0))
return s.getsockname()[1]
@pytest.fixture(scope="session")
def app_url():
"""Start the FastAPI app as a subprocess and return its URL."""
port = _free_port()
tmpdir = tempfile.mkdtemp()
sqlite_path = os.path.join(tmpdir, "e2e_test.db")
env = os.environ.copy()
env.update({
"FORCE_THIN_MODE": "true",
"SQLITE_DB_PATH": sqlite_path,
"ORACLE_DSN": "dummy",
"ORACLE_USER": "dummy",
"ORACLE_PASSWORD": "dummy",
"JSON_OUTPUT_DIR": tmpdir,
})
api_dir = os.path.join(os.path.dirname(__file__), "..", "..")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=api_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for startup (up to 15 seconds)
url = f"http://127.0.0.1:{port}"
for _ in range(30):
try:
import urllib.request
urllib.request.urlopen(f"{url}/health", timeout=1)
break
except Exception:
time.sleep(0.5)
else:
proc.kill()
stdout, stderr = proc.communicate()
raise RuntimeError(
f"App failed to start on port {port}.\n"
f"STDOUT: {stdout.decode()[-2000:]}\n"
f"STDERR: {stderr.decode()[-2000:]}"
)
yield url
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="session")
def seed_test_data(app_url):
"""
Seed SQLite with test data via API calls.
Oracle is unavailable in E2E tests — only SQLite-backed pages are
fully functional. This fixture exists as a hook for future seeding;
for now E2E tests validate UI structure on empty-state pages.
"""
return app_url

View File

@@ -0,0 +1,171 @@
"""
E2E verification: Dashboard page against the live app (localhost:5003).
Run with:
python -m pytest api/tests/e2e/test_dashboard_live.py -v --headed
This tests the LIVE app, not a test instance. Requires the app to be running.
"""
import pytest
from playwright.sync_api import sync_playwright, Page, expect
BASE_URL = "http://localhost:5003"
@pytest.fixture(scope="module")
def browser_page():
"""Launch browser and yield a page connected to the live app."""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 900})
page = context.new_page()
yield page
browser.close()
class TestDashboardPageLoad:
"""Verify dashboard page loads and shows expected structure."""
def test_dashboard_loads(self, browser_page: Page):
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
expect(browser_page.locator("h4")).to_contain_text("Panou de Comanda")
def test_sync_control_visible(self, browser_page: Page):
expect(browser_page.locator("#btnStartSync")).to_be_visible()
expect(browser_page.locator("#syncStatusBadge")).to_be_visible()
def test_last_sync_card_populated(self, browser_page: Page):
"""The lastSyncBody should show data from previous runs."""
last_sync_date = browser_page.locator("#lastSyncDate")
expect(last_sync_date).to_be_visible()
text = last_sync_date.text_content()
assert text and text != "-", f"Expected last sync date to be populated, got: '{text}'"
def test_last_sync_imported_count(self, browser_page: Page):
imported_el = browser_page.locator("#lastSyncImported")
text = imported_el.text_content()
count = int(text) if text and text.isdigit() else 0
assert count >= 0, f"Expected imported count >= 0, got: {text}"
def test_last_sync_status_badge(self, browser_page: Page):
status_el = browser_page.locator("#lastSyncStatus .badge")
expect(status_el).to_be_visible()
text = status_el.text_content()
assert text in ("completed", "running", "failed"), f"Unexpected status: {text}"
class TestDashboardOrdersTable:
"""Verify orders table displays data from SQLite."""
def test_orders_table_has_rows(self, browser_page: Page):
"""Dashboard should show orders from previous sync runs."""
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
# Wait for the orders to load (async fetch)
browser_page.wait_for_timeout(2000)
rows = browser_page.locator("#dashOrdersBody tr")
count = rows.count()
assert count > 0, "Expected at least 1 order row in dashboard table"
def test_orders_count_badges(self, browser_page: Page):
"""Filter badges should show counts."""
all_count = browser_page.locator("#dashCountAll").text_content()
assert all_count and int(all_count) > 0, f"Expected total count > 0, got: {all_count}"
def test_first_order_has_columns(self, browser_page: Page):
"""First row should have order number, date, customer, etc."""
first_row = browser_page.locator("#dashOrdersBody tr").first
cells = first_row.locator("td")
assert cells.count() >= 6, f"Expected at least 6 columns, got: {cells.count()}"
# Order number should be a code element
order_code = first_row.locator("td code").first
expect(order_code).to_be_visible()
def test_filter_imported(self, browser_page: Page):
"""Click 'Importate' filter and verify table updates."""
browser_page.locator("#dashFilterBtns button", has_text="Importate").click()
browser_page.wait_for_timeout(1000)
imported_count = browser_page.locator("#dashCountImported").text_content()
if imported_count and int(imported_count) > 0:
rows = browser_page.locator("#dashOrdersBody tr")
assert rows.count() > 0, "Expected imported orders to show"
# All visible rows should have 'Importat' badge
badges = browser_page.locator("#dashOrdersBody .badge.bg-success")
assert badges.count() > 0, "Expected green 'Importat' badges"
def test_filter_all_reset(self, browser_page: Page):
"""Click 'Toate' to reset filter."""
browser_page.locator("#dashFilterBtns button", has_text="Toate").click()
browser_page.wait_for_timeout(1000)
rows = browser_page.locator("#dashOrdersBody tr")
assert rows.count() > 0, "Expected orders after resetting filter"
class TestDashboardOrderDetail:
"""Verify order detail modal opens and shows data."""
def test_click_order_opens_modal(self, browser_page: Page):
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
browser_page.wait_for_timeout(2000)
# Click the first order row
first_row = browser_page.locator("#dashOrdersBody tr").first
first_row.click()
browser_page.wait_for_timeout(1500)
# Modal should be visible
modal = browser_page.locator("#orderDetailModal")
expect(modal).to_be_visible()
# Order number should be populated
order_num = browser_page.locator("#detailOrderNumber").text_content()
assert order_num and order_num != "#", f"Expected order number in modal, got: {order_num}"
def test_modal_shows_customer(self, browser_page: Page):
customer = browser_page.locator("#detailCustomer").text_content()
assert customer and customer not in ("...", "-"), f"Expected customer name, got: {customer}"
def test_modal_shows_items(self, browser_page: Page):
items_rows = browser_page.locator("#detailItemsBody tr")
assert items_rows.count() > 0, "Expected at least 1 item in order detail"
def test_close_modal(self, browser_page: Page):
browser_page.locator("#orderDetailModal .btn-close").click()
browser_page.wait_for_timeout(500)
class TestDashboardAPIEndpoints:
"""Verify API endpoints return expected data."""
def test_api_dashboard_orders(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/dashboard/orders")
assert response.ok, f"API returned {response.status}"
data = response.json()
assert "orders" in data, "Expected 'orders' key in response"
assert "counts" in data, "Expected 'counts' key in response"
assert len(data["orders"]) > 0, "Expected at least 1 order"
def test_api_sync_status(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/sync/status")
assert response.ok
data = response.json()
assert "status" in data
assert "stats" in data
def test_api_sync_history(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/sync/history?per_page=5")
assert response.ok
data = response.json()
assert "runs" in data
assert len(data["runs"]) > 0, "Expected at least 1 sync run"
def test_api_missing_skus(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/validate/missing-skus")
assert response.ok
data = response.json()
assert "missing_skus" in data

View File

@@ -0,0 +1,57 @@
"""E2E: Logs page with per-order filtering and date display."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_logs(page: Page, app_url: str):
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
def test_logs_page_loads(page: Page):
"""Verify the logs page renders with sync runs table."""
expect(page.locator("h4")).to_contain_text("Jurnale Import")
expect(page.locator("#runsTableBody")).to_be_visible()
def test_sync_runs_table_headers(page: Page):
"""Verify table has correct column headers."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert "Data" in texts, f"Expected 'Data' header, got: {texts}"
assert "Status" in texts, f"Expected 'Status' header, got: {texts}"
assert "Comenzi" in texts, f"Expected 'Comenzi' header, got: {texts}"
def test_filter_buttons_exist(page: Page):
"""Verify the log viewer section is initially hidden (no run selected yet)."""
viewer = page.locator("#logViewerSection")
expect(viewer).to_be_hidden()
def test_order_detail_modal_structure(page: Page):
"""Verify the order detail modal exists in DOM with required fields."""
modal = page.locator("#orderDetailModal")
expect(modal).to_be_attached()
expect(page.locator("#detailOrderNumber")).to_be_attached()
expect(page.locator("#detailCustomer")).to_be_attached()
expect(page.locator("#detailDate")).to_be_attached()
expect(page.locator("#detailItemsBody")).to_be_attached()
def test_quick_map_modal_structure(page: Page):
"""Verify quick map modal exists with multi-CODMAT support."""
modal = page.locator("#quickMapModal")
expect(modal).to_be_attached()
expect(page.locator("#qmSku")).to_be_attached()
expect(page.locator("#qmProductName")).to_be_attached()
expect(page.locator("#qmCodmatLines")).to_be_attached()
def test_text_log_toggle(page: Page):
"""Verify text log section is hidden initially and toggle button is in DOM."""
section = page.locator("#textLogSection")
expect(section).to_be_hidden()
# Toggle button lives inside logViewerSection which is also hidden
expect(page.locator("#btnShowTextLog")).to_be_attached()

View File

@@ -0,0 +1,81 @@
"""E2E: Mappings page with sortable headers, grouping, multi-CODMAT modal."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_mappings(page: Page, app_url: str):
page.goto(f"{app_url}/mappings")
page.wait_for_load_state("networkidle")
def test_mappings_page_loads(page: Page):
"""Verify mappings page renders."""
expect(page.locator("h4")).to_contain_text("Mapari SKU")
def test_sortable_headers_present(page: Page):
"""R7: Verify sortable column headers with sort icons."""
sortable_ths = page.locator("th.sortable")
count = sortable_ths.count()
assert count >= 5, f"Expected at least 5 sortable columns, got {count}"
sort_icons = page.locator(".sort-icon")
assert sort_icons.count() >= 5, f"Expected at least 5 sort-icon spans, got {sort_icons.count()}"
def test_product_name_column_exists(page: Page):
"""R4: Verify 'Produs Web' column exists in header."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert any("Produs Web" in t for t in texts), f"'Produs Web' column not found in headers: {texts}"
def test_um_column_exists(page: Page):
"""R12: Verify 'UM' column exists in header."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert any("UM" in t for t in texts), f"'UM' column not found in headers: {texts}"
def test_show_inactive_toggle_exists(page: Page):
"""R5: Verify 'Arata inactive' toggle is present."""
toggle = page.locator("#showInactive")
expect(toggle).to_be_visible()
label = page.locator("label[for='showInactive']")
expect(label).to_contain_text("Arata inactive")
def test_sort_click_changes_icon(page: Page):
"""R7: Clicking a sortable header should display a sort direction arrow."""
sku_header = page.locator("th.sortable", has_text="SKU")
sku_header.click()
page.wait_for_timeout(500)
icon = page.locator(".sort-icon[data-col='sku']")
text = icon.text_content()
assert text in ("", ""), f"Expected sort arrow (↑ or ↓), got '{text}'"
def test_add_modal_multi_codmat(page: Page):
"""R11: Verify the add mapping modal supports multiple CODMAT lines."""
page.locator("button", has_text="Adauga Mapare").click()
page.wait_for_timeout(500)
codmat_lines = page.locator(".codmat-line")
assert codmat_lines.count() >= 1, "Expected at least one CODMAT line in modal"
page.locator("button", has_text="Adauga CODMAT").click()
page.wait_for_timeout(300)
assert codmat_lines.count() >= 2, "Expected a second CODMAT line after clicking Adauga CODMAT"
# Second line must have a remove button
remove_btns = page.locator(".codmat-line:nth-child(2) button.btn-outline-danger")
assert remove_btns.count() >= 1, "Second CODMAT line is missing remove button"
def test_search_input_exists(page: Page):
"""Verify search input is present with the correct placeholder."""
search = page.locator("#searchInput")
expect(search).to_be_visible()
expect(search).to_have_attribute("placeholder", "Cauta SKU, CODMAT sau denumire...")

View File

@@ -0,0 +1,68 @@
"""E2E: Missing SKUs page with resolved toggle and multi-CODMAT modal."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_missing(page: Page, app_url: str):
page.goto(f"{app_url}/missing-skus")
page.wait_for_load_state("networkidle")
def test_missing_skus_page_loads(page: Page):
"""Verify the page renders with the correct heading."""
expect(page.locator("h4")).to_contain_text("SKU-uri Lipsa")
def test_resolved_toggle_buttons(page: Page):
"""R10: Verify resolved filter buttons exist and Nerezolvate is active by default."""
expect(page.locator("#btnUnresolved")).to_be_visible()
expect(page.locator("#btnResolved")).to_be_visible()
expect(page.locator("#btnAll")).to_be_visible()
classes = page.locator("#btnUnresolved").get_attribute("class")
assert "btn-primary" in classes, f"Expected #btnUnresolved to be active (btn-primary), got classes: {classes}"
def test_resolved_toggle_switches(page: Page):
"""R10: Clicking resolved/all toggles changes active state correctly."""
# Click "Rezolvate"
page.locator("#btnResolved").click()
page.wait_for_timeout(500)
classes_res = page.locator("#btnResolved").get_attribute("class")
assert "btn-success" in classes_res, f"Expected #btnResolved to be active (btn-success), got: {classes_res}"
classes_unr = page.locator("#btnUnresolved").get_attribute("class")
assert "btn-outline" in classes_unr, f"Expected #btnUnresolved to be outline after deactivation, got: {classes_unr}"
# Click "Toate"
page.locator("#btnAll").click()
page.wait_for_timeout(500)
classes_all = page.locator("#btnAll").get_attribute("class")
assert "btn-secondary" in classes_all, f"Expected #btnAll to be active (btn-secondary), got: {classes_all}"
def test_map_modal_multi_codmat(page: Page):
"""R11: Verify the mapping modal supports multiple CODMATs."""
modal = page.locator("#mapModal")
expect(modal).to_be_attached()
add_btn = page.locator("#mapModal button", has_text="Adauga CODMAT")
expect(add_btn).to_be_attached()
expect(page.locator("#mapProductName")).to_be_attached()
expect(page.locator("#mapPctWarning")).to_be_attached()
def test_export_csv_button(page: Page):
"""Verify Export CSV button is visible on the page."""
btn = page.locator("button", has_text="Export CSV")
expect(btn).to_be_visible()
def test_rescan_button(page: Page):
"""Verify Re-Scan button is visible on the page."""
btn = page.locator("button", has_text="Re-Scan")
expect(btn).to_be_visible()

View File

@@ -0,0 +1,52 @@
"""E2E: Order detail modal structure and inline mapping."""
import pytest
from playwright.sync_api import Page, expect
def test_order_detail_modal_has_roa_ids(page: Page, app_url: str):
"""R9: Verify order detail modal contains all ROA ID labels."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
modal = page.locator("#orderDetailModal")
expect(modal).to_be_attached()
modal_html = modal.inner_html()
assert "ID Comanda ROA" in modal_html, "Missing 'ID Comanda ROA' label in order detail modal"
assert "ID Partener" in modal_html, "Missing 'ID Partener' label in order detail modal"
assert "ID Adr. Facturare" in modal_html, "Missing 'ID Adr. Facturare' label in order detail modal"
assert "ID Adr. Livrare" in modal_html, "Missing 'ID Adr. Livrare' label in order detail modal"
def test_order_detail_items_table_columns(page: Page, app_url: str):
"""R9: Verify items table has all required columns."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
headers = page.locator("#orderDetailModal thead th")
texts = headers.all_text_contents()
required_columns = ["SKU", "Produs", "Cant.", "Pret", "TVA", "CODMAT", "Status", "Actiune"]
for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
def test_quick_map_from_order_detail(page: Page, app_url: str):
"""R9+R11: Verify quick map modal is reachable from order detail context."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
modal = page.locator("#quickMapModal")
expect(modal).to_be_attached()
expect(page.locator("#qmCodmatLines")).to_be_attached()
expect(page.locator("#qmPctWarning")).to_be_attached()
def test_dashboard_navigates_to_logs(page: Page, app_url: str):
"""Verify the sidebar on the dashboard contains a link to the logs page."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
logs_link = page.locator("a[href='/logs']")
expect(logs_link).to_be_visible()

View File

@@ -0,0 +1,613 @@
"""
Test Phase 5.1: Backend Functionality Tests (no Oracle required)
================================================================
Tests all new backend features: web_products, order_items, order detail,
run orders filtered, address updates, missing SKUs toggle, and API endpoints.
Run:
cd api && python -m pytest tests/test_requirements.py -v
"""
import os
import sys
import tempfile
# --- Set env vars BEFORE any app import ---
_tmpdir = tempfile.mkdtemp()
_sqlite_path = os.path.join(_tmpdir, "test_import.db")
os.environ["FORCE_THIN_MODE"] = "true"
os.environ["SQLITE_DB_PATH"] = _sqlite_path
os.environ["ORACLE_DSN"] = "dummy"
os.environ["ORACLE_USER"] = "dummy"
os.environ["ORACLE_PASSWORD"] = "dummy"
os.environ["JSON_OUTPUT_DIR"] = _tmpdir
# Add api/ to path so we can import app
_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)
import pytest
import pytest_asyncio
from app.database import init_sqlite
from app.services import sqlite_service
# Initialize SQLite once before any tests run
init_sqlite()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def client():
"""TestClient with lifespan (startup/shutdown) so SQLite routes work."""
from fastapi.testclient import TestClient
from app.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
@pytest.fixture(autouse=True, scope="module")
def seed_baseline_data():
"""
Seed the sync run and orders used by multiple tests so they run in any order.
We use asyncio.run() because this is a synchronous fixture but needs to call
async service functions.
"""
import asyncio
async def _seed():
# Create sync run RUN001
await sqlite_service.create_sync_run("RUN001", 1)
# Add the first order (IMPORTED) with items
await sqlite_service.add_import_order(
"RUN001", "ORD001", "2025-01-15", "Test Client", "IMPORTED",
id_comanda=100, id_partener=200, items_count=2
)
items = [
{
"sku": "SKU1",
"product_name": "Prod 1",
"quantity": 2.0,
"price": 10.0,
"vat": 1.9,
"mapping_status": "direct",
"codmat": "SKU1",
"id_articol": 500,
"cantitate_roa": 2.0,
},
{
"sku": "SKU2",
"product_name": "Prod 2",
"quantity": 1.0,
"price": 20.0,
"vat": 3.8,
"mapping_status": "missing",
"codmat": None,
"id_articol": None,
"cantitate_roa": None,
},
]
await sqlite_service.add_order_items("RUN001", "ORD001", items)
# Add more orders for filter tests
await sqlite_service.add_import_order(
"RUN001", "ORD002", "2025-01-16", "Client 2", "SKIPPED",
missing_skus=["SKU99"], items_count=1
)
await sqlite_service.add_import_order(
"RUN001", "ORD003", "2025-01-17", "Client 3", "ERROR",
error_message="Test error", items_count=3
)
asyncio.run(_seed())
yield
# ---------------------------------------------------------------------------
# Section 1: web_products CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upsert_web_product():
"""First upsert creates the row; second increments order_count."""
await sqlite_service.upsert_web_product("SKU001", "Product One")
name = await sqlite_service.get_web_product_name("SKU001")
assert name == "Product One"
# Second upsert should increment order_count (no assertion on count here,
# but must not raise and batch lookup should still find it)
await sqlite_service.upsert_web_product("SKU001", "Product One")
batch = await sqlite_service.get_web_products_batch(["SKU001", "NONEXIST"])
assert "SKU001" in batch
assert "NONEXIST" not in batch
@pytest.mark.asyncio
async def test_web_product_name_update():
"""Empty name should NOT overwrite an existing product name."""
await sqlite_service.upsert_web_product("SKU002", "Good Name")
await sqlite_service.upsert_web_product("SKU002", "")
name = await sqlite_service.get_web_product_name("SKU002")
assert name == "Good Name"
@pytest.mark.asyncio
async def test_get_web_product_name_missing():
"""Lookup for an SKU that was never inserted should return empty string."""
name = await sqlite_service.get_web_product_name("DEFINITELY_NOT_THERE_XYZ")
assert name == ""
@pytest.mark.asyncio
async def test_get_web_products_batch_empty():
"""Batch lookup with empty list should return empty dict without error."""
result = await sqlite_service.get_web_products_batch([])
assert result == {}
# ---------------------------------------------------------------------------
# Section 2: order_items CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_and_get_order_items():
"""Verify the items seeded in baseline data are retrievable."""
fetched = await sqlite_service.get_order_items("ORD001")
assert len(fetched) == 2
assert fetched[0]["sku"] == "SKU1"
assert fetched[1]["mapping_status"] == "missing"
@pytest.mark.asyncio
async def test_get_order_items_mapping_status():
"""First item should be 'direct', second should be 'missing'."""
fetched = await sqlite_service.get_order_items("ORD001")
assert fetched[0]["mapping_status"] == "direct"
assert fetched[1]["codmat"] is None
assert fetched[1]["id_articol"] is None
@pytest.mark.asyncio
async def test_get_order_items_for_nonexistent_order():
"""Items query for an unknown order should return an empty list."""
fetched = await sqlite_service.get_order_items("NONEXIST_ORDER")
assert fetched == []
# ---------------------------------------------------------------------------
# Section 3: order detail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_order_detail():
"""Order detail returns order metadata plus its line items."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail is not None
assert detail["order"]["order_number"] == "ORD001"
assert len(detail["items"]) == 2
@pytest.mark.asyncio
async def test_get_order_detail_not_found():
"""Non-existent order returns None."""
detail = await sqlite_service.get_order_detail("NONEXIST")
assert detail is None
@pytest.mark.asyncio
async def test_get_order_detail_status():
"""Seeded ORD001 should have IMPORTED status."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["status"] == "IMPORTED"
# ---------------------------------------------------------------------------
# Section 4: run orders filtered
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_run_orders_filtered_all():
"""All orders in run should total 3 with correct status counts."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 50)
assert result["total"] == 3
assert result["counts"]["imported"] == 1
assert result["counts"]["skipped"] == 1
assert result["counts"]["error"] == 1
@pytest.mark.asyncio
async def test_get_run_orders_filtered_imported():
"""Filter IMPORTED should return only ORD001."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "IMPORTED", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD001"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_skipped():
"""Filter SKIPPED should return only ORD002."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "SKIPPED", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD002"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_error():
"""Filter ERROR should return only ORD003."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "ERROR", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD003"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_unknown_run():
"""Unknown run_id should return zero orders without error."""
result = await sqlite_service.get_run_orders_filtered("NO_SUCH_RUN", "all", 1, 50)
assert result["total"] == 0
assert result["orders"] == []
@pytest.mark.asyncio
async def test_get_run_orders_filtered_pagination():
"""Pagination: page 1 with per_page=1 should return 1 order."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 1)
assert len(result["orders"]) == 1
assert result["total"] == 3
assert result["pages"] == 3
# ---------------------------------------------------------------------------
# Section 5: update_import_order_addresses
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_import_order_addresses():
"""Address IDs should be persisted and retrievable via get_order_detail."""
await sqlite_service.update_import_order_addresses(
"ORD001", "RUN001",
id_adresa_facturare=300,
id_adresa_livrare=400
)
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["id_adresa_facturare"] == 300
assert detail["order"]["id_adresa_livrare"] == 400
@pytest.mark.asyncio
async def test_update_import_order_addresses_null():
"""Updating with None should be accepted without error."""
await sqlite_service.update_import_order_addresses(
"ORD001", "RUN001",
id_adresa_facturare=None,
id_adresa_livrare=None
)
detail = await sqlite_service.get_order_detail("ORD001")
assert detail is not None # row still exists
# ---------------------------------------------------------------------------
# Section 6: missing SKUs resolved toggle (R10)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_missing_skus_resolved_toggle():
"""resolved=-1 returns all; resolved=0/1 returns only matching rows."""
await sqlite_service.track_missing_sku("MISS1", "Missing Product 1")
await sqlite_service.track_missing_sku("MISS2", "Missing Product 2")
await sqlite_service.resolve_missing_sku("MISS2")
# Unresolved only (default)
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0)
assert all(s["resolved"] == 0 for s in result["missing_skus"])
# Resolved only
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=1)
assert all(s["resolved"] == 1 for s in result["missing_skus"])
# All (resolved=-1)
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=-1)
assert result["total"] >= 2
@pytest.mark.asyncio
async def test_track_missing_sku_idempotent():
"""Tracking the same SKU twice should not raise (INSERT OR IGNORE)."""
await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product")
await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product")
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0)
sku_list = [s["sku"] for s in result["missing_skus"]]
assert sku_list.count("IDEMPOTENT_SKU") == 1
@pytest.mark.asyncio
async def test_missing_skus_pagination():
"""Pagination response includes total, page, per_page, pages fields."""
result = await sqlite_service.get_missing_skus_paginated(1, 1, resolved=-1)
assert "total" in result
assert "page" in result
assert "per_page" in result
assert "pages" in result
assert len(result["missing_skus"]) <= 1
# ---------------------------------------------------------------------------
# Section 7: API endpoints via TestClient
# ---------------------------------------------------------------------------
def test_api_sync_run_orders(client):
"""R1: GET /api/sync/run/{run_id}/orders returns orders and counts."""
resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=50")
assert resp.status_code == 200
data = resp.json()
assert "orders" in data
assert "counts" in data
def test_api_sync_run_orders_filtered(client):
"""R1: Filtering by status=IMPORTED returns only IMPORTED orders."""
resp = client.get("/api/sync/run/RUN001/orders?status=IMPORTED")
assert resp.status_code == 200
data = resp.json()
assert all(o["status"] == "IMPORTED" for o in data["orders"])
def test_api_sync_run_orders_pagination_fields(client):
"""R1: Paginated response includes total, page, per_page, pages."""
resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=10")
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "page" in data
assert "per_page" in data
assert "pages" in data
def test_api_sync_run_orders_unknown_run(client):
"""R1: Unknown run_id returns empty orders list, not 4xx/5xx."""
resp = client.get("/api/sync/run/NO_SUCH_RUN/orders")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
def test_api_order_detail(client):
"""R9: GET /api/sync/order/{order_number} returns order and items."""
resp = client.get("/api/sync/order/ORD001")
assert resp.status_code == 200
data = resp.json()
assert "order" in data
assert "items" in data
def test_api_order_detail_not_found(client):
"""R9: Non-existent order number returns error key."""
resp = client.get("/api/sync/order/NONEXIST")
assert resp.status_code == 200
data = resp.json()
assert "error" in data
def test_api_missing_skus_resolved_toggle(client):
"""R10: resolved=-1 returns all missing SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=-1")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
def test_api_missing_skus_resolved_unresolved(client):
"""R10: resolved=0 returns only unresolved SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=0")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
assert all(s["resolved"] == 0 for s in data["missing_skus"])
def test_api_missing_skus_resolved_only(client):
"""R10: resolved=1 returns only resolved SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=1")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
assert all(s["resolved"] == 1 for s in data["missing_skus"])
def test_api_missing_skus_csv_format(client):
"""R8: CSV export has mapping-compatible columns."""
resp = client.get("/api/validate/missing-skus-csv")
assert resp.status_code == 200
content = resp.content.decode("utf-8-sig")
header_line = content.split("\n")[0].strip()
assert header_line == "sku,codmat,cantitate_roa,procent_pret,product_name"
def test_api_mappings_sort_params(client):
"""R7: Sort params accepted - no 422 validation error even without Oracle."""
resp = client.get("/api/mappings?sort_by=sku&sort_dir=desc")
# 200 if Oracle available, 503 if not - but never 422 (invalid params)
assert resp.status_code in [200, 503]
def test_api_mappings_sort_params_asc(client):
"""R7: sort_dir=asc is also accepted without 422."""
resp = client.get("/api/mappings?sort_by=codmat&sort_dir=asc")
assert resp.status_code in [200, 503]
def test_api_batch_mappings_validation_percentage(client):
"""R11: Batch endpoint rejects procent_pret that does not sum to 100."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU",
"mappings": [
{"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60},
{"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 30},
]
})
data = resp.json()
# 60 + 30 = 90, not 100 -> must fail validation
assert data.get("success") is False
assert "100%" in data.get("error", "")
def test_api_batch_mappings_validation_exact_100(client):
"""R11: Batch with procent_pret summing to exactly 100 passes validation layer."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU_VALID",
"mappings": [
{"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60},
{"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 40},
]
})
data = resp.json()
# Validation passes; may fail with 503/error if Oracle is unavailable,
# but must NOT return the percentage error message
assert "100%" not in data.get("error", "")
def test_api_batch_mappings_no_mappings(client):
"""R11: Batch endpoint rejects empty mappings list."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU",
"mappings": []
})
data = resp.json()
assert data.get("success") is False
def test_api_sync_status(client):
"""GET /api/sync/status returns status and stats keys."""
resp = client.get("/api/sync/status")
assert resp.status_code == 200
data = resp.json()
assert "stats" in data
def test_api_sync_history(client):
"""GET /api/sync/history returns paginated run history."""
resp = client.get("/api/sync/history")
assert resp.status_code == 200
data = resp.json()
assert "runs" in data
assert "total" in data
def test_api_missing_skus_pagination_params(client):
"""Pagination params page and per_page are respected."""
resp = client.get("/api/validate/missing-skus?page=1&per_page=2&resolved=-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["missing_skus"]) <= 2
assert data["per_page"] == 2
def test_api_csv_template(client):
"""GET /api/mappings/csv-template returns a CSV file without Oracle."""
resp = client.get("/api/mappings/csv-template")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Section 8: Chronological sorting (R3)
# ---------------------------------------------------------------------------
def test_chronological_sort():
"""R3: Orders sorted oldest-first when sorted by date string."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="3", number="003", date="2025-03-01", billing=OrderBilling()),
OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling()),
OrderData(id="2", number="002", date="2025-02-01", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
assert orders[0].number == "001"
assert orders[1].number == "002"
assert orders[2].number == "003"
def test_chronological_sort_stable_on_equal_dates():
"""R3: Two orders with the same date preserve relative order."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="A", number="A01", date="2025-05-01", billing=OrderBilling()),
OrderData(id="B", number="B01", date="2025-05-01", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
# Both dates equal; stable sort preserves original order
assert orders[0].number == "A01"
assert orders[1].number == "B01"
def test_chronological_sort_empty_date_last():
"""R3: Orders with missing date (empty string) sort before dated orders."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="2", number="002", date="2025-06-01", billing=OrderBilling()),
OrderData(id="1", number="001", date="", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
# '' sorts before '2025-...' lexicographically
assert orders[0].number == "001"
assert orders[1].number == "002"
# ---------------------------------------------------------------------------
# Section 9: OrderData dataclass integrity
# ---------------------------------------------------------------------------
def test_order_data_defaults():
"""OrderData can be constructed with only id, number, date."""
from app.services.order_reader import OrderData, OrderBilling
order = OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling())
assert order.status == ""
assert order.items == []
assert order.shipping is None
def test_order_billing_defaults():
"""OrderBilling has sensible defaults."""
from app.services.order_reader import OrderBilling
b = OrderBilling()
assert b.is_company is False
assert b.company_name == ""
assert b.email == ""
def test_get_all_skus():
"""get_all_skus extracts a unique set of SKUs from all orders."""
from app.services.order_reader import OrderData, OrderBilling, OrderItem, get_all_skus
orders = [
OrderData(
id="1", number="001", date="2025-01-01",
billing=OrderBilling(),
items=[
OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9),
OrderItem(sku="B", name="Prod B", price=20, quantity=2, vat=3.8),
]
),
OrderData(
id="2", number="002", date="2025-01-02",
billing=OrderBilling(),
items=[
OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9),
OrderItem(sku="C", name="Prod C", price=5, quantity=3, vat=0.95),
]
),
]
skus = get_all_skus(orders)
assert skus == {"A", "B", "C"}