From 988b252f9de1704f5bec213cc47e300b324b9eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 25 Sep 2026 10:23:07 -0300 Subject: [PATCH] feat: check Tiny products and add a supervised order test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- app/tiny.py | 33 +++++++++--- app/tiny_probe.py | 132 +++++++++++++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 10 ++++ tests/test_tiny.py | 75 ++++++++++++++++++++++++-- web/kanban.js | 2 +- 5 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 app/tiny_probe.py diff --git a/app/tiny.py b/app/tiny.py index b8f6dea..e334d47 100644 --- a/app/tiny.py +++ b/app/tiny.py @@ -223,16 +223,35 @@ def order_payload(payload, contact_id, today=None): 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 - one contact listed, nothing created. Raises TinyNotConnected when there is - no usable connection; otherwise reports each read separately.""" - orders = TinyOrders(auth=auth or TinyAuth(), transport=transport) + one contact listed and each configured product found active, nothing + created. Raises TinyNotConnected when there is no usable connection; + 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 = {} - for name, path in (('pedidos', '/pedidos'), ('contatos', '/contatos')): + for name, read in reads: try: - orders.request('GET', path, params={'limit': 1}) - results[name] = 'ok' + results[name] = read() except TinyNotConnected: raise except TinyError as exc: diff --git a/app/tiny_probe.py b/app/tiny_probe.py new file mode 100644 index 0000000..b920106 --- /dev/null +++ b/app/tiny_probe.py @@ -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()) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5e31eed..666f7c6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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+), product ids, token lifetimes, whether pickup needs a transportador, and 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 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 diff --git a/tests/test_tiny.py b/tests/test_tiny.py index f261f73..3676583 100644 --- a/tests/test_tiny.py +++ b/tests/test_tiny.py @@ -31,12 +31,15 @@ class FakeAuth: return 'access-1' -@mock.patch.dict(os.environ, PRODUCTS) -class TinyTests(unittest.TestCase): +class FakeTinyCase(unittest.TestCase): + """A Tiny account in memory: contacts, orders and the four products.""" + def setUp(self): self.contacts = [] self.orders = [] 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): 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)} self.contacts.append(contact) 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': return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]}) 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), today=date(2026, 9, 24)) + +@mock.patch.dict(os.environ, PRODUCTS) +class TinyTests(FakeTinyCase): 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']), @@ -110,8 +123,15 @@ class TinyTests(unittest.TestCase): def test_connection_check_only_reads(self): from app.tiny import check 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.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'))) self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403')) @@ -121,5 +141,54 @@ class TinyTests(unittest.TestCase): 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__': unittest.main() diff --git a/web/kanban.js b/web/kanban.js index 82b3829..bcd26b2 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -632,7 +632,7 @@ function renderIntegrations(){ const actions=[connect]; if(t.connected){ 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', t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'): 'Conecte com uma conta do Tiny.',actions));