Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions _refactored/restapi/operations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from restapi.operations.base import RestBaseOperations
from restapi.operations.catalog_operations import CatalogOperations
from restapi.operations.category_operations import CategoryOperations
from restapi.operations.cms_content_operations import CmsContentOperations, MenuLinkOperations
from restapi.operations.contact_operations import ContactOperations
from restapi.operations.dynamic_content_operations import (
ContentFolderOperations,
Expand Down Expand Up @@ -30,6 +31,7 @@
"ApiKeyOperations",
"CatalogOperations",
"CategoryOperations",
"CmsContentOperations",
"ContentFolderOperations",
"ContentItemOperations",
"ContentPlaceOperations",
Expand All @@ -38,6 +40,7 @@
"CouponOperations",
"EmployeeOperations",
"MemberOperations",
"MenuLinkOperations",
"NotificationsOperations",
"OAuthOperations",
"OrderOperations",
Expand Down
134 changes: 134 additions & 0 deletions _refactored/restapi/operations/cms_content_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""REST API operations for VirtoCommerce CMS content (pages/blogs/themes folders+files)
and menu link lists.

Endpoints verified from Katalon Object Repository
(Object Repository/API/backWebServices/VirtoCommerce.Content/*.rs):

CMS file/folder content (`/api/content/{contentType}/{storeId}`):
POST /folder — create folder (ContentFolderCreate)
POST ?folderUrl={folder} — upload file (multipart) (ContentFileNew)
GET ?relativeUrl={path} — get file/folder data (ContentGet)
GET /search?keyword={kw} — search by keyword (ContentSearch)
GET /move?oldUrl=&newUrl= — rename/move (yes, GET) (ContentMove)
GET /unpack?archivePath=&destPath= — unpack zip archive (ContentUnpack)
DELETE ?urls={path} — delete file or folder (ContentDelete)

Store stats:
GET /api/content/{storeId}/stats — pages/blogs/themes counts (ContentStatsStoreGet)

Menu link lists (`/api/cms/{storeId}/menu`):
GET /api/cms/{storeId}/menu — list all (MenuLinkGet)
GET /api/cms/{storeId}/menu/{listId} — get by id (MenuLinkIdGet)
POST /api/cms/{storeId}/menu — create or update (MenuLinkCreateUpdate)
DELETE /api/cms/{storeId}/menu?listIds= — delete (MenuLinkDelete)
GET /api/cms/{storeId}/menu/checkname?name=&language= — name available (MenuLinkCheckname)
"""

from typing import Any

from restapi.operations.base import RestBaseOperations


class CmsContentOperations(RestBaseOperations):
"""File/folder operations on a store's `pages`, `blogs`, or `themes` content type."""

def _base(self, content_type: str, store_id: str) -> str:
return self._url(f"/api/content/{content_type}/{store_id}")

def get_stats(self, store_id: str) -> dict:
return self._client.get(self._url(f"/api/content/{store_id}/stats"))

def create_folder(self, *, content_type: str, store_id: str, folder_name: str) -> dict | None:
return self._client.post(
f"{self._base(content_type, store_id)}/folder",
json={"name": folder_name, "type": "folder"},
)

def upload_file(
self,
*,
content_type: str,
store_id: str,
folder_url: str,
file_name: str,
file_bytes: bytes,
file_content_type: str = "application/octet-stream",
) -> Any:
return self._client.post_multipart(
self._base(content_type, store_id),
params={"folderUrl": folder_url},
files={"file": (file_name, file_bytes, file_content_type)},
)

def get(self, *, content_type: str, store_id: str, relative_url: str) -> Any:
return self._client.get(
self._base(content_type, store_id),
params={"relativeUrl": relative_url},
)

def search(self, *, content_type: str, store_id: str, keyword: str) -> list[dict]:
return self._client.get(
f"{self._base(content_type, store_id)}/search",
params={"keyword": keyword},
)

def move(self, *, content_type: str, store_id: str, old_url: str, new_url: str) -> Any:
return self._client.get(
f"{self._base(content_type, store_id)}/move",
params={"oldUrl": old_url, "newUrl": new_url},
)

def unpack(self, *, content_type: str, store_id: str, archive_path: str, dest_path: str) -> Any:
return self._client.get(
f"{self._base(content_type, store_id)}/unpack",
params={"archivePath": archive_path, "destPath": dest_path},
)

def delete(self, *, content_type: str, store_id: str, urls: str | list[str]) -> None:
urls_param = urls if isinstance(urls, list) else [urls]
self._client.delete(self._base(content_type, store_id), params={"urls": urls_param})


class MenuLinkOperations(RestBaseOperations):
"""Operations on store menu link lists."""

def _base(self, store_id: str) -> str:
return self._url(f"/api/cms/{store_id}/menu")

def get_all(self, store_id: str) -> list[dict] | None:
return self._client.get(self._base(store_id))

def get_by_id(self, *, store_id: str, list_id: str) -> dict | None:
return self._client.get(f"{self._base(store_id)}/{list_id}")

def create_or_update(
self,
*,
store_id: str,
list_id: str,
name: str,
language: str = "en-US",
menu_links: list[dict] | None = None,
) -> Any:
return self._client.post(
self._base(store_id),
json={
"id": list_id,
"name": name,
"storeId": store_id,
"language": language,
"menuLinks": menu_links or [],
},
)

def delete(self, *, store_id: str, list_ids: str | list[str]) -> None:
# NB: the .rs file documents `?listIds=` but the current platform expects `?ids=`;
# `listIds` triggers a 500 NullReferenceException in the controller.
ids_param = list_ids if isinstance(list_ids, list) else [list_ids]
self._client.delete(self._base(store_id), params={"ids": ids_param})

def checkname(self, *, store_id: str, name: str, language: str = "en-US") -> dict:
return self._client.get(
f"{self._base(store_id)}/checkname",
params={"name": name, "language": language},
)
103 changes: 103 additions & 0 deletions _refactored/tests/restapi/content/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Content module fixtures — operations + factory fixtures + blacklist setup."""

import uuid
from collections.abc import Callable, Generator
from typing import Any

import pytest

from core.clients.rest import RestClient
from core.global_settings import GlobalSettings
from restapi.operations import CmsContentOperations, MenuLinkOperations, SettingsOperations


_FILE_EXTENSIONS_BLACKLIST_SETTING = "VirtoCommerce.Platform.Security.FileExtensionsBlackList"
_FORBIDDEN_EXTENSION = ".exe"


@pytest.fixture
def cms_content_ops(rest_client: RestClient, backend_base_url: str) -> CmsContentOperations:
return CmsContentOperations(rest_client, backend_base_url)


@pytest.fixture
def menu_ops(rest_client: RestClient, backend_base_url: str) -> MenuLinkOperations:
return MenuLinkOperations(rest_client, backend_base_url)


@pytest.fixture
def store_id(global_settings: GlobalSettings) -> str:
return global_settings.store_id


@pytest.fixture
def make_content_folder(
cms_content_ops: CmsContentOperations, store_id: str
) -> Generator[Callable[..., dict], None, None]:
"""Factory: create a folder under a content type, deletes it (and contents) at teardown."""
created: list[tuple[str, str]] = [] # (content_type, folder_name)

def _make(*, content_type: str, name: str | None = None) -> dict[str, Any]:
folder_name = name or f"qa-folder-{uuid.uuid4().hex[:8]}"
cms_content_ops.create_folder(content_type=content_type, store_id=store_id, folder_name=folder_name)
created.append((content_type, folder_name))
return {"contentType": content_type, "folderName": folder_name}

yield _make

for content_type, folder_name in reversed(created):
try:
cms_content_ops.delete(content_type=content_type, store_id=store_id, urls=folder_name)
except Exception:
pass


@pytest.fixture
def make_menu_link_list(menu_ops: MenuLinkOperations, store_id: str) -> Generator[Callable[..., dict], None, None]:
"""Factory: create a menu link list, deletes it at teardown."""
created_ids: list[str] = []

def _make(*, name: str | None = None, language: str = "en-US", menu_links: list[dict] | None = None) -> dict:
list_id = str(uuid.uuid4())
menu_name = name or f"QAMenu_{uuid.uuid4().hex[:6]}"
menu_ops.create_or_update(
store_id=store_id,
list_id=list_id,
name=menu_name,
language=language,
menu_links=menu_links,
)
created_ids.append(list_id)
return {"id": list_id, "name": menu_name, "language": language}

yield _make

for lid in reversed(created_ids):
try:
menu_ops.delete(store_id=store_id, list_ids=lid)
except Exception:
pass


@pytest.fixture(scope="session")
def ensure_exe_in_blacklist(
global_settings: GlobalSettings, admin_auth, backend_base_url: str
) -> Generator[None, None, None]:
"""Ensure `.exe` is in the platform's file-extensions blacklist for upload tests.

Mutates a global setting — tests that depend on this fixture must be marked
`@pytest.mark.serial`. Reverts to the original list at session end.
"""
with RestClient(global_settings=global_settings, auth=admin_auth) as client:
ops = SettingsOperations(client, backend_base_url)
original = ops.get_by_name(_FILE_EXTENSIONS_BLACKLIST_SETTING)
original_values = list(original.get("allowedValues") or [])
if _FORBIDDEN_EXTENSION not in original_values:
updated = {**original, "allowedValues": [*original_values, _FORBIDDEN_EXTENSION]}
ops.update([updated])

yield

if _FORBIDDEN_EXTENSION not in original_values:
reverted = {**original, "allowedValues": original_values}
ops.update([reverted])
Loading
Loading