All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
PDF artwork: a single-page PDF source is placed in the print file as a vector form through pikepdf, never rasterised, using the CropBox and inherited /Rotate the Site measured with pdf.js. Multi-page and protected PDFs go to hand preparation. PyMuPDF was not used because of its AGPL licence. Raster tests cover crop, page rotation, placement rotation and mirroring, and fail when the rotation or crop handling is broken. Card payment: Mercado Pago's Card Payment Brick on the Site when MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's secure fields. Each card attempt has its own idempotency key, and the intent route refuses new attempts once a payment is approved or a card is in review, so a quote cannot be charged twice. The Site CSP admits Mercado Pago's origins only through PAYMENT_CSP_SOURCES, empty by default. Logins: every attempt counts against the source address, only failures against the account. Counting successful sign-ins let ordinary use lock an operator out and made CI's final browser sign-in fail. No new required settings; production behaviour is unchanged until the provider credentials are configured. Verified with the full CI integration sequence locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
7.8 KiB
Python
159 lines
7.8 KiB
Python
"""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(kind='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()
|
|
if kind == 'PDF':
|
|
image.convert('RGB').save(out, 'PDF', resolution=72)
|
|
else:
|
|
image.save(out, kind)
|
|
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(), 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 single-page PDF is placed as a vector form, not rasterised.
|
|
pdf_upload = upload_bytes(client, artwork('PDF'), name='LOCAL-PRINT-TEST.pdf')
|
|
pdf_order = paid_order(client, loose_item(pdf_upload))
|
|
_, pdf_row = wait_print(client, pdf_order['id'], 'ready')
|
|
assert pdf_row['detail']['vector_sources'] == 1 and pdf_row['detail']['min_dpi'] is None, pdf_row
|
|
link = client.call('/operator/uploads/' + str(pdf_row['upload_id']) + '/download', operator=True)
|
|
with urlopen(link['url'], timeout=30) as response:
|
|
generated = response.read()
|
|
assert b'/Subtype /Form' in generated or b'/Subtype/Form' in generated
|
|
print('PASS: PDF artwork generated as a vector print file')
|
|
|
|
# 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()
|