Add CRUD operations for schedules and tasks in FeedClient; enhance tests for schedule and task management
This commit is contained in:
parent
184b0c6c17
commit
9592f38902
2 changed files with 340 additions and 5 deletions
|
|
@ -1,11 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ebay_client.core.http.transport import ApiTransport
|
||||
from ebay_client.generated.feed.models import (
|
||||
CreateTaskRequest,
|
||||
CreateUserScheduleRequest,
|
||||
ScheduleTemplateResponse,
|
||||
ScheduleTemplateCollection,
|
||||
Task,
|
||||
TaskCollection,
|
||||
UserScheduleResponse,
|
||||
UserScheduleCollection,
|
||||
UpdateUserScheduleRequest,
|
||||
)
|
||||
|
||||
FEED_INVENTORY_SCOPE = "https://api.ebay.com/oauth/api_scope/sell.inventory"
|
||||
|
|
@ -22,19 +31,96 @@ FEED_READ_SCOPE_OPTIONS = [
|
|||
]
|
||||
|
||||
|
||||
class CreatedFeedResource(BaseModel):
|
||||
location: str | None = None
|
||||
resource_id: str | None = None
|
||||
|
||||
|
||||
class FeedFileDownload(BaseModel):
|
||||
content: bytes
|
||||
content_disposition: str | None = None
|
||||
file_name: str | None = None
|
||||
|
||||
|
||||
def extract_feed_resource_id(location: str | None) -> str | None:
|
||||
if not location:
|
||||
return None
|
||||
|
||||
path = urlparse(location).path.rstrip("/")
|
||||
if not path:
|
||||
return None
|
||||
|
||||
_, _, resource_id = path.rpartition("/")
|
||||
return resource_id or None
|
||||
|
||||
|
||||
def _extract_file_name(content_disposition: str | None) -> str | None:
|
||||
if not content_disposition:
|
||||
return None
|
||||
|
||||
marker = "filename="
|
||||
if marker not in content_disposition:
|
||||
return None
|
||||
|
||||
value = content_disposition.split(marker, 1)[1].strip()
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
class FeedClient:
|
||||
def __init__(self, transport: ApiTransport) -> None:
|
||||
self.transport = transport
|
||||
|
||||
def get_tasks(self, *, feed_type: str | None = None) -> TaskCollection:
|
||||
def get_tasks(
|
||||
self,
|
||||
*,
|
||||
feed_type: str | None = None,
|
||||
schedule_id: str | None = None,
|
||||
date_range: str | None = None,
|
||||
look_back_days: int | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> TaskCollection:
|
||||
return self.transport.request_model(
|
||||
TaskCollection,
|
||||
"GET",
|
||||
"/sell/feed/v1/task",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
params={"feed_type": feed_type},
|
||||
params={
|
||||
"feed_type": feed_type,
|
||||
"schedule_id": schedule_id,
|
||||
"date_range": date_range,
|
||||
"look_back_days": look_back_days,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
payload: CreateTaskRequest,
|
||||
*,
|
||||
marketplace_id: str,
|
||||
accept_language: str | None = None,
|
||||
) -> CreatedFeedResource:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-EBAY-C-MARKETPLACE-ID": marketplace_id,
|
||||
}
|
||||
if accept_language is not None:
|
||||
headers["Accept-Language"] = accept_language
|
||||
|
||||
response = self.transport.request(
|
||||
"POST",
|
||||
"/sell/feed/v1/task",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
headers=headers,
|
||||
json_body=payload.model_dump(by_alias=True, exclude_none=True),
|
||||
)
|
||||
location = response.headers.get("Location")
|
||||
return CreatedFeedResource(location=location, resource_id=extract_feed_resource_id(location))
|
||||
|
||||
def get_task(self, task_id: str) -> Task:
|
||||
return self.transport.request_model(
|
||||
Task,
|
||||
|
|
@ -43,18 +129,108 @@ class FeedClient:
|
|||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
)
|
||||
|
||||
def get_schedule_templates(self) -> ScheduleTemplateCollection:
|
||||
def get_input_file(self, task_id: str) -> FeedFileDownload:
|
||||
return self._download_file(f"/sell/feed/v1/task/{task_id}/download_input_file")
|
||||
|
||||
def get_result_file(self, task_id: str) -> FeedFileDownload:
|
||||
return self._download_file(f"/sell/feed/v1/task/{task_id}/download_result_file")
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> None:
|
||||
self.transport.request_json(
|
||||
"POST",
|
||||
f"/sell/feed/v1/task/{task_id}/upload_file",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
files={"file": (file_name, content, content_type)},
|
||||
)
|
||||
|
||||
def get_schedule_templates(
|
||||
self,
|
||||
*,
|
||||
feed_type: str,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> ScheduleTemplateCollection:
|
||||
return self.transport.request_model(
|
||||
ScheduleTemplateCollection,
|
||||
"GET",
|
||||
"/sell/feed/v1/schedule_template",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
params={"feed_type": feed_type, "limit": limit, "offset": offset},
|
||||
)
|
||||
|
||||
def get_schedules(self) -> UserScheduleCollection:
|
||||
def get_schedule_template(self, schedule_template_id: str) -> ScheduleTemplateResponse:
|
||||
return self.transport.request_model(
|
||||
ScheduleTemplateResponse,
|
||||
"GET",
|
||||
f"/sell/feed/v1/schedule_template/{schedule_template_id}",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
)
|
||||
|
||||
def get_schedules(
|
||||
self,
|
||||
*,
|
||||
feed_type: str,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> UserScheduleCollection:
|
||||
return self.transport.request_model(
|
||||
UserScheduleCollection,
|
||||
"GET",
|
||||
"/sell/feed/v1/schedule",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
params={"feed_type": feed_type, "limit": limit, "offset": offset},
|
||||
)
|
||||
|
||||
def create_schedule(self, payload: CreateUserScheduleRequest) -> CreatedFeedResource:
|
||||
response = self.transport.request(
|
||||
"POST",
|
||||
"/sell/feed/v1/schedule",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_body=payload.model_dump(by_alias=True, exclude_none=True),
|
||||
)
|
||||
location = response.headers.get("Location")
|
||||
return CreatedFeedResource(location=location, resource_id=extract_feed_resource_id(location))
|
||||
|
||||
def get_schedule(self, schedule_id: str) -> UserScheduleResponse:
|
||||
return self.transport.request_model(
|
||||
UserScheduleResponse,
|
||||
"GET",
|
||||
f"/sell/feed/v1/schedule/{schedule_id}",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
)
|
||||
|
||||
def update_schedule(self, schedule_id: str, payload: UpdateUserScheduleRequest) -> None:
|
||||
self.transport.request_json(
|
||||
"PUT",
|
||||
f"/sell/feed/v1/schedule/{schedule_id}",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_body=payload.model_dump(by_alias=True, exclude_none=True),
|
||||
)
|
||||
|
||||
def delete_schedule(self, schedule_id: str) -> None:
|
||||
self.transport.request_json(
|
||||
"DELETE",
|
||||
f"/sell/feed/v1/schedule/{schedule_id}",
|
||||
scope_options=FEED_READ_SCOPE_OPTIONS,
|
||||
)
|
||||
|
||||
def get_latest_result_file(self, schedule_id: str) -> FeedFileDownload:
|
||||
return self._download_file(f"/sell/feed/v1/schedule/{schedule_id}/download_result_file")
|
||||
|
||||
def _download_file(self, path: str) -> FeedFileDownload:
|
||||
response = self.transport.request("GET", path, scope_options=FEED_READ_SCOPE_OPTIONS)
|
||||
content_disposition = response.headers.get("content-disposition")
|
||||
return FeedFileDownload(
|
||||
content=response.content,
|
||||
content_disposition=content_disposition,
|
||||
file_name=_extract_file_name(content_disposition),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pytest_httpx import HTTPXMock
|
|||
from ebay_client.account.client import AccountClient
|
||||
from ebay_client.core.auth.models import OAuthToken
|
||||
from ebay_client.core.http.transport import ApiTransport
|
||||
from ebay_client.feed.client import FeedClient
|
||||
from ebay_client.feed.client import CreatedFeedResource, FeedClient, FeedFileDownload
|
||||
from ebay_client.fulfillment.client import FulfillmentClient
|
||||
from ebay_client.generated.account.models import (
|
||||
FulfillmentPolicy,
|
||||
|
|
@ -19,6 +19,13 @@ from ebay_client.generated.account.models import (
|
|||
ReturnPolicyRequest,
|
||||
)
|
||||
from ebay_client.generated.feed.models import TaskCollection
|
||||
from ebay_client.generated.feed.models import (
|
||||
CreateTaskRequest,
|
||||
CreateUserScheduleRequest,
|
||||
ScheduleTemplateResponse,
|
||||
UpdateUserScheduleRequest,
|
||||
UserScheduleResponse,
|
||||
)
|
||||
from ebay_client.generated.fulfillment.models import Order
|
||||
from ebay_client.generated.inventory.models import InventoryItemWithSkuLocaleGroupid
|
||||
from ebay_client.generated.media.models import (
|
||||
|
|
@ -406,6 +413,158 @@ def test_feed_wrapper_returns_task_collection_model(httpx_mock: HTTPXMock) -> No
|
|||
assert result.tasks and result.tasks[0].taskId == "TASK-1"
|
||||
|
||||
|
||||
def test_feed_wrapper_supports_schedule_crud_and_template_lookup(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
method="POST",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule",
|
||||
status_code=201,
|
||||
headers={"Location": "https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1"},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1",
|
||||
json={"scheduleId": "SCHEDULE-1", "feedType": "LMS_ORDER_REPORT"},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="PUT",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1",
|
||||
status_code=204,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="DELETE",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1",
|
||||
status_code=204,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule_template/TEMPLATE-1",
|
||||
json={"scheduleTemplateId": "TEMPLATE-1", "feedType": "LMS_ORDER_REPORT"},
|
||||
)
|
||||
|
||||
client = FeedClient(build_transport())
|
||||
created = client.create_schedule(
|
||||
CreateUserScheduleRequest(feedType="LMS_ORDER_REPORT", scheduleTemplateId="TEMPLATE-1")
|
||||
)
|
||||
schedule = client.get_schedule("SCHEDULE-1")
|
||||
client.update_schedule("SCHEDULE-1", UpdateUserScheduleRequest(scheduleName="nightly"))
|
||||
client.delete_schedule("SCHEDULE-1")
|
||||
template = client.get_schedule_template("TEMPLATE-1")
|
||||
|
||||
assert isinstance(created, CreatedFeedResource)
|
||||
assert created.resource_id == "SCHEDULE-1"
|
||||
assert isinstance(schedule, UserScheduleResponse)
|
||||
assert schedule.scheduleId == "SCHEDULE-1"
|
||||
assert isinstance(template, ScheduleTemplateResponse)
|
||||
assert template.scheduleTemplateId == "TEMPLATE-1"
|
||||
|
||||
create_body = json.loads(httpx_mock.get_requests()[0].content.decode("utf-8"))
|
||||
assert create_body["feedType"] == "LMS_ORDER_REPORT"
|
||||
update_body = json.loads(httpx_mock.get_requests()[2].content.decode("utf-8"))
|
||||
assert update_body["scheduleName"] == "nightly"
|
||||
|
||||
|
||||
def test_feed_wrapper_supports_task_create_upload_and_file_downloads(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
method="POST",
|
||||
url="https://api.ebay.com/sell/feed/v1/task",
|
||||
status_code=202,
|
||||
headers={"Location": "https://api.ebay.com/sell/feed/v1/task/TASK-2"},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="POST",
|
||||
url="https://api.ebay.com/sell/feed/v1/task/TASK-2/upload_file",
|
||||
status_code=200,
|
||||
json={},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/task/TASK-2/download_input_file",
|
||||
status_code=200,
|
||||
headers={"content-disposition": 'attachment; filename="input.xml"'},
|
||||
content=b"<input />",
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/task/TASK-2/download_result_file",
|
||||
status_code=200,
|
||||
headers={"content-disposition": 'attachment; filename="result.csv"'},
|
||||
content=b"id,name\n1,demo\n",
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-9/download_result_file",
|
||||
status_code=200,
|
||||
headers={"content-disposition": 'attachment; filename="latest.csv"'},
|
||||
content=b"id,name\n2,latest\n",
|
||||
)
|
||||
|
||||
client = FeedClient(build_transport())
|
||||
created = client.create_task(
|
||||
CreateTaskRequest(feedType="LMS_ORDER_REPORT", schemaVersion="1.0"),
|
||||
marketplace_id="EBAY_US",
|
||||
)
|
||||
client.upload_file("TASK-2", file_name="input.xml", content=b"<input />", content_type="application/xml")
|
||||
input_file = client.get_input_file("TASK-2")
|
||||
result_file = client.get_result_file("TASK-2")
|
||||
latest_file = client.get_latest_result_file("SCHEDULE-9")
|
||||
|
||||
assert isinstance(created, CreatedFeedResource)
|
||||
assert created.resource_id == "TASK-2"
|
||||
assert isinstance(input_file, FeedFileDownload)
|
||||
assert input_file.file_name == "input.xml"
|
||||
assert input_file.content == b"<input />"
|
||||
assert result_file.file_name == "result.csv"
|
||||
assert result_file.content.startswith(b"id,name")
|
||||
assert latest_file.file_name == "latest.csv"
|
||||
|
||||
create_request = httpx_mock.get_requests()[0]
|
||||
assert create_request.headers["X-EBAY-C-MARKETPLACE-ID"] == "EBAY_US"
|
||||
create_body = json.loads(create_request.content.decode("utf-8"))
|
||||
assert create_body["feedType"] == "LMS_ORDER_REPORT"
|
||||
|
||||
upload_request = httpx_mock.get_requests()[1]
|
||||
assert upload_request.headers["Content-Type"].startswith("multipart/form-data;")
|
||||
assert b"filename=\"input.xml\"" in upload_request.content
|
||||
|
||||
|
||||
def test_feed_wrapper_passes_task_and_schedule_filters(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url=(
|
||||
"https://api.ebay.com/sell/feed/v1/task?feed_type=LMS_ORDER_REPORT"
|
||||
"&schedule_id=SCHEDULE-1&date_range=2026-01-01T00:00:00.000Z..2026-01-02T00:00:00.000Z"
|
||||
"&look_back_days=7&limit=50&offset=0"
|
||||
),
|
||||
json={"tasks": [], "total": 0},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule?feed_type=LMS_ORDER_REPORT&limit=25&offset=0",
|
||||
json={"schedules": [], "total": 0},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
method="GET",
|
||||
url="https://api.ebay.com/sell/feed/v1/schedule_template?feed_type=LMS_ORDER_REPORT&limit=25&offset=0",
|
||||
json={"scheduleTemplates": [], "total": 0},
|
||||
)
|
||||
|
||||
client = FeedClient(build_transport())
|
||||
tasks = client.get_tasks(
|
||||
feed_type="LMS_ORDER_REPORT",
|
||||
schedule_id="SCHEDULE-1",
|
||||
date_range="2026-01-01T00:00:00.000Z..2026-01-02T00:00:00.000Z",
|
||||
look_back_days=7,
|
||||
limit=50,
|
||||
offset=0,
|
||||
)
|
||||
schedules = client.get_schedules(feed_type="LMS_ORDER_REPORT", limit=25, offset=0)
|
||||
templates = client.get_schedule_templates(feed_type="LMS_ORDER_REPORT", limit=25, offset=0)
|
||||
|
||||
assert tasks.total == 0
|
||||
assert schedules.total == 0
|
||||
assert templates.total == 0
|
||||
|
||||
|
||||
def test_inventory_wrapper_accepts_readonly_or_full_scope_options(httpx_mock: HTTPXMock) -> None:
|
||||
oauth_client = RecordingOAuthClient()
|
||||
transport = ApiTransport(base_url="https://api.ebay.com", oauth_client=oauth_client)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue