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__':

105
tests/tiny_oauth_test.py Normal file
View File

@@ -0,0 +1,105 @@
"""The Tiny OAuth connection against the real database, with a fake token server.
Run inside the API container: python -m tests.tiny_oauth_test
It saves any existing Tiny connection first and restores it afterwards.
"""
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import httpx
from app.core.db import connect
from app.tiny import TinyAuth, TinyError, TinyNotConnected
os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret',
TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback')
def run():
with connect() as c:
saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
try:
exercise()
finally:
with connect() as c:
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
if saved:
columns = ','.join(saved)
c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})',
tuple(saved.values()))
def exercise():
issued = []
def token_server(request):
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
if form['grant_type'] == 'authorization_code':
assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback')
elif form['grant_type'] == 'refresh_token':
if form['refresh_token'] != issued[-1]:
return httpx.Response(400, json={'error': 'invalid_grant'})
n = len(issued) + 1
issued.append(f'refresh-{n}')
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
auth = TinyAuth(transport=httpx.MockTransport(token_server))
assert auth.status() == {'connected': False}
try:
auth.access_token()
raise AssertionError('an unconnected Tiny must not yield a token')
except TinyNotConnected:
pass
url = urlparse(auth.authorize_url('operator@example.test'))
query = {k: v[0] for k, v in parse_qs(url.query).items()}
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
try:
auth.complete('good-code', 'forged-state')
raise AssertionError('a state no operator created must be refused')
except TinyError:
pass
assert auth.complete('good-code', query['state']) == 'operator@example.test'
try:
auth.complete('good-code', query['state'])
raise AssertionError('a state must be single-use')
except TinyError:
pass
status = auth.status()
assert status['connected'] and status['connected_by'] == 'operator@example.test'
assert auth.access_token() == 'access-1'
print('PASS: operator-started state is required, single-use, and stores the connection')
# An access token about to expire is refreshed, and the refresh token rotates.
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
assert auth.access_token() == 'access-2'
with connect() as c:
row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'}
assert auth.access_token() == 'access-2'
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
with connect() as c:
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
WHERE provider='tiny'""")
try:
auth.access_token()
raise AssertionError('a refused refresh must report the connection as lost')
except TinyNotConnected:
pass
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
(datetime.now(timezone.utc) - timedelta(seconds=1),))
assert not auth.status()['connected']
print('PASS: revoked or expired connections report that Tiny must be connected again')
if __name__ == '__main__':
run()