mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-03 05:18:22 +00:00
Green on arrival: both fall out of the summary the run now returns and of the guarded site lookup. Pinned after verifying they bite — an unguarded DomnaSites[name] completes an "ECO" run that renames files under a different site's alias. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
355 lines
12 KiB
Python
355 lines
12 KiB
Python
"""Tests for the SharePoint Renamer Lambda handler on the app-owned-task lane.
|
|
|
|
A real Lambda event goes through the real ``@task_handler`` decorator against a
|
|
real ephemeral PostgreSQL database; only SharePoint is faked, and it is faked at
|
|
the **client** boundary with canned Graph responses so the real rename
|
|
orchestrator runs. What the tests assert is therefore what is visible outside
|
|
the module: the rows that land in Postgres, and the calls made to SharePoint.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Generator, Iterator
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from sqlalchemy import Engine
|
|
from sqlmodel import Session
|
|
|
|
from domain.pashub_fetcher.sharepoint_subfolders import SharepointSubfolders
|
|
from domain.tasks.subtasks import SubTaskStatus
|
|
from domain.tasks.tasks import Source, TaskStatus
|
|
from orchestration.sharepoint_renamer_orchestrator import (
|
|
ASSESSMENT_SUBFOLDER,
|
|
BASE_PATH,
|
|
)
|
|
from orchestration.task_orchestrator import TaskOrchestrator
|
|
from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository
|
|
from repositories.tasks.task_postgres_repository import TaskPostgresRepository
|
|
from utilities.aws_lambda.task_handler import task_handler
|
|
|
|
SITE = "SOCIAL_HOUSING_WAVE_3"
|
|
UPRN = "100"
|
|
ADDRESS = "1 High St"
|
|
POSTCODE = "AB1 2CD"
|
|
|
|
ASSESSMENT_PATH = (
|
|
f"{BASE_PATH}/{ADDRESS}, {POSTCODE}"
|
|
f"/{SharepointSubfolders.ASSESSMENT.value}/{ASSESSMENT_SUBFOLDER}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Harness: a TaskOrchestrator on a real ephemeral PostgreSQL database
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Harness:
|
|
orchestrator: TaskOrchestrator
|
|
tasks: TaskPostgresRepository
|
|
subtasks: SubTaskPostgresRepository
|
|
|
|
@contextmanager
|
|
def factory(self) -> Generator[TaskOrchestrator, None, None]:
|
|
yield self.orchestrator
|
|
|
|
|
|
@pytest.fixture
|
|
def harness(db_engine: Engine) -> Iterator[Harness]:
|
|
with Session(db_engine) as session:
|
|
tasks = TaskPostgresRepository(session=session)
|
|
subtasks = SubTaskPostgresRepository(session=session)
|
|
yield Harness(
|
|
orchestrator=TaskOrchestrator(task_repo=tasks, subtask_repo=subtasks),
|
|
tasks=tasks,
|
|
subtasks=subtasks,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake SharePoint: canned Graph responses keyed by path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class FakeSharepoint:
|
|
"""Stands in for DomnaSharepointClient with canned Graph listings. Paths not
|
|
in ``contents`` raise ValueError, exactly as the real client does for a
|
|
folder that does not exist."""
|
|
|
|
contents: dict[str, dict[str, Any]]
|
|
reject: dict[str, Exception] = field(default_factory=dict)
|
|
renamed: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
def get_folders_in_path(self, path: str) -> dict[str, Any]:
|
|
if path not in self.contents:
|
|
raise ValueError(f"not found: {path}")
|
|
return self.contents[path]
|
|
|
|
def rename_file(self, item_id: str, new_name: str) -> None:
|
|
if item_id in self.reject:
|
|
raise self.reject[item_id]
|
|
self.renamed.append((item_id, new_name))
|
|
|
|
|
|
def _file(name: str, item_id: str) -> dict[str, Any]:
|
|
return {"name": name, "id": item_id, "file": {}}
|
|
|
|
|
|
def _one_property_site(*files: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
"""A site holding exactly one property folder, with `files` in its
|
|
assessment folder."""
|
|
return {
|
|
BASE_PATH: {"value": []},
|
|
ASSESSMENT_PATH: {"value": list(files)},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wiring: the real handler body, re-decorated onto the test orchestrator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _install(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
sharepoint: FakeSharepoint,
|
|
*,
|
|
rows: list[tuple[str, str, str]] = [(UPRN, ADDRESS, POSTCODE)],
|
|
) -> None:
|
|
"""Point the handler at the fake SharePoint and a small address list, and
|
|
configure the site the tests target."""
|
|
import applications.sharepoint_renamer.handler as module
|
|
|
|
monkeypatch.setenv(f"{SITE}_SHAREPOINT_ID", "test-site-id")
|
|
monkeypatch.setattr(
|
|
module, "DomnaSharepointClient", lambda _site: sharepoint
|
|
)
|
|
|
|
csv_file = tmp_path / "addresses.csv"
|
|
csv_file.write_text(
|
|
"\n".join(["UPRN,Address,Postcode"] + [",".join(r) for r in rows]) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(module, "CSV_PATH", str(csv_file))
|
|
|
|
|
|
def _handler(harness: Harness) -> Callable[..., Any]:
|
|
"""The real handler body under the real decorator, wired to the test DB."""
|
|
import applications.sharepoint_renamer.handler as module
|
|
|
|
return task_handler(
|
|
task_source="sharepoint_renamer",
|
|
source=Source.SHAREPOINT_SITE,
|
|
orchestrator_cm=harness.factory,
|
|
)(module.handler.__wrapped__)
|
|
|
|
|
|
def _sqs_event(body: dict[str, Any]) -> dict[str, Any]:
|
|
return {"Records": [{"messageId": "msg-1", "body": json.dumps(body)}]}
|
|
|
|
|
|
def _subtask_id(result: Any) -> UUID:
|
|
return UUID(result["tasks"][0]["subtask_id"])
|
|
|
|
|
|
def _task_id(result: Any) -> UUID:
|
|
return UUID(result["tasks"][0]["task_id"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A completed run records what it did
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_completed_subtask_carries_the_run_summary(
|
|
harness: Harness, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Arrange
|
|
sharepoint = FakeSharepoint(
|
|
_one_property_site(
|
|
_file("Survey.pdf", "id-1"),
|
|
_file(f"{UPRN}_High St {POSTCODE}_Done.pdf", "id-2"),
|
|
_file("Front.jpg", "id-3"),
|
|
)
|
|
)
|
|
_install(monkeypatch, tmp_path, sharepoint)
|
|
|
|
# Act
|
|
result = _handler(harness)(_sqs_event({"sharepoint_site": SITE}), None)
|
|
|
|
# Assert
|
|
subtask = harness.subtasks.get(_subtask_id(result))
|
|
assert subtask.status is SubTaskStatus.COMPLETE
|
|
assert subtask.outputs == {
|
|
"result": {
|
|
"renamed": 1,
|
|
"would_rename": 0,
|
|
"skipped_already_canonical": 1,
|
|
"skipped_images": 1,
|
|
"missing_folders": [],
|
|
"failed": [],
|
|
}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A run is attributable: its request, and the site it targeted
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_records_its_request_and_target_site(
|
|
harness: Harness, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""``sharepoint_site`` round-trips through the real ``source`` pgEnum — the
|
|
member the FE's Drizzle migration must add before this deploys."""
|
|
# Arrange
|
|
sharepoint = FakeSharepoint(_one_property_site())
|
|
_install(monkeypatch, tmp_path, sharepoint)
|
|
body = {"sharepoint_site": SITE, "dry_run": True}
|
|
|
|
# Act
|
|
result = _handler(harness)(_sqs_event(body), None)
|
|
|
|
# Assert
|
|
task = harness.tasks.get(_task_id(result))
|
|
assert (task.task_source, task.source, task.source_id) == (
|
|
"sharepoint_renamer",
|
|
Source.SHAREPOINT_SITE,
|
|
SITE,
|
|
)
|
|
assert harness.subtasks.get(_subtask_id(result)).inputs == body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A queue-delivered dry run is actually a dry run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dry_run_in_an_sqs_record_body_renames_nothing(
|
|
harness: Harness, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The request must come from the SQS *record body*, not the raw Lambda
|
|
event. Validating the raw event leaves every unknown key ignored, so
|
|
``dry_run`` fell back to False and every queue-triggered run renamed live
|
|
files — the flag only ever worked on a direct invoke."""
|
|
# Arrange
|
|
sharepoint = FakeSharepoint(_one_property_site(_file("Survey.pdf", "id-1")))
|
|
_install(monkeypatch, tmp_path, sharepoint)
|
|
|
|
# Act
|
|
result = _handler(harness)(
|
|
_sqs_event({"sharepoint_site": SITE, "dry_run": True}), None
|
|
)
|
|
|
|
# Assert
|
|
assert sharepoint.renamed == []
|
|
assert harness.subtasks.get(_subtask_id(result)).outputs == {
|
|
"result": {
|
|
"renamed": 0,
|
|
"would_rename": 1,
|
|
"skipped_already_canonical": 0,
|
|
"skipped_images": 0,
|
|
"missing_folders": [],
|
|
"failed": [],
|
|
}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Partial failures complete, they do not fail the run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_completes_with_rejected_files_and_missing_folders_listed(
|
|
harness: Harness, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""One locked file, or a property with no folder, must not turn a
|
|
productive run red — the failures are the record (ADR-0060)."""
|
|
# Arrange
|
|
sharepoint = FakeSharepoint(
|
|
_one_property_site(
|
|
_file("Locked.pdf", "id-1"), _file("Survey.pdf", "id-2")
|
|
),
|
|
reject={"id-1": PermissionError("locked by another user")},
|
|
)
|
|
_install(
|
|
monkeypatch,
|
|
tmp_path,
|
|
sharepoint,
|
|
rows=[(UPRN, ADDRESS, POSTCODE), ("999", "9 Nowhere Rd", "ZZ9 9ZZ")],
|
|
)
|
|
|
|
# Act
|
|
result = _handler(harness)(_sqs_event({"sharepoint_site": SITE}), None)
|
|
|
|
# Assert
|
|
assert harness.tasks.get(_task_id(result)).status is TaskStatus.COMPLETE
|
|
subtask = harness.subtasks.get(_subtask_id(result))
|
|
assert subtask.status is SubTaskStatus.COMPLETE
|
|
assert subtask.outputs == {
|
|
"result": {
|
|
"renamed": 1,
|
|
"would_rename": 0,
|
|
"skipped_already_canonical": 0,
|
|
"skipped_images": 0,
|
|
"missing_folders": ["999"],
|
|
"failed": [
|
|
{
|
|
"uprn": UPRN,
|
|
"original_name": "Locked.pdf",
|
|
"new_name": f"{UPRN}_{ADDRESS} {POSTCODE}_Locked.pdf",
|
|
"error": "locked by another user",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# An unusable site is rejected before anything is touched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unconfigured_site_fails_the_run_without_touching_sharepoint(
|
|
harness: Harness, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Sites take their id from the environment at import, so every site this
|
|
Lambda does not configure collapses into an alias of the first one. An
|
|
unguarded lookup would return a valid member for the *wrong* site."""
|
|
# Arrange
|
|
sharepoint = FakeSharepoint(_one_property_site(_file("Survey.pdf", "id-1")))
|
|
_install(monkeypatch, tmp_path, sharepoint)
|
|
monkeypatch.delenv("ECO_SHAREPOINT_ID", raising=False)
|
|
|
|
# Act
|
|
result = _handler(harness)(_sqs_event({"sharepoint_site": "ECO"}), None)
|
|
|
|
# Assert
|
|
subtask = harness.subtasks.get(_subtask_id(result))
|
|
assert subtask.status is SubTaskStatus.FAILED
|
|
assert "ECO_SHAREPOINT_ID is unset" in (subtask.outputs or {})["error"]
|
|
assert sharepoint.renamed == []
|
|
|
|
|
|
def test_unrecognised_site_fails_the_run(
|
|
harness: Harness, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Arrange
|
|
sharepoint = FakeSharepoint(_one_property_site(_file("Survey.pdf", "id-1")))
|
|
_install(monkeypatch, tmp_path, sharepoint)
|
|
|
|
# Act
|
|
result = _handler(harness)(_sqs_event({"sharepoint_site": "NOT_A_SITE"}), None)
|
|
|
|
# Assert
|
|
subtask = harness.subtasks.get(_subtask_id(result))
|
|
assert subtask.status is SubTaskStatus.FAILED
|
|
assert "Unrecognised SharePoint site 'NOT_A_SITE'" in (subtask.outputs or {})["error"]
|
|
assert sharepoint.renamed == []
|