feat: generate print files, collect delivery addresses, add provider adapters
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped

Week 2 work that did not need client inputs.

Print files (1.4): each paid item gets a PDF the width of the film and the
length of the approved layout, with every copy at its reviewed position,
rotation and mirror. Sources are embedded once at original resolution; JPEG
bytes pass through and PNG alpha becomes a soft mask. Artwork the generator
cannot reproduce goes to hand preparation with the reason. The worker renders
outside any transaction, and the operator approves the generated file as the
final one through the existing review.

Delivery address (3.8): required for any non-pickup quote, bound to the
quoted CEP, carried into the order snapshot, the Kanban card and Tiny.

Kanban (1.5): print-file status per item, and a panel of payment events that
need a person (money without an order, refunds after an order) until an
operator records the resolution.

Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API
documentation and tested against fake transports only. Selectable for
sandbox testing with their credentials; the production preflight still
blocks release. Adds payment intents and a PIX step on the Site.

MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI
storage use Chainguard's MinIO build, pinned by digest.

Verified with the full CI integration sequence on a fresh local build,
including the new print_file_test and both browser suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-24 11:56:46 -03:00
parent cfcbe545f1
commit c18b9e5b87
35 changed files with 2032 additions and 61 deletions

144
tests/print_file_test.py Normal file
View File

@@ -0,0 +1,144 @@
"""Print files and payment issues, against a running stack.
A paid order must get a print file generated from its approved layout, which
the operator can download and approve as the final file without re-uploading
anything. Artwork the generator cannot read goes to hand preparation with a
reason, and a paid notification that did not become an order stays on the
Kanban until someone records what was done.
Run inside the API container (it needs Pillow): python -m tests.print_file_test
"""
import io
import re
import time
from urllib.request import urlopen
from uuid import uuid4
from PIL import Image
from tests.payment_test import deliver
from tests.smoke_test import Client, upload_bytes
PT_PER_CM = 72 / 2.54
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
def artwork_png():
image = Image.new('RGBA', (600, 300), (0, 0, 0, 0))
for x in range(40, 560):
for y in range(40, 260):
image.putpixel((x, y), (220, 30, 60, 255))
out = io.BytesIO()
image.save(out, 'PNG')
return out.getvalue()
def loose_item(uid, copies=2):
"""Two copies of a 20 x 10 cm artwork side by side on 57 cm film."""
placements = [{'source_index': 0, 'copy_index': i, 'x_cm': 20 * i, 'y_cm': 0,
'width_cm': 20, 'length_cm': 10, 'rotation_degrees': 0, 'mirrored': False}
for i in range(copies)]
return {'mode': 'avulsa', 'metres': '0.1', 'grade': 90, 'uploads': [uid],
'production': {'version': 2, 'film_width_cm': 57, 'height_cm': 10,
'sources': [{'upload_id': uid, 'kind': 'artwork', 'width_cm': 20,
'length_cm': 10, 'copies': copies, 'rotation_degrees': 0,
'mirrored': False, 'measurement': 'file'}],
'placements': placements},
'quality_status': 'ok', 'quality_acknowledged': False}
def approved_quote(client, item):
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
'items': [item], 'freight': {'service': 'pickup'}})
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
return quote['id'], approved['total_cents']
def paid_order(client, item):
quote_id, _ = approved_quote(client, item)
return client.call('/orders/dev-paid', {'quote_id': quote_id})
def wait_print(client, oid, wanted):
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
order = next(o for o in client.call('/operator/board', operator=True)['orders'] if o['id'] == oid)
rows = order['print_files']
if rows and rows[0]['status'] in ('ready', 'manual', 'failed'):
assert rows[0]['status'] == wanted, rows
return order, rows[0]
time.sleep(1)
raise AssertionError('Print file was not generated in time')
def run():
client = Client()
client.call('/session')
uid = upload_bytes(client, artwork_png(), name='LOCAL-PRINT-TEST.png')
order = paid_order(client, loose_item(uid))
order, row = wait_print(client, order['id'], 'ready')
assert row['detail']['placements'] == 2 and row['detail']['min_dpi'] == round(600 / (20 / 2.54))
link = client.call('/operator/uploads/' + str(row['upload_id']) + '/download', operator=True)
with urlopen(link['url'], timeout=30) as response:
pdf = response.read()
assert pdf.startswith(b'%PDF-') and pdf.rstrip().endswith(b'%%EOF')
width, height = map(float, re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf).groups())
assert abs(width - 57 * PT_PER_CM) < 0.01 and abs(height - 10 * PT_PER_CM) < 0.01, (width, height)
print('PASS: paid order generated a 57 x 10 cm print file with both copies')
# The operator approves the generated file as the final one, with no upload,
# and the order can then enter the print queue.
oid = order['id']
result = client.call('/operator/orders/' + oid + '/final-files',
{'version': order['version'], 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
'note': 'Generated print file checked'}, operator=True)
version = result['version']
for state in ('tra', 'fil'):
client.call('/operator/orders/' + oid + '/move', {'state': state, 'version': version}, operator=True)
version += 1
# Once queued for printing, the final set can no longer change.
client.call('/operator/orders/' + oid + '/final-files',
{'version': version, 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
'note': 'again'}, operator=True, expected=409)
print('PASS: generated file approved as final without re-uploading; order queued for printing')
# A customer cannot see or reuse another order's generated file.
other = Client()
other.call('/session')
other.call('/uploads/' + str(row['upload_id']), expected=404)
# Artwork the generator cannot read goes to hand preparation, with a reason.
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
order = paid_order(client, loose_item(manual, copies=1))
order, row = wait_print(client, order['id'], 'manual')
assert 'not an image' in row['detail']['reason'], row
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
assert rows[0]['status'] == 'pending'
wait_print(client, order['id'], 'manual')
print('PASS: unreadable artwork is routed to hand preparation and can be retried')
# A signed, paid notification for the wrong amount does not become an order;
# it waits on the Kanban until an operator records the resolution.
# Same client, so the same operator session: operator logins share a
# 10-per-15-minutes account limit with every other suite in the run.
quote_id, total = approved_quote(client, loose_item(uid, copies=1))
event_id = 'print-test-underpaid-' + uuid4().hex
outcome = deliver({'event_id': event_id, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 1})
assert outcome['outcome'].startswith('refused'), outcome
issues = client.call('/operator/board', operator=True)['payment_issues']
issue = next(i for i in issues if i['event_id'] == event_id)
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: second resolution'}, operator=True, expected=404)
issues = client.call('/operator/board', operator=True)['payment_issues']
assert not any(i['event_id'] == event_id for i in issues)
print('PASS: refused paid notification is listed until an operator resolves it')
if __name__ == '__main__':
run()

View File

@@ -129,8 +129,16 @@ def run():
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422)
# Freight quoted by CEP alone cannot ship: the address is required, must be
# the quoted CEP, and a pickup order takes none.
client.call('/quotes',{**draft,'destination':None},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'postal_code':'01001000'}},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'state':'XX'}},expected=422)
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=422)
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
outside={**items[0],'production':{**items[0]['production'],
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
@@ -139,7 +147,7 @@ def run():
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft)
assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
qid=quote['id']
other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
@@ -152,6 +160,7 @@ def run():
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert approved['destination']=={**draft['destination'],'complement':''}
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)

115
tests/test_mercadopago.py Normal file
View File

@@ -0,0 +1,115 @@
"""The Mercado Pago adapter against a fake HTTP transport.
This proves the adapter follows the documented contract. It does not prove the
integration: that needs the sandbox flows with the client's own account.
Runs where httpx is installed (the API image, or a local virtualenv).
"""
import hashlib
import hmac
import json
import unittest
import httpx
from app.mercadopago import MercadoPagoPayment, event_from_payment
SECRET = 'test-webhook-secret'
NOW = 1_790_000_000
def signature(data_id, request_id, ts, secret=SECRET):
manifest = ''
if data_id:
manifest += f'id:{data_id};'
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
return f'ts={ts},v1=' + hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest()
class MercadoPagoTests(unittest.TestCase):
def setUp(self):
self.requests = []
self.payments = {}
def handler(request):
self.requests.append(request)
if request.method == 'GET':
payment_id = request.url.path.rsplit('/', 1)[-1]
return httpx.Response(200, json=self.payments[payment_id])
body = json.loads(request.content)
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
'point_of_interaction': {'transaction_data': {
'qr_code': '000201PIX', 'qr_code_base64': 'aW1n', 'ticket_url': 'https://mp/t'}},
**{k: body[k] for k in ('transaction_amount', 'external_reference')}}
return httpx.Response(201, json=payment)
self.mp = MercadoPagoPayment('TEST-token', SECRET, 'https://dtf.example/api/payments/webhook',
transport=httpx.MockTransport(handler), clock=lambda: NOW)
def test_signature_follows_the_documented_manifest(self):
headers = {'x-signature': signature('123456', 'req-1', NOW), 'x-request-id': 'req-1'}
self.assertTrue(self.mp.verify(headers, b'{}', {'data.id': '123456'}))
# Any change to the signed values breaks it.
self.assertFalse(self.mp.verify(headers, b'{}', {'data.id': '123457'}))
self.assertFalse(self.mp.verify({**headers, 'x-request-id': 'req-2'}, b'{}', {'data.id': '123456'}))
self.assertFalse(self.mp.verify({'x-signature': signature('123456', 'req-1', NOW, 'other'),
'x-request-id': 'req-1'}, b'{}', {'data.id': '123456'}))
self.assertFalse(self.mp.verify({}, b'{}', {'data.id': '123456'}))
def test_absent_values_are_left_out_and_alphanumeric_ids_lowercased(self):
self.assertTrue(self.mp.verify({'x-signature': signature('abc123', None, NOW)}, b'{}',
{'data.id': 'ABC123'}))
def test_old_signatures_are_refused(self):
old = NOW - 3600
self.assertFalse(self.mp.verify({'x-signature': signature('1', 'r', old), 'x-request-id': 'r'},
b'{}', {'data.id': '1'}))
def test_notification_is_only_a_pointer(self):
# The body claims nothing about amount or status; the API is asked.
self.payments['999'] = {'id': 999, 'status': 'approved', 'currency_id': 'BRL',
'transaction_amount': 123.45, 'external_reference': 'quote-1'}
body = json.dumps({'id': 42, 'type': 'payment', 'action': 'payment.updated',
'data': {'id': '999'}}).encode()
event = self.mp.parse(body, {'data.id': '999', 'type': 'payment'})
self.assertEqual((event.status, event.amount_cents, event.reference), ('approved', 12345, 'quote-1'))
self.assertEqual(event.event_id, '999:approved')
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
def test_amounts_outside_brl_centavos_are_not_trusted(self):
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
{'currency_id': 'BRL', 'transaction_amount': 10.001},
{'currency_id': 'BRL', 'transaction_amount': None}):
self.assertIsNone(event_from_payment({'id': 1, 'status': 'approved', **payment}).amount_cents)
self.assertEqual(event_from_payment({'id': 1, 'status': 'approved', 'currency_id': 'BRL',
'transaction_amount': 0.1}).amount_cents, 10)
def test_statuses_map_to_the_service_vocabulary(self):
for provider, ours in (('in_process', 'pending'), ('charged_back', 'refunded'),
('cancelled', 'cancelled'), ('rejected', 'rejected')):
self.assertEqual(event_from_payment({'id': 1, 'status': provider}).status, ours)
def test_pix_payment_is_idempotent_on_the_quote(self):
created = self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
{'mail': 'a@example.test', 'cnpj': '11222333000181'})
request = self.requests[-1]
body = json.loads(request.content)
self.assertEqual(request.headers['x-idempotency-key'],
'dtf-quote-11111111-2222-3333-4444-555555555555-pix')
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555')
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
def test_card_payment_uses_the_browser_token_only(self):
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_abc', 'payment_method_id': 'visa', 'installments': 3})
body = json.loads(self.requests[-1].content)
self.assertEqual((body['token'], body['payment_method_id'], body['installments']), ('tok_abc', 'visa', 3))
self.assertNotIn('card_number', json.dumps(body))
if __name__ == '__main__':
unittest.main()

190
tests/test_printfile.py Normal file
View File

@@ -0,0 +1,190 @@
"""The print file must reproduce the reviewed layout: size, place, turn and mirror.
Runs where Pillow is installed (the API image, or a local virtualenv). The
raster checks additionally need PyMuPDF to draw the page, and skip without it;
the structural checks do not.
"""
import io
import os
import re
import tempfile
import unittest
import zlib
from PIL import Image
from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render
try:
import fitz # PyMuPDF, a development-only rasteriser
except ImportError:
fitz = None
RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0)
def quadrants(width=80, height=40, alpha=False):
"""Top-left red, top-right green, bottom-left blue, bottom-right yellow."""
image = Image.new('RGBA' if alpha else 'RGB', (width, height))
for x in range(width):
for y in range(height):
left, top = x < width // 2, y < height // 2
color = RED if left and top else GREEN if top else BLUE if left else YELLOW
image.putpixel((x, y), color + ((255,) if alpha else ()))
return image
def item(placements, sources, height_cm, billed='1.0', film=57):
return {'mode': 'avulsa', 'billed_metres': billed,
'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm,
'sources': sources, 'placements': placements}}
def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False):
return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork',
'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies,
'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'}
def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0):
return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y,
'width_cm': width, 'length_cm': length,
'rotation_degrees': rotation, 'mirrored': mirrored}
class PrintFileTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def save(self, image, name, **options):
path = os.path.join(self.dir.name, name)
image.save(path, **options)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_page_is_the_film_and_the_layout_length(self):
path = self.save(quadrants(), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf)
self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2)
self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2)
# One embedded image, drawn once per copy.
self.assertEqual(pdf.count(b'/Subtype /Image'), 1)
content = self.content(pdf)
self.assertEqual(content.count(b' Do '), 2)
self.assertEqual(detail['placements'], 2)
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF'))
def content(self, pdf):
# The page content is the last Flate stream before the page object.
streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S)
return zlib.decompress(streams[-1])
def test_jpeg_bytes_are_embedded_unchanged(self):
path = self.save(quadrants(), 'a.jpg', quality=90)
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
with open(path, 'rb') as original:
self.assertIn(original.read(), pdf)
self.assertIn(b'/DCTDecode', pdf)
def test_transparency_survives_as_a_soft_mask(self):
image = quadrants(alpha=True)
image.putpixel((0, 0), (0, 0, 0, 0))
path = self.save(image, 'a.png')
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
self.assertIn(b'/SMask', pdf)
self.assertEqual(pdf.count(b'/Subtype /Image'), 2)
def test_exif_orientation_is_honoured_like_a_browser(self):
# Stored landscape, tagged "rotate 90": browsers show it portrait.
image = quadrants(80, 40)
exif = Image.Exif()
exif[0x0112] = 6
path = self.save(image, 'a.jpg', exif=exif)
pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path])
self.assertNotIn(b'/DCTDecode', pdf)
with self.assertRaises(Unsupported):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png')
with self.assertRaisesRegex(Unsupported, 'proportions'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
with self.assertRaisesRegex(Unsupported, 'billed'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
with self.assertRaisesRegex(Unsupported, 'not an image'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
# 6 m is longer than a PDF page may be (about 5.08 m).
path = self.save(quadrants(40, 800), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600,
billed='6.0'), [path])
self.assertEqual(detail['user_unit'], 2)
self.assertIn(b'/UserUnit 2', pdf)
def test_matrix_maps_corners_like_the_canvas(self):
# Box 20 x 10 cm at the page's top-left; which image corner lands where.
page = 10 * PT_PER_CM
def corner(matrix, u, v):
a, b, c, d, e, f = matrix
x, y = a * u + c * v + e, b * u + d * v + f
return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6)
# Image top-left is unit (0, 1).
cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10),
(270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)}
for (rotation, mirrored), expected in cases.items():
matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page)
self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored))
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_page_matches_the_layout(self):
# Each case: the colour expected in the box's TL, TR, BL, BR quadrant.
cases = {
(0, False): (RED, GREEN, BLUE, YELLOW),
(90, False): (BLUE, RED, YELLOW, GREEN),
(180, False): (YELLOW, BLUE, GREEN, RED),
(0, True): (GREEN, RED, YELLOW, BLUE),
(90, True): (YELLOW, GREEN, BLUE, RED),
}
image = quadrants(80, 40)
for (rotation, mirrored), expected in cases.items():
turned = rotation % 180 == 90
width, length = (10, 20) if turned else (20, 10)
path = self.save(image, f'q{rotation}{mirrored}.png')
# Sources are described after the customer's turn, like the box.
spec = item([placement(12, 5, width, length, rotation, mirrored)],
[source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
px = int(x_cm / 2.54 * dpi)
py = int(y_cm / 2.54 * dpi)
return pixmap.pixel(px, py)
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'rotation {rotation} mirrored {mirrored}: {got} != {expected}')
# Outside the box the film stays empty.
self.assertEqual(sample(2, 2), (255, 255, 255))
if __name__ == '__main__':
unittest.main()

79
tests/test_tiny.py Normal file
View File

@@ -0,0 +1,79 @@
"""The Tiny 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.
"""
import json
import unittest
from urllib.parse import parse_qs
import httpx
from app.tiny import TinyError, TinyOrders, 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}}
class TinyTests(unittest.TestCase):
def setUp(self):
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}}]}})
self.tiny = TinyOrders('test-token', transport=httpx.MockTransport(handler))
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')
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'])
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['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')
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)
if __name__ == '__main__':
unittest.main()