Files
chatballs/apps/backend/hub_platform/orders/tests.py
T
AndreyandClaude Opus 4.8 c99bfdd828 feat(orders): product-backend order ingest webhook (ADR-HUB-0018)
Orders originate in the product backends (FoxRay/FirePage); Hub records
them. Added an authenticated ingest webhook instead of manual order entry:

- POST /api/v1/orders/ingest/ (AllowAny + product-token auth via
  X-Product-Token; token stored only as SHA-256). Idempotent by
  (organization, source=product.code, externalId). Resolves the contact
  from conversationId when given, else creates one from contactName;
  resolves items by offerCode within the product; PAID stamps paid_at +
  fulfillment PENDING. Audited (orders.ingested / ingest_duplicate).
- Order gains source/external_id with a partial-unique constraint;
  Product gains ingest_token_hash. Migrations included.
- issue_product_ingest_token <code> management command generates + prints
  the token once (user-run; rotates on re-run).

Tests: 10 orders tests (incl. ingest auth + idempotency); full suite 95/95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:58:22 +03:00

129 lines
6.4 KiB
Python

import json
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from hub_platform.conversations.models import Contact
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
from hub_platform.orders.models import Order, PaymentStatus
from hub_platform.orders.services import OrderItemInput, create_order, mark_paid
from hub_platform.products.models import BillingPeriod, Offer, OfferFulfillmentType, OfferPaymentType, Price, Product
class OrdersTestBase(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.contact = Contact.objects.create(organization=self.organization, name="Тестовый клиент")
product = Product.objects.get(code="firepage")
self.offer = Offer.objects.create(
product=product, code="box", name="Коробка",
fulfillment_type=OfferFulfillmentType.BOX_LICENSE, payment_type=OfferPaymentType.ONE_TIME,
)
self.price = Price.objects.create(
offer=self.offer, version=1, amount_minor=490_000, billing_period=BillingPeriod.ONE_TIME, valid_from=timezone.now(),
)
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
class OrderServiceTests(OrdersTestBase):
def test_create_order_uses_active_price(self) -> None:
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
self.assertEqual(order.amount_minor, 490_000)
self.assertEqual(order.payment_status, PaymentStatus.PENDING)
self.assertEqual(order.items.count(), 1)
self.assertEqual(order.product_id, self.offer.product_id)
def test_mark_paid_sets_paid_at_and_fulfillment(self) -> None:
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(order=order)
order.refresh_from_db()
self.assertEqual(order.payment_status, PaymentStatus.PAID)
self.assertIsNotNone(order.paid_at)
class OrderApiTests(OrdersTestBase):
def test_create_and_mark_paid_via_api(self) -> None:
create = self.client.post(
"/api/v1/orders/",
data=json.dumps({"contactId": self.contact.id, "items": [{"offerId": self.offer.id}]}),
content_type="application/json",
)
self.assertEqual(create.status_code, 201)
order_id = create.json()["order"]["id"]
paid = self.client.post(f"/api/v1/orders/{order_id}/mark-paid/")
self.assertEqual(paid.status_code, 200)
self.assertEqual(paid.json()["order"]["paymentStatus"], PaymentStatus.PAID)
def test_list_filters_by_payment_status(self) -> None:
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(order=order)
create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
response = self.client.get("/api/v1/orders/?paymentStatus=PAID")
self.assertEqual(response.status_code, 200)
items = response.json()["items"]
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["paymentStatus"], PaymentStatus.PAID)
def test_paid_order_reflected_in_sales_stats(self) -> None:
from hub_platform.conversations.stats import sales_overview_stats
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(order=order)
stats = sales_overview_stats(self.organization.id, "d30")["period"]
self.assertEqual(stats["sales"], 1)
self.assertEqual(stats["revenueMinor"], 490_000)
def test_order_scoped_to_organization(self) -> None:
other = Organization.objects.create(name="Other", slug="other")
other_contact = Contact.objects.create(organization=other, name="Чужой")
order = Order.objects.create(organization=other, contact=other_contact, amount_minor=100)
response = self.client.get(f"/api/v1/orders/{order.id}/")
self.assertEqual(response.status_code, 404)
class OrderIngestTests(OrdersTestBase):
def setUp(self) -> None:
super().setUp()
from hub_platform.orders.services import hash_ingest_token
self.token = "secret-product-token"
product = Product.objects.get(code="firepage")
product.ingest_token_hash = hash_ingest_token(self.token)
product.save(update_fields=["ingest_token_hash"])
# Вебхук без сессии.
self.webhook = APIClient()
def _post(self, body: dict, token: str | None = "secret-product-token"):
headers = {"HTTP_X_PRODUCT_TOKEN": token} if token is not None else {}
return self.webhook.post("/api/v1/orders/ingest/", data=json.dumps(body), content_type="application/json", **headers)
def test_ingest_creates_paid_order(self) -> None:
response = self._post({"externalId": "fp-1001", "paymentStatus": "PAID", "contactName": "Гость", "items": [{"offerCode": "box"}]})
self.assertEqual(response.status_code, 201)
order = Order.objects.get(source="firepage", external_id="fp-1001")
self.assertEqual(order.payment_status, PaymentStatus.PAID)
self.assertEqual(order.amount_minor, 490_000)
self.assertIsNotNone(order.paid_at)
def test_ingest_is_idempotent(self) -> None:
first = self._post({"externalId": "fp-1002", "paymentStatus": "PAID", "items": [{"offerCode": "box"}]})
second = self._post({"externalId": "fp-1002", "paymentStatus": "PAID", "items": [{"offerCode": "box"}]})
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 200)
self.assertEqual(Order.objects.filter(external_id="fp-1002").count(), 1)
def test_ingest_rejects_bad_token(self) -> None:
response = self._post({"externalId": "x", "items": [{"offerCode": "box"}]}, token="nope")
self.assertEqual(response.status_code, 401)
def test_ingest_requires_token(self) -> None:
response = self._post({"externalId": "x", "items": [{"offerCode": "box"}]}, token=None)
self.assertEqual(response.status_code, 401)