test(13-05): add failing RED tests for upload conflict semantics and keep-both naming
- TestUploadConflictSemantics: typed upload results across all 4 providers - TestKeepBothNaming: keep_both_name() helper with counter before extension - Tests verify credentials never appear in upload results (T-13-17) - Upload kind values must be MUT_KINDS constants - 16/23 pass (providers already have upload_file); 7 fail (keep_both_name missing)
This commit is contained in:
@@ -1477,3 +1477,520 @@ class TestWebDAVMutableContract:
|
||||
"WebDAVBackend must not import from services.cloud_items — "
|
||||
"only the service layer writes cloud metadata (provider-neutral contract)"
|
||||
)
|
||||
|
||||
|
||||
# ── Phase 13 Plan 05: Upload conflict semantics and keep-both naming ──────────
|
||||
|
||||
|
||||
class TestUploadConflictSemantics:
|
||||
"""TDD RED — Plan 05 Task 1: typed conflict and retryable-error upload results.
|
||||
|
||||
Verifies that all four provider adapters normalize upload outcomes into the
|
||||
shared typed vocabulary:
|
||||
- success → {kind: 'uploaded', reason: 'created', provider_item_id, name, parent_ref, size}
|
||||
- conflict → {kind: 'conflict', reason: 'name_collision', existing_name}
|
||||
- transient → {kind: 'offline', reason: 'provider_offline'}
|
||||
- reauth → {kind: 'reauth_required', reason: 'token_expired'|'invalid_grant'}
|
||||
|
||||
Keep-both naming (counter before extension) is tested on the shared utility
|
||||
and the upload route; the adapter layer only returns 'conflict' — the
|
||||
naming decision is the service layer's responsibility.
|
||||
|
||||
D-03, D-04, T-13-16, T-13-18 (SSRF guard preserved in WebDAV mutable path).
|
||||
"""
|
||||
|
||||
# ── Google Drive upload ────────────────────────────────────────────────────
|
||||
|
||||
def _make_google_backend(self):
|
||||
from storage.google_drive_backend import GoogleDriveBackend
|
||||
return GoogleDriveBackend({
|
||||
"access_token": "gd_tok_13",
|
||||
"refresh_token": "gd_ref_13",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client_id_13",
|
||||
"client_secret": "client_secret_13",
|
||||
})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_success_returns_typed_result(self):
|
||||
"""GoogleDriveBackend.upload_file success returns typed uploaded result.
|
||||
|
||||
D-03: No silent overwrite — callers must pre-check; adapter must return
|
||||
typed 'uploaded' result dict so service layer can proceed with reconcile.
|
||||
"""
|
||||
from storage.google_drive_backend import GoogleDriveBackend
|
||||
from storage.cloud_base import MUT_KIND_UPLOADED
|
||||
|
||||
backend = self._make_google_backend()
|
||||
fake_service = MagicMock()
|
||||
fake_service.files().create().execute.return_value = {
|
||||
"id": "gd_new_file_id",
|
||||
"name": "report.pdf",
|
||||
"size": "1024",
|
||||
}
|
||||
backend._get_service = MagicMock(return_value=fake_service)
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None,
|
||||
filename="report.pdf",
|
||||
content=b"%PDF-1.4 fake",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), "upload_file must return a dict"
|
||||
assert result.get("kind") == MUT_KIND_UPLOADED, (
|
||||
f"Expected kind='uploaded', got {result.get('kind')!r}"
|
||||
)
|
||||
assert result.get("provider_item_id") == "gd_new_file_id"
|
||||
assert result.get("name") == "report.pdf"
|
||||
assert "size" in result, "Result must include size"
|
||||
# Security: credentials must never appear in the result dict
|
||||
for forbidden in ("access_token", "refresh_token", "credentials_enc"):
|
||||
assert forbidden not in result, (
|
||||
f"upload result must not expose {forbidden!r} (T-13-17)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_transient_error_returns_offline_kind(self):
|
||||
"""GoogleDriveBackend.upload_file on provider 503 returns kind='offline'.
|
||||
|
||||
D-04: A transient provider error must pause the queue for user retry,
|
||||
not silently skip. The result must be typed so the service can present
|
||||
the 'Retry / Skip / Cancel all' dialog.
|
||||
"""
|
||||
from storage.google_drive_backend import GoogleDriveBackend
|
||||
from storage.cloud_base import MUT_KIND_OFFLINE
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
backend = self._make_google_backend()
|
||||
fake_service = MagicMock()
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status = 503
|
||||
fake_service.files().create().execute.side_effect = HttpError(
|
||||
resp=fake_resp, content=b'{"error":{"message":"Service Unavailable"}}'
|
||||
)
|
||||
backend._get_service = MagicMock(return_value=fake_service)
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None,
|
||||
filename="report.pdf",
|
||||
content=b"data",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), "upload_file must return normalized dict on error"
|
||||
assert result.get("kind") in (MUT_KIND_OFFLINE, "error"), (
|
||||
f"Expected kind='offline' or 'error' for transient failure, got {result.get('kind')!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_auth_error_returns_reauth_kind(self):
|
||||
"""GoogleDriveBackend.upload_file on 401 returns kind='reauth_required'.
|
||||
|
||||
CONN-02: Token expiry during upload must surface as reauth so the service
|
||||
layer can hand off refreshed credentials and retry.
|
||||
"""
|
||||
from storage.google_drive_backend import GoogleDriveBackend
|
||||
from storage.cloud_base import MUT_KIND_REAUTH
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
backend = self._make_google_backend()
|
||||
fake_service = MagicMock()
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status = 401
|
||||
fake_service.files().create().execute.side_effect = HttpError(
|
||||
resp=fake_resp, content=b'{"error":{"message":"Invalid Credentials"}}'
|
||||
)
|
||||
backend._get_service = MagicMock(return_value=fake_service)
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None,
|
||||
filename="report.pdf",
|
||||
content=b"data",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), "upload_file must return normalized dict on auth error"
|
||||
assert result.get("kind") == MUT_KIND_REAUTH, (
|
||||
f"Expected kind='reauth_required' for 401, got {result.get('kind')!r}"
|
||||
)
|
||||
|
||||
# ── OneDrive upload ────────────────────────────────────────────────────────
|
||||
|
||||
def _make_onedrive_backend(self):
|
||||
from storage.onedrive_backend import OneDriveBackend
|
||||
return OneDriveBackend({
|
||||
"access_token": "od_tok_13",
|
||||
"refresh_token": "od_ref_13",
|
||||
"expires_at": "2099-01-01T00:00:00",
|
||||
})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onedrive_upload_success_returns_typed_result(self):
|
||||
"""OneDriveBackend.upload_file success returns typed uploaded result.
|
||||
|
||||
D-03: Uses conflictBehavior=fail so server-detected conflicts return
|
||||
the conflict kind rather than silently overwriting.
|
||||
"""
|
||||
from storage.onedrive_backend import OneDriveBackend
|
||||
from storage.cloud_base import MUT_KIND_UPLOADED
|
||||
|
||||
backend = self._make_onedrive_backend()
|
||||
session_resp = MagicMock()
|
||||
session_resp.is_success = True
|
||||
session_resp.json.return_value = {"uploadUrl": "https://upload.microsoft.com/session123"}
|
||||
|
||||
chunk_resp = MagicMock()
|
||||
chunk_resp.status_code = 201
|
||||
chunk_resp.is_success = True
|
||||
chunk_resp.json.return_value = {"id": "od_new_file_id"}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.post = AsyncMock(return_value=session_resp)
|
||||
mock_client.put = AsyncMock(return_value=chunk_resp)
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None,
|
||||
filename="doc.pdf",
|
||||
content=b"%PDF-1.4",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), "upload_file must return a dict"
|
||||
assert result.get("kind") == MUT_KIND_UPLOADED, (
|
||||
f"Expected kind='uploaded', got {result.get('kind')!r}"
|
||||
)
|
||||
assert result.get("provider_item_id") == "od_new_file_id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onedrive_upload_conflict_returns_typed_conflict_kind(self):
|
||||
"""OneDriveBackend.upload_file 409 on session create returns kind='conflict'.
|
||||
|
||||
D-03: conflictBehavior=fail means OneDrive returns 409 on same-name file.
|
||||
The adapter must translate this into the typed conflict result — not raise.
|
||||
"""
|
||||
from storage.onedrive_backend import OneDriveBackend
|
||||
from storage.cloud_base import MUT_KIND_CONFLICT, MUT_REASON_NAME_COLLISION
|
||||
|
||||
backend = self._make_onedrive_backend()
|
||||
session_resp = MagicMock()
|
||||
session_resp.is_success = False
|
||||
session_resp.status_code = 409
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.post = AsyncMock(return_value=session_resp)
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None,
|
||||
filename="existing.pdf",
|
||||
content=b"data",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("kind") == MUT_KIND_CONFLICT, (
|
||||
f"Expected kind='conflict' for 409, got {result.get('kind')!r}"
|
||||
)
|
||||
assert result.get("reason") == MUT_REASON_NAME_COLLISION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onedrive_upload_never_writes_cloud_items(self):
|
||||
"""OneDriveBackend.upload_file must not import or call cloud_items service.
|
||||
|
||||
T-13-12: Only the service layer writes to cloud_items. Provider adapters
|
||||
must never call reconcile_cloud_listing or upsert_cloud_item directly.
|
||||
"""
|
||||
import importlib
|
||||
spec = importlib.util.find_spec("storage.onedrive_backend")
|
||||
if spec and spec.origin:
|
||||
with open(spec.origin) as f:
|
||||
source = f.read()
|
||||
assert "from services.cloud_items" not in source, (
|
||||
"OneDriveBackend must not import from services.cloud_items (T-13-12)"
|
||||
)
|
||||
assert "import cloud_items" not in source, (
|
||||
"OneDriveBackend must not import cloud_items directly (T-13-12)"
|
||||
)
|
||||
|
||||
# ── WebDAV upload ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_webdav_backend(self):
|
||||
from storage.webdav_backend import WebDAVBackend
|
||||
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||
with patch("webdav3.client.Client"):
|
||||
backend = WebDAVBackend.__new__(WebDAVBackend)
|
||||
backend._server_url = "https://dav.example.com/dav/"
|
||||
backend._client = MagicMock()
|
||||
return backend
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webdav_upload_success_returns_typed_result(self):
|
||||
"""WebDAVBackend.upload_file success returns typed uploaded result."""
|
||||
from storage.webdav_backend import WebDAVBackend
|
||||
from storage.cloud_base import MUT_KIND_UPLOADED
|
||||
|
||||
backend = self._make_webdav_backend()
|
||||
backend._client.upload_to.return_value = None
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref="Documents",
|
||||
filename="report.pdf",
|
||||
content=b"%PDF-1.4",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("kind") == MUT_KIND_UPLOADED, (
|
||||
f"Expected kind='uploaded', got {result.get('kind')!r}"
|
||||
)
|
||||
assert result.get("provider_item_id") == "Documents/report.pdf"
|
||||
assert result.get("name") == "report.pdf"
|
||||
assert result.get("size") == len(b"%PDF-1.4")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webdav_upload_provider_error_returns_error_kind(self):
|
||||
"""WebDAVBackend.upload_file on write failure returns typed error result."""
|
||||
from storage.webdav_backend import WebDAVBackend
|
||||
from storage.cloud_base import MUT_KIND_ERROR, MUT_KIND_OFFLINE
|
||||
|
||||
backend = self._make_webdav_backend()
|
||||
backend._client.upload_to.side_effect = Exception("connection timeout")
|
||||
|
||||
result = await backend.upload_file(
|
||||
parent_ref="Documents",
|
||||
filename="report.pdf",
|
||||
content=b"data",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(),
|
||||
user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
# Connection timeout maps to offline
|
||||
assert result.get("kind") in (MUT_KIND_OFFLINE, MUT_KIND_ERROR), (
|
||||
f"Unexpected kind: {result.get('kind')!r}"
|
||||
)
|
||||
|
||||
# ── Cross-provider contract ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("provider", ["google_drive", "onedrive", "webdav", "nextcloud"])
|
||||
async def test_upload_result_never_contains_credentials(self, provider):
|
||||
"""Upload results from all providers must never expose credentials (T-13-17)."""
|
||||
forbidden_keys = {
|
||||
"access_token", "refresh_token", "credentials_enc",
|
||||
"client_secret", "client_id", "password",
|
||||
}
|
||||
|
||||
if provider == "google_drive":
|
||||
from storage.google_drive_backend import GoogleDriveBackend
|
||||
from googleapiclient.errors import HttpError
|
||||
backend = self._make_google_backend()
|
||||
fake_service = MagicMock()
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status = 401
|
||||
fake_service.files().create().execute.side_effect = HttpError(
|
||||
resp=fake_resp, content=b"Unauthorized"
|
||||
)
|
||||
backend._get_service = MagicMock(return_value=fake_service)
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
elif provider == "onedrive":
|
||||
backend = self._make_onedrive_backend()
|
||||
err_resp = MagicMock()
|
||||
err_resp.is_success = False
|
||||
err_resp.status_code = 401
|
||||
with patch("httpx.AsyncClient") as mc:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.post = AsyncMock(return_value=err_resp)
|
||||
mc.return_value = mock_client
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
elif provider == "webdav":
|
||||
backend = self._make_webdav_backend()
|
||||
backend._client.upload_to.side_effect = Exception("server error")
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
elif provider == "nextcloud":
|
||||
from storage.nextcloud_backend import NextcloudBackend
|
||||
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||
with patch("webdav3.client.Client"):
|
||||
nc = NextcloudBackend.__new__(NextcloudBackend)
|
||||
nc._server_url = "https://nc.example.com/"
|
||||
nc._client = MagicMock()
|
||||
nc._client.upload_to.side_effect = Exception("error")
|
||||
result = await nc.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), f"{provider}: upload_file must return dict"
|
||||
leaked = forbidden_keys.intersection(result.keys())
|
||||
assert not leaked, (
|
||||
f"{provider}: upload result exposes credential keys: {leaked} (T-13-17)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("provider", ["google_drive", "onedrive", "webdav", "nextcloud"])
|
||||
async def test_upload_result_kind_in_known_kinds(self, provider):
|
||||
"""Upload results from all providers must use only known MUT_KIND_* constants."""
|
||||
from storage.cloud_base import MUT_KINDS
|
||||
|
||||
if provider == "google_drive":
|
||||
backend = self._make_google_backend()
|
||||
fake_service = MagicMock()
|
||||
fake_service.files().create().execute.return_value = {
|
||||
"id": "gd_id", "name": "f.pdf", "size": "8"
|
||||
}
|
||||
backend._get_service = MagicMock(return_value=fake_service)
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
elif provider == "onedrive":
|
||||
backend = self._make_onedrive_backend()
|
||||
sess_r = MagicMock()
|
||||
sess_r.is_success = True
|
||||
sess_r.json.return_value = {"uploadUrl": "https://upload.microsoft.com/sess"}
|
||||
chunk_r = MagicMock()
|
||||
chunk_r.status_code = 201
|
||||
chunk_r.is_success = True
|
||||
chunk_r.json.return_value = {"id": "od_id"}
|
||||
with patch("httpx.AsyncClient") as mc:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.post = AsyncMock(return_value=sess_r)
|
||||
mock_client.put = AsyncMock(return_value=chunk_r)
|
||||
mc.return_value = mock_client
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
elif provider == "webdav":
|
||||
backend = self._make_webdav_backend()
|
||||
backend._client.upload_to.return_value = None
|
||||
result = await backend.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
elif provider == "nextcloud":
|
||||
from storage.nextcloud_backend import NextcloudBackend
|
||||
with patch("storage.webdav_backend.validate_cloud_url"):
|
||||
with patch("webdav3.client.Client"):
|
||||
nc = NextcloudBackend.__new__(NextcloudBackend)
|
||||
nc._server_url = "https://nc.example.com/"
|
||||
nc._client = MagicMock()
|
||||
nc._client.upload_to.return_value = None
|
||||
result = await nc.upload_file(
|
||||
parent_ref=None, filename="f.pdf", content=b"x",
|
||||
content_type="application/pdf",
|
||||
connection_id=_uuid.uuid4(), user_id=_uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), f"{provider}: upload_file must return dict"
|
||||
assert result.get("kind") in MUT_KINDS, (
|
||||
f"{provider}: upload kind {result.get('kind')!r} is not a known MUT_KIND_* constant"
|
||||
)
|
||||
|
||||
|
||||
class TestKeepBothNaming:
|
||||
"""TDD RED — Plan 05 Task 1: keep-both naming format (counter before extension).
|
||||
|
||||
D-05: Keep-both collision names place the counter BEFORE the file extension:
|
||||
'Report.pdf' → 'Report (1).pdf'
|
||||
'Report (1).pdf' → 'Report (2).pdf'
|
||||
'archive.tar.gz' → 'archive (1).tar.gz'
|
||||
'README' → 'README (1)'
|
||||
|
||||
The naming helper lives in services.cloud_operations so the upload queue can
|
||||
use it consistently across all providers.
|
||||
"""
|
||||
|
||||
def test_keep_both_naming_module_exists(self):
|
||||
"""services.cloud_operations must expose a keep-both naming helper."""
|
||||
import services.cloud_operations as svc
|
||||
assert hasattr(svc, "keep_both_name"), (
|
||||
"services.cloud_operations must expose keep_both_name(filename, counter) helper"
|
||||
)
|
||||
|
||||
def test_keep_both_basic_pdf(self):
|
||||
"""Report.pdf + counter 1 → Report (1).pdf (counter before extension)."""
|
||||
from services.cloud_operations import keep_both_name
|
||||
assert keep_both_name("Report.pdf", 1) == "Report (1).pdf"
|
||||
|
||||
def test_keep_both_counter_increments(self):
|
||||
"""Report.pdf + counter 2 → Report (2).pdf."""
|
||||
from services.cloud_operations import keep_both_name
|
||||
assert keep_both_name("Report.pdf", 2) == "Report (2).pdf"
|
||||
|
||||
def test_keep_both_no_extension(self):
|
||||
"""Files without extension: README → README (1)."""
|
||||
from services.cloud_operations import keep_both_name
|
||||
assert keep_both_name("README", 1) == "README (1)"
|
||||
|
||||
def test_keep_both_compound_extension(self):
|
||||
"""Compound extension: archive.tar.gz → archive (1).tar.gz.
|
||||
|
||||
Counter goes before the first dot, not the last.
|
||||
'archive.tar.gz' stem='archive', suffix='.tar.gz'.
|
||||
"""
|
||||
from services.cloud_operations import keep_both_name
|
||||
result = keep_both_name("archive.tar.gz", 1)
|
||||
# The counter must go before the extension (not appended after)
|
||||
assert result.endswith(".tar.gz"), (
|
||||
f"Compound extension must be preserved: got {result!r}"
|
||||
)
|
||||
assert "(1)" in result, f"Counter must be present: got {result!r}"
|
||||
|
||||
def test_keep_both_preserves_existing_counter(self):
|
||||
"""If name already has a counter suffix, bump the number."""
|
||||
from services.cloud_operations import keep_both_name
|
||||
# Should produce the passed counter value regardless
|
||||
assert keep_both_name("Report (1).pdf", 2) == "Report (1) (2).pdf"
|
||||
|
||||
def test_keep_both_spaces_in_name(self):
|
||||
"""Filenames with spaces are handled correctly."""
|
||||
from services.cloud_operations import keep_both_name
|
||||
assert keep_both_name("My Document.docx", 1) == "My Document (1).docx"
|
||||
|
||||
Reference in New Issue
Block a user