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
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:
@@ -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
105
tests/tiny_oauth_test.py
Normal 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()
|
||||
Reference in New Issue
Block a user