feat: sync latest ARR implementation
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user