Complete testing system: pyproject.toml (pytest markers), test.sh orchestrator with auto app start/stop and colorful summary, pre-push hook, Gitea Actions workflow. New QA tests: API health (7 endpoints), responsive (3 viewports), log monitoring (ERROR/ORA-/Traceback detection), real GoMag sync, PL/SQL package validation, smoke prod (read-only). Converted test_app_basic.py and test_integration.py to pytest. Added pytestmark to all existing tests (unit/e2e/oracle). E2E conftest upgraded: console error collector, screenshot on failure, auto-detect live app on :5003. Usage: ./test.sh ci (30s) | ./test.sh full (2-3min) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
617 lines
22 KiB
Python
617 lines
22 KiB
Python
"""
|
|
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 pytest
|
|
|
|
pytestmark = pytest.mark.unit
|
|
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"}
|