"""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 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 datetime import date from unittest import mock import httpx 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'}, 'items': [{'mode': 'avulsa', 'grade': 90, 'billed_metres': '2.8', 'unit_cents': 2490}], 'freight': {'service': 'mock-standard', 'total_cents': 1500}, 'destination': {'recipient': 'Loja Teste', 'street': 'Rua A', 'number': '10', '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', 'TINY_FORMA_ENVIO_RETIRADA': '77'} PICKUP = {**PAID, 'order': {**PAID['order'], 'destination': None, 'freight': {'service': 'pickup', 'total_cents': 0}}} READY = {'order_id': PAID['order_id'], 'number': 42, 'event': 'ready', 'reason': '', 'order': PICKUP['order']} class FakeAuth: def access_token(self): return 'access-1' class FakeTinyCase(unittest.TestCase): """A Tiny account in memory: contacts, orders, products and formas de envio.""" def setUp(self): self.contacts = [] self.orders = [] self.calls = [] self.refuse_status = 0 self.methods = {'77': {'id': 77, 'nome': 'Retirar pessoalmente', 'tipo': '6'}, '78': {'id': 78, 'nome': 'Correios', 'tipo': '1'}} 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') self.calls.append((request.method, path)) assert request.headers['authorization'] == 'Bearer access-1' if request.method == 'GET' and path == '/contatos': cnpj = request.url.params.get('cpfCnpj') return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]}) 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.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.startswith('/formas-envio/'): wanted = path.rsplit('/', 1)[-1] if wanted not in self.methods: return httpx.Response(404, json={'mensagem': 'não encontrado'}) return httpx.Response(200, json=self.methods[wanted]) if request.method == 'GET' and path == '/formas-envio': return httpx.Response(200, json={'itens': list(self.methods.values())}) if request.method == 'PUT' and path.startswith('/pedidos/') and path.endswith('/situacao'): if self.refuse_status: self.refuse_status -= 1 return httpx.Response(503, text='indisponível') wanted = int(path.split('/')[2]) next(o for o in self.orders if o['id'] == wanted)['situacao'] = json.loads(request.content)['situacao'] return httpx.Response(204) 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 = {'situacao': 0, **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.handler = handler 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']), (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}}}, 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'], second['status']), ('created', 'already-created')) self.assertEqual(first['tiny_id'], second['tiny_id']) 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_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_connection_check_only_reads(self): from app.tiny import check results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler)) self.assertEqual(set(results.values()), {'ok'}) self.assertEqual(len(results), 7) 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')) 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) @mock.patch.dict(os.environ, PRODUCTS) class TinyStatusTests(FakeTinyCase): """Situações that the client's Tiny -> n8n notices turn into WhatsApp messages.""" def puts(self): return [call for call in self.calls if call[0] == 'PUT'] def test_nothing_changes_while_status_updates_are_off(self): self.assertEqual(self.tiny.deliver('k1', PICKUP)['status'], 'created') self.assertEqual(self.orders[0]['situacao'], 0) self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'not-applicable') self.assertEqual(self.puts(), []) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_paid_order_is_approved_once(self): first = self.tiny.deliver('k1', PICKUP) self.assertEqual((first['status'], first['situacao']), ('created', 'Aprovada')) self.assertEqual(self.orders[0]['situacao'], 3) second = self.tiny.deliver('k1', PICKUP) self.assertEqual(second['status'], 'already-created') self.assertNotIn('situacao', second) self.assertEqual(len(self.puts()), 1) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_retry_after_a_failed_approval_finishes_it(self): self.refuse_status = 1 with self.assertRaises(TinyError): self.tiny.deliver('k1', PICKUP) self.assertEqual((len(self.orders), self.orders[0]['situacao']), (1, 0)) retry = self.tiny.deliver('k1', PICKUP) self.assertEqual((retry['status'], retry['situacao']), ('already-created', 'Aprovada')) self.assertEqual(self.calls.count(('POST', '/pedidos')), 1) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_an_order_already_moved_on_is_not_approved_again(self): self.tiny.deliver('k1', PICKUP) self.orders[0]['situacao'] = 7 self.tiny.deliver('k1', PICKUP) self.assertEqual(self.orders[0]['situacao'], 7) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_ready_pickup_order_is_set_ready_once_by_its_known_id(self): sale = self.tiny.deliver('k1', PICKUP) self.calls.clear() ready = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']}) self.assertEqual((ready['status'], ready['situacao'], ready['tiny_number']), ('status-updated', 'Pronto para envio', sale['tiny_number'])) self.assertEqual(self.orders[0]['situacao'], 7) self.assertNotIn(('GET', '/pedidos'), self.calls, 'a known id needs no search') again = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']}) self.assertEqual(again['status'], 'status-unchanged') self.assertEqual(len(self.puts()), 1) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_ready_without_an_id_finds_the_order(self): self.tiny.deliver('k1', PICKUP) self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'status-updated') self.assertEqual(self.orders[0]['situacao'], 7) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_ready_before_the_sale_reached_tiny_is_retried(self): with self.assertRaisesRegex(TinyError, 'not in Tiny yet'): self.tiny.deliver('k2', READY) self.assertEqual(self.puts(), []) @mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'}) def test_ready_delivery_order_waits_for_freight(self): self.tiny.deliver('k1', PAID) self.calls.clear() ready = self.tiny.deliver('k2', {**READY, 'order': PAID['order']}) self.assertEqual(ready['status'], 'not-applicable') self.assertEqual(self.calls, []) def test_pickup_orders_carry_the_pickup_forma_de_envio(self): self.assertEqual(order_payload(PICKUP, 1)['transportador'], {'formaEnvio': {'id': 77}}) self.assertNotIn('transportador', order_payload(PAID, 1)) with mock.patch.dict(os.environ, {'TINY_FORMA_ENVIO_RETIRADA': ''}): self.assertNotIn('transportador', order_payload(PICKUP, 1)) @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.assertIn('TINY_FORMA_ENVIO_RETIRADA (retirada): ok Retirar pessoalmente', text) code, text = self.probe('formas-envio') self.assertIn('77\t6\tRetirar pessoalmente', 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()