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:
82
api/tests/e2e/conftest.py
Normal file
82
api/tests/e2e/conftest.py
Normal 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
|
||||
171
api/tests/e2e/test_dashboard_live.py
Normal file
171
api/tests/e2e/test_dashboard_live.py
Normal 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
|
||||
57
api/tests/e2e/test_logs_filtering.py
Normal file
57
api/tests/e2e/test_logs_filtering.py
Normal 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()
|
||||
81
api/tests/e2e/test_mappings.py
Normal file
81
api/tests/e2e/test_mappings.py
Normal 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...")
|
||||
68
api/tests/e2e/test_missing_skus.py
Normal file
68
api/tests/e2e/test_missing_skus.py
Normal 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()
|
||||
52
api/tests/e2e/test_order_detail.py
Normal file
52
api/tests/e2e/test_order_detail.py
Normal 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()
|
||||
613
api/tests/test_requirements.py
Normal file
613
api/tests/test_requirements.py
Normal 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"}
|
||||
Reference in New Issue
Block a user