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