853 lines
34 KiB
Python
853 lines
34 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import tempfile
|
||
import unittest
|
||
from datetime import date
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List
|
||
|
||
from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger
|
||
from arr_web.auth import LoginCredentials
|
||
from arr_web.downloads import (
|
||
ArtifactDescriptor,
|
||
ControlledProjectArtifactReader,
|
||
ManagedObjectArtifactReader,
|
||
RoutedArtifactReader,
|
||
)
|
||
from arr_web.repository import PortalDataError
|
||
from arr_storage.filesystem import FilesystemObjectBackend
|
||
from arr_storage.store import ManagedObjectStore
|
||
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
STATIC_ROOT = PROJECT_ROOT / "arr_web" / "static"
|
||
TEST_CREDENTIALS = LoginCredentials(
|
||
username="arr-test-operator",
|
||
password="arr-test-password",
|
||
)
|
||
|
||
|
||
class FakeRepository:
|
||
def __init__(self) -> None:
|
||
self.months = [
|
||
{
|
||
"month_key": "2026-07",
|
||
"max_arrival_date": "2026-07-26",
|
||
"updated_at": "2026-07-28T12:00:00+08:00",
|
||
"filename": "monthly.xlsx",
|
||
"source_monthly_sha256": "a" * 64,
|
||
"channel_count": 2,
|
||
"row_count": 3,
|
||
}
|
||
]
|
||
|
||
def list_jobs(
|
||
self,
|
||
month_key: str,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> tuple[List[Dict[str, Any]], int]:
|
||
return [
|
||
{"job_id": "job-1", "status": "succeeded", "filename": "ARR.XML"}
|
||
], 1
|
||
|
||
def get_job_trace(self, job_id: str) -> Dict[str, Any]:
|
||
return {
|
||
"trace_version": "arr-job-trace-2",
|
||
"job": {
|
||
"job_id": job_id,
|
||
"status": "succeeded",
|
||
"active": False,
|
||
"current_stage": "database",
|
||
},
|
||
"evidence": {"attempts": 1},
|
||
"logs": [
|
||
{
|
||
"id": "run.received",
|
||
"timestamp": "2026-07-29T11:00:00+00:00",
|
||
"stage": "upload",
|
||
"level": "success",
|
||
"code": "UPLOAD_REGISTERED",
|
||
"title": "上传已安全登记",
|
||
"message": "任务已创建",
|
||
}
|
||
],
|
||
}
|
||
|
||
def list_monthly_runs(
|
||
self,
|
||
month_key: str,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> tuple[List[Dict[str, Any]], int]:
|
||
return [
|
||
{
|
||
"report_id": 3,
|
||
"version_no": 3,
|
||
"status": "active",
|
||
"max_arrival_date": "2026-07-26",
|
||
}
|
||
], 1
|
||
|
||
def list_months(self) -> List[Dict[str, Any]]:
|
||
return self.months
|
||
|
||
def read_dashboard(self, month_key: str) -> Dict[str, Any]:
|
||
if month_key == "2026-06":
|
||
raise PortalDataError(
|
||
"ANALYTICS_MONTH_NOT_FOUND",
|
||
"current monthly database projection was not found",
|
||
)
|
||
return {
|
||
"version": "1.2",
|
||
"month_key": month_key,
|
||
"updated_at": "2026-07-28T12:00:00+08:00",
|
||
"max_arrival_date": "2026-07-26",
|
||
"source_monthly_sha256": "a" * 64,
|
||
"overall": {
|
||
"totals": {
|
||
"rooms_sold": 3,
|
||
"total_price": 1200,
|
||
"room_nights": 5,
|
||
"reservation_rows": 3,
|
||
"room_type_count": 1,
|
||
"channel_count": 2,
|
||
},
|
||
"room_types": [{"room_type": "KING", "rooms_sold": 3}],
|
||
},
|
||
"channels": [
|
||
{
|
||
"worksheet": "COMPANY-A",
|
||
"totals": {"rooms_sold": 2, "total_price": 800},
|
||
"room_types": [{"room_type": "KING", "rooms_sold": 2}],
|
||
},
|
||
{
|
||
"worksheet": "COMPANY-B",
|
||
"totals": {"rooms_sold": 1, "total_price": 400},
|
||
"room_types": [{"room_type": "KING", "rooms_sold": 1}],
|
||
},
|
||
],
|
||
}
|
||
|
||
def read_channel_detail(
|
||
self,
|
||
month_key: str,
|
||
worksheet: str,
|
||
limit: int,
|
||
offset: int,
|
||
) -> Dict[str, Any]:
|
||
return {"month_key": month_key, "worksheet": worksheet, "rows": []}
|
||
|
||
def resolve_daily_download(self, job_id: str) -> ArtifactDescriptor:
|
||
return ArtifactDescriptor(
|
||
"daily_xlsx", "7.26.xlsx", "private/daily.xlsx", "a" * 64, 4, "application/xlsx"
|
||
)
|
||
|
||
def resolve_monthly_download(self, report_id: int) -> ArtifactDescriptor:
|
||
return ArtifactDescriptor(
|
||
"monthly_xlsx", "月报.xlsx", "private/monthly.xlsx", "b" * 64, 4, "application/xlsx"
|
||
)
|
||
|
||
|
||
class FakeUpload:
|
||
def __init__(self) -> None:
|
||
self.calls: list[tuple[str, bytes]] = []
|
||
|
||
def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
|
||
self.calls.append((original_filename, payload))
|
||
return {"job_id": "arr-job-1", "status": "queued"}
|
||
|
||
|
||
class FakeMonthly:
|
||
def __init__(self) -> None:
|
||
self.calls: list[tuple[str, date]] = []
|
||
|
||
def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]:
|
||
self.calls.append((month_key, as_of_date))
|
||
return {"status": "success", "month_key": month_key}
|
||
|
||
|
||
class FakeArtifactReader:
|
||
def read(self, descriptor: ArtifactDescriptor) -> bytes:
|
||
return b"xlsx"
|
||
|
||
|
||
def decoded(response: Any) -> Dict[str, Any]:
|
||
return json.loads(response.body.decode("utf-8"))
|
||
|
||
|
||
def login(
|
||
app: PortalApplication,
|
||
) -> tuple[Any, Dict[str, str]]:
|
||
response = app.handle(
|
||
"POST",
|
||
"/api/login",
|
||
{"Content-Type": "application/json"},
|
||
json.dumps(
|
||
{
|
||
"username": TEST_CREDENTIALS.username,
|
||
"password": TEST_CREDENTIALS.password,
|
||
}
|
||
).encode("utf-8"),
|
||
)
|
||
if response.status != 200:
|
||
raise AssertionError(f"test login failed with HTTP {response.status}")
|
||
payload = decoded(response)
|
||
cookie = response.headers["Set-Cookie"].split(";", 1)[0]
|
||
return response, {
|
||
"Cookie": cookie,
|
||
"X-ARR-CSRF": payload["data"]["csrf_token"],
|
||
}
|
||
|
||
|
||
class PortalApplicationTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.upload = FakeUpload()
|
||
self.monthly = FakeMonthly()
|
||
self.app = PortalApplication(
|
||
repository=FakeRepository(),
|
||
upload=self.upload,
|
||
monthly=self.monthly,
|
||
health=RuntimeHealth(True, True, True),
|
||
sessions=SessionLedger(),
|
||
credentials=TEST_CREDENTIALS,
|
||
)
|
||
_, self.auth_headers = login(self.app)
|
||
|
||
def test_daily_history_hides_internal_filename_when_provenance_is_missing(self) -> None:
|
||
script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||
self.assertIn('job.filename || "—"', script)
|
||
|
||
def session_headers(self) -> Dict[str, str]:
|
||
return dict(self.auth_headers)
|
||
|
||
def test_session_cookie_is_secure_only_when_explicitly_enabled(self) -> None:
|
||
default_cookie = login(self.app)[0].headers["Set-Cookie"]
|
||
secure_app = PortalApplication(
|
||
sessions=SessionLedger(),
|
||
secure_cookies=True,
|
||
credentials=TEST_CREDENTIALS,
|
||
)
|
||
secure_cookie = login(secure_app)[0].headers["Set-Cookie"]
|
||
|
||
self.assertNotIn("; Secure", default_cookie)
|
||
self.assertIn("; Secure", secure_cookie)
|
||
self.assertIn("; HttpOnly", secure_cookie)
|
||
self.assertIn("; SameSite=Strict", secure_cookie)
|
||
|
||
def test_fixed_static_routes_and_required_content(self) -> None:
|
||
desktop = self.app.handle("GET", "/", self.auth_headers)
|
||
mobile = self.app.handle("GET", "/h5", self.auth_headers)
|
||
self.assertEqual(desktop.status, 200)
|
||
self.assertEqual(mobile.status, 200)
|
||
desktop_text = desktop.body.decode("utf-8")
|
||
mobile_text = mobile.body.decode("utf-8")
|
||
for expected in (
|
||
'<span class="brand-name">ARR Report</span>',
|
||
'<h2 id="daily-history-title">Daily Report</h2>',
|
||
"ARRIVAL DATE",
|
||
"NO. OF ROOM",
|
||
"月报",
|
||
"月报处理",
|
||
"渠道BI",
|
||
"公司渠道明细",
|
||
"各公司本月销售情况",
|
||
"渠道 × 房型售出房数",
|
||
"任务日志",
|
||
"arr-task.log",
|
||
"$ arr trace",
|
||
):
|
||
self.assertIn(expected, desktop_text)
|
||
for removed in (
|
||
"标准月报",
|
||
"VERSION HISTORY",
|
||
"标准月报版本记录",
|
||
"公司渠道明细按 C/O(离店日)所属期间分页",
|
||
):
|
||
self.assertNotIn(removed, desktop_text)
|
||
self.assertNotIn(
|
||
'<table class="monthly-table">\n <thead><tr><th>版本</th>',
|
||
desktop_text,
|
||
)
|
||
for required_id in (
|
||
'id="process-log"',
|
||
'id="trace-live-state"',
|
||
'id="refresh-trace"',
|
||
'id="copy-trace"',
|
||
'id="jobs-prev"',
|
||
'id="jobs-next"',
|
||
'id="monthly-prev"',
|
||
'id="monthly-next"',
|
||
'id="company-prev"',
|
||
'id="company-next"',
|
||
'id="company-excel-file"',
|
||
'id="company-upload-button"',
|
||
'id="company-review-panel"',
|
||
'id="company-review-filename"',
|
||
'id="company-review-body"',
|
||
'id="company-review-activate"',
|
||
'id="company-review-discard"',
|
||
'id="company-review-delete-selected"',
|
||
'id="company-review-select-page"',
|
||
'id="company-review-confirm-dialog"',
|
||
'id="company-report-confirm-dialog"',
|
||
'id="company-report-confirm-cancel"',
|
||
'id="company-report-confirm-submit"',
|
||
):
|
||
self.assertIn(required_id, desktop_text)
|
||
self.assertIn("复制全部日志", desktop_text)
|
||
self.assertIn('<pre class="trace-log"', desktop_text)
|
||
self.assertNotIn('id="trace-stage-filter"', desktop_text)
|
||
self.assertNotIn('id="trace-level-filter"', desktop_text)
|
||
self.assertNotIn('id="trace-search"', desktop_text)
|
||
self.assertNotIn("process-orbit", desktop_text)
|
||
self.assertNotIn('class="pipeline"', desktop_text)
|
||
self.assertNotIn("brand-mark", desktop_text)
|
||
self.assertNotIn("DAILY ARRIVAL", desktop_text)
|
||
self.assertNotIn(
|
||
"上传 Opera ARR.XML,处理结果验收通过后写入业务数据库。",
|
||
desktop_text,
|
||
)
|
||
self.assertNotIn("DATABASE REPORT", desktop_text)
|
||
self.assertNotIn(
|
||
"按数据库中已验收的日报事实生成月报,版本与文件月内持续留痕。",
|
||
desktop_text,
|
||
)
|
||
self.assertNotIn("自动发布", desktop_text)
|
||
self.assertNotIn(
|
||
"日报入库后自动生成;“更新至”取本月数据中的最新 ARRIVAL。"
|
||
"发布后列表自动新增,无需手动刷新。",
|
||
desktop_text,
|
||
)
|
||
for removed in (
|
||
"COMPANY DETAIL REPORTS",
|
||
"基于 finance 已校验数据,按 C/O(离店日)所属期间生成五家公司 Excel。",
|
||
"FINANCE DATA",
|
||
"选择月份和本月生成期间",
|
||
"点击任一期间后,一次固定生成 LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour 五份工作簿。",
|
||
"本月生成期间(一次生成五家公司)",
|
||
"同月重跑会生成新的正式版本并保留历史归档;浏览器只提交月份和期间,不提交公司名单、数据库参数或文件路径。",
|
||
"ROOMS SOLD",
|
||
"支持已开放期间重跑和历史月份补录。",
|
||
"页面与期间时区",
|
||
"公司已生成",
|
||
"TASK HISTORY",
|
||
"公司渠道明细生成记录",
|
||
"程序读取 Tour Code 与 โรงแรม,自动拆分房型和数量;异常项目可在下方人工确认。",
|
||
"提取草稿",
|
||
"核对房型记录",
|
||
"还有 12 条待人工记录;填写房型并保存,或删除不需要的记录。",
|
||
"当前数据源",
|
||
):
|
||
self.assertNotIn(removed, desktop_text)
|
||
self.assertNotIn("ROOMS SOLD", mobile_text)
|
||
self.assertNotIn("company-timezone", desktop_text)
|
||
styles = (STATIC_ROOT / "styles.css").read_text(encoding="utf-8")
|
||
self.assertIn("background: #080b10", styles)
|
||
self.assertIn("[hidden] { display: none !important; }", styles)
|
||
self.assertIn(".company-period-button:active:not(:disabled)", styles)
|
||
self.assertIn(".company-period-button:disabled .company-period-cta", styles)
|
||
self.assertIn("width: clamp(72px, 34%, 94px)", styles)
|
||
self.assertIn(".company-report-card-row", styles)
|
||
self.assertIn("grid-template-columns: minmax(320px, 1.35fr) repeat(3", styles)
|
||
self.assertIn(".company-setup-heading", styles)
|
||
self.assertIn(".company-upload-button:active:not(:disabled)", styles)
|
||
self.assertIn("min-width: 104px", styles)
|
||
self.assertIn("align-self: flex-end", styles)
|
||
self.assertIn(".company-page-title", styles)
|
||
self.assertIn(".company-fixed-companies", styles)
|
||
self.assertIn(".company-source-action", styles)
|
||
self.assertIn(".company-review-file", styles)
|
||
self.assertIn("overflow-wrap: anywhere", styles)
|
||
self.assertIn(".company-review-row.is-pending", styles)
|
||
self.assertIn(".company-review-input:focus-visible", styles)
|
||
self.assertIn(".company-review-delete-selected", styles)
|
||
self.assertIn(".company-review-confirm-dialog::backdrop", styles)
|
||
self.assertIn(".company-report-confirm-dialog::backdrop", styles)
|
||
script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||
self.assertIn('navigator.clipboard?.writeText', script)
|
||
self.assertIn('document.execCommand("copy")', script)
|
||
self.assertIn('$("#copy-trace").addEventListener("click", copyAllTraceLogs)', script)
|
||
self.assertIn("运行固定处理器", script)
|
||
self.assertIn("saveCompanyReviewItem", script)
|
||
self.assertIn("deleteCompanyReviewItem", script)
|
||
self.assertIn("deleteSelectedCompanyReviewItems", script)
|
||
self.assertIn("const COMPANY_REVIEW_PAGE_SIZE = 50", script)
|
||
self.assertIn('api("/api/company-reports/source/draft/items"', script)
|
||
self.assertIn('if (event.key !== "Escape") return;', script)
|
||
self.assertIn("activateCompanyReviewDraft", script)
|
||
self.assertNotIn("window.confirm", script)
|
||
self.assertIn("companyReportConfirmIsOpen", script)
|
||
self.assertIn("confirmCompanyReportAction", script)
|
||
self.assertIn("state.companyReportConfirmRequest", script)
|
||
self.assertNotIn("company-review-guidance", script)
|
||
self.assertNotIn("company-review-description", script)
|
||
self.assertNotIn("还有 ${formatInteger(pending)} 条待人工记录", script)
|
||
self.assertNotIn("历史数据源已就绪", script)
|
||
self.assertNotIn("Excel 已校验", script)
|
||
self.assertNotIn('id="company-source-state"', desktop_text)
|
||
self.assertNotIn('id="company-source-summary"', desktop_text)
|
||
self.assertIn('String(summary.filename || "").trim()', script)
|
||
self.assertIn('$("#company-review-filename").textContent = filename || "未记录文件名"', script)
|
||
self.assertLess(
|
||
desktop_text.index('id="company-review-title"'),
|
||
desktop_text.index('id="company-review-filename"'),
|
||
)
|
||
self.assertNotIn("派发远端处理", script)
|
||
self.assertNotIn("尚未开放:", script)
|
||
self.assertNotIn("开放", desktop_text)
|
||
self.assertNotIn("开放", script)
|
||
self.assertIn("周期结束:", desktop_text)
|
||
self.assertIn("周期未结束", script)
|
||
self.assertIn("周期已结束", script)
|
||
self.assertIn("当前已入库数据", script)
|
||
self.assertIn("monthFuture", script)
|
||
self.assertIn("!monthValid || monthFuture", script)
|
||
self.assertIn(".company-period-state.is-complete", styles)
|
||
self.assertIn(".company-period-state.is-in-progress", styles)
|
||
self.assertNotIn("TASK FINISHED", script)
|
||
self.assertNotIn("company-period-availability", desktop_text)
|
||
self.assertIn('<span class="brand-name">ARR Report</span>', mobile_text)
|
||
self.assertNotIn("<span aria-hidden=\"true\"><i></i><i></i></span>", mobile_text)
|
||
self.assertIn("各公司本月销售情况", mobile_text)
|
||
self.assertIn("泰国曼谷(UTC+7)", mobile_text)
|
||
self.assertNotIn('id="generate-monthly"', desktop_text)
|
||
self.assertNotIn('id="monthly-as-of"', desktop_text)
|
||
self.assertNotIn('id="monthly-month"', desktop_text)
|
||
self.assertNotIn('id="refresh-monthly"', desktop_text)
|
||
self.assertNotIn('$("#generate-monthly")', script)
|
||
self.assertNotIn('$("#refresh-monthly")', script)
|
||
self.assertIn("const MONTHLY_POLL_INTERVAL = 4000", script)
|
||
self.assertIn("const HISTORY_PAGE_SIZE = 50", script)
|
||
self.assertIn("function renderPagination", script)
|
||
self.assertIn("function changeHistoryPage", script)
|
||
self.assertIn("page.total > previousTotal", script)
|
||
self.assertIn("state.monthlyOffset = 0", script)
|
||
self.assertNotIn("limit=100", script)
|
||
self.assertIn("function scheduleMonthlyPoll", script)
|
||
self.assertIn("loadMonthly(false, true)", script)
|
||
self.assertIn("自动更新重试中", script)
|
||
self.assertIn('api("/api/company-reports/source"', script)
|
||
self.assertIn("handleCompanySourceUpload", script)
|
||
self.assertNotIn("company-chip-list", desktop_text)
|
||
self.assertIn(
|
||
'<p class="company-fixed-companies">固定生成:LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour</p>',
|
||
desktop_text,
|
||
)
|
||
self.assertNotIn('class="company-scope"', desktop_text)
|
||
self.assertNotIn("固定生成公司", desktop_text)
|
||
self.assertIn('title="重新读取服务状态、Excel 来源和生成记录"', desktop_text)
|
||
self.assertIn('title="选择 C/O 所属报表月份,并筛选下方生成记录"', desktop_text)
|
||
self.assertIn('aria-label="报表月份"', desktop_text)
|
||
self.assertNotIn("生成截至", desktop_text)
|
||
self.assertEqual(desktop_text.count('<span>生成</span>'), 3)
|
||
self.assertIn("生成时间", desktop_text)
|
||
self.assertNotIn('data-label="版本"', script)
|
||
self.assertIn("function uniqueCompanyProblems", script)
|
||
self.assertIn("companyProblemsForResult(result)", script)
|
||
self.assertIn("job.finished_at || job.created_at", script)
|
||
for period_label in ("C/O:01-10", "C/O:11-20", "C/O:21-30"):
|
||
self.assertIn(f">{period_label}</strong>", desktop_text)
|
||
self.assertIn("function companyPeriodDisplayLabel(reportMonth, period)", script)
|
||
self.assertIn('return monthEnd ? `C/O:21-${monthEnd}` : "C/O:21-30";', script)
|
||
self.assertIn("companyPeriodDisplayLabel(reportMonth, period)", script)
|
||
for removed_period_name in ("第一期", "第二期", "第三期"):
|
||
self.assertNotIn(removed_period_name, desktop_text)
|
||
self.assertNotIn('class="company-generator-section"', desktop_text)
|
||
self.assertNotIn('class="company-source-section"', desktop_text)
|
||
self.assertLess(
|
||
desktop_text.index('id="company-report-month"'),
|
||
desktop_text.index('id="company-excel-file"'),
|
||
)
|
||
|
||
def test_all_public_assets_exclude_removed_copy(self) -> None:
|
||
content = "\n".join(
|
||
path.read_text(encoding="utf-8")
|
||
for path in STATIC_ROOT.iterdir()
|
||
if path.suffix in {".html", ".css", ".js"}
|
||
).lower()
|
||
for removed in (
|
||
"codex",
|
||
"本地报表处理",
|
||
"业务月份",
|
||
"渠道工作表",
|
||
"所有渠道工作表",
|
||
"room mix",
|
||
"channel mix",
|
||
"房型售卖排行",
|
||
"下载后更新月报",
|
||
"源记录",
|
||
"白名单删除",
|
||
"重复删除",
|
||
"最终输出",
|
||
):
|
||
self.assertNotIn(removed, content)
|
||
self.assertIn("正在处理arr.xml文件", content)
|
||
|
||
def test_read_apis_return_wrapped_database_data(self) -> None:
|
||
jobs = decoded(
|
||
self.app.handle("GET", "/api/jobs?month=2026-07", self.auth_headers)
|
||
)
|
||
trace = decoded(
|
||
self.app.handle("GET", "/api/jobs/job-1/trace", self.auth_headers)
|
||
)
|
||
monthly = decoded(
|
||
self.app.handle(
|
||
"GET",
|
||
"/api/monthly-runs?month=2026-07",
|
||
self.auth_headers,
|
||
)
|
||
)
|
||
analytics = decoded(
|
||
self.app.handle(
|
||
"GET",
|
||
"/api/analytics?month=2026-07",
|
||
self.auth_headers,
|
||
)
|
||
)
|
||
h5_months = decoded(
|
||
self.app.handle("GET", "/api/h5/months", self.auth_headers)
|
||
)
|
||
compatible_analytics = decoded(
|
||
self.app.handle(
|
||
"GET",
|
||
"/api/monthly/2026-07/analytics",
|
||
self.auth_headers,
|
||
)
|
||
)
|
||
self.assertEqual(jobs["data"][0]["job_id"], "job-1")
|
||
self.assertEqual(
|
||
jobs["pagination"],
|
||
{
|
||
"total": 1,
|
||
"limit": 50,
|
||
"offset": 0,
|
||
"has_previous": False,
|
||
"has_next": False,
|
||
},
|
||
)
|
||
self.assertEqual(trace["data"]["job"]["job_id"], "job-1")
|
||
self.assertEqual(trace["data"]["logs"][0]["code"], "UPLOAD_REGISTERED")
|
||
self.assertEqual(monthly["data"][0]["version_no"], 3)
|
||
self.assertEqual(monthly["data"][0]["max_arrival_date"], "2026-07-26")
|
||
self.assertEqual(monthly["pagination"]["total"], 1)
|
||
self.assertEqual(monthly["pagination"]["limit"], 50)
|
||
self.assertEqual(analytics["data"]["channels"][0]["worksheet"], "COMPANY-A")
|
||
self.assertEqual(
|
||
h5_months,
|
||
{
|
||
"months": [
|
||
{
|
||
"month_key": "2026-07",
|
||
"updated_at": "2026-07-28T12:00:00+08:00",
|
||
"max_arrival_date": "2026-07-26",
|
||
}
|
||
]
|
||
},
|
||
)
|
||
self.assertNotIn("ok", compatible_analytics)
|
||
self.assertEqual(compatible_analytics["version"], "1.2")
|
||
self.assertEqual(
|
||
compatible_analytics["channels"][0]["worksheet"],
|
||
"COMPANY-A",
|
||
)
|
||
|
||
def test_health_exposes_only_programmatic_processing_readiness(self) -> None:
|
||
payload = decoded(
|
||
self.app.handle("GET", "/api/health", self.auth_headers)
|
||
)["data"]
|
||
self.assertTrue(payload["database_ready"])
|
||
self.assertTrue(payload["processing_ready"])
|
||
self.assertNotIn("agent_writeback_ready", payload)
|
||
|
||
def test_legacy_h5_analytics_route_preserves_status_contract(self) -> None:
|
||
invalid = self.app.handle(
|
||
"GET",
|
||
"/api/monthly/2026-13/analytics",
|
||
self.auth_headers,
|
||
)
|
||
missing = self.app.handle(
|
||
"GET",
|
||
"/api/monthly/2026-06/analytics",
|
||
self.auth_headers,
|
||
)
|
||
self.assertEqual(invalid.status, 422)
|
||
self.assertEqual(decoded(invalid)["error"]["code"], "MONTH_KEY_INVALID")
|
||
self.assertEqual(missing.status, 404)
|
||
self.assertEqual(decoded(missing)["error"]["code"], "MONTHLY_NOT_FOUND")
|
||
|
||
def test_upload_requires_csrf_and_accepts_raw_xml_only(self) -> None:
|
||
encoded = base64.urlsafe_b64encode("到店明细.XML".encode("utf-8")).decode("ascii").rstrip("=")
|
||
no_csrf = self.app.handle(
|
||
"POST",
|
||
"/api/jobs",
|
||
{
|
||
"Cookie": self.auth_headers["Cookie"],
|
||
"X-ARR-Filename-B64": encoded,
|
||
},
|
||
b"<root />",
|
||
)
|
||
self.assertEqual(no_csrf.status, 403)
|
||
response = self.app.handle(
|
||
"POST",
|
||
"/api/jobs",
|
||
{**self.session_headers(), "X-ARR-Filename-B64": encoded},
|
||
b"\xef\xbb\xbf <root />",
|
||
)
|
||
self.assertEqual(response.status, 202)
|
||
self.assertEqual(self.upload.calls, [("到店明细.XML", b"\xef\xbb\xbf <root />")])
|
||
|
||
def test_upload_rejects_non_xml_bytes_before_coordinator(self) -> None:
|
||
encoded = base64.urlsafe_b64encode(b"report.xml").decode("ascii").rstrip("=")
|
||
response = self.app.handle(
|
||
"POST",
|
||
"/api/jobs",
|
||
{**self.session_headers(), "X-ARR-Filename-B64": encoded},
|
||
b"not xml",
|
||
)
|
||
self.assertEqual(response.status, 400)
|
||
self.assertEqual(decoded(response)["error"]["code"], "UPLOAD_CONTENT_INVALID")
|
||
self.assertEqual(self.upload.calls, [])
|
||
|
||
def test_monthly_generation_is_csrf_protected_and_typed(self) -> None:
|
||
response = self.app.handle(
|
||
"POST",
|
||
"/api/monthly-runs",
|
||
{**self.session_headers(), "Content-Type": "application/json"},
|
||
b'{"month":"2026-07","as_of_date":"2026-07-26"}',
|
||
)
|
||
self.assertEqual(response.status, 202)
|
||
self.assertEqual(self.monthly.calls, [("2026-07", date(2026, 7, 26))])
|
||
|
||
def test_agent_result_callback_is_not_exposed_in_programmatic_runtime(self) -> None:
|
||
response = self.app.handle(
|
||
"POST",
|
||
"/api/integrations/super-agent/results",
|
||
{
|
||
**self.session_headers(),
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
},
|
||
b"{}",
|
||
)
|
||
self.assertEqual(response.status, 404)
|
||
self.assertEqual(decoded(response)["error"]["code"], "ROUTE_NOT_FOUND")
|
||
|
||
def test_download_uses_internal_descriptor_without_exposing_storage_key(self) -> None:
|
||
app = PortalApplication(
|
||
repository=FakeRepository(),
|
||
artifact_reader=FakeArtifactReader(),
|
||
health=RuntimeHealth(True, True, True, True),
|
||
credentials=TEST_CREDENTIALS,
|
||
)
|
||
_, headers = login(app)
|
||
response = app.handle(
|
||
"GET",
|
||
"/api/download/monthly?report_id=3",
|
||
headers,
|
||
)
|
||
self.assertEqual(response.status, 200)
|
||
self.assertEqual(response.body, b"xlsx")
|
||
self.assertIn("filename*=UTF-8''", response.headers["Content-Disposition"])
|
||
self.assertEqual(response.headers["Cache-Control"], "private, no-store")
|
||
self.assertNotIn("private/monthly.xlsx", str(response.headers))
|
||
|
||
def test_query_validation_fails_without_repository_call(self) -> None:
|
||
response = self.app.handle(
|
||
"GET",
|
||
"/api/jobs?month=2026-13",
|
||
self.auth_headers,
|
||
)
|
||
self.assertEqual(response.status, 400)
|
||
self.assertEqual(decoded(response)["error"]["code"], "MONTH_INVALID")
|
||
|
||
invalid_job = self.app.handle(
|
||
"GET",
|
||
"/api/jobs/not%2Fsafe/trace",
|
||
self.auth_headers,
|
||
)
|
||
self.assertEqual(invalid_job.status, 400)
|
||
self.assertEqual(decoded(invalid_job)["error"]["code"], "JOB_ID_INVALID")
|
||
|
||
invalid_offset = self.app.handle(
|
||
"GET",
|
||
"/api/monthly-runs?month=2026-07&offset=-1",
|
||
self.auth_headers,
|
||
)
|
||
self.assertEqual(invalid_offset.status, 400)
|
||
self.assertEqual(decoded(invalid_offset)["error"]["code"], "QUERY_INVALID")
|
||
|
||
def test_history_routes_forward_limit_and_offset_with_exact_totals(self) -> None:
|
||
class PagingRepository(FakeRepository):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.job_request: tuple[str, int, int] | None = None
|
||
self.monthly_request: tuple[str, int, int] | None = None
|
||
|
||
def list_jobs(
|
||
self,
|
||
month_key: str,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> tuple[List[Dict[str, Any]], int]:
|
||
self.job_request = (month_key, limit, offset)
|
||
return [{"job_id": "job-51"}], 73
|
||
|
||
def list_monthly_runs(
|
||
self,
|
||
month_key: str,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> tuple[List[Dict[str, Any]], int]:
|
||
self.monthly_request = (month_key, limit, offset)
|
||
return [{"report_id": 51}], 101
|
||
|
||
repository = PagingRepository()
|
||
app = PortalApplication(
|
||
repository=repository,
|
||
credentials=TEST_CREDENTIALS,
|
||
)
|
||
_, headers = login(app)
|
||
jobs = decoded(
|
||
app.handle(
|
||
"GET",
|
||
"/api/jobs?month=2026-07&limit=50&offset=50",
|
||
headers,
|
||
)
|
||
)
|
||
monthly = decoded(
|
||
app.handle(
|
||
"GET",
|
||
"/api/monthly-runs?month=2026-07&limit=50&offset=50",
|
||
headers,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(repository.job_request, ("2026-07", 50, 50))
|
||
self.assertEqual(repository.monthly_request, ("2026-07", 50, 50))
|
||
self.assertEqual(jobs["pagination"]["total"], 73)
|
||
self.assertTrue(jobs["pagination"]["has_previous"])
|
||
self.assertTrue(jobs["pagination"]["has_next"])
|
||
self.assertEqual(monthly["pagination"]["total"], 101)
|
||
self.assertTrue(monthly["pagination"]["has_previous"])
|
||
self.assertTrue(monthly["pagination"]["has_next"])
|
||
|
||
|
||
class UnavailableDataTests(unittest.TestCase):
|
||
def test_repository_failures_are_privacy_safe_503(self) -> None:
|
||
class Broken(FakeRepository):
|
||
def list_jobs(
|
||
self,
|
||
month_key: str,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> tuple[List[Dict[str, Any]], int]:
|
||
raise PortalDataError("DATABASE_UNAVAILABLE", "数据库连接失败")
|
||
|
||
app = PortalApplication(
|
||
repository=Broken(),
|
||
credentials=TEST_CREDENTIALS,
|
||
)
|
||
_, headers = login(app)
|
||
response = app.handle(
|
||
"GET",
|
||
"/api/jobs?month=2026-07",
|
||
headers,
|
||
)
|
||
self.assertEqual(response.status, 503)
|
||
payload = decoded(response)
|
||
self.assertEqual(payload["error"]["message"], "数据库连接失败")
|
||
self.assertNotIn("password", response.body.decode("utf-8").lower())
|
||
|
||
|
||
class ControlledDownloadTests(unittest.TestCase):
|
||
def test_reader_rehashes_controlled_file(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
path = root / "outputs" / "report.xlsx"
|
||
path.parent.mkdir()
|
||
path.write_bytes(b"trusted-xlsx")
|
||
payload = path.read_bytes()
|
||
descriptor = ArtifactDescriptor(
|
||
"monthly_xlsx",
|
||
"月报.xlsx",
|
||
"outputs/report.xlsx",
|
||
hashlib.sha256(payload).hexdigest(),
|
||
len(payload),
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
)
|
||
self.assertEqual(ControlledProjectArtifactReader(root).read(descriptor), payload)
|
||
|
||
def test_reader_rejects_hash_mismatch_and_parent_escape(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
path = root / "report.xlsx"
|
||
path.write_bytes(b"changed")
|
||
bad_hash = ArtifactDescriptor(
|
||
"daily_xlsx",
|
||
"daily.xlsx",
|
||
"report.xlsx",
|
||
"0" * 64,
|
||
len(b"changed"),
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
)
|
||
with self.assertRaisesRegex(Exception, "完整性"):
|
||
ControlledProjectArtifactReader(root).read(bad_hash)
|
||
escaped = ArtifactDescriptor(
|
||
"daily_xlsx",
|
||
"daily.xlsx",
|
||
"../report.xlsx",
|
||
"0" * 64,
|
||
1,
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
)
|
||
with self.assertRaisesRegex(Exception, "文件身份"):
|
||
ControlledProjectArtifactReader(root).read(escaped)
|
||
|
||
def test_daily_download_reads_and_reverifies_committed_object(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
root = Path(temporary)
|
||
source = root / "7.27.xlsx"
|
||
source.write_bytes(b"trusted-daily-xlsx")
|
||
store = ManagedObjectStore(
|
||
FilesystemObjectBackend(root / "objects", create=True)
|
||
)
|
||
stored = store.upload_committed(
|
||
job_id="arrjob-download-001",
|
||
attempt_no=1,
|
||
role="daily_report",
|
||
source=source,
|
||
original_filename=source.name,
|
||
)
|
||
descriptor = ArtifactDescriptor(
|
||
"daily_xlsx",
|
||
source.name,
|
||
stored.object_key,
|
||
stored.sha256,
|
||
stored.byte_size,
|
||
stored.mime_type,
|
||
)
|
||
|
||
reader = RoutedArtifactReader(
|
||
daily_reader=ManagedObjectArtifactReader(store),
|
||
local_reader=ControlledProjectArtifactReader(root),
|
||
)
|
||
self.assertEqual(reader.read(descriptor), source.read_bytes())
|
||
|
||
bad = ArtifactDescriptor(
|
||
descriptor.file_kind,
|
||
descriptor.original_filename,
|
||
descriptor.storage_key,
|
||
"0" * 64,
|
||
descriptor.byte_size,
|
||
descriptor.mime_type,
|
||
)
|
||
with self.assertRaisesRegex(Exception, "完整性"):
|
||
reader.read(bad)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|