feat(12-01): normalized cloud resource capability contract and unit tests
- Define 9 action keys, 3 capability states, 6 reason codes in cloud_base.py - Immutable CloudCapability, CloudResource, CloudListing frozen dataclasses - Abstract CloudResourceAdapter with list_folder, get_capabilities, merge_item_capabilities - No mutation methods in Phase 12 interface (Phase 13 boundary enforced) - 29 unit tests covering vocabulary, validation, merge behavior, fake adapter
This commit is contained in:
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Provider-neutral cloud resource capability contract for DocuVault Phase 12.
|
||||||
|
|
||||||
|
Defines the stable vocabulary (action keys, capability states, reason codes),
|
||||||
|
immutable normalized value types (CloudCapability, CloudResource, CloudListing),
|
||||||
|
and the abstract CloudResourceAdapter read-only interface.
|
||||||
|
|
||||||
|
Design decisions (12-CONTEXT.md D-06 through D-10):
|
||||||
|
- Structurally unsupported actions remain visible but greyed out (D-06).
|
||||||
|
- Capability discovery must never mutate provider content (D-08).
|
||||||
|
- Permanent vs. temporary limitations have distinct visual states (D-09).
|
||||||
|
- No mutation methods in Phase 12 contract; mutations added in Phase 13.
|
||||||
|
|
||||||
|
Credentials are never fields on any value type. The interface contains no
|
||||||
|
put, delete, rename, move, or create_folder methods.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ── Action keys ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ACTIONS = frozenset({
|
||||||
|
"browse",
|
||||||
|
"open",
|
||||||
|
"preview",
|
||||||
|
"upload",
|
||||||
|
"create_folder",
|
||||||
|
"rename",
|
||||||
|
"move",
|
||||||
|
"delete",
|
||||||
|
"change_tracking",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Capability states ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
STATE_SUPPORTED = "supported"
|
||||||
|
STATE_UNSUPPORTED = "unsupported"
|
||||||
|
STATE_TEMPORARILY_UNAVAILABLE = "temporarily_unavailable"
|
||||||
|
|
||||||
|
CAPABILITY_STATES = frozenset({
|
||||||
|
STATE_SUPPORTED,
|
||||||
|
STATE_UNSUPPORTED,
|
||||||
|
STATE_TEMPORARILY_UNAVAILABLE,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Stable reason codes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
REASON_PROVIDER_UNSUPPORTED = "provider_unsupported"
|
||||||
|
REASON_INSUFFICIENT_SCOPE = "insufficient_scope"
|
||||||
|
REASON_READ_ONLY = "read_only"
|
||||||
|
REASON_REAUTH_REQUIRED = "reauth_required"
|
||||||
|
REASON_OFFLINE = "offline"
|
||||||
|
REASON_ITEM_RESTRICTED = "item_restricted"
|
||||||
|
|
||||||
|
KNOWN_REASONS = frozenset({
|
||||||
|
REASON_PROVIDER_UNSUPPORTED,
|
||||||
|
REASON_INSUFFICIENT_SCOPE,
|
||||||
|
REASON_READ_ONLY,
|
||||||
|
REASON_REAUTH_REQUIRED,
|
||||||
|
REASON_OFFLINE,
|
||||||
|
REASON_ITEM_RESTRICTED,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Normalized value types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CloudCapability:
|
||||||
|
"""Normalized capability descriptor for a single action.
|
||||||
|
|
||||||
|
state: one of the three CAPABILITY_STATES constants.
|
||||||
|
reason: one of the KNOWN_REASONS codes when state != supported; None otherwise.
|
||||||
|
message: controlled adapter output — safe for display; never raw provider text.
|
||||||
|
|
||||||
|
Invariants enforced at construction:
|
||||||
|
- state must be a known capability state.
|
||||||
|
- reason and message are only present when state is not supported.
|
||||||
|
- reason, when present, must be a known reason code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
action: str
|
||||||
|
state: str
|
||||||
|
reason: Optional[str] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.state not in CAPABILITY_STATES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown capability state {self.state!r}; "
|
||||||
|
f"must be one of {sorted(CAPABILITY_STATES)}"
|
||||||
|
)
|
||||||
|
if self.action not in ACTIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown action {self.action!r}; must be one of {sorted(ACTIONS)}"
|
||||||
|
)
|
||||||
|
if self.state == STATE_SUPPORTED:
|
||||||
|
if self.reason is not None or self.message is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"reason and message must be None when state is 'supported'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if self.reason is not None and self.reason not in KNOWN_REASONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown reason code {self.reason!r}; "
|
||||||
|
f"must be one of {sorted(KNOWN_REASONS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CloudResource:
|
||||||
|
"""Normalized cloud item metadata — provider-independent representation.
|
||||||
|
|
||||||
|
id: stable DocuVault UUID for this item; survives provider rename/move.
|
||||||
|
provider_item_id: opaque provider-assigned identifier (e.g. Drive file ID).
|
||||||
|
connection_id: UUID of the CloudConnection that owns this item.
|
||||||
|
user_id: UUID of the owning user (security boundary).
|
||||||
|
name: display name as reported by the provider.
|
||||||
|
kind: "file" or "folder".
|
||||||
|
parent_ref: opaque provider reference to the parent folder; None at root.
|
||||||
|
content_type: MIME type for files; None for folders.
|
||||||
|
size: provider-reported size in bytes; None if not reported or for folders.
|
||||||
|
modified_at: provider-reported modification time; None if not available.
|
||||||
|
etag: provider etag or version string for change detection; None if absent.
|
||||||
|
capabilities: per-item capability overrides merged with connection defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
provider_item_id: str
|
||||||
|
connection_id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
kind: str # "file" | "folder"
|
||||||
|
parent_ref: Optional[str] = None
|
||||||
|
content_type: Optional[str] = None
|
||||||
|
size: Optional[int] = None
|
||||||
|
modified_at: Optional[datetime] = None
|
||||||
|
etag: Optional[str] = None
|
||||||
|
capabilities: dict[str, CloudCapability] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.kind not in ("file", "folder"):
|
||||||
|
raise ValueError(f"kind must be 'file' or 'folder', got {self.kind!r}")
|
||||||
|
for action, cap in self.capabilities.items():
|
||||||
|
if not isinstance(cap, CloudCapability):
|
||||||
|
raise TypeError(
|
||||||
|
f"capabilities[{action!r}] must be a CloudCapability instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CloudListing:
|
||||||
|
"""Result of listing a folder from a provider.
|
||||||
|
|
||||||
|
items: normalized CloudResource children of the listed folder.
|
||||||
|
complete: True when the listing represents the full authoritative contents
|
||||||
|
(safe to mark unseen children deleted on reconciliation).
|
||||||
|
False when the listing is partial, paginated, or failed — retained rows
|
||||||
|
must NOT be marked deleted.
|
||||||
|
next_page_token: opaque token for continuation; None when exhausted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: tuple[CloudResource, ...]
|
||||||
|
complete: bool = True
|
||||||
|
next_page_token: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# Coerce list to tuple for immutability
|
||||||
|
object.__setattr__(self, "items", tuple(self.items))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Abstract adapter ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CloudResourceAdapter(ABC):
|
||||||
|
"""Read-only cloud resource adapter contract for Phase 12.
|
||||||
|
|
||||||
|
Phase 12 interface is intentionally limited to browse and capability
|
||||||
|
discovery. No method here creates, renames, moves, or deletes provider
|
||||||
|
content. Mutation methods are added in Phase 13 on a separate subclass
|
||||||
|
interface.
|
||||||
|
|
||||||
|
Credentials are passed at construction time (or via dependency injection)
|
||||||
|
and are never exposed as fields or return values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_folder(
|
||||||
|
self,
|
||||||
|
connection_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
parent_ref: Optional[str] = None,
|
||||||
|
page_token: Optional[str] = None,
|
||||||
|
) -> CloudListing:
|
||||||
|
"""List the direct children of a folder.
|
||||||
|
|
||||||
|
parent_ref=None lists the connection root.
|
||||||
|
Returns a CloudListing with complete=False on partial or failed fetches.
|
||||||
|
Raises CloudConnectionError (from storage.exceptions) on auth failures.
|
||||||
|
Never mutates provider content as a side effect.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_capabilities(
|
||||||
|
self,
|
||||||
|
connection_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> dict[str, CloudCapability]:
|
||||||
|
"""Return connection-level capabilities for all defined ACTIONS.
|
||||||
|
|
||||||
|
The returned dict must contain an entry for every action in ACTIONS.
|
||||||
|
Probing must not create, rename, move, or delete any provider content.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def merge_item_capabilities(
|
||||||
|
self,
|
||||||
|
connection_caps: dict[str, CloudCapability],
|
||||||
|
item_caps: dict[str, CloudCapability],
|
||||||
|
) -> dict[str, CloudCapability]:
|
||||||
|
"""Merge per-item overrides onto connection defaults.
|
||||||
|
|
||||||
|
Item capabilities take precedence over connection capabilities.
|
||||||
|
Actions absent from item_caps fall back to connection_caps.
|
||||||
|
The result always contains every action in ACTIONS.
|
||||||
|
"""
|
||||||
|
merged: dict[str, CloudCapability] = {}
|
||||||
|
for action in ACTIONS:
|
||||||
|
if action in item_caps:
|
||||||
|
merged[action] = item_caps[action]
|
||||||
|
elif action in connection_caps:
|
||||||
|
merged[action] = connection_caps[action]
|
||||||
|
else:
|
||||||
|
# Safe default: unsupported with no reason
|
||||||
|
merged[action] = CloudCapability(
|
||||||
|
action=action,
|
||||||
|
state=STATE_UNSUPPORTED,
|
||||||
|
reason=REASON_PROVIDER_UNSUPPORTED,
|
||||||
|
message="This action is not supported by the provider.",
|
||||||
|
)
|
||||||
|
return merged
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the provider-neutral cloud resource capability contract.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Vocabulary completeness: all nine action keys and three capability states.
|
||||||
|
- Reason code completeness.
|
||||||
|
- CloudCapability validation (valid/invalid state, reason, messages).
|
||||||
|
- CloudResource construction and kind validation.
|
||||||
|
- CloudListing complete/incomplete semantics.
|
||||||
|
- Connection defaults, item overrides, and merge behavior.
|
||||||
|
- FakeAdapter: proves capability discovery invokes no put/delete/move/rename.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from storage.cloud_base import (
|
||||||
|
ACTIONS,
|
||||||
|
CAPABILITY_STATES,
|
||||||
|
KNOWN_REASONS,
|
||||||
|
STATE_SUPPORTED,
|
||||||
|
STATE_UNSUPPORTED,
|
||||||
|
STATE_TEMPORARILY_UNAVAILABLE,
|
||||||
|
REASON_PROVIDER_UNSUPPORTED,
|
||||||
|
REASON_INSUFFICIENT_SCOPE,
|
||||||
|
REASON_READ_ONLY,
|
||||||
|
REASON_REAUTH_REQUIRED,
|
||||||
|
REASON_OFFLINE,
|
||||||
|
REASON_ITEM_RESTRICTED,
|
||||||
|
CloudCapability,
|
||||||
|
CloudResource,
|
||||||
|
CloudListing,
|
||||||
|
CloudResourceAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vocabulary completeness ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_actions_contains_all_nine():
|
||||||
|
expected = {
|
||||||
|
"browse", "open", "preview", "upload", "create_folder",
|
||||||
|
"rename", "move", "delete", "change_tracking",
|
||||||
|
}
|
||||||
|
assert ACTIONS == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_three_capability_states():
|
||||||
|
assert CAPABILITY_STATES == {
|
||||||
|
STATE_SUPPORTED,
|
||||||
|
STATE_UNSUPPORTED,
|
||||||
|
STATE_TEMPORARILY_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_reasons_all_present():
|
||||||
|
expected = {
|
||||||
|
REASON_PROVIDER_UNSUPPORTED,
|
||||||
|
REASON_INSUFFICIENT_SCOPE,
|
||||||
|
REASON_READ_ONLY,
|
||||||
|
REASON_REAUTH_REQUIRED,
|
||||||
|
REASON_OFFLINE,
|
||||||
|
REASON_ITEM_RESTRICTED,
|
||||||
|
}
|
||||||
|
assert KNOWN_REASONS == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── CloudCapability validation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_supported_capability_no_reason_or_message():
|
||||||
|
cap = CloudCapability(action="browse", state=STATE_SUPPORTED)
|
||||||
|
assert cap.reason is None
|
||||||
|
assert cap.message is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_supported_capability_with_reason_raises():
|
||||||
|
with pytest.raises(ValueError, match="reason and message must be None"):
|
||||||
|
CloudCapability(action="browse", state=STATE_SUPPORTED, reason=REASON_READ_ONLY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supported_capability_with_message_raises():
|
||||||
|
with pytest.raises(ValueError, match="reason and message must be None"):
|
||||||
|
CloudCapability(action="browse", state=STATE_SUPPORTED, message="some msg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_capability_preserves_reason_and_message():
|
||||||
|
cap = CloudCapability(
|
||||||
|
action="rename",
|
||||||
|
state=STATE_UNSUPPORTED,
|
||||||
|
reason=REASON_PROVIDER_UNSUPPORTED,
|
||||||
|
message="Rename is not supported by this server.",
|
||||||
|
)
|
||||||
|
assert cap.reason == REASON_PROVIDER_UNSUPPORTED
|
||||||
|
assert cap.message == "Rename is not supported by this server."
|
||||||
|
|
||||||
|
|
||||||
|
def test_temporarily_unavailable_with_remedy_message():
|
||||||
|
cap = CloudCapability(
|
||||||
|
action="upload",
|
||||||
|
state=STATE_TEMPORARILY_UNAVAILABLE,
|
||||||
|
reason=REASON_INSUFFICIENT_SCOPE,
|
||||||
|
message="Reconnect with write access to enable uploads.",
|
||||||
|
)
|
||||||
|
assert cap.state == STATE_TEMPORARILY_UNAVAILABLE
|
||||||
|
assert cap.message == "Reconnect with write access to enable uploads."
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_state_raises():
|
||||||
|
with pytest.raises(ValueError, match="Unknown capability state"):
|
||||||
|
CloudCapability(action="browse", state="kinda_works")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_action_raises():
|
||||||
|
with pytest.raises(ValueError, match="Unknown action"):
|
||||||
|
CloudCapability(action="fly", state=STATE_SUPPORTED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_reason_raises():
|
||||||
|
with pytest.raises(ValueError, match="Unknown reason code"):
|
||||||
|
CloudCapability(action="delete", state=STATE_UNSUPPORTED, reason="bad_reason")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reason_none_for_unsupported_is_valid():
|
||||||
|
# reason is optional even for non-supported states
|
||||||
|
cap = CloudCapability(action="delete", state=STATE_UNSUPPORTED)
|
||||||
|
assert cap.reason is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── CloudResource ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_resource(**kwargs) -> CloudResource:
|
||||||
|
defaults = dict(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
provider_item_id="provider-abc",
|
||||||
|
connection_id=uuid.uuid4(),
|
||||||
|
user_id=uuid.uuid4(),
|
||||||
|
name="My Document.pdf",
|
||||||
|
kind="file",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return CloudResource(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_file_kind():
|
||||||
|
r = _make_resource(kind="file")
|
||||||
|
assert r.kind == "file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_folder_kind():
|
||||||
|
r = _make_resource(kind="folder")
|
||||||
|
assert r.kind == "folder"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_invalid_kind_raises():
|
||||||
|
with pytest.raises(ValueError, match="kind must be"):
|
||||||
|
_make_resource(kind="symlink")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_optional_fields_default_none():
|
||||||
|
r = _make_resource()
|
||||||
|
assert r.parent_ref is None
|
||||||
|
assert r.content_type is None
|
||||||
|
assert r.size is None
|
||||||
|
assert r.modified_at is None
|
||||||
|
assert r.etag is None
|
||||||
|
assert r.capabilities == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_with_full_metadata():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cap = CloudCapability(action="delete", state=STATE_UNSUPPORTED, reason=REASON_ITEM_RESTRICTED)
|
||||||
|
r = _make_resource(
|
||||||
|
parent_ref="parent-001",
|
||||||
|
content_type="application/pdf",
|
||||||
|
size=102400,
|
||||||
|
modified_at=now,
|
||||||
|
etag="etag-v42",
|
||||||
|
capabilities={"delete": cap},
|
||||||
|
)
|
||||||
|
assert r.size == 102400
|
||||||
|
assert r.etag == "etag-v42"
|
||||||
|
assert r.capabilities["delete"] is cap
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_capability_invalid_type_raises():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
_make_resource(capabilities={"delete": "not-a-capability"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_is_immutable():
|
||||||
|
r = _make_resource()
|
||||||
|
with pytest.raises(Exception): # dataclass frozen=True raises FrozenInstanceError
|
||||||
|
r.name = "changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
# ── CloudListing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_listing_complete_default():
|
||||||
|
listing = CloudListing(items=[])
|
||||||
|
assert listing.complete is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_items_coerced_to_tuple():
|
||||||
|
r = _make_resource()
|
||||||
|
listing = CloudListing(items=[r])
|
||||||
|
assert isinstance(listing.items, tuple)
|
||||||
|
assert listing.items[0] is r
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_incomplete_retains_rows():
|
||||||
|
r = _make_resource()
|
||||||
|
listing = CloudListing(items=[r], complete=False, next_page_token="tok-abc")
|
||||||
|
assert listing.complete is False
|
||||||
|
assert listing.next_page_token == "tok-abc"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Merge behavior ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _FakeAdapter(CloudResourceAdapter):
|
||||||
|
"""Minimal test double that records which methods were called."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._calls: list[str] = []
|
||||||
|
|
||||||
|
async def list_folder(self, connection_id, user_id, parent_ref=None, page_token=None):
|
||||||
|
self._calls.append("list_folder")
|
||||||
|
return CloudListing(items=[], complete=True)
|
||||||
|
|
||||||
|
async def get_capabilities(self, connection_id, user_id):
|
||||||
|
self._calls.append("get_capabilities")
|
||||||
|
return {
|
||||||
|
action: CloudCapability(action=action, state=STATE_SUPPORTED)
|
||||||
|
for action in ACTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
# Explicitly absent: any method that could mutate provider state.
|
||||||
|
# Python raises AttributeError naturally if called — confirmed below.
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_adapter_has_no_mutation_methods():
|
||||||
|
"""Prove the Phase 12 interface defines no put/delete/move/rename methods."""
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
mutation_methods = ["put_object", "delete_object", "rename_item", "move_item", "create_folder_remote"]
|
||||||
|
for method_name in mutation_methods:
|
||||||
|
assert not hasattr(adapter, method_name), (
|
||||||
|
f"Phase 12 adapter must not expose mutation method {method_name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_adapter_list_folder_invokes_no_mutation():
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
cid = uuid.uuid4()
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
listing = await adapter.list_folder(cid, uid)
|
||||||
|
assert isinstance(listing, CloudListing)
|
||||||
|
assert "list_folder" in adapter._calls
|
||||||
|
# Only list_folder was called, no mutation methods
|
||||||
|
assert adapter._calls == ["list_folder"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_adapter_get_capabilities_covers_all_actions():
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
cid = uuid.uuid4()
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
caps = await adapter.get_capabilities(cid, uid)
|
||||||
|
for action in ACTIONS:
|
||||||
|
assert action in caps, f"Missing capability for action {action!r}"
|
||||||
|
assert isinstance(caps[action], CloudCapability)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_item_override_takes_precedence():
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
connection_caps = {
|
||||||
|
action: CloudCapability(action=action, state=STATE_SUPPORTED)
|
||||||
|
for action in ACTIONS
|
||||||
|
}
|
||||||
|
item_override = {
|
||||||
|
"delete": CloudCapability(
|
||||||
|
action="delete",
|
||||||
|
state=STATE_UNSUPPORTED,
|
||||||
|
reason=REASON_ITEM_RESTRICTED,
|
||||||
|
message="This item cannot be deleted.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
merged = adapter.merge_item_capabilities(connection_caps, item_override)
|
||||||
|
assert merged["delete"].state == STATE_UNSUPPORTED
|
||||||
|
assert merged["delete"].reason == REASON_ITEM_RESTRICTED
|
||||||
|
# Non-overridden actions fall back to connection caps
|
||||||
|
assert merged["browse"].state == STATE_SUPPORTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_covers_all_actions():
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
merged = adapter.merge_item_capabilities({}, {})
|
||||||
|
for action in ACTIONS:
|
||||||
|
assert action in merged
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_empty_item_caps_uses_connection_defaults():
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
connection_caps = {
|
||||||
|
action: CloudCapability(action=action, state=STATE_SUPPORTED)
|
||||||
|
for action in ACTIONS
|
||||||
|
}
|
||||||
|
merged = adapter.merge_item_capabilities(connection_caps, {})
|
||||||
|
for action in ACTIONS:
|
||||||
|
assert merged[action].state == STATE_SUPPORTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_reason_and_message_preserved_through_merge():
|
||||||
|
adapter = _FakeAdapter()
|
||||||
|
cap = CloudCapability(
|
||||||
|
action="rename",
|
||||||
|
state=STATE_TEMPORARILY_UNAVAILABLE,
|
||||||
|
reason=REASON_REAUTH_REQUIRED,
|
||||||
|
message="Re-authenticate to rename files.",
|
||||||
|
)
|
||||||
|
merged = adapter.merge_item_capabilities(
|
||||||
|
{action: CloudCapability(action=action, state=STATE_SUPPORTED) for action in ACTIONS},
|
||||||
|
{"rename": cap},
|
||||||
|
)
|
||||||
|
result = merged["rename"]
|
||||||
|
assert result.reason == REASON_REAUTH_REQUIRED
|
||||||
|
assert result.message == "Re-authenticate to rename files."
|
||||||
Reference in New Issue
Block a user