From 0912b640f46da6cb9bf55f0380ed078f595e1587 Mon Sep 17 00:00:00 2001 From: Kevin Reznicek Date: Tue, 16 Jun 2026 22:46:59 -0700 Subject: [PATCH] Add Etsy listings editor --- src/ecommerce_agent/api/etsy_routes.py | 44 +++ src/ecommerce_agent/api/schemas.py | 65 +++- .../dashboard/pages/6_Etsy_Draft_Listings.py | 343 ++++++++++++++++-- src/ecommerce_agent/services/etsy.py | 46 +++ src/ecommerce_agent/services/interfaces.py | 8 + src/ecommerce_agent/services/mocks.py | 81 +++++ tests/unit/test_etsy_client.py | 62 ++++ tests/unit/test_etsy_listing_routes.py | 39 ++ 8 files changed, 665 insertions(+), 23 deletions(-) create mode 100644 tests/unit/test_etsy_client.py create mode 100644 tests/unit/test_etsy_listing_routes.py diff --git a/src/ecommerce_agent/api/etsy_routes.py b/src/ecommerce_agent/api/etsy_routes.py index 9a18fd9..f548397 100644 --- a/src/ecommerce_agent/api/etsy_routes.py +++ b/src/ecommerce_agent/api/etsy_routes.py @@ -6,7 +6,10 @@ from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession +from ecommerce_agent.api.dependencies import get_services +from ecommerce_agent.api.schemas import EtsyInventoryUpdateRequest, EtsyListingUpdateRequest from ecommerce_agent.db.session import SessionFactory, get_session +from ecommerce_agent.services import ServiceContainer from ecommerce_agent.services.oauth import ( ETSY_REQUIRED_SCOPES, EtsyAuthenticatedHttpClient, @@ -22,6 +25,7 @@ router = APIRouter(prefix="/etsy", tags=["etsy"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] +ServicesDep = Annotated[ServiceContainer, Depends(get_services)] def _oauth_settings(request: Request) -> tuple[Any, TokenCipher]: @@ -154,3 +158,43 @@ async def disconnect_etsy_oauth(request: Request, session: SessionDep) -> dict[s await disconnect_etsy(session) await session.commit() return {"connected": False} + + +@router.get("/listings") +async def list_etsy_shop_listings( + services: ServicesDep, + state: str = Query(default="draft", pattern="^(draft|active)$"), +) -> list[dict[str, Any]]: + return await services.etsy.list_shop_listings(state) + + +@router.get("/listings/{listing_id}") +async def get_etsy_listing(listing_id: str, services: ServicesDep) -> dict[str, Any]: + return await services.etsy.get_listing_detail(listing_id) + + +@router.patch("/listings/{listing_id}") +async def update_etsy_listing( + listing_id: str, + body: EtsyListingUpdateRequest, + services: ServicesDep, +) -> dict[str, Any]: + payload = body.model_dump(exclude_none=True) + return await services.etsy.update_listing( + listing_id, + payload, + idempotency_key=f"etsy-listing:{listing_id}:update", + ) + + +@router.put("/listings/{listing_id}/inventory") +async def update_etsy_listing_inventory( + listing_id: str, + body: EtsyInventoryUpdateRequest, + services: ServicesDep, +) -> dict[str, Any]: + return await services.etsy.update_inventory( + listing_id, + body.model_dump(), + idempotency_key=f"etsy-listing:{listing_id}:inventory", + ) diff --git a/src/ecommerce_agent/api/schemas.py b/src/ecommerce_agent/api/schemas.py index f725456..54790ef 100644 --- a/src/ecommerce_agent/api/schemas.py +++ b/src/ecommerce_agent/api/schemas.py @@ -2,7 +2,7 @@ from decimal import Decimal from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from ecommerce_agent.domain.enums import ApprovalDecision, ApprovalType @@ -89,6 +89,69 @@ class PrintfulAttachRequest(BaseModel): product_id: uuid.UUID +class EtsyListingUpdateRequest(BaseModel): + title: str | None = Field(default=None, max_length=140) + description: str | None = Field(default=None, max_length=50000) + tags: list[str] | None = None + materials: list[str] | None = None + taxonomy_id: int | None = Field(default=None, ge=1) + shipping_profile_id: int | None = Field(default=None, ge=1) + return_policy_id: int | None = Field(default=None, ge=1) + readiness_state_id: int | None = Field(default=None, ge=1) + who_made: str | None = None + when_made: str | None = None + is_supply: bool | None = None + should_auto_renew: bool | None = None + production_partner_ids: list[int] | None = None + + @field_validator("title") + @classmethod + def title_is_not_blank(cls, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError("Title is required.") + return value + + @field_validator("tags") + @classmethod + def valid_tags(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return None + tags = [tag.strip() for tag in value if tag.strip()] + if len(tags) > 13: + raise ValueError("Use at most 13 tags.") + if any(len(tag) > 20 for tag in tags): + raise ValueError("Each tag must be at most 20 characters.") + return tags + + @field_validator("materials") + @classmethod + def valid_materials(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return None + materials = [material.strip() for material in value if material.strip()] + if any(len(material) > 45 for material in materials): + raise ValueError("Each material must be at most 45 characters.") + return materials + + +class EtsyInventoryUpdateRequest(BaseModel): + products: list[dict[str, Any]] = Field(min_length=1) + price_on_property: list[int] = Field(default_factory=list) + quantity_on_property: list[int] = Field(default_factory=list) + sku_on_property: list[int] = Field(default_factory=list) + readiness_state_on_property: list[int] = Field(default_factory=list) + + @field_validator("products") + @classmethod + def products_have_positive_quantities(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]: + for product in value: + for offering in product.get("offerings", []): + quantity = offering.get("quantity") + if quantity is not None and int(quantity) < 1: + raise ValueError("Quantity must be positive.") + return value + + class PostingSessionCreateRequest(BaseModel): name: str = Field(default="Etsy posting session", min_length=1, max_length=255) template_ids: list[int] = Field(min_length=1) diff --git a/src/ecommerce_agent/dashboard/pages/6_Etsy_Draft_Listings.py b/src/ecommerce_agent/dashboard/pages/6_Etsy_Draft_Listings.py index ffef2fe..13f09ff 100644 --- a/src/ecommerce_agent/dashboard/pages/6_Etsy_Draft_Listings.py +++ b/src/ecommerce_agent/dashboard/pages/6_Etsy_Draft_Listings.py @@ -1,31 +1,330 @@ -import uuid +from datetime import UTC, datetime +from typing import Any import streamlit as st -from ecommerce_agent.dashboard.client import get, post +from ecommerce_agent.dashboard.client import get, patch, post, request from ecommerce_agent.dashboard.ui import page_header, show_error -page_header("Etsy Draft Listings", "Mock Etsy drafts awaiting manual publication.") +page_header("Etsy Listings", "Draft and published listings from Etsy.") + + +def money(value: Any) -> str: + if isinstance(value, dict): + amount = value.get("amount") + divisor = value.get("divisor") or 100 + currency = value.get("currency_code") or "" + if amount is not None: + return f"{amount / divisor:.2f} {currency}".strip() + if value not in (None, ""): + return str(value) + return "" + + +def price_number(value: Any) -> float: + if isinstance(value, dict): + amount = value.get("amount") + divisor = value.get("divisor") or 100 + return float(amount or 0) / float(divisor) + return float(value or 0) + + +def image_url(listing: dict[str, Any]) -> str: + images = listing.get("images") or [] + if not images: + return "" + image = sorted(images, key=lambda row: int(row.get("rank") or 999))[0] + return str(image.get("url_170x135") or image.get("url_fullxfull") or "") + + +def timestamp(value: Any) -> str: + if not value: + return "" + return datetime.fromtimestamp(int(value), UTC).strftime("%Y-%m-%d") + + +def tags_text(value: list[str] | None) -> str: + return ", ".join(value or []) + + +def split_text(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def first_offering(inventory: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None: + products = inventory.get("products") or [] + if not products: + return None + product = products[0] + offerings = product.get("offerings") or [] + if not offerings: + return None + return product, offerings[0] + + +def listing_rows(listings: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "thumbnail": image_url(listing), + "title": listing.get("title"), + "listing_id": str(listing.get("listing_id")), + "price": money(listing.get("price")), + "quantity": listing.get("quantity"), + "state": listing.get("state"), + "favorites": listing.get("num_favorers"), + "views": listing.get("views"), + "updated": timestamp( + listing.get("updated_timestamp") or listing.get("last_modified_timestamp") + ), + } + for listing in listings + ] + + +def filtered(listings: list[dict[str, Any]], search: str) -> list[dict[str, Any]]: + if not search.strip(): + return listings + needle = search.casefold() + return [ + listing + for listing in listings + if needle + in " ".join( + [ + str(listing.get("listing_id", "")), + str(listing.get("title", "")), + " ".join(listing.get("tags") or []), + ] + ).casefold() + ] + + +def changed(original: dict[str, Any], edited: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in edited.items() if original.get(key) != value} + + +def edit_listing(summary: dict[str, Any]) -> None: + detail = get(f"/etsy/listings/{summary['listing_id']}") + url = image_url(detail) + if url: + st.image(url, width="stretch") + if detail.get("url"): + st.link_button("Open in Etsy", detail["url"]) + + metrics = st.columns(3) + metrics[0].metric("Favorites", detail.get("num_favorers") or 0) + metrics[1].metric("Views", detail.get("views") or 0) + metrics[2].metric("Quantity", detail.get("quantity") or 0) + + with st.form(f"listing-{detail['listing_id']}"): + st.subheader("Copy") + title = st.text_input("Title", value=detail.get("title") or "", max_chars=140) + description = st.text_area("Description", value=detail.get("description") or "", height=220) + tags = st.text_input("Tags", value=tags_text(detail.get("tags"))) + materials = st.text_input("Materials", value=tags_text(detail.get("materials"))) + + st.subheader("Setup") + setup_columns = st.columns(2) + with setup_columns[0]: + taxonomy_id = st.number_input( + "Taxonomy ID", + min_value=0, + value=int(detail.get("taxonomy_id") or 0), + step=1, + ) + shipping_profile_id = st.number_input( + "Shipping profile ID", + min_value=0, + value=int(detail.get("shipping_profile_id") or 0), + step=1, + ) + return_policy_id = st.number_input( + "Return policy ID", + min_value=0, + value=int(detail.get("return_policy_id") or 0), + step=1, + ) + readiness_state_id = st.number_input( + "Readiness state ID", + min_value=0, + value=int(detail.get("readiness_state_id") or 0), + step=1, + ) + with setup_columns[1]: + who_made = st.selectbox( + "Who made it", + ["i_did", "someone_else", "collective"], + index=["i_did", "someone_else", "collective"].index( + detail.get("who_made") + if detail.get("who_made") in {"i_did", "someone_else", "collective"} + else "someone_else" + ), + ) + when_options = [ + "made_to_order", + "2020_2026", + "2010_2019", + "2007_2009", + "before_2007", + "2000_2006", + "1990s", + "1980s", + "1970s", + "1960s", + "1950s", + "1940s", + "1930s", + "1920s", + "1910s", + "1900s", + "1800s", + "1700s", + "before_1700", + ] + when_made = st.selectbox( + "When made", + when_options, + index=when_options.index( + detail.get("when_made") + if detail.get("when_made") in set(when_options) + else "made_to_order" + ), + ) + is_supply = st.checkbox("Supply", value=bool(detail.get("is_supply"))) + should_auto_renew = st.checkbox( + "Auto renew", value=bool(detail.get("should_auto_renew")) + ) + production_partner_ids = st.text_input( + "Production partner IDs", + value=", ".join(str(value) for value in detail.get("production_partner_ids", [])), + ) + + st.subheader("Inventory") + inventory = detail.get("inventory") or {} + first = first_offering(inventory) + inventory_changed = False + if first is None: + st.info("No editable inventory returned.") + else: + product, offering = first + inventory_columns = st.columns(4) + sku = inventory_columns[0].text_input("SKU", value=str(product.get("sku") or "")) + price = inventory_columns[1].number_input( + "Price", + min_value=0.01, + value=price_number(offering.get("price")) or 1.0, + step=1.0, + ) + quantity = inventory_columns[2].number_input( + "Quantity", + min_value=1, + value=int(offering.get("quantity") or 1), + step=1, + ) + enabled = inventory_columns[3].checkbox( + "Enabled", value=bool(offering.get("is_enabled", True)) + ) + inventory_changed = ( + product.get("sku") != sku + or price_number(offering.get("price")) != price + or int(offering.get("quantity") or 0) != quantity + or bool(offering.get("is_enabled", True)) != enabled + ) + + st.subheader("Images") + for image in sorted(detail.get("images") or [], key=lambda row: int(row.get("rank") or 0)): + st.caption(f"#{image.get('rank')} | {image.get('alt_text') or 'No alt text'}") + if image.get("url_170x135") or image.get("url_fullxfull"): + st.image(image.get("url_170x135") or image.get("url_fullxfull"), width=170) + + saved = st.form_submit_button("Save changes", type="primary") + + if not saved: + with st.expander("Raw Etsy JSON"): + st.json(detail) + return + + listing_payload = changed( + detail, + { + "title": title.strip(), + "description": description, + "tags": split_text(tags), + "materials": split_text(materials), + "taxonomy_id": int(taxonomy_id) or None, + "shipping_profile_id": int(shipping_profile_id) or None, + "return_policy_id": int(return_policy_id) or None, + "readiness_state_id": int(readiness_state_id) or None, + "who_made": who_made, + "when_made": when_made, + "is_supply": is_supply, + "should_auto_renew": should_auto_renew, + "production_partner_ids": [int(value) for value in split_text(production_partner_ids)], + }, + ) + if listing_payload: + patch(f"/etsy/listings/{detail['listing_id']}", listing_payload, timeout=45) + if first is not None and inventory_changed: + product, offering = first + product["sku"] = sku + offering["price"] = price + offering["quantity"] = quantity + offering["is_enabled"] = enabled + request( + "PUT", f"/etsy/listings/{detail['listing_id']}/inventory", json=inventory, timeout=45 + ) + st.success("Listing saved.") + st.rerun() + + try: - listings = get("/etsy-listings") - if not listings: - st.info("No Etsy drafts exist.") - for listing in listings: - with st.expander( - f"{listing.get('external_listing_id') or 'Pending external ID'}", expanded=True - ): - st.json(listing) - if listing["state"] == "ready_for_manual_publish": - if st.button("Mark manually published", key=f"publish-{listing['id']}"): - post( - f"/etsy-listings/{listing['id']}/mark-published", - { - "actor": "streamlit-admin", - "note": "Confirmed manually in Etsy.", - "idempotency_key": f"publish:{listing['id']}:{uuid.uuid4()}", - }, + auth = get("/etsy/oauth/status") + status_columns = st.columns([3, 1, 1]) + with status_columns[0]: + if auth["connected"]: + st.caption(f"Connected to Etsy shop {auth['shop_id']}.") + else: + st.warning("Etsy OAuth is not connected. Mock mode can still show sample listings.") + with status_columns[1]: + if st.button("Refresh OAuth", width="stretch", disabled=not auth["connected"]): + post("/etsy/oauth/refresh") + st.rerun() + with status_columns[2]: + st.link_button( + "Connect Etsy", "http://localhost:8000/api/etsy/oauth/start", width="stretch" + ) + + search = st.text_input("Search listings", placeholder="Title, listing ID, or tag") + tabs = st.tabs(["Draft", "Published"]) + for tab, state in zip(tabs, ["draft", "active"], strict=True): + with tab: + listings = filtered(get(f"/etsy/listings?state={state}"), search) + left, right = st.columns([3, 2]) + with left: + rows = listing_rows(listings) + st.dataframe( + rows, + hide_index=True, + width="stretch", + column_config={ + "thumbnail": st.column_config.ImageColumn(""), + "title": "Title", + "listing_id": "Listing ID", + }, + ) + if rows: + labels = {row["listing_id"]: row["title"] for row in rows} + selected_id = st.radio( + "Selected listing", + [row["listing_id"] for row in rows], + format_func=labels.__getitem__, + key=f"selected-{state}", ) - st.success("Publication recorded.") - st.rerun() + else: + st.info("No listings found.") + selected_id = None + with right: + if selected_id: + edit_listing({"listing_id": selected_id}) except Exception as error: show_error(error) diff --git a/src/ecommerce_agent/services/etsy.py b/src/ecommerce_agent/services/etsy.py index 813db74..505d9a8 100644 --- a/src/ecommerce_agent/services/etsy.py +++ b/src/ecommerce_agent/services/etsy.py @@ -42,6 +42,52 @@ def __init__( self._session_factory = session_factory self._sleep = sleep + async def list_shop_listings(self, state: str) -> list[dict[str, Any]]: + return _results( + await self._request( + "GET", + f"/shops/{self._shop_id}/listings", + params={ + "state": state, + "limit": 100, + "offset": 0, + "sort_on": "updated", + "sort_order": "desc", + "includes": "Images,Inventory,Personalization", + "legacy": "false", + }, + ) + ) + + async def get_listing_detail(self, listing_id: str) -> dict[str, Any]: + return _object( + await self._request( + "GET", + f"/listings/{listing_id}", + params={ + "includes": "Images,Inventory,Personalization", + "legacy": "false", + "allow_suggested_title": "true", + }, + ) + ) + + async def update_listing( + self, listing_id: str, payload: Mapping[str, Any], *, idempotency_key: str + ) -> dict[str, Any]: + del idempotency_key + return _object( + await self._request( + "PATCH", + f"/shops/{self._shop_id}/listings/{listing_id}", + params={"legacy": "false"}, + data={ + key: _form_value(value) for key, value in payload.items() if value is not None + }, + ambiguous_write=True, + ) + ) + async def create_draft_listing( self, payload: Mapping[str, Any], *, idempotency_key: str ) -> dict[str, Any]: diff --git a/src/ecommerce_agent/services/interfaces.py b/src/ecommerce_agent/services/interfaces.py index ac81079..3598f51 100644 --- a/src/ecommerce_agent/services/interfaces.py +++ b/src/ecommerce_agent/services/interfaces.py @@ -74,6 +74,14 @@ async def generate_listing_copy( class EtsyGateway(Protocol): + async def list_shop_listings(self, state: str) -> list[dict[str, Any]]: ... + + async def get_listing_detail(self, listing_id: str) -> dict[str, Any]: ... + + async def update_listing( + self, listing_id: str, payload: Mapping[str, Any], *, idempotency_key: str + ) -> dict[str, Any]: ... + async def create_draft_listing( self, payload: Mapping[str, Any], *, idempotency_key: str ) -> dict[str, Any]: ... diff --git a/src/ecommerce_agent/services/mocks.py b/src/ecommerce_agent/services/mocks.py index 5011e3d..b34c2f3 100644 --- a/src/ecommerce_agent/services/mocks.py +++ b/src/ecommerce_agent/services/mocks.py @@ -98,6 +98,35 @@ async def discover(self, limit: int = 5) -> list[TrendCandidate]: class MockEtsyGateway: + def __init__(self) -> None: + self._listings = { + "draft": [ + self._listing("mock-draft-1", "Workshop draft tee", "draft"), + self._listing("mock-draft-2", "Workshop draft hat", "draft"), + ], + "active": [ + self._listing("mock-active-1", "Published workshop shirt", "active"), + ], + } + + async def list_shop_listings(self, state: str) -> list[dict[str, Any]]: + return [dict(listing) for listing in self._listings.get(state, [])] + + async def get_listing_detail(self, listing_id: str) -> dict[str, Any]: + for listings in self._listings.values(): + for listing in listings: + if str(listing["listing_id"]) == listing_id: + return dict(listing) + return self._listing(listing_id, f"Mock listing {listing_id}", "active") + + async def update_listing( + self, listing_id: str, payload: Mapping[str, Any], *, idempotency_key: str + ) -> dict[str, Any]: + del idempotency_key + listing = await self.get_listing_detail(listing_id) + listing.update(dict(payload)) + return listing + async def create_draft_listing( self, payload: Mapping[str, Any], *, idempotency_key: str ) -> dict[str, Any]: @@ -182,6 +211,58 @@ async def get_taxonomy_properties(self, taxonomy_id: int) -> list[dict[str, Any] async def get_payment(self, receipt_id: str) -> dict[str, Any]: return {"receipt_id": receipt_id, "payment_adjustments": [], "status": "paid"} + def _listing(self, listing_id: str, title: str, state: str) -> dict[str, Any]: + return { + "listing_id": listing_id, + "title": title, + "description": f"{title} description.", + "state": state, + "price": {"amount": 3499, "divisor": 100, "currency_code": "USD"}, + "quantity": 25, + "tags": ["workshop", "made to order"], + "materials": ["cotton"], + "taxonomy_id": 1, + "shipping_profile_id": 1, + "return_policy_id": 1, + "readiness_state_id": 1, + "who_made": "someone_else", + "when_made": "made_to_order", + "is_supply": False, + "should_auto_renew": False, + "url": f"https://www.etsy.com/listing/{listing_id}", + "num_favorers": 4, + "views": 82 if state == "active" else 0, + "updated_timestamp": 1780000000, + "images": [ + { + "listing_image_id": f"{listing_id}-image-1", + "rank": 1, + "url_170x135": "https://placehold.co/170x135/png", + "url_fullxfull": "https://placehold.co/800x600/png", + "alt_text": f"{title} product image", + } + ], + "inventory": { + "products": [ + { + "sku": f"SKU-{listing_id}", + "property_values": [], + "offerings": [ + { + "price": 34.99, + "quantity": 25, + "is_enabled": True, + } + ], + } + ], + "price_on_property": [], + "quantity_on_property": [], + "sku_on_property": [], + "readiness_state_on_property": [], + }, + } + class MockCopyGenerationProvider: async def generate_listing_copy( diff --git a/tests/unit/test_etsy_client.py b/tests/unit/test_etsy_client.py new file mode 100644 index 0000000..b84eebe --- /dev/null +++ b/tests/unit/test_etsy_client.py @@ -0,0 +1,62 @@ +import httpx +from cryptography.fernet import Fernet + +from ecommerce_agent.services.etsy import EtsyClient + + +def client(db_sessions) -> EtsyClient: + return EtsyClient( + api_key="key", + shared_secret="secret", + shop_id="123", + token_encryption_key=Fernet.generate_key().decode(), + session_factory=db_sessions, + ) + + +async def test_lists_shop_listings_with_includes(monkeypatch, db_sessions) -> None: + calls = [] + + async def fake_request(self, method: str, url: str, **kwargs): + del self + calls.append((method, url, kwargs)) + return httpx.Response(200, json={"results": [{"listing_id": 1}]}) + + monkeypatch.setattr( + "ecommerce_agent.services.etsy.EtsyAuthenticatedHttpClient.request", + fake_request, + ) + + assert await client(db_sessions).list_shop_listings("draft") == [{"listing_id": 1}] + method, url, kwargs = calls[0] + assert method == "GET" + assert url.endswith("/v3/application/shops/123/listings") + assert kwargs["params"]["state"] == "draft" + assert kwargs["params"]["includes"] == "Images,Inventory,Personalization" + + +async def test_updates_listing_as_form_without_state(monkeypatch, db_sessions) -> None: + calls = [] + + async def fake_request(self, method: str, url: str, **kwargs): + del self + calls.append((method, url, kwargs)) + return httpx.Response(200, json={"listing_id": 7, "title": "Edited"}) + + monkeypatch.setattr( + "ecommerce_agent.services.etsy.EtsyAuthenticatedHttpClient.request", + fake_request, + ) + + result = await client(db_sessions).update_listing( + "7", + {"title": "Edited", "tags": ["one", "two"], "is_supply": False}, + idempotency_key="test:update", + ) + + assert result["title"] == "Edited" + method, url, kwargs = calls[0] + assert method == "PATCH" + assert url.endswith("/v3/application/shops/123/listings/7") + assert kwargs["params"] == {"legacy": "false"} + assert kwargs["data"] == {"title": "Edited", "tags": "one,two", "is_supply": "false"} diff --git a/tests/unit/test_etsy_listing_routes.py b/tests/unit/test_etsy_listing_routes.py new file mode 100644 index 0000000..420c147 --- /dev/null +++ b/tests/unit/test_etsy_listing_routes.py @@ -0,0 +1,39 @@ +from httpx import ASGITransport, AsyncClient + +from ecommerce_agent.api.main import create_app +from ecommerce_agent.config import Settings + + +def app_with_services(tmp_path, services): + app = create_app( + Settings( + _env_file=None, + app_env="test", + printful_mode="mock", + asset_root=tmp_path, + worker_enabled=False, + ) + ) + app.state.services = services + return app + + +async def test_live_etsy_listing_routes_separate_draft_and_active(tmp_path, services) -> None: + app = app_with_services(tmp_path, services) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + drafts = (await client.get("/api/etsy/listings?state=draft")).json() + active = (await client.get("/api/etsy/listings?state=active")).json() + + assert {listing["state"] for listing in drafts} == {"draft"} + assert {listing["state"] for listing in active} == {"active"} + + +async def test_live_etsy_listing_update_validates_core_fields(tmp_path, services) -> None: + app = app_with_services(tmp_path, services) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.patch( + "/api/etsy/listings/mock-draft-1", + json={"title": "", "tags": ["this tag is far too long"] * 14}, + ) + + assert response.status_code == 422