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));