feat: add public h5 dashboard and publish traceability
This commit is contained in:
@@ -370,6 +370,9 @@ class PortalApplicationTests(unittest.TestCase):
|
||||
self.assertIn(".company-page-title", styles)
|
||||
self.assertIn(".company-fixed-companies", styles)
|
||||
self.assertIn(".company-source-action", styles)
|
||||
self.assertIn(".company-source-current", styles)
|
||||
self.assertIn(".company-job-source", styles)
|
||||
self.assertIn(".company-history-source", styles)
|
||||
self.assertIn(".company-review-file", styles)
|
||||
self.assertIn("overflow-wrap: anywhere", styles)
|
||||
self.assertIn(".company-review-row.is-pending", styles)
|
||||
@@ -392,6 +395,8 @@ class PortalApplicationTests(unittest.TestCase):
|
||||
self.assertNotIn("window.confirm", script)
|
||||
self.assertIn("companyReportConfirmIsOpen", script)
|
||||
self.assertIn("confirmCompanyReportAction", script)
|
||||
self.assertIn("BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED", script)
|
||||
self.assertIn("requestError.code", script)
|
||||
self.assertIn("state.companyReportConfirmRequest", script)
|
||||
self.assertNotIn("company-review-guidance", script)
|
||||
self.assertNotIn("company-review-description", script)
|
||||
@@ -400,6 +405,17 @@ class PortalApplicationTests(unittest.TestCase):
|
||||
self.assertNotIn("Excel 已校验", script)
|
||||
self.assertNotIn('id="company-source-state"', desktop_text)
|
||||
self.assertNotIn('id="company-source-summary"', desktop_text)
|
||||
for required in (
|
||||
'id="company-source-current"',
|
||||
'id="company-source-current-filename"',
|
||||
'id="company-source-current-meta"',
|
||||
'id="company-source-status"',
|
||||
'id="company-report-job-source"',
|
||||
'id="company-report-job-source-file"',
|
||||
'id="company-report-job-source-meta"',
|
||||
"来源 Excel",
|
||||
):
|
||||
self.assertIn(required, desktop_text)
|
||||
self.assertIn('String(summary.filename || "").trim()', script)
|
||||
self.assertIn('$("#company-review-filename").textContent = filename || "未记录文件名"', script)
|
||||
self.assertLess(
|
||||
@@ -580,6 +596,36 @@ class PortalApplicationTests(unittest.TestCase):
|
||||
"COMPANY-A",
|
||||
)
|
||||
|
||||
def test_anonymous_h5_exposes_only_sanitized_read_only_dashboard(self) -> None:
|
||||
mobile = self.app.handle("GET", "/h5", {})
|
||||
mobile_css = self.app.handle("GET", "/assets/h5.css", {})
|
||||
mobile_js = self.app.handle("GET", "/assets/h5.js", {})
|
||||
months = decoded(self.app.handle("GET", "/api/public/h5/months", {}))
|
||||
analytics_response = self.app.handle(
|
||||
"GET",
|
||||
"/api/public/h5/analytics?month=2026-07",
|
||||
{},
|
||||
)
|
||||
analytics = decoded(analytics_response)
|
||||
protected_generic = self.app.handle(
|
||||
"GET",
|
||||
"/api/analytics?month=2026-07",
|
||||
{},
|
||||
)
|
||||
protected_legacy = self.app.handle("GET", "/api/h5/months", {})
|
||||
|
||||
self.assertEqual(mobile.status, 200)
|
||||
self.assertEqual(mobile_css.status, 200)
|
||||
self.assertEqual(mobile_js.status, 200)
|
||||
self.assertEqual(months["data"][0]["month_key"], "2026-07")
|
||||
self.assertEqual(analytics_response.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(analytics["data"]["month_key"], "2026-07")
|
||||
self.assertIn("channels", analytics["data"])
|
||||
self.assertNotIn("source_monthly_sha256", analytics["data"])
|
||||
self.assertNotIn("filename", months["data"][0])
|
||||
self.assertEqual(protected_generic.status, 401)
|
||||
self.assertEqual(protected_legacy.status, 401)
|
||||
|
||||
def test_health_exposes_only_programmatic_processing_readiness(self) -> None:
|
||||
payload = decoded(
|
||||
self.app.handle("GET", "/api/health", self.auth_headers)
|
||||
|
||||
@@ -66,9 +66,11 @@ class PortalAuthenticationTests(unittest.TestCase):
|
||||
health=RuntimeHealth(True, True, False),
|
||||
)
|
||||
|
||||
def test_only_login_assets_and_minimal_readiness_are_public(self) -> None:
|
||||
def test_h5_shell_is_public_while_desktop_and_detail_routes_stay_private(self) -> None:
|
||||
desktop = self.app.handle("GET", "/", {})
|
||||
mobile = self.app.handle("GET", "/h5", {})
|
||||
mobile_css = self.app.handle("GET", "/assets/h5.css", {})
|
||||
mobile_js = self.app.handle("GET", "/assets/h5.js", {})
|
||||
protected_api = self.app.handle("GET", "/api/health", {})
|
||||
protected_asset = self.app.handle("GET", "/assets/app.js", {})
|
||||
login_page = self.app.handle("GET", "/login", {})
|
||||
@@ -76,7 +78,9 @@ class PortalAuthenticationTests(unittest.TestCase):
|
||||
readiness = self.app.handle("GET", "/healthz", {})
|
||||
|
||||
self.assertEqual((desktop.status, desktop.headers["Location"]), (303, "/login?next=%2F"))
|
||||
self.assertEqual((mobile.status, mobile.headers["Location"]), (303, "/login?next=%2Fh5"))
|
||||
self.assertEqual(mobile.status, 200)
|
||||
self.assertEqual(mobile_css.status, 200)
|
||||
self.assertEqual(mobile_js.status, 200)
|
||||
self.assertEqual(protected_api.status, 401)
|
||||
self.assertEqual(decoded(protected_api)["error"]["code"], "AUTH_REQUIRED")
|
||||
self.assertEqual(protected_asset.status, 303)
|
||||
@@ -207,11 +211,14 @@ class StaticLoginContractTests(unittest.TestCase):
|
||||
self.assertIn('<a class="logout-link" id="logout-button" href="#logout" hidden>', desktop_html)
|
||||
self.assertIn('<a class="mobile-logout" id="h5-logout" href="#logout" hidden>', mobile_html)
|
||||
self.assertIn("if (!session.username) return", desktop_js)
|
||||
self.assertIn("if (session.username)", mobile_js)
|
||||
self.assertIn("if (session?.username)", mobile_js)
|
||||
self.assertIn("response.status === 401", desktop_js)
|
||||
self.assertIn('api("/api/logout", { method: "POST" })', desktop_js)
|
||||
self.assertIn("response.status === 401", mobile_js)
|
||||
self.assertIn('api("/api/logout", { method: "POST" })', mobile_js)
|
||||
self.assertIn("/api/public/h5/months", mobile_js)
|
||||
self.assertIn("/api/public/h5/analytics", mobile_js)
|
||||
self.assertIn('fetch("/healthz"', mobile_js)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Mapping
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger
|
||||
@@ -219,9 +219,22 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
|
||||
now=lambda: datetime(2026, 8, 1, tzinfo=BANGKOK),
|
||||
)
|
||||
try:
|
||||
created = coordinator.create("2026-07", "11-20")
|
||||
source = {
|
||||
"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",
|
||||
}
|
||||
created = coordinator.create("2026-07", "11-20", source=source)
|
||||
self.assertEqual(created["source"], source)
|
||||
job = wait_terminal(coordinator, created["job_id"])
|
||||
self.assertEqual(job["state"], "succeeded")
|
||||
self.assertEqual(job["source"], source)
|
||||
self.assertEqual(job["requested_companies"], list(COMPANY_NAMES))
|
||||
self.assertEqual(len(job["company_results"]), 5)
|
||||
self.assertEqual(job["company_results"][1]["warnings"][0]["record_ids"], [2])
|
||||
@@ -314,7 +327,7 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
|
||||
|
||||
class FakeCompanyCoordinator:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[tuple[str, str]] = []
|
||||
self.created: list[tuple[str, str, Optional[Mapping[str, Any]]]] = []
|
||||
self.listed: list[tuple[object, int, int]] = []
|
||||
self.descriptor = ArtifactDescriptor(
|
||||
"company_ten_day_xlsx",
|
||||
@@ -325,9 +338,15 @@ class FakeCompanyCoordinator:
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
def create(self, report_month: str, period: str) -> Dict[str, Any]:
|
||||
self.created.append((report_month, period))
|
||||
return {"job_id": "a" * 32, "state": "queued"}
|
||||
def create(
|
||||
self,
|
||||
report_month: str,
|
||||
period: str,
|
||||
*,
|
||||
source: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
self.created.append((report_month, period, source))
|
||||
return {"job_id": "a" * 32, "state": "queued", "source": source}
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
@@ -477,7 +496,11 @@ class CompanyReportRouteTests(unittest.TestCase):
|
||||
body,
|
||||
)
|
||||
self.assertEqual(response.status, 202)
|
||||
self.assertEqual(self.coordinator.created, [("2026-07", "01-10")])
|
||||
self.assertEqual(
|
||||
self.coordinator.created,
|
||||
[("2026-07", "01-10", self.booking_sources.source)],
|
||||
)
|
||||
self.assertEqual(decoded(response)["data"]["source"]["source_batch_id"], 7)
|
||||
invalid = self.app.handle(
|
||||
"POST",
|
||||
"/api/company-reports/jobs",
|
||||
|
||||
@@ -92,6 +92,9 @@ class JobTraceTests(unittest.TestCase):
|
||||
self.assertEqual(trace["job"]["current_stage"], "downstream")
|
||||
self.assertFalse(trace["job"]["active"])
|
||||
self.assertIsNone(trace["job"]["remote_run_id"])
|
||||
self.assertEqual(trace["job"]["execution_scope"], "arr_runtime")
|
||||
self.assertEqual(trace["job"]["processor_mode"], "fixed_processor")
|
||||
self.assertEqual(trace["job"]["remote_dispatch"], "none")
|
||||
self.assertEqual(trace["job"]["version_no"], 2)
|
||||
self.assertEqual(trace["job"]["output_rows"], 17)
|
||||
codes = [item["code"] for item in trace["logs"]]
|
||||
@@ -121,6 +124,9 @@ class JobTraceTests(unittest.TestCase):
|
||||
"MCP",
|
||||
):
|
||||
self.assertNotIn(private_value, rendered)
|
||||
self.assertNotIn("本地处理", rendered)
|
||||
self.assertIn("ARR 固定处理器输出", rendered)
|
||||
self.assertIn("Finance 数据库提交已完成", rendered)
|
||||
|
||||
def test_runtime_failure_is_located_at_processor_boundary(self) -> None:
|
||||
finished = self.started + timedelta(minutes=4)
|
||||
@@ -160,6 +166,43 @@ class JobTraceTests(unittest.TestCase):
|
||||
self.assertGreaterEqual(len(errors), 2)
|
||||
self.assertTrue(all(item["stage"] == "processor" for item in errors))
|
||||
|
||||
def test_legacy_direct_mcp_trace_exposes_remote_semantics(self) -> None:
|
||||
finished = self.started + timedelta(minutes=4)
|
||||
trace = build_job_trace(
|
||||
{
|
||||
"job_id": "arrjob-legacy-001",
|
||||
"run_status": "failed",
|
||||
"public_status": "failed",
|
||||
"delivery_mode": "direct_mcp",
|
||||
"failure_code": "REMOTE_FAILED",
|
||||
"failure_message": "remote processor failed",
|
||||
"created_at": self.started,
|
||||
"updated_at": finished,
|
||||
"finished_at": finished,
|
||||
"filename": "source.xml",
|
||||
"byte_size": 99,
|
||||
"source_sha256": "d" * 64,
|
||||
},
|
||||
attempts=[
|
||||
{
|
||||
"attempt_no": 1,
|
||||
"attempt_status": "failed",
|
||||
"remote_run_id": "remote-run-123",
|
||||
"failure_code": "REMOTE_FAILED",
|
||||
"failure_message": "remote processor failed",
|
||||
"created_at": self.started + timedelta(seconds=1),
|
||||
"started_at": self.started + timedelta(seconds=2),
|
||||
"finished_at": finished,
|
||||
}
|
||||
],
|
||||
refreshed_at=finished,
|
||||
)
|
||||
|
||||
self.assertEqual(trace["job"]["execution_scope"], "remote_agent")
|
||||
self.assertEqual(trace["job"]["processor_mode"], "legacy_direct_mcp")
|
||||
self.assertEqual(trace["job"]["remote_dispatch"], "mcp")
|
||||
self.assertEqual(trace["job"]["remote_run_id"], "remote-run-123")
|
||||
|
||||
def test_active_run_ends_with_current_programmatic_state(self) -> None:
|
||||
trace = build_job_trace(
|
||||
{
|
||||
|
||||
@@ -9,7 +9,13 @@ from dataclasses import replace
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from company_reports.publishing import ArtifactToolBuilder, AtomicReportPublisher
|
||||
from company_reports.publishing import (
|
||||
ArtifactToolBuilder,
|
||||
AtomicReportPublisher,
|
||||
BuiltWorkbook,
|
||||
sha256_file,
|
||||
)
|
||||
from company_reports.repository import ReservedReport
|
||||
from company_reports.service import CompanyReportService, RunRequest
|
||||
from tests.test_company_reports_service import FakeRepository, synthetic_snapshot
|
||||
|
||||
@@ -94,16 +100,50 @@ class CompanyReportIntegrationTests(unittest.TestCase):
|
||||
(output_root / "2026" / "07" / "HanaTour-July-2026.xlsx").is_file()
|
||||
)
|
||||
|
||||
def test_same_snapshot_rerun_rebuilds_full_month_with_equal_semantics(self):
|
||||
def test_same_snapshot_rerun_reuses_publication_when_binary_changes(self):
|
||||
node = node_binary()
|
||||
if not node:
|
||||
self.skipTest("Node.js is unavailable")
|
||||
|
||||
class StableVersionRepository(FakeRepository):
|
||||
def reserve_report(self, report):
|
||||
reservation = ReservedReport(9001, 1, report.company)
|
||||
self.reserved.append(reservation)
|
||||
return reservation
|
||||
|
||||
class RebuildingBuilder:
|
||||
def __init__(self, delegate):
|
||||
self.delegate = delegate
|
||||
self.binary_sha256s = []
|
||||
|
||||
def build(self, report, work_dir):
|
||||
built = self.delegate.build(report, work_dir)
|
||||
with built.path.open("ab") as handle:
|
||||
handle.write(
|
||||
f"\nsynthetic-rebuild-marker-{len(self.binary_sha256s)}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(built.path, 0o600)
|
||||
rebuilt = BuiltWorkbook(
|
||||
path=built.path,
|
||||
sha256=sha256_file(built.path),
|
||||
byte_size=built.path.stat().st_size,
|
||||
summary=built.summary,
|
||||
)
|
||||
self.binary_sha256s.append(rebuilt.sha256)
|
||||
return rebuilt
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="company-report-integration-") as temp_dir:
|
||||
project_root = Path(temp_dir) / "project"
|
||||
output_root = project_root / "outputs" / "company_reports"
|
||||
project_root.mkdir(parents=True)
|
||||
repository = FakeRepository(synthetic_snapshot())
|
||||
builder = ArtifactToolBuilder(BUILDER_SCRIPT, node_binary=node, timeout_seconds=90)
|
||||
repository = StableVersionRepository(synthetic_snapshot())
|
||||
builder = RebuildingBuilder(
|
||||
ArtifactToolBuilder(BUILDER_SCRIPT, node_binary=node, timeout_seconds=90)
|
||||
)
|
||||
publisher = AtomicReportPublisher(project_root, output_root)
|
||||
service = CompanyReportService(
|
||||
repository,
|
||||
@@ -124,21 +164,26 @@ class CompanyReportIntegrationTests(unittest.TestCase):
|
||||
second_semantic = second.companies[0].artifact["semantic_sha256"]
|
||||
self.assertEqual(len(first_semantic), 64)
|
||||
self.assertEqual(first_semantic, second_semantic)
|
||||
self.assertNotEqual(builder.binary_sha256s[0], builder.binary_sha256s[1])
|
||||
self.assertEqual(
|
||||
first.companies[0].artifact["sha256"],
|
||||
second.companies[0].artifact["sha256"],
|
||||
)
|
||||
self.assertEqual(
|
||||
first.companies[0].period_row_counts,
|
||||
second.companies[0].period_row_counts,
|
||||
)
|
||||
self.assertEqual(first.companies[0].version_no, 1)
|
||||
self.assertEqual(second.companies[0].version_no, 2)
|
||||
self.assertEqual(second.companies[0].version_no, 1)
|
||||
|
||||
month_root = output_root / "2026" / "07"
|
||||
filename = "QBD-July-2026.xlsx"
|
||||
current = month_root / filename
|
||||
first_archive = month_root / "archive" / "v0001" / filename
|
||||
second_archive = month_root / "archive" / "v0002" / filename
|
||||
self.assertTrue(first_archive.is_file())
|
||||
self.assertTrue(second_archive.is_file())
|
||||
self.assertEqual(current.read_bytes(), second_archive.read_bytes())
|
||||
self.assertFalse((month_root / "archive" / "v0002").exists())
|
||||
first_archive_bytes = first_archive.read_bytes()
|
||||
self.assertEqual(current.read_bytes(), first_archive_bytes)
|
||||
self.assertEqual(current.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(len(repository.activated), 2)
|
||||
|
||||
@@ -147,14 +192,9 @@ class CompanyReportIntegrationTests(unittest.TestCase):
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
second_result = json.loads(
|
||||
(month_root / "archive" / "v0002" / "QBD-v0002.result.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
first_result["artifact"]["semantic_sha256"],
|
||||
second_result["artifact"]["semantic_sha256"],
|
||||
second.companies[0].artifact["semantic_sha256"],
|
||||
)
|
||||
serialized = json.dumps(second.to_dict(), ensure_ascii=False)
|
||||
self.assertNotIn("SYN-GROUP-A", serialized)
|
||||
|
||||
@@ -53,6 +53,22 @@ def empty_report():
|
||||
)
|
||||
|
||||
|
||||
def synthetic_built_workbook(
|
||||
work_dir: Path,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
semantic_sha256: str,
|
||||
) -> BuiltWorkbook:
|
||||
path = work_dir / filename
|
||||
path.write_bytes(content)
|
||||
return BuiltWorkbook(
|
||||
path=path,
|
||||
sha256=sha256_file(path),
|
||||
byte_size=path.stat().st_size,
|
||||
summary={"semantic_sha256": semantic_sha256},
|
||||
)
|
||||
|
||||
|
||||
class CompanyReportPublishingTests(unittest.TestCase):
|
||||
def test_publish_archives_prior_current_and_activates_new_version(self):
|
||||
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
|
||||
@@ -135,6 +151,135 @@ class CompanyReportPublishingTests(unittest.TestCase):
|
||||
self.assertEqual(repository.failed[0], reservation)
|
||||
self.assertEqual(repository.failed[1], ErrorCode.PUBLISH_FAILED)
|
||||
|
||||
def test_same_semantic_retry_reuses_first_publication_when_binary_changes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
|
||||
project_root = Path(temp_dir) / "project"
|
||||
output_root = project_root / "outputs" / "company_reports"
|
||||
work_dir = project_root / "staging"
|
||||
project_root.mkdir(parents=True)
|
||||
work_dir.mkdir()
|
||||
report = empty_report()
|
||||
semantic_sha256 = "a" * 64
|
||||
reservation = ReservedReport(46, 4, report.company)
|
||||
repository = FakeRepository()
|
||||
publisher = AtomicReportPublisher(project_root, output_root)
|
||||
|
||||
first_built = synthetic_built_workbook(
|
||||
work_dir,
|
||||
report.filename,
|
||||
b"synthetic-first-workbook",
|
||||
semantic_sha256,
|
||||
)
|
||||
first = publisher.publish(
|
||||
report, reservation, first_built, repository, work_dir
|
||||
)
|
||||
archive_before = first.archive_path.read_bytes()
|
||||
result_before = first.result_path.read_bytes()
|
||||
current_before = first.current_path.read_bytes()
|
||||
|
||||
second_built = synthetic_built_workbook(
|
||||
work_dir,
|
||||
report.filename,
|
||||
b"synthetic-rebuilt-workbook-with-different-bytes",
|
||||
semantic_sha256,
|
||||
)
|
||||
self.assertNotEqual(first_built.sha256, second_built.sha256)
|
||||
second = publisher.publish(
|
||||
report, reservation, second_built, repository, work_dir
|
||||
)
|
||||
|
||||
self.assertEqual(second.archive_path.read_bytes(), archive_before)
|
||||
self.assertEqual(second.result_path.read_bytes(), result_before)
|
||||
self.assertEqual(second.current_path.read_bytes(), current_before)
|
||||
self.assertEqual(second.artifact.sha256, first.artifact.sha256)
|
||||
self.assertEqual(second.result_json.sha256, first.result_json.sha256)
|
||||
self.assertEqual(repository.activated[1].sha256, first.artifact.sha256)
|
||||
self.assertIsNone(repository.failed)
|
||||
|
||||
def test_semantic_mismatch_does_not_replace_existing_publication(self):
|
||||
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
|
||||
project_root = Path(temp_dir) / "project"
|
||||
output_root = project_root / "outputs" / "company_reports"
|
||||
work_dir = project_root / "staging"
|
||||
project_root.mkdir(parents=True)
|
||||
work_dir.mkdir()
|
||||
report = empty_report()
|
||||
reservation = ReservedReport(47, 5, report.company)
|
||||
repository = FakeRepository()
|
||||
publisher = AtomicReportPublisher(project_root, output_root)
|
||||
|
||||
first_built = synthetic_built_workbook(
|
||||
work_dir, report.filename, b"synthetic-first-workbook", "a" * 64
|
||||
)
|
||||
first = publisher.publish(
|
||||
report, reservation, first_built, repository, work_dir
|
||||
)
|
||||
archive_before = first.archive_path.read_bytes()
|
||||
result_before = first.result_path.read_bytes()
|
||||
current_before = first.current_path.read_bytes()
|
||||
|
||||
second_built = synthetic_built_workbook(
|
||||
work_dir, report.filename, b"synthetic-conflicting-workbook", "b" * 64
|
||||
)
|
||||
with self.assertRaises(PublicationError) as caught:
|
||||
publisher.publish(report, reservation, second_built, repository, work_dir)
|
||||
|
||||
self.assertEqual(caught.exception.code, ErrorCode.PUBLISH_FAILED)
|
||||
self.assertEqual(first.archive_path.read_bytes(), archive_before)
|
||||
self.assertEqual(first.result_path.read_bytes(), result_before)
|
||||
self.assertEqual(first.current_path.read_bytes(), current_before)
|
||||
self.assertEqual(repository.failed[0], reservation)
|
||||
self.assertEqual(repository.failed[1], ErrorCode.PUBLISH_FAILED)
|
||||
|
||||
def test_partial_existing_publication_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
|
||||
project_root = Path(temp_dir) / "project"
|
||||
output_root = project_root / "outputs" / "company_reports"
|
||||
work_dir = project_root / "staging"
|
||||
project_root.mkdir(parents=True)
|
||||
work_dir.mkdir()
|
||||
report = empty_report()
|
||||
reservation = ReservedReport(48, 6, report.company)
|
||||
repository = FakeRepository()
|
||||
publisher = AtomicReportPublisher(project_root, output_root)
|
||||
built = synthetic_built_workbook(
|
||||
work_dir, report.filename, b"synthetic-first-workbook", "c" * 64
|
||||
)
|
||||
first = publisher.publish(report, reservation, built, repository, work_dir)
|
||||
first.result_path.unlink()
|
||||
|
||||
with self.assertRaises(PublicationError) as caught:
|
||||
publisher.publish(report, reservation, built, repository, work_dir)
|
||||
|
||||
self.assertEqual(caught.exception.code, ErrorCode.PUBLISH_FAILED)
|
||||
self.assertTrue(first.archive_path.is_file())
|
||||
self.assertFalse(first.result_path.exists())
|
||||
self.assertEqual(repository.failed[0], reservation)
|
||||
|
||||
def test_corrupt_existing_archive_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
|
||||
project_root = Path(temp_dir) / "project"
|
||||
output_root = project_root / "outputs" / "company_reports"
|
||||
work_dir = project_root / "staging"
|
||||
project_root.mkdir(parents=True)
|
||||
work_dir.mkdir()
|
||||
report = empty_report()
|
||||
reservation = ReservedReport(49, 7, report.company)
|
||||
repository = FakeRepository()
|
||||
publisher = AtomicReportPublisher(project_root, output_root)
|
||||
built = synthetic_built_workbook(
|
||||
work_dir, report.filename, b"synthetic-first-workbook", "d" * 64
|
||||
)
|
||||
first = publisher.publish(report, reservation, built, repository, work_dir)
|
||||
first.archive_path.write_bytes(b"corrupted-archive")
|
||||
|
||||
with self.assertRaises(PublicationError) as caught:
|
||||
publisher.publish(report, reservation, built, repository, work_dir)
|
||||
|
||||
self.assertEqual(caught.exception.code, ErrorCode.PUBLISH_FAILED)
|
||||
self.assertEqual(first.current_path.read_bytes(), b"synthetic-first-workbook")
|
||||
self.assertEqual(repository.failed[0], reservation)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user