Model/tests/applications/sharepoint_renamer/test_handler.py
Daniel Roth be1d6faea0 Complete a rename run that hit locked files or missing folders, and reject an unusable site 🟩
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>
2026-07-29 09:03:43 +00:00

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 == []