feat: check Tiny products and add a supervised order test
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 2m39s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m43s
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 2m39s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m43s
"Testar conexão" now also reads the four configured Tiny products and requires each to be active. app/tiny_probe.py runs from the worker console to list products, confirm the configured ids, and create one marked test order through the worker's own delivery path, proving the duplicate guard by search before a second delivery. Nothing is sent without --confirmar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
33
app/tiny.py
33
app/tiny.py
@@ -223,16 +223,35 @@ def order_payload(payload, contact_id, today=None):
|
|||||||
return pedido
|
return pedido
|
||||||
|
|
||||||
|
|
||||||
def check(auth=None, transport=None):
|
def configured_product(orders, mode):
|
||||||
|
"""The Tiny product a mode's setting points at, or a reason it cannot be used."""
|
||||||
|
setting = PRODUCT_SETTINGS[mode]
|
||||||
|
value = os.environ.get(setting, '')
|
||||||
|
if not value.isdigit():
|
||||||
|
return None, f'{setting} sem id'
|
||||||
|
found = orders.request('GET', f'/produtos/{value}')
|
||||||
|
if found.get('situacao') != 'A':
|
||||||
|
return found, f"produto {value} não está ativo no Tiny ({found.get('situacao')})"
|
||||||
|
return found, 'ok'
|
||||||
|
|
||||||
|
|
||||||
|
def check(auth=None, transport=None, orders=None):
|
||||||
"""Read-only proof that the connection and permissions work: one order and
|
"""Read-only proof that the connection and permissions work: one order and
|
||||||
one contact listed, nothing created. Raises TinyNotConnected when there is
|
one contact listed and each configured product found active, nothing
|
||||||
no usable connection; otherwise reports each read separately."""
|
created. Raises TinyNotConnected when there is no usable connection;
|
||||||
orders = TinyOrders(auth=auth or TinyAuth(), transport=transport)
|
otherwise reports each read separately."""
|
||||||
|
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport)
|
||||||
|
|
||||||
|
def listed(path):
|
||||||
|
orders.request('GET', path, params={'limit': 1})
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))]
|
||||||
|
reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS]
|
||||||
results = {}
|
results = {}
|
||||||
for name, path in (('pedidos', '/pedidos'), ('contatos', '/contatos')):
|
for name, read in reads:
|
||||||
try:
|
try:
|
||||||
orders.request('GET', path, params={'limit': 1})
|
results[name] = read()
|
||||||
results[name] = 'ok'
|
|
||||||
except TinyNotConnected:
|
except TinyNotConnected:
|
||||||
raise
|
raise
|
||||||
except TinyError as exc:
|
except TinyError as exc:
|
||||||
|
|||||||
132
app/tiny_probe.py
Normal file
132
app/tiny_probe.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Supervised checks against the client's real Tiny, run by hand from a
|
||||||
|
container console (docker exec, or Portainer > Containers > worker > Console).
|
||||||
|
Tiny has no sandbox, so this is how the adapter is proven on the real account.
|
||||||
|
|
||||||
|
python -m app.tiny_probe produtos [termo] list active products (read-only)
|
||||||
|
python -m app.tiny_probe conferir connection and the four product ids (read-only)
|
||||||
|
python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
|
||||||
|
show the test order; --confirmar creates it
|
||||||
|
|
||||||
|
The test order goes through TinyOrders.deliver, exactly as the worker sends a
|
||||||
|
paid order. The duplicate guard is then proven read-only first (the order must
|
||||||
|
be found by its purchase-order number) and only then by a second delivery,
|
||||||
|
which must return the existing order. If the search cannot find the order, the
|
||||||
|
second delivery is not attempted: it would create a duplicate. Cancel the test
|
||||||
|
order in Olist afterwards.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .core.pricing import TIERS
|
||||||
|
from .core.secrets import load as load_secret_files
|
||||||
|
from . import tiny
|
||||||
|
|
||||||
|
FIND_ATTEMPTS = 4
|
||||||
|
FIND_WAIT_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_order(cnpj, email, celular, mode, now=None):
|
||||||
|
number = 'TESTE-' + (now or datetime.now(tiny.BRASILIA)).strftime('%Y%m%d%H%M')
|
||||||
|
unit = TIERS[mode][0][1]
|
||||||
|
return {'order_id': 'teste-integracao', 'number': number, 'event': 'payment_approved',
|
||||||
|
'order': {'customer': {'cnpj': cnpj, 'mail': email, 'zap': celular},
|
||||||
|
'items': [{'mode': mode, 'grade': 100, 'billed_metres': '1', 'unit_cents': unit}],
|
||||||
|
'freight': {'service': 'pickup', 'total_cents': 0},
|
||||||
|
'destination': None, 'total_cents': unit}}
|
||||||
|
|
||||||
|
|
||||||
|
def produtos(orders, termo, out):
|
||||||
|
params = {'situacao': 'A', 'limit': 100}
|
||||||
|
if termo:
|
||||||
|
params['nome'] = termo
|
||||||
|
found = orders.request('GET', '/produtos', params=params).get('itens') or []
|
||||||
|
for item in found:
|
||||||
|
preco = (item.get('precos') or {}).get('preco')
|
||||||
|
out(f"{item['id']}\t{item.get('sku') or '-'}\t{item.get('descricao')}\tR$ {preco}")
|
||||||
|
out(f'{len(found)} produto(s) ativo(s)' + (f' com "{termo}"' if termo else '') + '.')
|
||||||
|
|
||||||
|
|
||||||
|
def conferir(orders, out):
|
||||||
|
ok = True
|
||||||
|
for mode, setting in tiny.PRODUCT_SETTINGS.items():
|
||||||
|
product, result = tiny.configured_product(orders, mode)
|
||||||
|
ok &= result == 'ok'
|
||||||
|
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
|
||||||
|
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip())
|
||||||
|
for name, result in tiny.check(orders=orders).items():
|
||||||
|
if name in ('pedidos', 'contatos'):
|
||||||
|
ok &= result == 'ok'
|
||||||
|
out(f'{name}: {result}')
|
||||||
|
out('Tudo conferido.' if ok else 'Há pendências acima.')
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def pedido(orders, payload, confirm, out, wait=time.sleep):
|
||||||
|
cnpj = payload['order']['customer']['cnpj']
|
||||||
|
contacts = orders.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5}).get('itens') or []
|
||||||
|
known = [c for c in contacts if ''.join(ch for ch in str(c.get('cpfCnpj') or '') if ch.isdigit()) == cnpj]
|
||||||
|
out(f"Contato {cnpj}: " + (f"já existe no Tiny (id {known[0]['id']})" if known
|
||||||
|
else 'não existe; será criado com o e-mail como nome'))
|
||||||
|
out('Pedido que será enviado:')
|
||||||
|
out(json.dumps(tiny.order_payload(payload, known[0]['id'] if known else 0), ensure_ascii=False, indent=2))
|
||||||
|
if not confirm:
|
||||||
|
out('Nada foi criado. Repita com --confirmar para criar este pedido no Tiny.')
|
||||||
|
return None
|
||||||
|
first = orders.deliver('teste-integracao', payload)
|
||||||
|
out(f"1º envio: {first['status']} · Tiny id {first['tiny_id']} · nº {first['tiny_number']}")
|
||||||
|
for attempt in range(FIND_ATTEMPTS):
|
||||||
|
found = orders.find(payload)
|
||||||
|
if found:
|
||||||
|
break
|
||||||
|
if attempt < FIND_ATTEMPTS - 1:
|
||||||
|
wait(FIND_WAIT_SECONDS)
|
||||||
|
else:
|
||||||
|
out(f'FALHA: a busca por {tiny.purchase_order(payload["number"])} não encontrou o pedido. '
|
||||||
|
'Um reenvio criaria um duplicado; o 2º envio não foi feito. Cancele o pedido no Olist.')
|
||||||
|
return False
|
||||||
|
second = orders.deliver('teste-integracao', payload)
|
||||||
|
out(f"2º envio: {second['status']} · Tiny id {second['tiny_id']}")
|
||||||
|
passed = second['status'] == 'already-created' and second['tiny_id'] == first['tiny_id']
|
||||||
|
out(('OK: o reenvio encontrou o mesmo pedido.' if passed else 'FALHA: o reenvio não devolveu o mesmo pedido.')
|
||||||
|
+ f" Cancele o pedido nº {first['tiny_number']} no Olist.")
|
||||||
|
return passed
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None, orders=None, out=print):
|
||||||
|
parser = argparse.ArgumentParser(prog='python -m app.tiny_probe')
|
||||||
|
commands = parser.add_subparsers(dest='command', required=True)
|
||||||
|
listing = commands.add_parser('produtos')
|
||||||
|
listing.add_argument('termo', nargs='?', default='')
|
||||||
|
commands.add_parser('conferir')
|
||||||
|
order = commands.add_parser('pedido')
|
||||||
|
order.add_argument('--cnpj', required=True)
|
||||||
|
order.add_argument('--email', required=True)
|
||||||
|
order.add_argument('--celular', required=True)
|
||||||
|
order.add_argument('--modo', choices=sorted(tiny.PRODUCT_SETTINGS), default='file')
|
||||||
|
order.add_argument('--confirmar', action='store_true')
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if orders is None:
|
||||||
|
load_secret_files()
|
||||||
|
orders = tiny.TinyOrders(auth=tiny.TinyAuth())
|
||||||
|
try:
|
||||||
|
if args.command == 'produtos':
|
||||||
|
produtos(orders, args.termo, out)
|
||||||
|
return 0
|
||||||
|
if args.command == 'conferir':
|
||||||
|
return 0 if conferir(orders, out) else 1
|
||||||
|
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())
|
||||||
|
if len(cnpj) != 14:
|
||||||
|
out('Informe um CNPJ com 14 dígitos.')
|
||||||
|
return 2
|
||||||
|
payload = test_order(cnpj, args.email, args.celular, args.modo)
|
||||||
|
return 0 if pedido(orders, payload, args.confirmar, out) is not False else 1
|
||||||
|
except tiny.TinyError as exc:
|
||||||
|
out(f'Tiny: {exc}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -268,6 +268,16 @@ From the report already sent. These are dated promises, not backlog.
|
|||||||
real orders. Still to confirm on the client's account: plan (Construa+),
|
real orders. Still to confirm on the client's account: plan (Construa+),
|
||||||
product ids, token lifetimes, whether pickup needs a transportador, and
|
product ids, token lifetimes, whether pickup needs a transportador, and
|
||||||
rate limits.
|
rate limits.
|
||||||
|
**Supervised run (2026-09-25):** the payload and query parameters were
|
||||||
|
checked against Tiny's published v3 OpenAPI spec (`GET /pedidos` accepts
|
||||||
|
`cpfCnpj` and `dataInicial`; `GET /pedidos/{id}` returns
|
||||||
|
`numeroOrdemCompra`). "Testar conexão" on the Kanban now also reads the four
|
||||||
|
configured products and requires each to be active. `python -m
|
||||||
|
app.tiny_probe` runs from the worker console: `produtos` and `conferir` are
|
||||||
|
read-only; `pedido` shows the test order and creates it only with
|
||||||
|
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
|
||||||
|
guard by search first and only then by a second delivery. Not yet run
|
||||||
|
against the client's account.
|
||||||
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||||
must survive checkout before an output engine can reproduce the approved job).
|
must survive checkout before an output engine can reproduce the approved job).
|
||||||
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ class FakeAuth:
|
|||||||
return 'access-1'
|
return 'access-1'
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, PRODUCTS)
|
class FakeTinyCase(unittest.TestCase):
|
||||||
class TinyTests(unittest.TestCase):
|
"""A Tiny account in memory: contacts, orders and the four products."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.contacts = []
|
self.contacts = []
|
||||||
self.orders = []
|
self.orders = []
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}',
|
||||||
|
'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()}
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
path = request.url.path.removeprefix('/public-api/v3')
|
path = request.url.path.removeprefix('/public-api/v3')
|
||||||
@@ -49,6 +52,13 @@ class TinyTests(unittest.TestCase):
|
|||||||
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
||||||
self.contacts.append(contact)
|
self.contacts.append(contact)
|
||||||
return httpx.Response(200, json={'id': contact['id']})
|
return httpx.Response(200, json={'id': contact['id']})
|
||||||
|
if request.method == 'GET' and path.startswith('/produtos/'):
|
||||||
|
wanted = path.rsplit('/', 1)[-1]
|
||||||
|
if wanted not in self.products:
|
||||||
|
return httpx.Response(404, json={'mensagem': 'não encontrado'})
|
||||||
|
return httpx.Response(200, json=self.products[wanted])
|
||||||
|
if request.method == 'GET' and path == '/produtos':
|
||||||
|
return httpx.Response(200, json={'itens': list(self.products.values())})
|
||||||
if request.method == 'GET' and path == '/pedidos':
|
if request.method == 'GET' and path == '/pedidos':
|
||||||
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
||||||
if request.method == 'GET' and path.startswith('/pedidos/'):
|
if request.method == 'GET' and path.startswith('/pedidos/'):
|
||||||
@@ -65,6 +75,9 @@ class TinyTests(unittest.TestCase):
|
|||||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
||||||
today=date(2026, 9, 24))
|
today=date(2026, 9, 24))
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PRODUCTS)
|
||||||
|
class TinyTests(FakeTinyCase):
|
||||||
def test_payload_carries_contact_products_address_and_freight(self):
|
def test_payload_carries_contact_products_address_and_freight(self):
|
||||||
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
||||||
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
||||||
@@ -110,8 +123,15 @@ class TinyTests(unittest.TestCase):
|
|||||||
def test_connection_check_only_reads(self):
|
def test_connection_check_only_reads(self):
|
||||||
from app.tiny import check
|
from app.tiny import check
|
||||||
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||||
self.assertEqual(results, {'pedidos': 'ok', 'contatos': 'ok'})
|
self.assertEqual(set(results.values()), {'ok'})
|
||||||
|
self.assertEqual(len(results), 6)
|
||||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||||
|
self.products['103']['situacao'] = 'I'
|
||||||
|
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
|
||||||
|
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||||
|
self.assertIn('não está ativo', results['DTF UV 28,5 cm · folha montada'])
|
||||||
|
self.assertEqual(results['DTF UV 28,5 cm · artes avulsas'], 'TINY_PRODUCT_UV_AVULSA sem id')
|
||||||
|
self.assertEqual(results['pedidos'], 'ok')
|
||||||
denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
|
denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
|
||||||
self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
|
self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
|
||||||
|
|
||||||
@@ -121,5 +141,54 @@ class TinyTests(unittest.TestCase):
|
|||||||
tiny.deliver('k4', PAID)
|
tiny.deliver('k4', PAID)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PRODUCTS)
|
||||||
|
class TinyProbeTests(FakeTinyCase):
|
||||||
|
"""The supervised console tool run against the fake Tiny."""
|
||||||
|
|
||||||
|
def probe(self, *argv):
|
||||||
|
from app import tiny_probe
|
||||||
|
lines = []
|
||||||
|
code = tiny_probe.main(list(argv), orders=self.tiny, out=lines.append)
|
||||||
|
return code, '\n'.join(lines)
|
||||||
|
|
||||||
|
def test_products_and_settings_are_listed_without_writing(self):
|
||||||
|
code, text = self.probe('produtos', 'DTF')
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertIn('101\tDTF-101\tProduto 101', text)
|
||||||
|
code, text = self.probe('conferir')
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text)
|
||||||
|
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||||
|
|
||||||
|
def test_order_is_shown_and_not_created_without_confirmation(self):
|
||||||
|
code, text = self.probe('pedido', '--cnpj', '11.222.333/0001-81', '--email', 'a@example.test',
|
||||||
|
'--celular', '16999999999')
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertIn('"numeroOrdemCompra": "DTF-TESTE-', text)
|
||||||
|
self.assertIn('Nada foi criado', text)
|
||||||
|
self.assertEqual(self.orders, [])
|
||||||
|
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||||
|
|
||||||
|
def test_confirmed_order_is_created_once_and_found_on_resend(self):
|
||||||
|
code, text = self.probe('pedido', '--cnpj', '11222333000181', '--email', 'a@example.test',
|
||||||
|
'--celular', '16999999999', '--modo', 'uv', '--confirmar')
|
||||||
|
self.assertEqual(code, 0, text)
|
||||||
|
self.assertEqual(len(self.orders), 1)
|
||||||
|
self.assertEqual(self.orders[0]['itens'][0]['produto'], {'id': 104})
|
||||||
|
self.assertIn('2º envio: already-created', text)
|
||||||
|
|
||||||
|
def test_resend_is_skipped_when_the_search_cannot_find_the_order(self):
|
||||||
|
from app import tiny_probe
|
||||||
|
blind = lambda request: (httpx.Response(200, json={'itens': []})
|
||||||
|
if request.method == 'GET' and request.url.path.endswith('/pedidos')
|
||||||
|
else self.handler(request))
|
||||||
|
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(blind), today=date(2026, 9, 24))
|
||||||
|
payload = tiny_probe.test_order('11222333000181', 'a@example.test', '16999999999', 'file')
|
||||||
|
lines = []
|
||||||
|
self.assertFalse(tiny_probe.pedido(self.tiny, payload, True, lines.append, wait=lambda s: None))
|
||||||
|
self.assertEqual(len(self.orders), 1)
|
||||||
|
self.assertIn('o 2º envio não foi feito', lines[-1])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -632,7 +632,7 @@ function renderIntegrations(){
|
|||||||
const actions=[connect];
|
const actions=[connect];
|
||||||
if(t.connected){
|
if(t.connected){
|
||||||
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
|
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
|
||||||
say(r.ok?'Conexão com o Tiny ok.':'Tiny: pedidos '+r.results.pedidos+' · contatos '+r.results.contatos,!r.ok);}));}
|
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
|
||||||
cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn',
|
cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn',
|
||||||
t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
|
t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
|
||||||
'Conecte com uma conta do Tiny.',actions));
|
'Conecte com uma conta do Tiny.',actions));
|
||||||
|
|||||||
Reference in New Issue
Block a user