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
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:
144
tests/print_file_test.py
Normal file
144
tests/print_file_test.py
Normal 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()
|
||||
@@ -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
115
tests/test_mercadopago.py
Normal 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
190
tests/test_printfile.py
Normal 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
79
tests/test_tiny.py
Normal 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()
|
||||
Reference in New Issue
Block a user