feat: sync latest ARR implementation

This commit is contained in:
Wyndham ARR
2026-07-31 15:11:42 +08:00
parent d6f8a747fa
commit bf7939dd1a
185 changed files with 17527 additions and 2260 deletions

View File

@@ -1,20 +1,23 @@
from __future__ import annotations
import base64
import hashlib
import json
import tempfile
import threading
import time
import unittest
from datetime import datetime
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, Mapping
from zoneinfo import ZoneInfo
from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger
from arr_web.auth import LoginCredentials
from arr_web.company_jobs import (
COMPANY_NAMES,
PersistentCompanyReportCoordinator,
period_complete_at,
period_release_at,
period_to_as_of,
)
@@ -24,12 +27,38 @@ from company_reports.contracts import ENGLISH_MONTH_NAMES, RESULT_SCHEMA_VERSION
BANGKOK = ZoneInfo("Asia/Bangkok")
TEST_CREDENTIALS = LoginCredentials(
username="arr-test-operator",
password="arr-test-password",
)
def decoded(response: Any) -> Dict[str, Any]:
return json.loads(response.body.decode("utf-8"))
def login_headers(app: PortalApplication) -> 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 {
"Cookie": cookie,
"X-ARR-CSRF": payload["data"]["csrf_token"],
}
class FixtureExecutor:
def __init__(self, project_root: Path, *, failed_company: str = "") -> None:
self.project_root = project_root
@@ -164,16 +193,20 @@ def wait_terminal(
class CompanyReportCoordinatorTests(unittest.TestCase):
def test_period_mapping_and_bangkok_release_instants(self) -> None:
def test_period_mapping_and_bangkok_completion_instants(self) -> None:
self.assertEqual(str(period_to_as_of("2028-02", "21-month-end")), "2028-02-29")
self.assertEqual(
period_release_at("2026-07", "01-10").isoformat(),
period_complete_at("2026-07", "01-10").isoformat(),
"2026-07-11T00:00:00+07:00",
)
self.assertEqual(
period_release_at("2026-12", "21-month-end").isoformat(),
period_complete_at("2026-12", "21-month-end").isoformat(),
"2027-01-01T00:00:00+07:00",
)
self.assertEqual(
period_release_at("2026-07", "01-10"),
period_complete_at("2026-07", "01-10"),
)
def test_success_is_private_and_download_is_reverified(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -222,10 +255,27 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
finally:
coordinator.close()
def test_unreleased_and_duplicate_active_jobs_are_rejected(self) -> None:
def test_incomplete_period_can_run_and_future_month_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
blocked = PersistentCompanyReportCoordinator(
executor = FixtureExecutor(root)
coordinator = PersistentCompanyReportCoordinator(
root,
root / "outputs" / "company_reports",
executor,
now=lambda: datetime(2026, 7, 10, 23, 59, tzinfo=BANGKOK),
)
try:
created = coordinator.create("2026-07", "01-10")
job = wait_terminal(coordinator, created["job_id"])
self.assertEqual(job["state"], "succeeded")
self.assertEqual(executor.calls, [(2026, 7, date(2026, 7, 10))])
finally:
coordinator.close()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
coordinator = PersistentCompanyReportCoordinator(
root,
root / "outputs" / "company_reports",
FixtureExecutor(root),
@@ -233,10 +283,10 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
)
try:
with self.assertRaises(PortalError) as caught:
blocked.create("2026-07", "01-10")
self.assertEqual(caught.exception.code, "COMPANY_REPORT_PERIOD_NOT_OPEN")
coordinator.create("2026-08", "01-10")
self.assertEqual(caught.exception.code, "COMPANY_REPORT_MONTH_IN_FUTURE")
finally:
blocked.close()
coordinator.close()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -254,7 +304,9 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
coordinator.create("2026-07", "01-10")
self.assertEqual(caught.exception.code, "COMPANY_REPORT_ALREADY_RUNNING")
executor.release.set()
self.assertEqual(wait_terminal(coordinator, first["job_id"])["state"], "failed")
failed_job = wait_terminal(coordinator, first["job_id"])
self.assertEqual(failed_job["state"], "failed")
self.assertIsNone(failed_job["message"])
finally:
executor.release.set()
coordinator.close()
@@ -263,6 +315,7 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
class FakeCompanyCoordinator:
def __init__(self) -> None:
self.created: list[tuple[str, str]] = []
self.listed: list[tuple[object, int, int]] = []
self.descriptor = ArtifactDescriptor(
"company_ten_day_xlsx",
"QBD-July-2026.xlsx",
@@ -276,8 +329,22 @@ class FakeCompanyCoordinator:
self.created.append((report_month, period))
return {"job_id": "a" * 32, "state": "queued"}
def list_jobs(self, report_month: object = None, limit: int = 100) -> list[Dict[str, Any]]:
return [{"job_id": "a" * 32, "report_month": report_month, "limit": limit}]
def list_jobs(
self,
report_month: object = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Dict[str, Any]], int]:
self.listed.append((report_month, limit, offset))
return [
{
"job_id": f"{index:032x}",
"report_month": report_month,
"limit": limit,
"offset": offset,
}
for index in range(offset, min(offset + limit, 72))
], 72
def get_job(self, job_id: str) -> Dict[str, Any]:
return {"job_id": job_id, "state": "succeeded"}
@@ -291,26 +358,110 @@ class FakeReader:
return b"xlsx"
class FakeBookingSources:
def __init__(self) -> None:
self.uploads: list[tuple[str, bytes]] = []
self.draft_page: Dict[str, Any] | None = None
self.review_updates: list[tuple[Any, int, Any, Any]] = []
self.review_deletes: list[tuple[Any, int]] = []
self.review_bulk_deletes: list[tuple[Any, tuple[int, ...]]] = []
self.review_discards: list[Any] = []
self.review_activations: list[Any] = []
self.source: Dict[str, Any] | None = {
"source_batch_id": 7,
"source_type": "excel",
"filename": "Booking 报表.xlsx",
"source_rows": 12,
"worksheet_count": 2,
"distinct_group_codes": 8,
"room_quantity": 14,
"activated_at": "2026-07-31T08:00:00+07:00",
"disposition": "current",
}
def current(self) -> Dict[str, Any] | None:
return self.source
def draft(self, limit: int = 50, offset: int = 0) -> Dict[str, Any] | None:
_ = (limit, offset)
return self.draft_page
def submit(self, filename: str, payload: bytes) -> Dict[str, Any]:
self.uploads.append((filename, payload))
self.draft_page = {
"summary": {
"draft_id": "bookingdraft-" + "a" * 32,
"filename": filename,
"pending_items": 1,
},
"items": [],
"pagination": {"total": 0, "limit": 50, "offset": 0},
}
return self.draft_page
def update_item(
self,
draft_id: Any,
item_id: int,
room_type: Any,
quantity: Any,
) -> Dict[str, Any]:
self.review_updates.append((draft_id, item_id, room_type, quantity))
return {"item_id": item_id, "room_type": room_type, "quantity": quantity}
def delete_item(self, draft_id: Any, item_id: int) -> Dict[str, Any]:
self.review_deletes.append((draft_id, item_id))
return {"item_id": item_id, "deleted": True}
def delete_items(self, draft_id: Any, item_ids: object) -> Dict[str, Any]:
assert isinstance(item_ids, list)
values = tuple(item_ids)
self.review_bulk_deletes.append((draft_id, values))
return {
"draft_id": draft_id,
"item_ids": list(values),
"deleted_count": len(values),
"deleted": True,
}
def discard(self, draft_id: Any) -> Dict[str, Any]:
self.review_discards.append(draft_id)
self.draft_page = None
return {"draft_id": draft_id, "discarded": True}
def activate(self, draft_id: Any) -> Dict[str, Any]:
self.review_activations.append(draft_id)
self.draft_page = None
assert self.source is not None
return self.source
class CompanyReportRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.coordinator = FakeCompanyCoordinator()
self.booking_sources = FakeBookingSources()
self.app = PortalApplication(
company_reports=self.coordinator,
booking_sources=self.booking_sources,
artifact_reader=FakeReader(),
health=RuntimeHealth(False, False, False, True, True),
health=RuntimeHealth(False, False, False, True, True, True),
sessions=SessionLedger(),
credentials=TEST_CREDENTIALS,
)
self.auth_headers = login_headers(self.app)
def session_headers(self) -> Dict[str, str]:
response = self.app.handle("GET", "/api/session", {})
payload = decoded(response)
cookie = response.headers["Set-Cookie"].split(";", 1)[0]
return {"Cookie": cookie, "X-ARR-CSRF": payload["data"]["csrf_token"]}
return dict(self.auth_headers)
def test_create_is_csrf_protected_and_strict(self) -> None:
body = b'{"report_month":"2026-07","period":"01-10"}'
self.assertEqual(
self.app.handle("POST", "/api/company-reports/jobs", {}, body).status,
self.app.handle(
"POST",
"/api/company-reports/jobs",
{"Cookie": self.auth_headers["Cookie"]},
body,
).status,
403,
)
response = self.app.handle(
@@ -330,17 +481,174 @@ class CompanyReportRouteTests(unittest.TestCase):
self.assertEqual(invalid.status, 400)
self.assertEqual(decoded(invalid)["error"]["code"], "COMPANY_REPORT_REQUEST_INVALID")
def test_source_read_and_upload_are_authenticated_and_csrf_protected(self) -> None:
current = self.app.handle(
"GET",
"/api/company-reports/source",
self.auth_headers,
)
self.assertEqual(decoded(current)["data"]["filename"], "Booking 报表.xlsx")
encoded = base64.urlsafe_b64encode("七月预订.xlsx".encode("utf-8")).decode("ascii").rstrip("=")
without_csrf = self.app.handle(
"POST",
"/api/company-reports/source",
{
"Cookie": self.auth_headers["Cookie"],
"X-ARR-Filename-B64": encoded,
},
b"xlsx",
)
self.assertEqual(without_csrf.status, 403)
uploaded = self.app.handle(
"POST",
"/api/company-reports/source",
{**self.session_headers(), "X-ARR-Filename-B64": encoded},
b"xlsx",
)
self.assertEqual(uploaded.status, 200)
self.assertEqual(self.booking_sources.uploads, [("七月预订.xlsx", b"xlsx")])
self.assertEqual(decoded(uploaded)["data"]["summary"]["pending_items"], 1)
def test_review_routes_are_authenticated_csrf_protected_and_strict(self) -> None:
draft_id = "bookingdraft-" + "a" * 32
self.booking_sources.draft_page = {
"summary": {"draft_id": draft_id, "pending_items": 1},
"items": [],
"pagination": {"total": 0, "limit": 50, "offset": 0},
}
page = self.app.handle(
"GET",
"/api/company-reports/source/draft?limit=50&offset=0",
self.auth_headers,
)
self.assertEqual(decoded(page)["data"]["summary"]["draft_id"], draft_id)
update_body = json.dumps(
{"draft_id": draft_id, "room_type": "EXTRA BED", "quantity": 2}
).encode("utf-8")
without_csrf = self.app.handle(
"PATCH",
"/api/company-reports/source/draft/items/9",
{"Cookie": self.auth_headers["Cookie"]},
update_body,
)
self.assertEqual(without_csrf.status, 403)
updated = self.app.handle(
"PATCH",
"/api/company-reports/source/draft/items/9",
{**self.session_headers(), "Content-Type": "application/json"},
update_body,
)
self.assertEqual(updated.status, 200)
self.assertEqual(
self.booking_sources.review_updates,
[(draft_id, 9, "EXTRA BED", 2)],
)
deleted = self.app.handle(
"DELETE",
"/api/company-reports/source/draft/items/9",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"draft_id": draft_id}).encode("utf-8"),
)
self.assertEqual(deleted.status, 200)
self.assertEqual(self.booking_sources.review_deletes, [(draft_id, 9)])
bulk_body = json.dumps({"draft_id": draft_id, "item_ids": [9, 10]}).encode("utf-8")
without_bulk_csrf = self.app.handle(
"DELETE",
"/api/company-reports/source/draft/items",
{"Cookie": self.auth_headers["Cookie"]},
bulk_body,
)
self.assertEqual(without_bulk_csrf.status, 403)
bulk_deleted = self.app.handle(
"DELETE",
"/api/company-reports/source/draft/items",
{**self.session_headers(), "Content-Type": "application/json"},
bulk_body,
)
self.assertEqual(bulk_deleted.status, 200)
self.assertEqual(decoded(bulk_deleted)["data"]["deleted_count"], 2)
self.assertEqual(
self.booking_sources.review_bulk_deletes,
[(draft_id, (9, 10))],
)
invalid_bulk = self.app.handle(
"DELETE",
"/api/company-reports/source/draft/items",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"draft_id": draft_id, "item_ids": [9], "extra": True}).encode(
"utf-8"
),
)
self.assertEqual(invalid_bulk.status, 400)
self.assertEqual(
decoded(invalid_bulk)["error"]["code"],
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
)
activated = self.app.handle(
"POST",
"/api/company-reports/source/draft/activate",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"draft_id": draft_id}).encode("utf-8"),
)
self.assertEqual(activated.status, 200)
self.assertEqual(self.booking_sources.review_activations, [draft_id])
def test_open_review_draft_blocks_company_generation(self) -> None:
self.booking_sources.draft_page = {
"summary": {"draft_id": "bookingdraft-" + "a" * 32},
"items": [],
"pagination": {"total": 0, "limit": 1, "offset": 0},
}
response = self.app.handle(
"POST",
"/api/company-reports/jobs",
{**self.session_headers(), "Content-Type": "application/json"},
b'{"report_month":"2026-07","period":"01-10"}',
)
self.assertEqual(response.status, 409)
self.assertEqual(decoded(response)["error"]["code"], "BOOKING_EXCEL_REVIEW_OPEN")
def test_generation_requires_an_accepted_source(self) -> None:
self.booking_sources.source = None
response = self.app.handle(
"POST",
"/api/company-reports/jobs",
{**self.session_headers(), "Content-Type": "application/json"},
b'{"report_month":"2026-07","period":"01-10"}',
)
self.assertEqual(response.status, 409)
self.assertEqual(
decoded(response)["error"]["code"],
"BOOKING_EXCEL_SOURCE_REQUIRED",
)
def test_list_detail_and_download_routes(self) -> None:
listing = self.app.handle(
"GET", "/api/company-reports/jobs?month=2026-07&limit=20", {}
"GET",
"/api/company-reports/jobs?month=2026-07&limit=50&offset=50",
self.auth_headers,
)
listing_payload = decoded(listing)
self.assertEqual(listing_payload["data"][0]["report_month"], "2026-07")
self.assertEqual(self.coordinator.listed, [("2026-07", 50, 50)])
self.assertEqual(listing_payload["pagination"]["total"], 72)
self.assertTrue(listing_payload["pagination"]["has_previous"])
self.assertFalse(listing_payload["pagination"]["has_next"])
detail = self.app.handle(
"GET",
f"/api/company-reports/jobs/{'a' * 32}",
self.auth_headers,
)
self.assertEqual(decoded(listing)["data"][0]["report_month"], "2026-07")
detail = self.app.handle("GET", f"/api/company-reports/jobs/{'a' * 32}", {})
self.assertEqual(decoded(detail)["data"]["state"], "succeeded")
download = self.app.handle(
"GET",
f"/api/company-reports/jobs/{'a' * 32}/downloads/QBD",
{},
self.auth_headers,
)
self.assertEqual(download.status, 200)
self.assertEqual(download.body, b"xlsx")