Files
ewoooc/tests/test_auto_import_failure_boundaries.py
ogt 6e65ef00bb
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
feat(pchome): automate authorized sales acquisition
2026-07-11 01:30:42 +08:00

467 lines
18 KiB
Python

import importlib
import json
import os
import sys
import types
import pandas as pd
from sqlalchemy import text
def _load_import_service(monkeypatch, database_url):
os.environ.setdefault("MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS", "true")
import config
monkeypatch.setattr(config, "DATABASE_PATH", database_url)
import services.import_service as import_service
return importlib.reload(import_service)
def _prepare_daily_sales_tables(import_service):
import_service.Base.metadata.create_all(import_service.engine)
with import_service.engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS daily_sales_snapshot"))
conn.execute(text("DROP TABLE IF EXISTS realtime_sales_monthly"))
conn.execute(text("""
CREATE TABLE daily_sales_snapshot (
"日期" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"銷售金額" INTEGER,
snapshot_date TEXT
)
"""))
conn.execute(text("""
CREATE TABLE realtime_sales_monthly (
"日期" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"銷售金額" INTEGER
)
"""))
def test_daily_sales_import_fails_when_monthly_sync_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_daily_sales_tables(import_service)
class FakeNotificationManager:
sent_messages = []
def _send_telegram_messages(self, messages):
self.sent_messages.extend(messages)
monkeypatch.setitem(
sys.modules,
"services.notification_manager",
types.SimpleNamespace(NotificationManager=FakeNotificationManager),
)
source_df = pd.DataFrame([
{
"日期": pd.Timestamp.today().strftime("%Y-%m-%d"),
"商品ID": "A001",
"商品名稱": "測試商品",
"銷售金額": 1200,
}
])
monkeypatch.setattr(
import_service,
"_read_daily_sales_excel",
lambda _path: (
source_df.copy(),
{"date_col": "日期", "sheet_name": "即時業績明細", "header_row": 1},
),
)
original_to_sql = pd.DataFrame.to_sql
def fail_monthly_sync(self, name, *args, **kwargs):
if name == "realtime_sales_monthly":
raise RuntimeError("monthly sync boom")
return original_to_sql(self, name, *args, **kwargs)
monkeypatch.setattr(pd.DataFrame, "to_sql", fail_monthly_sync)
service = import_service.ImportService()
job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024)
assert service.process_daily_sales_import(job_id, str(tmp_path / "daily.xlsx")) is False
session = import_service.Session()
try:
job = session.query(import_service.ImportJob).filter_by(id=job_id).one()
assert job.status == "failed"
assert job.progress_percent == 95
assert job.current_step == "交易驗證失敗,已回滾"
assert "monthly sync boom" in job.error_message
summary = json.loads(job.import_summary)
assert summary["sync_success"] is False
assert summary["synced_to"] is None
assert "monthly sync boom" in summary["sync_error"]
assert summary["atomic"] is True
assert summary["rolled_back"] is True
finally:
session.close()
with import_service.engine.connect() as conn:
snapshot_rows = conn.execute(text("SELECT COUNT(*) FROM daily_sales_snapshot")).scalar()
monthly_rows = conn.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar()
assert snapshot_rows == 0
assert monthly_rows == 0
assert FakeNotificationManager.sent_messages
def test_daily_sales_import_preserves_today_fallback_when_date_column_is_missing(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_daily_sales_tables(import_service)
source_df = pd.DataFrame([{
"商品ID": "A001",
"商品名稱": "測試商品",
"銷售金額": 1200,
}])
monkeypatch.setattr(
import_service,
"_read_daily_sales_excel",
lambda _path: (
source_df.copy(),
{"date_col": None, "sheet_name": "即時業績明細", "header_row": 1},
),
)
service = import_service.ImportService()
job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024)
assert service.process_daily_sales_import(job_id, str(tmp_path / "daily.xlsx")) is True
expected_date = import_service.datetime.now(import_service.TAIPEI_TZ).date().isoformat()
with import_service.engine.connect() as conn:
snapshot = conn.execute(text(
'SELECT "日期", snapshot_date FROM daily_sales_snapshot'
)).one()
monthly = conn.execute(text(
'SELECT "日期" FROM realtime_sales_monthly'
)).one()
assert snapshot[0] == expected_date
assert str(snapshot[1])[:10] == expected_date
assert monthly[0] == expected_date
def test_auto_import_does_not_move_drive_file_when_import_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
class FakeDriveService:
moved_files = []
def list_files_in_folder(self, folder_path, file_pattern):
return [{"id": "drive-file-1", "name": "daily.xlsx", "size": 1024}]
def download_file(self, file_id, local_path):
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as handle:
handle.write(b"test")
return True
def move_file(self, file_id, folder, create_missing=False):
self.moved_files.append((file_id, folder, create_missing))
return True
fake_drive = FakeDriveService()
monkeypatch.setattr(import_service, "drive_service", fake_drive)
service = import_service.ImportService()
monkeypatch.setattr(service, "process_daily_sales_import", lambda job_id, path: False)
result = service.auto_import_from_drive()
assert result["success"] is False
assert result["failed_count"] == 1
assert fake_drive.moved_files == []
def test_auto_import_reports_partial_when_drive_archive_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
monkeypatch.chdir(tmp_path)
class FakeDriveService:
last_error_kind = None
last_error = None
def list_files_in_folder(self, folder_path, file_pattern):
return [{"id": "drive-file-1", "name": "daily.xlsx", "size": 1024}]
def download_file(self, file_id, local_path):
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as handle:
handle.write(b"test")
return True
def move_file(self, file_id, folder, create_missing=False):
return False
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
service = import_service.ImportService()
monkeypatch.setattr(service, "process_daily_sales_import", lambda job_id, path: True)
result = service.auto_import_from_drive()
assert result["success"] is False
assert result["status"] == "partial_source_finalize_failed"
assert result["imported_count"] == 1
assert result["source_finalize_ok"] is False
assert result["archive_failed_count"] == 1
session = import_service.Session()
try:
job = session.query(import_service.ImportJob).one()
assert job.status == "completed"
assert job.current_step == "匯入完成,來源封存待自動重試"
finally:
session.close()
def test_auto_import_fails_closed_when_drive_auth_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
class FakeDriveService:
last_error_kind = "authentication_failed"
last_error = "could not locate runnable browser"
def list_files_in_folder(self, folder_path, file_pattern):
return []
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
service = import_service.ImportService()
result = service.auto_import_from_drive()
assert result["success"] is False
assert result["file_count"] == 0
assert result["imported_count"] == 0
assert result["failed_count"] == 1
assert result["connection_error"] is True
assert result["error_kind"] == "authentication_failed"
assert "Google Drive" in result["message"]
assert "重新完成雲端授權" in result["message"]
assert "could not locate runnable browser" not in result["message"]
def test_import_job_public_payload_hides_database_error(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
service = import_service.ImportService()
job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024)
raw_error = (
"(psycopg2.errors.UndefinedFunction) operator does not exist: "
"daily_sales_snapshot.snapshot_date >= realtime_sales_monthly.snapshot_date"
)
service.update_job_status(job_id, "failed", 100, "業績分析儀表板同步失敗", raw_error)
public_job = service.get_job_status(job_id)
public_error = public_job["error_message"]
assert public_job["display_error_message"] == public_error
assert "重新匯入最新檔案" in public_error
assert "psycopg2" not in public_error
assert "daily_sales_snapshot" not in public_error
assert "realtime_sales_monthly" not in public_error
assert "snapshot_date" not in public_error
session = import_service.Session()
try:
stored = session.query(import_service.ImportJob).filter_by(id=job_id).one()
assert raw_error in stored.error_message
finally:
session.close()
def test_import_job_public_payload_hides_import_summary_internals(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
service = import_service.ImportService()
job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024)
session = import_service.Session()
try:
job = session.query(import_service.ImportJob).filter_by(id=job_id).one()
job.status = "completed"
job.import_summary = json.dumps({
"imported_count": 42,
"table_name": "daily_sales_snapshot",
"synced_to": "realtime_sales_monthly",
"sync_success": True,
"sync_error": None,
"verified": True,
"date_min": "2026-06-24",
"date_max": "2026-06-25",
"source_sheet": "即時業績明細",
"source_header_row": 1,
"message": "成功匯入 42 筆資料,已同步至業績分析儀表板",
}, ensure_ascii=False)
job.local_file_path = "data/temp/daily.xlsx"
session.commit()
finally:
session.close()
public_job = service.get_job_status(job_id)
public_text = json.dumps(public_job, ensure_ascii=False)
assert public_job["import_summary"]["imported_count"] == 42
assert public_job["import_summary"]["sync_status"] == "已更新業績分析儀表板"
assert "daily_sales_snapshot" not in public_text
assert "realtime_sales_monthly" not in public_text
assert "table_name" not in public_text
assert "synced_to" not in public_text
assert "drive-file-1" not in public_text
assert "local_file_path" not in public_text
def test_google_drive_auth_refuses_browser_without_json_token(monkeypatch, tmp_path):
import services.google_drive_service as google_drive_service
google_drive_service = importlib.reload(google_drive_service)
token_file = tmp_path / "google_token.json"
legacy_file = tmp_path / "google_token.pickle"
credentials_file = tmp_path / "google_credentials.json"
legacy_file.write_bytes(b"legacy-token-placeholder")
credentials_file.write_text("{}", encoding="utf-8")
monkeypatch.setattr(google_drive_service, "TOKEN_FILE", str(token_file))
monkeypatch.setattr(google_drive_service, "_LEGACY_PICKLE_FILE", str(legacy_file))
monkeypatch.setattr(google_drive_service, "CREDENTIALS_FILE", str(credentials_file))
monkeypatch.delenv("GOOGLE_DRIVE_ALLOW_INTERACTIVE_AUTH", raising=False)
def fail_if_browser_flow_is_started(*args, **kwargs):
raise AssertionError("背景匯入不可啟動瀏覽器 OAuth")
monkeypatch.setattr(
google_drive_service.InstalledAppFlow,
"from_client_secrets_file",
fail_if_browser_flow_is_started,
)
service = google_drive_service.GoogleDriveService()
assert service.authenticate() is False
assert service.last_error_kind == "reauthorization_required"
assert "google_token.json" in service.last_error
assert "google_token.pickle" in service.last_error
def test_google_drive_auth_paths_can_be_pinned_by_container_env(monkeypatch):
monkeypatch.setenv("GOOGLE_DRIVE_CREDENTIALS_FILE", "/app/config/google_credentials.json")
monkeypatch.setenv("GOOGLE_DRIVE_TOKEN_FILE", "/app/config/google_token.json")
monkeypatch.setenv("GOOGLE_DRIVE_LEGACY_PICKLE_FILE", "/app/config/google_token.pickle")
import services.google_drive_service as google_drive_service
google_drive_service = importlib.reload(google_drive_service)
assert google_drive_service.CREDENTIALS_FILE == "/app/config/google_credentials.json"
assert google_drive_service.TOKEN_FILE == "/app/config/google_token.json"
assert google_drive_service._LEGACY_PICKLE_FILE == "/app/config/google_token.pickle"
def test_google_drive_auth_fails_when_token_cannot_be_persisted(monkeypatch, tmp_path):
import services.google_drive_service as google_drive_service
google_drive_service = importlib.reload(google_drive_service)
token_file = tmp_path / "readonly" / "google_token.json"
token_file.parent.mkdir()
credentials_file = tmp_path / "google_credentials.json"
credentials_file.write_text("{}", encoding="utf-8")
class FakeCredentials:
valid = False
expired = True
refresh_token = "refresh-token"
def refresh(self, request):
self.valid = True
def to_json(self):
return "{}"
monkeypatch.setattr(google_drive_service, "TOKEN_FILE", str(token_file))
monkeypatch.setattr(google_drive_service, "CREDENTIALS_FILE", str(credentials_file))
monkeypatch.setattr(
google_drive_service.Credentials,
"from_authorized_user_info",
lambda *_args, **_kwargs: FakeCredentials(),
)
monkeypatch.setattr(
google_drive_service.os,
"replace",
lambda *_args, **_kwargs: (_ for _ in ()).throw(PermissionError("permission denied")),
)
token_file.write_text("{}", encoding="utf-8")
service = google_drive_service.GoogleDriveService()
assert service.authenticate() is False
assert service.last_error_kind == "token_store_failed"
assert "主機重啟後自動匯入會再次失敗" in service.last_error
def test_auto_import_empty_drive_folder_remains_success(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
class FakeDriveService:
last_error_kind = None
last_error = None
def list_files_in_folder(self, folder_path, file_pattern):
return []
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
service = import_service.ImportService()
result = service.auto_import_from_drive()
assert result["success"] is True
assert result["file_count"] == 0
assert result["status"] == "upstream_missing"
assert result["decision_ready"] is False
assert result["message"] == "沒有找到待匯入的檔案"
def test_auto_import_empty_drive_exposes_stale_upstream_as_degraded(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
with import_service.engine.begin() as conn:
conn.execute(text("CREATE TABLE IF NOT EXISTS daily_sales_snapshot (snapshot_date TEXT)"))
conn.execute(text("INSERT INTO daily_sales_snapshot (snapshot_date) VALUES ('2020-01-01')"))
class FakeDriveService:
last_error_kind = None
last_error = None
def list_files_in_folder(self, folder_path, file_pattern):
return []
import services.openclaw_strategist_service as strategist
alerts = []
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
monkeypatch.setattr(
strategist,
"_send_data_stale_alert",
lambda **kwargs: alerts.append(kwargs),
)
result = import_service.ImportService().auto_import_from_drive()
assert result["success"] is True
assert result["status"] == "upstream_stale"
assert result["decision_ready"] is False
assert result["requires_upstream_acquisition"] is True
assert result["latest_sales_date"] == "2020-01-01"
assert result["data_lag_days"] >= 3
assert alerts and alerts[0]["report_type"] == "upstream_drive"