fix(analytics): harden period-linked sales queries
This commit is contained in:
423
tests/test_sales_analysis_query_contract.py
Normal file
423
tests/test_sales_analysis_query_contract.py
Normal file
@@ -0,0 +1,423 @@
|
||||
from io import BytesIO
|
||||
import re
|
||||
|
||||
from flask import Flask
|
||||
from openpyxl import load_workbook
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
import routes.sales_routes as sales_routes
|
||||
from services.sales_analysis_export_service import (
|
||||
combine_sales_marketing_export_frames,
|
||||
query_sales_marketing_export_frames,
|
||||
query_sales_vendor_export_frame,
|
||||
sanitize_excel_dataframe,
|
||||
)
|
||||
from services.sales_analysis_query_service import (
|
||||
SALES_TIME_TEXT_PATTERN,
|
||||
build_sales_where_clause,
|
||||
normalize_sales_query_args,
|
||||
prepare_sales_query_context,
|
||||
resolve_sales_query_columns,
|
||||
)
|
||||
|
||||
|
||||
def _build_sales_engine():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
'''
|
||||
CREATE TABLE realtime_sales_monthly (
|
||||
"日期" TEXT,
|
||||
"時間" TEXT,
|
||||
"商品ID" TEXT,
|
||||
"商品名稱" TEXT,
|
||||
"商品館" TEXT,
|
||||
"品牌" TEXT,
|
||||
"廠商名稱" TEXT,
|
||||
"總業績" REAL,
|
||||
"數量" REAL,
|
||||
"總成本" REAL,
|
||||
"折扣活動名稱" TEXT,
|
||||
"折價券活動名稱" TEXT
|
||||
)
|
||||
'''
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
'''
|
||||
INSERT INTO realtime_sales_monthly
|
||||
("日期", "時間", "商品ID", "商品名稱", "商品館", "品牌",
|
||||
"廠商名稱", "總業績", "數量", "總成本", "折扣活動名稱", "折價券活動名稱")
|
||||
VALUES
|
||||
('2025/04/07', '10:00:00', 'P1', '商品 A', '美妝', '品牌 A', '廠商 A', 100, 2, 60, '母親節', ''),
|
||||
('2025/04/07', '20:00:00', 'P2', '商品 B', '美妝', '品牌 B', '廠商 B', 300, 3, 200, '', '折價券'),
|
||||
('2025/04/08', '10:00:00', 'P3', '商品 C', '美妝', '品牌 C', '廠商 C', 600, 4, 300, '會員日', ''),
|
||||
('2026/04/06', '10:00:00', 'P1', '商品 A', '美妝', '品牌 A', '廠商 A', 150, 3, 80, '母親節', ''),
|
||||
('2026/04/06', '20:00:00', 'P2', '商品 B', '美妝', '品牌 B', '廠商 B', 400, 4, 250, '', '折價券'),
|
||||
('2026/04/07', '10:00:00', 'P3', '商品 C', '美妝', '品牌 C', '廠商 C', 800, 5, 400, '會員日', ''),
|
||||
('2026/04/06', '10:00:00', 'P4', '商品 D', '3C', '品牌 D', '廠商 D', 9999, 1, 1, '清倉', '')
|
||||
'''
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
def test_query_filter_values_are_bound_instead_of_interpolated():
|
||||
engine = _build_sales_engine()
|
||||
attack = "美妝' OR 1=1 --"
|
||||
filters = normalize_sales_query_args({
|
||||
"data_range": "0",
|
||||
"category": attack,
|
||||
"start_date": "2026-04-30",
|
||||
"end_date": "2026-04-01",
|
||||
})
|
||||
columns = resolve_sales_query_columns(engine, "realtime_sales_monthly")
|
||||
|
||||
clause, params = build_sales_where_clause(engine, columns, filters)
|
||||
|
||||
assert attack not in clause
|
||||
assert params["filter_category"] == attack
|
||||
assert params["filter_start_date"] == "2026-04-01"
|
||||
assert params["filter_end_date"] == "2026-04-30"
|
||||
|
||||
|
||||
def test_keyword_search_is_case_insensitive_and_treats_wildcards_as_text():
|
||||
engine = _build_sales_engine()
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
"UPDATE realtime_sales_monthly SET \"商品名稱\" = 'Alpha%_Beta' "
|
||||
"WHERE \"商品ID\" = 'P1'"
|
||||
)
|
||||
filters = normalize_sales_query_args({
|
||||
"data_range": "0",
|
||||
"keyword": "alpha%_beta",
|
||||
})
|
||||
columns = resolve_sales_query_columns(engine, "realtime_sales_monthly")
|
||||
where_sql, params = build_sales_where_clause(engine, columns, filters)
|
||||
|
||||
with engine.connect() as connection:
|
||||
product_ids = connection.execute(
|
||||
text(f'SELECT "商品ID" FROM realtime_sales_monthly WHERE {where_sql}'),
|
||||
params,
|
||||
).scalars().all()
|
||||
|
||||
assert product_ids == ["P1", "P1"]
|
||||
assert params["filter_keyword"] == "%alpha\\%\\_beta%"
|
||||
|
||||
|
||||
def test_postgres_filter_contract_uses_bound_temporal_dimensions():
|
||||
class PostgresEngine:
|
||||
dialect = postgresql.dialect()
|
||||
|
||||
filters = normalize_sales_query_args({
|
||||
"data_range": "0",
|
||||
"start_date": "2026-04-01",
|
||||
"end_date": "2026-04-30",
|
||||
"dow": "0",
|
||||
"hour": "10",
|
||||
"keyword": "Alpha",
|
||||
})
|
||||
columns = {
|
||||
"date": "日期",
|
||||
"time": "時間",
|
||||
"name": "商品名稱",
|
||||
"pid": "商品ID",
|
||||
"brand": "品牌",
|
||||
"vendor": "廠商名稱",
|
||||
}
|
||||
|
||||
where_sql, params = build_sales_where_clause(PostgresEngine(), columns, filters)
|
||||
|
||||
assert "EXTRACT(DOW FROM TO_DATE" in where_sql
|
||||
assert "SUBSTRING(TRIM(CAST" in where_sql
|
||||
assert "(\\.[0-9]+)?)?$" in where_sql
|
||||
assert "LOWER(CAST" in where_sql
|
||||
assert params["filter_db_dow"] == 1
|
||||
assert params["filter_hour"] == 10
|
||||
assert re.fullmatch(SALES_TIME_TEXT_PATTERN, "10:30:59")
|
||||
assert not re.fullmatch(SALES_TIME_TEXT_PATTERN, "10:30junk")
|
||||
|
||||
|
||||
def test_invalid_time_text_never_matches_midnight_filter():
|
||||
engine = _build_sales_engine()
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
"UPDATE realtime_sales_monthly SET \"時間\" = '' WHERE \"商品ID\" = 'P1'"
|
||||
)
|
||||
filters = normalize_sales_query_args({"data_range": "0", "hour": "0"})
|
||||
columns = resolve_sales_query_columns(engine, "realtime_sales_monthly")
|
||||
where_sql, params = build_sales_where_clause(engine, columns, filters)
|
||||
|
||||
with engine.connect() as connection:
|
||||
count = connection.execute(
|
||||
text(f"SELECT COUNT(*) FROM realtime_sales_monthly WHERE {where_sql}"),
|
||||
params,
|
||||
).scalar_one()
|
||||
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_top_detail_rejects_injection_as_data_and_keeps_the_table_intact():
|
||||
engine = _build_sales_engine()
|
||||
frame, _meta = sales_routes._query_sales_top_detail_frame(engine, {
|
||||
"data_range": "0",
|
||||
"start_date": "2026-04-01",
|
||||
"end_date": "2026-04-30",
|
||||
"category": "美妝' OR 1=1 --",
|
||||
"metric": "amount",
|
||||
"view": "product",
|
||||
"type": "revenue",
|
||||
})
|
||||
|
||||
assert frame.empty
|
||||
with engine.connect() as conn:
|
||||
count = conn.exec_driver_sql("SELECT COUNT(*) FROM realtime_sales_monthly").scalar_one()
|
||||
assert count == 7
|
||||
|
||||
|
||||
def test_top_detail_rejects_a_mismatched_business_type_and_metric():
|
||||
engine = _build_sales_engine()
|
||||
|
||||
try:
|
||||
sales_routes._query_sales_top_detail_frame(engine, {
|
||||
"data_range": "0",
|
||||
"type": "margin",
|
||||
"metric": "amount",
|
||||
"view": "product",
|
||||
})
|
||||
except ValueError as exc:
|
||||
assert "type 與 metric" in str(exc)
|
||||
else:
|
||||
raise AssertionError("mismatched type/metric must be rejected")
|
||||
|
||||
|
||||
def test_yoy_applies_month_weekday_hour_and_category_together(monkeypatch):
|
||||
engine = _build_sales_engine()
|
||||
|
||||
class FakeDatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
|
||||
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/sales_analysis/yoy_comparison"
|
||||
"?year1=2025&year2=2026&metric=revenue&data_range=0"
|
||||
"&month=2026-04&dow=0&hour=10&category=美妝"
|
||||
):
|
||||
response = sales_routes.api_yoy_comparison.__wrapped__()
|
||||
|
||||
payload = response.get_json()
|
||||
assert payload["year1"]["total"] == 100
|
||||
assert payload["year2"]["total"] == 150
|
||||
assert payload["growth_rate"] == 50
|
||||
|
||||
|
||||
def test_table_api_normalizes_reverse_dates_and_preserves_metric_order(monkeypatch):
|
||||
engine = _build_sales_engine()
|
||||
|
||||
class FakeDatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
|
||||
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
|
||||
sales_routes._TABLE_DATA_CACHE.clear()
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/sales_analysis/table_data?metric=qty&data_range=0"
|
||||
"&start_date=2026-04-30&end_date=2026-04-01&category=美妝"
|
||||
):
|
||||
response = sales_routes.api_sales_table_data.__wrapped__()
|
||||
|
||||
payload = response.get_json()
|
||||
assert [item["product_id"] for item in payload["data"]] == ["P3", "P2", "P1"]
|
||||
assert [item["qty"] for item in payload["data"]] == [5, 4, 3]
|
||||
|
||||
|
||||
def test_sales_page_redirects_reverse_dates_to_one_canonical_url(monkeypatch):
|
||||
engine = _build_sales_engine()
|
||||
|
||||
class FakeDatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
|
||||
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
|
||||
sales_routes._SALES_OPTIONS_CACHE.clear()
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(sales_routes.sales_bp)
|
||||
with app.test_request_context(
|
||||
"/sales_analysis?data_range=0&start_date=2026-04-30&end_date=2026-04-01"
|
||||
):
|
||||
response = sales_routes.sales_analysis.__wrapped__()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "start_date=2026-04-01" in response.location
|
||||
assert "end_date=2026-04-30" in response.location
|
||||
|
||||
|
||||
def test_sales_page_closes_one_sided_dates_with_live_source_bounds(monkeypatch):
|
||||
engine = _build_sales_engine()
|
||||
|
||||
class FakeDatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
|
||||
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
|
||||
sales_routes._SALES_OPTIONS_CACHE.clear()
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(sales_routes.sales_bp)
|
||||
|
||||
with app.test_request_context(
|
||||
"/sales_analysis?data_range=0&end_date=2026-04-10"
|
||||
):
|
||||
response = sales_routes.sales_analysis.__wrapped__()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "start_date=2025-04-07" in response.location
|
||||
assert "end_date=2026-04-10" in response.location
|
||||
|
||||
|
||||
def test_other_category_uses_the_same_filtered_top_twelve_contract():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
'CREATE TABLE realtime_sales_monthly ('
|
||||
'"日期" TEXT, "商品名稱" TEXT, "商品館" TEXT, "總業績" REAL)'
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
'INSERT INTO realtime_sales_monthly '
|
||||
'("日期", "商品名稱", "商品館", "總業績") '
|
||||
'VALUES (:sale_date, :name, :category, :amount)'
|
||||
),
|
||||
[
|
||||
{
|
||||
"sale_date": "2026/04/01",
|
||||
"name": f"商品 {index}",
|
||||
"category": f"分類 {index:02d}",
|
||||
"amount": 100 - index if index <= 10 else 1,
|
||||
}
|
||||
for index in range(14)
|
||||
] + [{
|
||||
"sale_date": "2026/04/01",
|
||||
"name": "未分類商品",
|
||||
"category": None,
|
||||
"amount": 1,
|
||||
}],
|
||||
)
|
||||
|
||||
filters, columns = prepare_sales_query_context(
|
||||
engine,
|
||||
"realtime_sales_monthly",
|
||||
{"data_range": "0", "category": "其他"},
|
||||
)
|
||||
where_sql, params = build_sales_where_clause(engine, columns, filters)
|
||||
with engine.connect() as connection:
|
||||
categories = connection.execute(
|
||||
text(
|
||||
'SELECT "商品館" FROM realtime_sales_monthly '
|
||||
f'WHERE {where_sql} ORDER BY "商品館"'
|
||||
),
|
||||
params,
|
||||
).scalars().all()
|
||||
|
||||
assert categories == ["分類 12", "分類 13"]
|
||||
|
||||
sales_routes._SALES_PROCESSED_CACHE["other-contract"] = {
|
||||
"df": pd.read_sql(text("SELECT * FROM realtime_sales_monthly"), engine),
|
||||
"cols": {
|
||||
"name": "商品名稱",
|
||||
"category": "商品館",
|
||||
"amount": "總業績",
|
||||
},
|
||||
}
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/sales_analysis?category=其他"):
|
||||
page_frame, _columns, error = sales_routes._get_filtered_sales_data(
|
||||
"other-contract"
|
||||
)
|
||||
sales_routes._SALES_PROCESSED_CACHE.pop("other-contract", None)
|
||||
|
||||
assert error is None
|
||||
assert page_frame["商品館"].tolist() == ["分類 12", "分類 13"]
|
||||
|
||||
|
||||
def test_vendor_and_marketing_exports_apply_the_page_filters():
|
||||
engine = _build_sales_engine()
|
||||
statements = []
|
||||
|
||||
def capture_statement(_conn, _cursor, statement, _params, _context, _many):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", capture_statement)
|
||||
args = {
|
||||
"metric": "amount",
|
||||
"data_range": "0",
|
||||
"start_date": "2026-04-01",
|
||||
"end_date": "2026-04-30",
|
||||
"category": "美妝",
|
||||
"hour": "10",
|
||||
}
|
||||
|
||||
vendor_frame = query_sales_vendor_export_frame(engine, args)
|
||||
marketing_frames = query_sales_marketing_export_frames(engine, args)
|
||||
|
||||
assert vendor_frame[["廠商", "銷售金額"]].to_dict("records") == [
|
||||
{"廠商": "廠商 C", "銷售金額": 800.0},
|
||||
{"廠商": "廠商 A", "銷售金額": 150.0},
|
||||
]
|
||||
assert marketing_frames["折扣活動"][["活動名稱", "銷售金額"]].to_dict("records") == [
|
||||
{"活動名稱": "會員日", "銷售金額": 800.0},
|
||||
{"活動名稱": "母親節", "銷售金額": 150.0},
|
||||
]
|
||||
assert "折價券活動" not in marketing_frames
|
||||
assert sum("UNION ALL" in statement for statement in statements) == 1
|
||||
|
||||
|
||||
def test_excel_export_sanitizer_blocks_formula_cells():
|
||||
frame = sanitize_excel_dataframe(pd.DataFrame({"活動名稱": ["=2+2", "一般活動"]}))
|
||||
|
||||
assert frame["活動名稱"].tolist() == ["'=2+2", "一般活動"]
|
||||
|
||||
|
||||
def test_marketing_combined_sheet_follows_the_active_metric():
|
||||
frames = {
|
||||
"折扣活動": pd.DataFrame([
|
||||
{"活動名稱": "高營收", "銷售金額": 1000, "銷售數量": 1, "毛利金額": 20},
|
||||
]),
|
||||
"折價券活動": pd.DataFrame([
|
||||
{"活動名稱": "高銷量", "銷售金額": 100, "銷售數量": 10, "毛利金額": 50},
|
||||
]),
|
||||
}
|
||||
|
||||
combined = combine_sales_marketing_export_frames(frames, "qty")
|
||||
|
||||
assert combined["活動名稱"].tolist() == ["高銷量", "高營收"]
|
||||
|
||||
|
||||
def test_sales_export_routes_return_valid_period_linked_workbooks(monkeypatch):
|
||||
engine = _build_sales_engine()
|
||||
|
||||
class FakeDatabaseManager:
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
|
||||
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
|
||||
app = Flask(__name__)
|
||||
query = (
|
||||
"?metric=amount&data_range=0&start_date=2026-04-01&end_date=2026-04-30"
|
||||
"&category=美妝&hour=10"
|
||||
)
|
||||
routes = (
|
||||
(sales_routes.api_export_sales_vendor, "/api/sales_analysis/export_vendor", "廠商分析"),
|
||||
(sales_routes.api_export_sales_marketing, "/api/sales_analysis/export_marketing", "折扣活動"),
|
||||
)
|
||||
|
||||
for route, path, expected_sheet in routes:
|
||||
with app.test_request_context(f"{path}{query}"):
|
||||
response = route.__wrapped__()
|
||||
response.direct_passthrough = False
|
||||
workbook = load_workbook(BytesIO(response.get_data()), read_only=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert expected_sheet in workbook.sheetnames
|
||||
Reference in New Issue
Block a user