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([ { "日期": "2026-06-24", "商品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"] 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 == 1 assert monthly_rows == 0 assert FakeNotificationManager.sent_messages 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_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["message"] == "沒有找到待匯入的檔案"