955 lines
34 KiB
Python
955 lines
34 KiB
Python
"""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
|