feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped

Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from
the Kanban; the callback is authorised by a single-use state, because Tiny's
cross-site redirect does not carry the SameSite=Strict operator cookie.
Tokens are kept in provider_tokens, the refresh token rotates under a row
lock, and the worker keeps the connection alive while order creation is off.

Orders find or create the customer's contact by CNPJ, then POST /pedidos
with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA
and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the
customer's recent orders for that number first. The product settings avoid a
_FILE suffix, which the secrets loader reads as a secret file path.

Production passes the application credentials through but keeps
TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a
supervised test. compose.providers.yaml gives the local API and worker an
internet route for provider testing; the default local stack still has none.

Verified with the full CI integration sequence locally, including the new
tiny_oauth_test against the real database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-24 12:46:09 -03:00
parent c18b9e5b87
commit e3d5558198
17 changed files with 613 additions and 131 deletions

View File

@@ -1,15 +1,18 @@
"""The Tiny adapter against a fake HTTP transport: payload and idempotency.
"""The Tiny v3 adapter against a fake HTTP transport: payload and idempotency.
This proves the documented contract only; the client's account must still
confirm endpoints, product codes and tags. Runs where httpx is installed.
confirm products, contacts and order fields. Runs where httpx is installed.
The OAuth connection against the real database is tests/tiny_oauth_test.py.
"""
import json
import os
import unittest
from urllib.parse import parse_qs
from datetime import date
from unittest import mock
import httpx
from app.tiny import TinyError, TinyOrders, order_payload
from app.tiny import TinyError, TinyOrders, contact_payload, order_payload
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
@@ -19,60 +22,94 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event
'complement': '', 'district': 'Centro', 'city': 'Franca',
'state': 'SP', 'postal_code': '14400000'},
'total_cents': 8472}}
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'}
class FakeAuth:
def access_token(self):
return 'access-1'
@mock.patch.dict(os.environ, PRODUCTS)
class TinyTests(unittest.TestCase):
def setUp(self):
self.orders = {}
self.contacts = []
self.orders = []
self.calls = []
def handler(request):
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
method = request.url.path.rsplit('/', 1)[-1].removesuffix('.php')
self.calls.append((method, form))
if method == 'pedidos.pesquisa':
found = self.orders.get(form['numeroEcommerce'])
if not found:
return httpx.Response(200, json={'retorno': {'status': 'Erro', 'codigo_erro': 20}})
return httpx.Response(200, json={'retorno': {'status': 'OK', 'pedidos': [{'pedido': found}]}})
pedido = json.loads(form['pedido'])['pedido']
record = {'id': 9000 + len(self.orders), 'numero': 100 + len(self.orders), 'status': 'OK'}
self.orders[pedido['numero_pedido_ecommerce']] = {**record, 'numero_ecommerce': pedido['numero_pedido_ecommerce']}
return httpx.Response(200, json={'retorno': {'status': 'OK', 'registros': [{'registro': {'sequencia': 1, **record}}]}})
path = request.url.path.removeprefix('/public-api/v3')
self.calls.append((request.method, path))
assert request.headers['authorization'] == 'Bearer access-1'
if request.method == 'GET' and path == '/contatos':
cnpj = request.url.params['cpfCnpj']
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
if request.method == 'POST' and path == '/contatos':
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
self.contacts.append(contact)
return httpx.Response(200, json={'id': contact['id']})
if request.method == 'GET' and path == '/pedidos':
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
if request.method == 'GET' and path.startswith('/pedidos/'):
wanted = int(path.rsplit('/', 1)[-1])
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
if request.method == 'POST' and path == '/pedidos':
order = {**json.loads(request.content), 'id': 9000 + len(self.orders),
'numeroPedido': str(100 + len(self.orders))}
self.orders.append(order)
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
return httpx.Response(404)
self.tiny = TinyOrders('test-token', transport=httpx.MockTransport(handler))
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
today=date(2026, 9, 24))
def test_payload_carries_customer_address_items_and_freight(self):
pedido = order_payload(PAID)['pedido']
self.assertEqual(pedido['numero_pedido_ecommerce'], 'DTF-42')
self.assertEqual((pedido['cliente']['cpf_cnpj'], pedido['cliente']['cep'], pedido['cliente']['uf']),
('11222333000181', '14400000', 'SP'))
item = pedido['itens'][0]['item']
self.assertEqual((item['quantidade'], item['valor_unitario'], item['unidade']), ('2.8', '24.90', 'M'))
self.assertEqual(pedido['valor_frete'], '15.00')
def test_payload_carries_contact_products_address_and_freight(self):
pedido = order_payload(PAID, 777, date(2026, 9, 24))
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
(777, 'DTF-42', '2026-09-24'))
item = pedido['itens'][0]
self.assertEqual((item['produto'], item['quantidade'], item['valorUnitario']),
({'id': 102}, 2.8, 24.9))
self.assertEqual(pedido['valorFrete'], 15.0)
self.assertEqual((pedido['enderecoEntrega']['cep'], pedido['enderecoEntrega']['enderecoNro'],
pedido['enderecoEntrega']['nomeDestinatario']), ('14400000', '10', 'Loja Teste'))
contact = contact_payload(PAID['order'])
self.assertEqual((contact['tipoPessoa'], contact['cpfCnpj'], contact['endereco']['uf']),
('J', '11222333000181', 'SP'))
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
'freight': {'service': 'pickup', 'total_cents': 0}}})['pedido']
self.assertEqual((pickup['forma_envio'], pickup['frete_por_conta']), ('X', 'R'))
self.assertNotIn('endereco', pickup['cliente'])
'freight': {'service': 'pickup', 'total_cents': 0}}}, 1)
self.assertNotIn('enderecoEntrega', pickup)
self.assertIn('retirada', pickup['observacoes'])
def test_second_delivery_finds_the_first_order(self):
first = self.tiny.deliver('k1', PAID)
second = self.tiny.deliver('k1', PAID)
self.assertEqual(first['status'], 'created')
self.assertEqual(second['status'], 'already-created')
self.assertEqual((first['status'], second['status']), ('created', 'already-created'))
self.assertEqual(first['tiny_id'], second['tiny_id'])
self.assertEqual([m for m, _ in self.calls].count('pedido.incluir'), 1)
self.assertEqual(self.calls[0][1]['token'], 'test-token')
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
self.assertEqual(self.calls.count(('POST', '/contatos')), 1)
def test_existing_contact_is_reused(self):
self.contacts.append({'id': 321, 'cpfCnpj': '11222333000181'})
self.tiny.deliver('k1', PAID)
self.assertNotIn(('POST', '/contatos'), self.calls)
self.assertEqual(self.orders[0]['idContato'], 321)
def test_production_events_are_not_sent(self):
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
self.assertEqual(self.calls, [])
def test_errors_reported_in_the_body_are_failures(self):
tiny = TinyOrders('t', transport=httpx.MockTransport(lambda r: httpx.Response(
200, json={'retorno': {'status': 'Erro', 'codigo_erro': 6, 'erros': [{'erro': 'API Bloqueada'}]}})))
with self.assertRaises(TinyError):
tiny.deliver('k3', PAID)
def test_missing_product_mapping_fails_rather_than_guessing(self):
with mock.patch.dict(os.environ, {'TINY_PRODUCT_TEXTIL_AVULSA': ''}):
with self.assertRaises(TinyError):
self.tiny.deliver('k3', PAID)
self.assertNotIn(('POST', '/pedidos'), self.calls)
def test_rate_limit_is_a_retryable_failure(self):
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
with self.assertRaisesRegex(TinyError, 'rate limit'):
tiny.deliver('k4', PAID)
if __name__ == '__main__':