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()
|
||||
Reference in New Issue
Block a user