producteca 2.0.15__tar.gz → 2.0.16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {producteca-2.0.15 → producteca-2.0.16}/PKG-INFO +1 -1
  2. producteca-2.0.16/producteca/__init__.py +3 -0
  3. producteca-2.0.16/producteca/abstract/abstract_dataclass.py +23 -0
  4. {producteca-2.0.15 → producteca-2.0.16}/producteca/client.py +2 -0
  5. {producteca-2.0.15 → producteca-2.0.16}/producteca/payments/tests/test_payments.py +18 -2
  6. {producteca-2.0.15 → producteca-2.0.16}/producteca/products/products.py +161 -39
  7. producteca-2.0.16/producteca/products/search_products.py +136 -0
  8. producteca-2.0.16/producteca/products/tests/test_products.py +178 -0
  9. {producteca-2.0.15 → producteca-2.0.16}/producteca/products/tests/test_search_products.py +10 -10
  10. producteca-2.0.16/producteca/sales_orders/sales_orders.py +391 -0
  11. {producteca-2.0.15 → producteca-2.0.16}/producteca/sales_orders/search_sale_orders.py +21 -21
  12. {producteca-2.0.15 → producteca-2.0.16}/producteca/sales_orders/tests/test_sales_orders.py +49 -19
  13. {producteca-2.0.15 → producteca-2.0.16}/producteca/sales_orders/tests/test_search_so.py +2 -4
  14. {producteca-2.0.15 → producteca-2.0.16}/producteca/shipments/tests/test_shipment.py +19 -7
  15. {producteca-2.0.15 → producteca-2.0.16}/pyproject.toml +1 -1
  16. producteca-2.0.15/producteca/abstract/abstract_dataclass.py +0 -11
  17. producteca-2.0.15/producteca/products/search_products.py +0 -139
  18. producteca-2.0.15/producteca/products/tests/test_products.py +0 -93
  19. producteca-2.0.15/producteca/sales_orders/sales_orders.py +0 -348
  20. producteca-2.0.15/producteca/shipments/tests/__init__.py +0 -0
  21. {producteca-2.0.15 → producteca-2.0.16}/README.md +0 -0
  22. {producteca-2.0.15/producteca → producteca-2.0.16/producteca/abstract}/__init__.py +0 -0
  23. {producteca-2.0.15/producteca/abstract → producteca-2.0.16/producteca/config}/__init__.py +0 -0
  24. {producteca-2.0.15 → producteca-2.0.16}/producteca/config/config.py +0 -0
  25. {producteca-2.0.15/producteca/config → producteca-2.0.16/producteca/payments}/__init__.py +0 -0
  26. {producteca-2.0.15 → producteca-2.0.16}/producteca/payments/payments.py +0 -0
  27. {producteca-2.0.15/producteca/payments → producteca-2.0.16/producteca/payments/tests}/__init__.py +0 -0
  28. {producteca-2.0.15/producteca/payments/tests → producteca-2.0.16/producteca/products}/__init__.py +0 -0
  29. {producteca-2.0.15/producteca/products → producteca-2.0.16/producteca/products/tests}/__init__.py +0 -0
  30. {producteca-2.0.15/producteca/products/tests → producteca-2.0.16/producteca/sales_orders}/__init__.py +0 -0
  31. {producteca-2.0.15/producteca/sales_orders → producteca-2.0.16/producteca/sales_orders/tests}/__init__.py +0 -0
  32. {producteca-2.0.15 → producteca-2.0.16}/producteca/sales_orders/tests/search.json +0 -0
  33. {producteca-2.0.15/producteca/sales_orders/tests → producteca-2.0.16/producteca/shipments}/__init__.py +0 -0
  34. {producteca-2.0.15 → producteca-2.0.16}/producteca/shipments/shipment.py +0 -0
  35. {producteca-2.0.15/producteca/shipments → producteca-2.0.16/producteca/shipments/tests}/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: producteca
3
- Version: 2.0.15
3
+ Version: 2.0.16
4
4
  Summary:
5
5
  Author: Chroma Agency, Matias Rivera
6
6
  Author-email: mrivera@chroma.agency
@@ -0,0 +1,3 @@
1
+ # flake8: noqa
2
+
3
+ from .client import ProductecaClient
@@ -0,0 +1,23 @@
1
+ from abc import ABC
2
+ from ..config.config import ConfigProducteca
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class BaseService[T](ABC):
9
+ config: ConfigProducteca
10
+ endpoint: str
11
+ _record: Optional[T] = None
12
+
13
+ def __repr__(self):
14
+ return repr(self._record)
15
+
16
+ def to_dict(self):
17
+ return self._record.model_dump(by_alias=True)
18
+
19
+ def to_json(self):
20
+ return self._record.model_dump_json(by_alias=True)
21
+
22
+ def __getattr__(self, key):
23
+ return getattr(self._record, key)
@@ -1,6 +1,7 @@
1
1
  from producteca.config.config import ConfigProducteca
2
2
  from producteca.products.products import ProductService
3
3
  from producteca.sales_orders.sales_orders import SaleOrderService
4
+
4
5
  import os
5
6
 
6
7
 
@@ -20,3 +21,4 @@ class ProductecaClient:
20
21
  @property
21
22
  def SalesOrder(self):
22
23
  return SaleOrderService(self.config)
24
+
@@ -35,7 +35,15 @@ class TestPayments(unittest.TestCase):
35
35
  payment = Payment(**self.payment_data)
36
36
 
37
37
  # Test create method
38
- result = self.client.SalesOrder(id=self.sale_order_id).add_payment(payment)
38
+ result = self.client.SalesOrder(id=self.sale_order_id, invoiceIntegration={
39
+ 'id': 1,
40
+ 'integrationId': 'test-integration',
41
+ 'app': 1,
42
+ 'createdAt': '2023-01-01',
43
+ 'decreaseStock': True,
44
+ "documentUrl": "https://aallala.copm",
45
+ "xmlUrl": "https://aallala.copm",
46
+ }).add_payment(payment.model_dump(by_alias=True))
39
47
 
40
48
  # Assertions
41
49
  mock_post.assert_called_once()
@@ -55,7 +63,15 @@ class TestPayments(unittest.TestCase):
55
63
  payment = Payment(**self.payment_data)
56
64
 
57
65
  # Test update method
58
- result = self.client.SalesOrder(id=self.sale_order_id).update_payment(self.payment_id, payment)
66
+ result = self.client.SalesOrder(id=self.sale_order_id, invoiceIntegration={
67
+ 'id': 1,
68
+ 'integrationId': 'test-integration',
69
+ 'app': 1,
70
+ 'createdAt': '2023-01-01',
71
+ 'decreaseStock': True,
72
+ "documentUrl": "https://aallala.copm",
73
+ "xmlUrl": "https://aallala.copm",
74
+ }).update_payment(self.payment_id, payment.model_dump(by_alias=True))
59
75
 
60
76
  # Assertions
61
77
  mock_put.assert_called_once()
@@ -1,5 +1,5 @@
1
1
  from typing import List, Optional, Union
2
- from pydantic import BaseModel, Field
2
+ from pydantic import BaseModel, Field, ValidationError
3
3
  from dataclasses import dataclass
4
4
  from producteca.abstract.abstract_dataclass import BaseService
5
5
  from producteca.products.search_products import SearchProduct, SearchProductParams
@@ -88,23 +88,71 @@ class MeliCategory(BaseModel):
88
88
  fixed: Optional[bool] = None
89
89
 
90
90
 
91
+ class BundleComponent(BaseModel):
92
+ quantity: int
93
+ variation_id: int = Field(alias='variationId')
94
+ product_id: int = Field(alias='productId')
95
+
96
+
97
+ class BundleVariation(BaseModel):
98
+ variation_id: int = Field(alias='variationId')
99
+ components: List[BundleComponent]
100
+
101
+
102
+ class BundleResult(BaseModel):
103
+ company_id: int = Field(alias='companyId')
104
+ product_id: int = Field(alias='productId')
105
+ variations: List[BundleVariation]
106
+ id: str
107
+
108
+
109
+ class BundleResponse(BaseModel):
110
+ results: List[BundleResult]
111
+ count: int
112
+
113
+
91
114
  class Product(BaseModel):
115
+ integrations: Optional[List[Integration]] = None
116
+ variations: Optional[List[Variation]] = None
117
+ is_simple: Optional[bool] = Field(default=None, alias='isSimple')
118
+ has_variations: Optional[bool] = Field(default=None, alias='hasVariations')
119
+ thumbnail: Optional[str] = None
120
+ category: Optional[str] = None
121
+ notes: Optional[str] = None
122
+ prices: Optional[List[Price]] = None
123
+ buying_price: Optional[float] = Field(default=None, alias='buyingPrice')
124
+ is_archived: Optional[bool] = Field(default=None, alias='isArchived')
125
+ dimensions: Optional[Dimensions] = None
126
+ attributes: Optional[List[Attribute]] = None
127
+ metadata: Optional[List[str]] = None
128
+ is_original: Optional[bool] = Field(default=None, alias='isOriginal')
129
+ name: str
130
+ code: Optional[str] = None
92
131
  sku: Optional[str] = None
93
- variation_id: Optional[int] = None
132
+ brand: Optional[str] = None
133
+ id: Optional[int] = None
134
+
135
+
136
+ class ProductVariationBase(BaseModel):
137
+ sku: str
138
+ variation_id: Optional[int] = Field(default=None, alias='variationId')
94
139
  code: Optional[str] = None
95
- name: Optional[str] = None
96
140
  barcode: Optional[str] = None
97
- attributes: Optional[List[Attribute]] = None
98
- tags: Optional[List[str]] = None
99
- buying_price: Optional[float] = None
100
- dimensions: Optional[Dimensions] = None
101
- category: Optional[Union[str, MeliCategory]]
102
- brand: Optional[str] = None
103
- notes: Optional[str] = None
104
- deals: Optional[List[Deal]] = None
105
- stocks: Optional[List[Stock]] = None
106
- prices: Optional[List[Price]] = None
107
- pictures: Optional[List[Picture]] = None
141
+ attributes: List[Attribute] = []
142
+ tags: Optional[List[str]] = []
143
+ buying_price: Optional[float] = Field(0, alias='buyingPrice')
144
+ dimensions: Optional[Dimensions] = Field(default_factory=Dimensions)
145
+ brand: Optional[str] = ''
146
+ notes: Optional[str] = ''
147
+ deals: Optional[List[Deal]] = []
148
+ stocks: List[Stock]
149
+ prices: Optional[List[Price]] = []
150
+ pictures: Optional[List[Picture]] = []
151
+
152
+
153
+ class ProductVariation(ProductVariationBase):
154
+ category: Optional[str] = Field(default=None)
155
+ name: str
108
156
 
109
157
 
110
158
  class Shipping(BaseModel):
@@ -128,13 +176,14 @@ class AttributeCompletion(BaseModel):
128
176
  total: Optional[int] = None
129
177
 
130
178
 
131
- class MeliProduct(Product):
179
+ class MeliProduct(BaseModel):
132
180
  product_id: Optional[int] = Field(default=None, alias='productId')
181
+ tags: Optional[List[str]] = Field(default=None)
133
182
  has_custom_shipping_costs: Optional[bool] = Field(default=None, alias='hasCustomShippingCosts')
134
183
  shipping: Optional[Shipping] = None
135
184
  mshops_shipping: Optional[MShopsShipping] = Field(default=None, alias='mShopsShipping')
136
185
  add_free_shipping_cost_to_price: Optional[bool] = Field(default=None, alias='addFreeShippingCostToPrice')
137
- category: Optional[Union[str, MeliCategory]] # will never be str, but needs compat with super class
186
+ category: MeliCategory
138
187
  attribute_completion: Optional[AttributeCompletion] = Field(default=None, alias='attributeCompletion')
139
188
  catalog_products: Optional[List[str]] = Field(default=None, alias='catalogProducts')
140
189
  warranty: Optional[str] = None
@@ -143,65 +192,138 @@ class MeliProduct(Product):
143
192
  catalog_products_status: Optional[str] = Field(default=None, alias='catalogProductsStatus')
144
193
 
145
194
 
195
+ class ErrorMessage(BaseModel):
196
+ en: str
197
+ es: str
198
+ pt: str
199
+
200
+
201
+ class ErrorReason(BaseModel):
202
+ code: str
203
+ error: str
204
+ message: ErrorMessage
205
+ data: Optional[dict] = None
206
+
207
+
208
+ class ResolvedValue(BaseModel):
209
+ updated: bool
210
+
211
+
212
+ class ResolvedError(BaseModel):
213
+ resolved: Optional[bool] = None
214
+ reason: Optional[ErrorReason] = None
215
+ value: Optional[ResolvedValue] = None
216
+ statusCode: Optional[int] = None
217
+
218
+
219
+ class ErrorContext(BaseModel):
220
+ _ns_name: str
221
+ id: int
222
+ requestId: str
223
+ tokenAppId: str
224
+ appId: str
225
+ bearer: str
226
+ eventId: str
227
+
228
+
229
+ class SynchronizeResponse(BaseModel):
230
+ product: Optional[ResolvedError] = None
231
+ variation: Optional[ResolvedError] = None
232
+ deals: Optional[ResolvedError] = None
233
+ bundles: Optional[ResolvedError] = None
234
+ taxes: Optional[ResolvedError] = None
235
+ meliProductListingIntegrations: Optional[ResolvedError] = None
236
+ tags: Optional[ResolvedError] = None
237
+ productIntegrations: Optional[ResolvedError] = None
238
+ statusCode: Optional[int] = None
239
+ error_context: Optional[ErrorContext] = Field(None, alias='error@context')
240
+
241
+
242
+ class ListedSynchronizeResponse(BaseModel):
243
+ results: List[SynchronizeResponse]
244
+
245
+
146
246
  @dataclass
147
247
  class ProductService(BaseService[Product]):
148
- endpoint: str = Field(default='products', exclude=True)
248
+ endpoint: str = 'products'
149
249
  create_if_it_doesnt_exist: bool = Field(default=False, exclude=True)
150
250
 
151
251
  def __call__(self, **payload):
152
252
  self._record = Product(**payload)
153
253
  return self
154
254
 
155
- def create(self):
255
+ def synchronize(self, payload) -> Union[Product, SynchronizeResponse]:
256
+
156
257
  endpoint_url = self.config.get_endpoint(f'{self.endpoint}/synchronize')
157
258
  headers = self.config.headers.copy()
158
259
  headers.update({"createifitdoesntexist": str(self.create_if_it_doesnt_exist).lower()})
159
- data = self._record.model_dump_json(by_alias=True, exclude_none=True)
160
- response = requests.post(endpoint_url, data=data, headers=headers)
161
- if response.status_code == 204:
162
- raise Exception("Product does not exist and the request cant create if it does not exist")
163
- return Product(**response.json())
260
+ product_variation = ProductVariation(**payload)
261
+ if not product_variation.code and not product_variation.sku:
262
+ raise Exception("Sku or code should be provided to update the product")
263
+ data = product_variation.model_dump(by_alias=True, exclude_none=True)
264
+ response = requests.post(endpoint_url, json=data, headers=headers)
265
+ response_data = response.json()
266
+ try:
267
+ return Product(**response_data)
268
+ except ValidationError:
269
+ pass
270
+ if isinstance(response_data, list):
271
+ res = ListedSynchronizeResponse(results=response_data)
272
+ if any([r.error_context for r in res.results]):
273
+ raise Exception(f"Errored while updating {res.results[0].error_context} {res.model_dump_json()}")
274
+ else:
275
+ return res.results[0]
276
+ else:
277
+ try:
278
+ sync_resp = SynchronizeResponse(**response_data)
279
+ if sync_resp.error_context:
280
+ raise Exception(f"Errored while updating {sync_resp.error_context} - {sync_resp.model_dump_json()}")
281
+ else:
282
+ return sync_resp
283
+ except ValidationError:
284
+ try:
285
+ error_res = ErrorReason(**response_data)
286
+ raise Exception(f"Errored with the following message {error_res.message} - {error_res.model_dump_json()}")
287
+ except ValidationError:
288
+ pass
164
289
 
165
- def update(self):
166
- # TODO: Change name to synchronize
167
- endpoint_url = self.config.get_endpoint(f'{self.endpoint}/synchronize')
168
- headers = self.config.headers.copy()
169
- data = self._record.model_dump_json(by_alias=True, exclude_none=True)
170
- if not self._record.code and not self._record.sku:
171
- raise "Sku or code should be provided to update the product"
172
- response = requests.post(endpoint_url, data=data, headers=headers)
290
+ if not response.ok:
291
+ raise Exception(f"Error getting product {product_variation.sku} - {product_variation.code}\n {response.text}")
173
292
  if response.status_code == 204:
174
- raise Exception("Product does not exist and the request cant create if it does not exist")
175
- return Product(**response.json())
293
+ raise Exception("Status code is 204, meaning nothing was updated or created")
294
+ raise Exception(f"Unhandled error, check response {response.text}")
176
295
 
177
- def get(self, product_id: int) -> "Product":
296
+ def get(self, product_id: int) -> "ProductService":
178
297
  endpoint_url = self.config.get_endpoint(f'{self.endpoint}/{product_id}')
179
298
  headers = self.config.headers
180
299
  response = requests.get(endpoint_url, headers=headers)
181
300
  if not response.ok:
182
301
  raise Exception(f"Error getting product {product_id}\n {response.text}")
183
302
  response_data = response.json()
184
- return Product(**response_data)
303
+ return self(**response_data)
185
304
 
186
- def get_bundle(self, product_id: int) -> "Product":
305
+ def get_bundle(self, product_id: int) -> BundleResponse:
187
306
  endpoint_url = self.config.get_endpoint(f'{self.endpoint}/{product_id}/bundles')
188
307
  headers = self.config.headers
189
308
  response = requests.get(endpoint_url, headers=headers)
190
309
  if not response.ok:
191
310
  raise Exception(f"Error getting bundle {product_id}\n {response.text}")
192
- return Product(**response.json())
311
+ return BundleResponse(**response.json())
193
312
 
194
- def get_ml_integration(self, product_id: int) -> "MeliProduct":
313
+ def get_ml_integration(self, product_id: int) -> MeliProduct:
195
314
  endpoint_url = self.config.get_endpoint(f'{self.endpoint}/{product_id}/listingintegration')
196
315
  headers = self.config.headers
197
316
  response = requests.get(endpoint_url, headers=headers)
198
317
  if not response.ok:
199
318
  raise Exception(f"Error getting ml integration {product_id}\n {response.text}")
200
- return MeliProduct(**response.json())
319
+ response_data = response.json()
320
+ return MeliProduct(**response_data)
201
321
 
202
322
  def search(self, params: SearchProductParams) -> SearchProduct:
203
323
  endpoint: str = f'search/{self.endpoint}'
204
324
  headers = self.config.headers
205
325
  url = self.config.get_endpoint(endpoint)
206
326
  response = requests.get(url, headers=headers, params=params.model_dump(by_alias=True, exclude_none=True))
327
+ if not response.ok:
328
+ raise Exception(f"error in searching products {response.text}")
207
329
  return SearchProduct(**response.json())
@@ -0,0 +1,136 @@
1
+ from typing import List, Optional, Union
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class FacetValue(BaseModel):
6
+ count: int
7
+ value: Optional[Union[str, bool]] = None
8
+ label: Union[str, bool]
9
+
10
+
11
+ class Facet(BaseModel):
12
+ key: str
13
+ value: List[FacetValue]
14
+ is_collection: Optional[bool] = False
15
+ translate: bool
16
+
17
+
18
+ class SearchStocks(BaseModel):
19
+ warehouse: str
20
+ quantity: int
21
+ reserved: int
22
+
23
+
24
+ class SearchPrices(BaseModel):
25
+ price_list_id: int = Field(..., alias='priceListId')
26
+ price_list: str = Field(..., alias='priceList')
27
+ amount: float
28
+ currency: str
29
+
30
+
31
+ class SearchIntegration(BaseModel):
32
+ app: Optional[int] = None
33
+ integration_id: Optional[str] = Field(None, alias='integrationId')
34
+ permalink: Optional[str] = None
35
+ status: Optional[str] = None
36
+ listing_type: Optional[str] = Field(None, alias='listingType')
37
+ safety_stock: Optional[int] = Field(None, alias='safetyStock')
38
+ synchronize_stock: Optional[bool] = Field(None, alias='synchronizeStock')
39
+ is_active: Optional[bool] = Field(None, alias='isActive')
40
+ is_active_or_paused: Optional[bool] = Field(None, alias='isActiveOrPaused')
41
+ id: Optional[int] = None
42
+
43
+
44
+ class SearchDeals(BaseModel):
45
+ campaign: str
46
+ product: int
47
+ variation: str
48
+ deal_price: float
49
+ discount: float
50
+ regular_price: float
51
+ enabled: bool
52
+ currency: str
53
+ id: str
54
+
55
+
56
+ class SearchResultItem(BaseModel):
57
+ search_score: float = Field(..., alias='@search.score')
58
+ id: int
59
+ product_id: int = Field(..., alias='productId')
60
+ company_id: int = Field(..., alias='companyId')
61
+ name: str
62
+ code: str
63
+ skus: List[str]
64
+ brand: str
65
+ category: str
66
+ thumbnail: str
67
+ stocks: List[SearchStocks]
68
+ warehouses_with_stock: List[str] = Field(..., alias='warehousesWithStock')
69
+ total_stock: int = Field(..., alias='totalStock')
70
+ has_pictures: bool = Field(..., alias='hasPictures')
71
+ buying_price: float = Field(..., alias='buyingPrice')
72
+ prices: List[SearchPrices]
73
+ integration_ids: List[str] = Field(..., alias='integrationIds')
74
+ integration_apps: List[str] = Field(..., alias='integrationApps')
75
+ integrations: List[SearchIntegration]
76
+ campaigns: List[str]
77
+ app: Optional[int] = None
78
+ status: Optional[str] = None
79
+ synchronize_stock: Optional[bool] = Field(None, alias='synchronizeStock')
80
+ listing_type: Optional[str] = Field(None, alias='listingType')
81
+ price_amount: Optional[float] = Field(None, alias='priceAmount')
82
+ price_currency: Optional[str] = Field(None, alias='priceCurrency')
83
+ category_id: Optional[str] = Field(None, alias='categoryId')
84
+ category_base_id: Optional[str] = Field(None, alias='categoryBaseId')
85
+ category_l1: Optional[str] = Field(None, alias='categoryL1')
86
+ category_l2: Optional[str] = Field(None, alias='categoryL2')
87
+ category_l3: Optional[str] = Field(None, alias='categoryL3')
88
+ category_l4: Optional[str] = Field(None, alias='categoryL4')
89
+ category_l5: Optional[str] = Field(None, alias='categoryL5')
90
+ category_l6: Optional[str] = Field(None, alias='categoryL6')
91
+ has_category: Optional[bool] = Field(None, alias='hasCategory')
92
+ category_fixed: Optional[bool] = Field(None, alias='categoryFixed')
93
+ accepts_mercadoenvios: Optional[bool] = Field(None, alias='acceptsMercadoenvios')
94
+ shipping_mode: Optional[str] = Field(None, alias='shippingMode')
95
+ local_pickup: Optional[bool] = Field(None, alias='localPickup')
96
+ mandatory_free_shipping: Optional[bool] = Field(None, alias='mandatoryFreeShipping')
97
+ free_shipping: Optional[bool] = Field(None, alias='freeShipping')
98
+ free_shipping_cost: Optional[float] = Field(None, alias='freeShippingCost')
99
+ template: Optional[str] = None
100
+ youtube_id: Optional[str] = Field(None, alias='youtubeId')
101
+ warranty: Optional[str] = None
102
+ permalink: Optional[str] = None
103
+ domain: Optional[str] = None
104
+ attribute_completion_status: Optional[str] = Field(None, alias='attributeCompletionStatus')
105
+ attribute_completion_count: Optional[int] = Field(None, alias='attributeCompletionCount')
106
+ attribute_completion_total: Optional[int] = Field(None, alias='attributeCompletionTotal')
107
+ deals: Optional[SearchDeals] = None
108
+ campaign_status: Optional[List[str]] = Field(None, alias='campaignStatus')
109
+ size_chart: Optional[str] = Field(None, alias='sizeChart')
110
+ channel_status: Optional[List[str]] = Field(None, alias='channelStatus')
111
+ channel_category_l1: Optional[List[str]] = Field(None, alias='channelCategoryL1')
112
+ channel_category_l2: Optional[List[str]] = Field(None, alias='channelCategoryL2')
113
+ channel_category_l3: Optional[List[str]] = Field(None, alias='channelCategoryL3')
114
+ channel_category_id: Optional[List[str]] = Field(None, alias='channelCategoryId')
115
+ channel_synchronizes_stock: Optional[List[str]] = Field(None, alias='channelSynchronizesStock')
116
+ channel_has_category: Optional[List[str]] = Field(None, alias='channelHasCategory')
117
+ catalog_products_status: Optional[List[str]] = Field(None, alias='catalogProductsStatus')
118
+ metadata: Optional[List[str]] = None
119
+ integration_tags: Optional[List[str]] = Field(None, alias='integrationTags')
120
+ variations_integration_ids: Optional[List[str]] = Field(None, alias='variationsIntegrationIds')
121
+ channel_pictures_templates: Optional[List[str]] = Field(None, alias='channelPicturesTemplates')
122
+ channel_pictures_templates_apps: Optional[List[str]] = Field(None, alias='channelPicturesTemplatesApps')
123
+
124
+
125
+ class SearchProduct(BaseModel):
126
+ count: int
127
+ facets: List[Facet]
128
+ results: List[SearchResultItem]
129
+
130
+
131
+ class SearchProductParams(BaseModel):
132
+ top: Optional[int]
133
+ skip: Optional[int]
134
+ filter: Optional[str] = Field(default=None, alias='$filter')
135
+ search: Optional[str] = Field(default=None)
136
+ sales_channel: Optional[str] = Field(default='2', alias='salesChannel')
@@ -0,0 +1,178 @@
1
+ import unittest
2
+ from unittest.mock import patch, Mock
3
+ from producteca.products.products import Product
4
+ from producteca.client import ProductecaClient
5
+
6
+
7
+ class TestProduct(unittest.TestCase):
8
+ def setUp(self):
9
+ self.client = ProductecaClient(token="test_client_id", api_key="test_client_secret")
10
+ self.test_product = Product(
11
+ sku="TEST001",
12
+ name="Test Product",
13
+ code="TEST001",
14
+ category="Test"
15
+ )
16
+ self.product_to_create_payload = {
17
+ "sku": "9817234",
18
+ "code": "871234",
19
+ "name": "Hola test",
20
+ "buyingPrice": 0,
21
+ "deals": [
22
+ {
23
+ "campaign": "string",
24
+ "regularPrice": 0,
25
+ "dealPrice": 0
26
+ }
27
+ ],
28
+ "prices": [
29
+ {
30
+ "amount": 10,
31
+ "currency": "Local",
32
+ "priceList": "Default"
33
+ }
34
+ ],
35
+ "stocks": [
36
+ {
37
+ "quantity": 2,
38
+ "availableQuantity": 2,
39
+ "warehouse": "Default"
40
+ }
41
+ ],
42
+ }
43
+
44
+ @patch('requests.post')
45
+ def test_create_product_success(self, mock_post):
46
+ # Mock successful response
47
+ mock_response = Mock()
48
+ mock_response.status_code = 200
49
+ mock_response.json.return_value = self.test_product.model_dump()
50
+ mock_post.return_value = mock_response
51
+
52
+ response = self.client.Product(**self.test_product.model_dump()).synchronize(self.product_to_create_payload)
53
+
54
+ self.assertEqual(response.sku, "TEST001")
55
+
56
+ @patch('requests.post')
57
+ def test_create_product_not_exist(self, mock_post):
58
+ # Mock product not found response
59
+ mock_response = Mock()
60
+ mock_response.status_code = 204
61
+ mock_post.return_value = mock_response
62
+
63
+ with self.assertRaises(Exception):
64
+ self.client.Product.synchronize(self.product_to_create_payload)
65
+
66
+ @patch('requests.post')
67
+ def test_update_product_success(self, mock_post):
68
+ payload = self.product_to_create_payload
69
+ mock_response = Mock()
70
+ mock_response.status_code = 200
71
+ mock_response.json.return_value = self.test_product.model_dump()
72
+ mock_post.return_value = mock_response
73
+
74
+ response = self.client.Product(**self.test_product.model_dump()).synchronize(payload)
75
+
76
+ self.assertEqual(response.name, "Test Product")
77
+
78
+ @patch('requests.get')
79
+ def test_get_product(self, mock_get):
80
+ # Mock get product response
81
+ mock_response = Mock()
82
+ mock_response.status_code = 200
83
+ mock_response.json.return_value = self.test_product.model_dump()
84
+ mock_get.return_value = mock_response
85
+
86
+ response = self.client.Product.get(1)
87
+
88
+ self.assertEqual(response.sku, "TEST001")
89
+
90
+ @patch('requests.get')
91
+ def test_get_bundle(self, mock_get):
92
+ # Mock get bundle response
93
+ mock_response = Mock()
94
+ mock_response.status_code = 200
95
+ test_prod = {
96
+ "results": [
97
+ {
98
+ "companyId": 0,
99
+ "productId": 0,
100
+ "variations": [
101
+ {
102
+ "variationId": 0,
103
+ "components": [
104
+ {
105
+ "quantity": 0,
106
+ "variationId": 0,
107
+ "productId": 0
108
+ }
109
+ ]
110
+ }
111
+ ],
112
+ "id": "string"
113
+ }
114
+ ],
115
+ "count": 0
116
+ }
117
+ mock_response.json.return_value = test_prod
118
+ mock_get.return_value = mock_response
119
+
120
+ product = self.client.Product.get_bundle(1)
121
+
122
+ self.assertEqual(product.count, 0)
123
+
124
+ @patch('requests.get')
125
+ def test_get_ml_integration(self, mock_get):
126
+ # Mock ML integration response
127
+ mock_response = Mock()
128
+ mock_response.status_code = 200
129
+ meli_product = {
130
+ "hasCustomShippingCosts": True,
131
+ "productId": 0,
132
+ "shipping": {
133
+ "localPickup": True,
134
+ "mode": "string",
135
+ "freeShipping": True,
136
+ "freeShippingCost": 0,
137
+ "mandatoryFreeShipping": True,
138
+ "freeShippingMethod": "string"
139
+ },
140
+ "mShopsShipping": {
141
+ "enabled": True
142
+ },
143
+ "addFreeShippingCostToPrice": True,
144
+ "category": {
145
+ "meliId": "string",
146
+ "acceptsMercadoenvios": True,
147
+ "suggest": True,
148
+ "fixed": True
149
+ },
150
+ "attributeCompletion": {
151
+ "productIdentifierStatus": "Complete",
152
+ "dataSheetStatus": "Complete",
153
+ "status": "Complete",
154
+ "count": 0,
155
+ "total": 0
156
+ },
157
+ "catalogProducts": [
158
+ "string"
159
+ ],
160
+ "warranty": "string",
161
+ "domain": "string",
162
+ "listingTypeId": "GoldSpecial",
163
+ "catalogProductsStatus": "Unlinked",
164
+ "tags": [
165
+ "string"
166
+ ]
167
+ }
168
+
169
+ mock_response.json.return_value = meli_product
170
+ mock_get.return_value = mock_response
171
+
172
+ product = self.client.Product.get_ml_integration(1)
173
+
174
+ self.assertEqual(product.listing_type_id, "GoldSpecial")
175
+
176
+
177
+ if __name__ == '__main__':
178
+ unittest.main()