"""Tests for finance daily summary service.""" from __future__ import annotations import datetime as _dt import json from datetime import date from datetime import datetime, timezone from unittest.mock import MagicMock import httpx from app.services.finance_service import ( classify_upstream, date_to_shanghai_timestamps, fetch_upstream_cost_sub2api, fetch_upstream_cost_new_api, fetch_upstream_cost_balance_delta, fetch_website_revenue, get_daily_summary, ) # ───────────────────────────────────────────── # Fake model helpers # ───────────────────────────────────────────── class FakeUpstream: def __init__(self, **kw): self.id = kw.get("id", 1) self.name = kw.get("name", "U1") self.base_url = kw.get("base_url", "http://up.test") self.api_prefix = kw.get("api_prefix", "api/v1") self.auth_type = kw.get("auth_type", "bearer") self.auth_config_json = kw.get("auth_config_json", json.dumps({"token": "tok"})) self.timeout_seconds = kw.get("timeout_seconds", 30) self.finance_cost_mode = kw.get("finance_cost_mode", "usage_stats") self.enabled = True class FakeWebsite: def __init__(self, **kw): self.id = kw.get("id", 1) self.name = kw.get("name", "W1") self.base_url = kw.get("base_url", "http://web.test") self.api_prefix = kw.get("api_prefix", "api/v1/admin") self.auth_type = kw.get("auth_type", "api_key") self.auth_config_json = kw.get("auth_config_json", json.dumps({"key": "k1", "header": "x-api-key"})) self.timeout_seconds = kw.get("timeout_seconds", 30) self.enabled = True class FakeWebsiteClientCtx: def __init__(self, response: dict | None = None, raise_exc: Exception | None = None): self.response = response or {"data": {"total_actual_cost": 123.45}} self.raise_exc = raise_exc self.calls: list[dict] = [] def __enter__(self): return self def __exit__(self, *a): pass def _request(self, method, path, body=None, params=None): self.calls.append({"method": method, "path": path, "body": body, "params": params}) if self.raise_exc: raise self.raise_exc return self.response class FakeUpstreamClientCtx: """Context manager mock for UpstreamClient.""" def __init__( self, token="tok", user_id="u1", quota_per_unit=500000, response: dict | None = None, raise_exc: Exception | None = None, ): self._token = token self._new_api_user = user_id self._quota_per_unit = quota_per_unit self.response = response or {"data": {"quota": 5_000_000}} self.raise_exc = raise_exc self.calls: list[dict] = [] def __enter__(self): return self def __exit__(self, *a): pass def ensure_authenticated(self): pass def _new_api_quota_per_unit(self): return self._quota_per_unit def _url(self, path): return f"http://up.test/{path.lstrip('/')}" def _send_request(self, method, url, **kwargs): self.calls.append({"method": method, "url": url, **kwargs}) if self.raise_exc: raise self.raise_exc return _make_mock_response(self.response) # ───────────────────────────────────────────── # classify_upstream tests # ───────────────────────────────────────────── def test_classify_upstream_sub2api(): u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") assert classify_upstream(u) == "sub2api" def test_classify_upstream_new_api_by_token_type(): u = FakeUpstream(api_prefix="", auth_type="new_api_token") assert classify_upstream(u) == "new_api" def test_classify_upstream_nox_token_type(): u = FakeUpstream(api_prefix="", auth_type="nox_token") assert classify_upstream(u) == "new_api" def test_classify_upstream_new_api_by_cookie_mode(): u = FakeUpstream( api_prefix="", auth_type="cookie", auth_config_json=json.dumps({"cookie_string": "sid=x", "user_id": "7"}), ) assert classify_upstream(u) == "new_api" def test_classify_upstream_new_api_by_login_path(): u = FakeUpstream( api_prefix="", auth_type="login_password", auth_config_json=json.dumps({"email": "a@b.com", "password": "p", "login_path": "/api/user/login"}), ) assert classify_upstream(u) == "new_api" def test_classify_upstream_unknown(): u = FakeUpstream(api_prefix="", auth_type="bearer") assert classify_upstream(u) == "unknown" def test_classify_upstream_login_password_without_new_api_path(): u = FakeUpstream( api_prefix="", auth_type="login_password", auth_config_json=json.dumps({"email": "a@b.com", "password": "p"}), ) assert classify_upstream(u) == "unknown" # ───────────────────────────────────────────── # Date/timestamp tests # ───────────────────────────────────────────── def test_date_to_shanghai_timestamps(): start_ts, end_ts = date_to_shanghai_timestamps(date(2026, 7, 2)) # 2026-07-02 00:00:00 CST = 2026-07-01 16:00:00 UTC = 1782921600 # 2026-07-02 23:59:59 CST = 2026-07-02 15:59:59 UTC = 1783007999 assert start_ts == 1782921600 assert end_ts == 1783007999 assert end_ts - start_ts == 86399 # exactly one day minus one second # ───────────────────────────────────────────── # fetch_website_revenue tests # ───────────────────────────────────────────── def _make_mock_response(json_body: dict, status_code: int = 200): mock_resp = MagicMock() mock_resp.status_code = status_code mock_resp.json.return_value = json_body mock_resp.text = json.dumps(json_body) mock_resp.raise_for_status = MagicMock() if status_code >= 400: mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( "error", request=MagicMock(), response=mock_resp ) return mock_resp def test_website_revenue_success(monkeypatch): w = FakeWebsite() client = FakeWebsiteClientCtx({"data": {"total_actual_cost": 123.45}}) monkeypatch.setattr("app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: client) amount, err = fetch_website_revenue(w, date(2026, 7, 2)) assert err is None assert abs(amount - 123.45) < 1e-6 assert client.calls == [{ "method": "GET", "path": "/usage/stats", "body": None, "params": { "start_date": "2026-07-02", "end_date": "2026-07-02", "timezone": "Asia/Shanghai", "nocache": "true", }, }] def test_website_revenue_nested_data(monkeypatch): """total_actual_cost directly in response root (no wrapping data key).""" w = FakeWebsite() monkeypatch.setattr( "app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: FakeWebsiteClientCtx({"total_actual_cost": 55.5}), ) amount, err = fetch_website_revenue(w, date(2026, 7, 2)) assert err is None assert abs(amount - 55.5) < 1e-6 def test_website_revenue_no_field(monkeypatch): w = FakeWebsite() monkeypatch.setattr( "app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: FakeWebsiteClientCtx({"data": {"some_other_field": 99}}), ) amount, err = fetch_website_revenue(w, date(2026, 7, 2)) assert amount == 0.0 assert err is not None assert "total_actual_cost" in err def test_website_revenue_http_error(monkeypatch): w = FakeWebsite() monkeypatch.setattr( "app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: FakeWebsiteClientCtx(raise_exc=Exception("HTTP 403: Forbidden")), ) amount, err = fetch_website_revenue(w, date(2026, 7, 2)) assert amount == 0.0 assert err is not None assert "403" in err def test_website_revenue_connection_error(monkeypatch): w = FakeWebsite() monkeypatch.setattr( "app.services.finance_service.Sub2ApiWebsiteClient", lambda **kw: FakeWebsiteClientCtx(raise_exc=Exception("connection refused")), ) amount, err = fetch_website_revenue(w, date(2026, 7, 2)) assert amount == 0.0 assert err is not None # ───────────────────────────────────────────── # upstream cost tests # ───────────────────────────────────────────── def test_upstream_cost_sub2api_uses_upstream_client(monkeypatch): u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") client = FakeUpstreamClientCtx(response={"data": {"total_actual_cost": 42.0}}) monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: client) amount, err = fetch_upstream_cost_sub2api(u, date(2026, 7, 2)) assert err is None assert abs(amount - 42.0) < 1e-6 assert client.calls[0]["method"] == "GET" assert client.calls[0]["url"] == "http://up.test/usage/stats" assert client.calls[0]["params"] == { "start_date": "2026-07-02", "end_date": "2026-07-02", "timezone": "Asia/Shanghai", } def test_upstream_cost_new_api_success(monkeypatch): u = FakeUpstream(api_prefix="", auth_type="new_api_token") mock_client_instance = FakeUpstreamClientCtx(quota_per_unit=500000) monkeypatch.setattr("app.services.finance_service.UpstreamClient", lambda **kw: mock_client_instance) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) assert err is None assert abs(amount - 10.0) < 1e-6 # 5_000_000 / 500_000 = 10.0 assert mock_client_instance.calls[0]["method"] == "GET" assert mock_client_instance.calls[0]["url"] == "http://up.test/api/log/self/stat" assert mock_client_instance.calls[0]["params"] == { "type": 2, "start_timestamp": 1782921600, "end_timestamp": 1783007999, } def test_upstream_cost_new_api_zero_quota_success(monkeypatch): u = FakeUpstream(api_prefix="", auth_type="new_api_token") monkeypatch.setattr( "app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx(response={"data": {"quota": 0}}), ) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) assert err is None assert amount == 0.0 def test_upstream_cost_new_api_success_false_uses_remote_message(monkeypatch): u = FakeUpstream(api_prefix="", auth_type="new_api_token") monkeypatch.setattr( "app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx(response={"success": False, "message": "无权访问日志统计"}), ) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) assert amount == 0.0 assert err == "无权访问日志统计" assert "quota" not in err def test_upstream_cost_new_api_missing_quota(monkeypatch): u = FakeUpstream(api_prefix="", auth_type="new_api_token") monkeypatch.setattr( "app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx(response={"data": {}}), ) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) assert amount == 0.0 assert err is not None assert "quota" in err def test_upstream_cost_new_api_http_error(monkeypatch): u = FakeUpstream(api_prefix="", auth_type="new_api_token") bad_resp = MagicMock() bad_resp.status_code = 401 bad_resp.text = "Unauthorized" exc = httpx.HTTPStatusError("401", request=MagicMock(), response=bad_resp) monkeypatch.setattr( "app.services.finance_service.UpstreamClient", lambda **kw: FakeUpstreamClientCtx(raise_exc=exc), ) amount, err = fetch_upstream_cost_new_api(u, date(2026, 7, 2)) assert amount == 0.0 assert err is not None assert "401" in err # ───────────────────────────────────────────── # get_daily_summary integration-style tests # ───────────────────────────────────────────── def test_get_daily_summary_website_failure_excluded_from_total(monkeypatch): """Failed websites must NOT be counted in total_revenue.""" w_ok = FakeWebsite(id=1, name="OK") w_fail = FakeWebsite(id=2, name="Fail") def mock_fetch_website(row, _date): return (50.0, None) if row.id == 1 else (0.0, "timeout") monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", mock_fetch_website) result = get_daily_summary([w_ok, w_fail], [], date(2026, 7, 2)) assert abs(result["total_revenue"] - 50.0) < 1e-6 assert result["total_cost"] == 0.0 assert result["failed_count"] == 1 assert result["success"] is False failed = [i for i in result["website_items"] if i["status"] == "failed"] ok = [i for i in result["website_items"] if i["status"] == "success"] assert len(failed) == 1 assert len(ok) == 1 def test_get_daily_summary_unknown_upstream_is_failed(): """Unknown upstream type must produce status='failed', not silently contribute 0.""" u = FakeUpstream(api_prefix="", auth_type="bearer") # unknown type result = get_daily_summary([], [u], date(2026, 7, 2)) assert result["failed_count"] == 1 assert result["success"] is False assert result["upstream_items"][0]["status"] == "failed" assert "未知上游类型" in result["upstream_items"][0]["error"] assert result["total_cost"] == 0.0 # unknown upstreams don't contribute def test_get_daily_summary_all_success(monkeypatch): """When all items succeed, success=True, totals are correct.""" w = FakeWebsite() u = FakeUpstream(api_prefix="api/v1", auth_type="bearer") # sub2api monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) result = get_daily_summary([w], [u], date(2026, 7, 2)) assert result["success"] is True assert result["failed_count"] == 0 assert abs(result["total_revenue"] - 100.0) < 1e-6 assert abs(result["total_cost"] - 40.0) < 1e-6 assert abs(result["net_income"] - 60.0) < 1e-6 assert abs(result["margin_percent"] - 60.0) < 1e-6 def _add_balance_snapshot(db, upstream_id: int, balance: float, captured_at: datetime): from app.models.snapshot import UpstreamBalanceSnapshot row = UpstreamBalanceSnapshot( upstream_id=upstream_id, balance=balance, captured_at=captured_at, ) db.add(row) db.commit() return row def _add_recharge_event(db, upstream_id: int, recharge_date: date, amount: float, note: str = ""): from app.models.upstream_recharge_event import UpstreamRechargeEvent row = UpstreamRechargeEvent( upstream_id=upstream_id, recharge_date=recharge_date, amount=amount, note=note, ) db.add(row) db.commit() return row def test_balance_delta_cost_uses_manual_recharge_and_ending_balance(): """Balance-delta cost is baseline + recharge_total - ending.""" db = _make_inmemory_db() u = FakeUpstream(id=1, finance_cost_mode="balance_delta") _add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc)) _add_balance_snapshot(db, 1, 90.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc)) _add_balance_snapshot(db, 1, 110.0, datetime(2026, 7, 1, 18, 0, tzinfo=timezone.utc)) _add_balance_snapshot(db, 1, 80.0, datetime(2026, 7, 2, 15, 59, tzinfo=timezone.utc)) _add_recharge_event(db, 1, date(2026, 7, 2), 20.0) amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db) assert err is None assert abs(amount - 40.0) < 1e-6 db.close() def test_balance_delta_balance_rise_without_recharge_fails(): db = _make_inmemory_db() u = FakeUpstream(id=1, finance_cost_mode="balance_delta") _add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc)) _add_balance_snapshot(db, 1, 120.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc)) amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db) assert amount == 0.0 assert "无充值记录" in err db.close() def test_balance_delta_recharge_less_than_observed_rise_fails(): db = _make_inmemory_db() u = FakeUpstream(id=1, finance_cost_mode="balance_delta") _add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc)) _add_balance_snapshot(db, 1, 130.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc)) _add_recharge_event(db, 1, date(2026, 7, 2), 20.0) amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db) assert amount == 0.0 assert "充值金额可能漏填" in err db.close() def test_balance_delta_missing_baseline_fails(): """Balance-delta requires a pre-day baseline sample.""" db = _make_inmemory_db() u = FakeUpstream(id=1, finance_cost_mode="balance_delta") _add_balance_snapshot(db, 1, 90.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc)) amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db) assert amount == 0.0 assert "基线样本" in err db.close() def test_get_daily_summary_balance_delta_failure_excluded_from_total(): """Incomplete balance-delta data must not be counted as zero-cost success.""" db = _make_inmemory_db() u = FakeUpstream(id=1, api_prefix="api/v1", auth_type="bearer", finance_cost_mode="balance_delta") result = get_daily_summary([], [u], date(2026, 7, 2), db=db) assert result["failed_count"] == 1 assert result["total_cost"] == 0.0 assert result["upstream_items"][0]["status"] == "failed" assert "基线样本" in result["upstream_items"][0]["error"] db.close() def test_upstream_recharge_crud_api_validates_and_scopes_to_upstream(): from fastapi.testclient import TestClient from app.database import get_db from app.main import app from app.models.upstream import Upstream from app.utils.auth import get_current_user db = _make_inmemory_db() db.add_all([ Upstream(id=1, name="U1", base_url="http://u1", api_prefix="api/v1", auth_type="bearer", auth_config_json="{}"), Upstream(id=2, name="U2", base_url="http://u2", api_prefix="api/v1", auth_type="bearer", auth_config_json="{}"), ]) db.commit() app.dependency_overrides[get_db] = lambda: db app.dependency_overrides[get_current_user] = lambda: None try: client = TestClient(app) bad_amount = client.post("/api/upstreams/1/recharges", json={"date": "2026-07-02", "amount": 0}) assert bad_amount.status_code == 422 future = client.post("/api/upstreams/1/recharges", json={"date": "2999-01-01", "amount": 10}) assert future.status_code == 422 created = client.post( "/api/upstreams/1/recharges", json={"date": "2026-07-02", "amount": 50.5, "note": "manual"}, ) assert created.status_code == 201 body = created.json() rid = body["id"] assert body["date"] == "2026-07-02" assert body["amount"] == 50.5 listed = client.get("/api/upstreams/1/recharges", params={"date": "2026-07-02"}) assert listed.status_code == 200 assert [row["id"] for row in listed.json()] == [rid] wrong_upstream = client.put(f"/api/upstreams/2/recharges/{rid}", json={"amount": 60}) assert wrong_upstream.status_code == 404 updated = client.put(f"/api/upstreams/1/recharges/{rid}", json={"amount": 60, "note": "fixed"}) assert updated.status_code == 200 assert updated.json()["amount"] == 60 assert updated.json()["note"] == "fixed" deleted = client.delete(f"/api/upstreams/1/recharges/{rid}") assert deleted.status_code == 204 empty = client.get("/api/upstreams/1/recharges", params={"date": "2026-07-02"}) assert empty.json() == [] finally: app.dependency_overrides.clear() db.close() # ───────────────────────────────────────────── # DB-backed snapshot tests (FinanceDailySummary) # ───────────────────────────────────────────── def _make_inmemory_db(): """Create an isolated in-memory SQLite session with all tables.""" from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.database import Base # Import models so they register with Base.metadata from app.models import finance_daily_summary # noqa: F401 from app.models import snapshot # noqa: F401 from app.models import upstream_recharge_event # noqa: F401 from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog # noqa: F401 from app.models.upstream import Upstream # noqa: F401 engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) Session = sessionmaker(bind=engine) return Session() def _add_minimal_test_data(db): """Add one Website and one Upstream so compute_daily_summary has data to iterate.""" from app.models.website import Website from app.models.upstream import Upstream w = Website( id=1, name="TestSite", base_url="http://t1", api_prefix="/api/v1/admin", auth_type="api_key", auth_config_json="{}", enabled=True, ) u = Upstream( id=1, name="TestUp", base_url="http://u1", api_prefix="api/v1", auth_type="bearer", auth_config_json='{"token":"t"}', enabled=True, ) db.add_all([w, u]) db.commit() def test_get_or_create_no_existing_computes_and_saves(monkeypatch): """Missing date triggers compute and save, returning from_snapshot=False.""" db = _make_inmemory_db() _add_minimal_test_data(db) monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) from app.services.finance_service import get_or_create_daily_summary result = get_or_create_daily_summary(db, date(2026, 7, 2)) assert result["from_snapshot"] is False assert abs(result["total_revenue"] - 100.0) < 1e-6 assert abs(result["total_cost"] - 40.0) < 1e-6 # Verify it was saved from app.models.finance_daily_summary import FinanceDailySummary row = db.query(FinanceDailySummary).filter( FinanceDailySummary.stat_date == date(2026, 7, 2) ).first() assert row is not None assert row.success is True db.close() def test_get_or_create_existing_returns_stored(monkeypatch): """Existing date returns stored data without calling upstream fetchers.""" from app.models.finance_daily_summary import FinanceDailySummary from app.services.finance_service import get_or_create_daily_summary db = _make_inmemory_db() # Pre-save a row stored_data = { "date": "2026-07-02", "total_revenue": 200.0, "total_cost": 80.0, "net_income": 120.0, "margin_percent": 60.0, "website_items": [], "upstream_items": [], "failed_count": 0, "success": True, } row = FinanceDailySummary( stat_date=date(2026, 7, 2), summary_json=json.dumps(stored_data), success=True, ) db.add(row) db.commit() # If it calls fetch_*, the monkeypatched function will raise called = [] def _fail(*a, **kw): called.append(True) raise RuntimeError("should not be called") monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", _fail) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", _fail) result = get_or_create_daily_summary(db, date(2026, 7, 2)) assert result["from_snapshot"] is True assert abs(result["total_revenue"] - 200.0) < 1e-6 assert len(called) == 0 db.close() def test_compare_does_not_write(monkeypatch): """POST /daily-summary/compare must NOT write to the database.""" from app.routers.finance import compare_daily_summary db = _make_inmemory_db() _add_minimal_test_data(db) monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) result = compare_daily_summary(date="2026-07-02", db=db, _=None) assert result["has_difference"] is True # no stored data assert result["stored"] is None assert abs(result["current"]["total_revenue"] - 100.0) < 1e-6 # Verify NOT saved from app.models.finance_daily_summary import FinanceDailySummary row = db.query(FinanceDailySummary).filter( FinanceDailySummary.stat_date == date(2026, 7, 2) ).first() assert row is None db.close() def test_overwrite_replaces_existing(monkeypatch): """Overwrite updates the same date's record.""" from app.models.finance_daily_summary import FinanceDailySummary from app.services.finance_service import overwrite_daily_summary db = _make_inmemory_db() _add_minimal_test_data(db) monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) # Save once overwrite_daily_summary(db, date(2026, 7, 2)) # Change revenue for second save monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (200.0, None)) result2 = overwrite_daily_summary(db, date(2026, 7, 2)) assert abs(result2["total_revenue"] - 200.0) < 1e-6 # Only one row rows = db.query(FinanceDailySummary).filter( FinanceDailySummary.stat_date == date(2026, 7, 2) ).all() assert len(rows) == 1 assert rows[0].success is True db.close() def test_error_truncation(monkeypatch): """Error messages over 500 chars are truncated.""" from app.services.finance_service import get_or_create_daily_summary db = _make_inmemory_db() _add_minimal_test_data(db) long_err = "x" * 1000 monkeypatch.setattr( "app.services.finance_service.fetch_website_revenue", lambda *_: (0.0, long_err), ) monkeypatch.setattr( "app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None), ) result = get_or_create_daily_summary(db, date(2026, 7, 2)) err = result["website_items"][0]["error"] assert len(err) == 503 # 500 + "..." assert err.endswith("...") from app.models.finance_daily_summary import FinanceDailySummary row = db.query(FinanceDailySummary).filter( FinanceDailySummary.stat_date == date(2026, 7, 2) ).first() stored = json.loads(row.summary_json) assert len(stored["website_items"][0]["error"]) == 503 db.close() def test_future_date_rejected(): """Future dates must return 400.""" from app.routers.finance import _validate_not_future from fastapi import HTTPException import pytest from app.services.finance_service import today_shanghai with pytest.raises(HTTPException) as exc: _validate_not_future(today_shanghai() + _dt.timedelta(days=1)) assert exc.value.status_code == 400 def test_invalid_date_format_rejected(): """Invalid date format must return 400.""" from app.routers.finance import _resolve_date from fastapi import HTTPException import pytest with pytest.raises(HTTPException) as exc: _resolve_date("not-a-date") assert exc.value.status_code == 400 def test_partial_failure_still_saved(monkeypatch): """Partially failed results are saved, with success=False and failed_count.""" from app.services.finance_service import get_or_create_daily_summary db = _make_inmemory_db() _add_minimal_test_data(db) # Override the first website to fail original_revenue_calls = [] def mock_website_revenue(website, _date): original_revenue_calls.append(website.id) if website.id == 1: return (0.0, "timeout") return (50.0, None) monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", mock_website_revenue) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) result = get_or_create_daily_summary(db, date(2026, 7, 2)) assert result["success"] is False assert result["failed_count"] >= 1 from app.models.finance_daily_summary import FinanceDailySummary row = db.query(FinanceDailySummary).filter( FinanceDailySummary.stat_date == date(2026, 7, 2) ).first() assert row is not None assert row.success is False db.close() def test_save_summary_if_absent_saves_new(monkeypatch): """save_summary_if_absent computes and saves when no snapshot exists.""" from app.services.finance_service import save_summary_if_absent db = _make_inmemory_db() _add_minimal_test_data(db) monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) saved = save_summary_if_absent(db, date(2026, 7, 2)) assert saved is True from app.models.finance_daily_summary import FinanceDailySummary row = db.query(FinanceDailySummary).filter( FinanceDailySummary.stat_date == date(2026, 7, 2) ).first() assert row is not None db.close() def test_save_summary_if_absent_skips_existing(monkeypatch): """save_summary_if_absent returns False and does nothing when snapshot exists.""" from app.models.finance_daily_summary import FinanceDailySummary from app.services.finance_service import save_summary_if_absent db = _make_inmemory_db() # Pre-save a row row = FinanceDailySummary( stat_date=date(2026, 7, 2), summary_json='{"date":"2026-07-02"}', success=True, ) db.add(row) db.commit() # If it tries to compute, the monkeypatched function would raise def _fail(*a, **kw): raise RuntimeError("should not be called") monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", _fail) saved = save_summary_if_absent(db, date(2026, 7, 2)) assert saved is False db.close() def _make_test_summary(website_items, upstream_items=None): """Return a summary dict with deep-copied items to prevent cross-contamination.""" return { "total_revenue": 100.0, "total_cost": 40.0, "net_income": 60.0, "success": True, "website_items": [dict(it) for it in website_items], "upstream_items": [dict(it) for it in (upstream_items or [ {"id": 1, "name": "U1", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None}, ])], } def test_has_difference_by_identity(): """_has_difference compares items by (id, upstream_type), not by position.""" from app.routers.finance import _has_difference items = [ {"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None}, {"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None}, ] stored = _make_test_summary(items) current = _make_test_summary(list(reversed(items))) assert _has_difference(stored, current) is False # Different amount on same identity — should be different current["website_items"][1]["amount"] = 50.0 # item id=1 amount changed assert _has_difference(stored, current) is True # Different item count — should be different current["website_items"].pop() assert _has_difference(stored, current) is True def test_compute_diff_items_unchanged(): """_compute_diff_items returns empty diff_items when data matches.""" from app.routers.finance import _compute_diff_items items = [ {"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None}, {"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None}, ] stored = _make_test_summary(items) current = _make_test_summary(list(reversed(items))) diff_items, has_diff = _compute_diff_items(stored, current) assert has_diff is False assert diff_items == [] def test_compute_diff_items_amount_change(): """_compute_diff_items returns the changed item when amount differs.""" from app.routers.finance import _compute_diff_items stored = _make_test_summary([ {"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None}, ]) current = _make_test_summary([ {"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 55.0, "status": "success", "error": None}, ]) diff_items, has_diff = _compute_diff_items(stored, current) assert has_diff is True assert len(diff_items) == 1 d = diff_items[0] assert d["side"] == "website_items" assert d["id"] == 1 assert d["name"] == "A" assert d["old_amount"] == 60.0 assert d["new_amount"] == 55.0 def test_compute_diff_items_added_and_removed(): """_compute_diff_items handles items added or removed between snapshots.""" from app.routers.finance import _compute_diff_items stored = _make_test_summary([ {"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None}, ]) current = _make_test_summary([ {"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None}, {"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None}, ]) diff_items, has_diff = _compute_diff_items(stored, current) assert has_diff is True # B is new — find it added = [d for d in diff_items if d["name"] == "B"] assert len(added) == 1 assert added[0]["old_amount"] is None assert added[0]["new_amount"] == 40.0