Files
dtf-system/tests/smoke_test.py
Cauê Faleiros aec5b1d054
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m45s
feat: send order situações to Tiny for the client's WhatsApp notices
The client already sends WhatsApp notices from Tiny's order situação
(Tiny webhook -> middleware -> n8n). With TINY_STATUS_UPDATES on, a paid
order is set to "Aprovada" once and a finished pickup order to "Pronto
para envio"; pickup orders carry the client's pickup forma de envio
(TINY_FORMA_ENVIO_RETIRADA). The ready event now carries the order and
the Tiny id from the sale's receipt. Off by default until go-live, when
n8n stops sending the DTFIMP designer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:17:00 -03:00

216 lines
13 KiB
Python

"""Local stack integration checks; creates and retains clearly named test orders.
Run: python3 -m tests.smoke_test. Standard library only. Honors .env/environment.
"""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import time
from urllib.error import HTTPError
from urllib.request import build_opener, HTTPCookieProcessor, Request, urlopen
from uuid import uuid4
if Path('.env').exists():
for line in Path('.env').read_text().splitlines():
if line.strip() and not line.startswith('#') and '=' in line:
key,value=line.split('=',1)
os.environ.setdefault(key,value)
# CI runs these from a container on the stack's own network, because a runner
# container cannot reach ports published on the host's loopback. SITE_BASE_URL
# points at the gateway by service name; SITE_HOST_HEADER keeps the Host the
# gateway and TrustedHostMiddleware expect, so the security configuration under
# test stays identical to a developer's localhost run.
BASE=os.environ.get('SITE_BASE_URL') or 'http://localhost:'+os.environ.get('SITE_PORT','8080')
HOST_HEADER=os.environ.get('SITE_HOST_HEADER')
def with_host(headers=None):
headers=dict(headers or {})
if HOST_HEADER and not any(k.lower()=='host' for k in headers):
headers['Host']=HOST_HEADER
return headers
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar()
self.opener=build_opener(HTTPCookieProcessor(self.jar))
self.operator_client=None
def call(self,path,body=None,operator=False,expected=200,method=None):
headers=with_host({'Content-Type':'application/json'})
if operator:
if self.operator_client is None:
self.operator_client=Client()
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
return self.operator_client.call(path,body,expected=expected,method=method)
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),
headers=headers,method=method)
try:
with self.opener.open(request,timeout=30) as response:
assert response.status==expected,(path,response.status,expected)
return json.load(response)
except HTTPError as exc:
if exc.code!=expected:raise AssertionError((path,exc.code,exc.read().decode())) from exc
return json.load(exc)
def wait_scan(client, uid, operator=False, expected='clean'):
prefix='/operator/uploads' if operator else '/uploads'
deadline=time.monotonic()+150
while time.monotonic()<deadline:
state=client.call(prefix+'/'+uid,operator=operator)
if state['scan_state'] in ('clean','rejected','error'):
assert state['scan_state']==expected,state
return state
time.sleep(0.5)
raise AssertionError('Malware scan did not finish')
def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected_scan='clean'):
operator=order_id is not None
prefix='/operator/uploads' if operator else '/uploads'
start='/operator/orders/'+order_id+'/uploads' if operator else prefix
data=client.call(start,{'name':name,'size':len(content)},operator=operator)
uid=data['id'];size=data['part_bytes']
for offset in range(0,len(content),size):
url=client.call(prefix+'/'+uid+'/parts/'+str(offset//size+1),{},operator=operator)['url']
with urlopen(Request(url,data=content[offset:offset+size],method='PUT'),timeout=30) as response:
assert response.status==200
client.call(prefix+'/'+uid+'/complete',{},operator=operator)
wait_scan(client,uid,operator,expected_scan)
return uid
def item_spec(mode, metres, grade, uid):
film_width=28.5 if mode in ('uvfile','uv') else 57
length_cm=float(metres)*100
return {'mode':mode,'metres':str(metres),'grade':grade,'uploads':[uid],
'production':{'version':2,'film_width_cm':film_width,'height_cm':length_cm,
'sources':[{'upload_id':uid,
'kind':'sheet' if mode in ('file','uvfile') else 'artwork',
'width_cm':film_width,'length_cm':length_cm,'copies':1,
'rotation_degrees':0,'mirrored':False,'measurement':'customer'}],
'placements':[{'source_index':0,'copy_index':0,'x_cm':0,'y_cm':0,
'width_cm':film_width,'length_cm':length_cm,
'rotation_degrees':0,'mirrored':False}]},
'quality_status':'unverified' if grade==0 else 'ok',
'quality_acknowledged':False}
def run():
client=Client();other=Client()
config=client.call('/session');other.call('/session')
assert client.call('/health')['integrations']=='fake'
assert 0 < config['max_upload_bytes'] <= 128 * 1024 * 1024
client.call('/uploads',{'name':'too-large.cdr',
'size':config['max_upload_bytes']+1},expected=413)
cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id']
other.call('/uploads/'+cancelled,expected=404,method='DELETE')
assert client.call('/uploads/'+cancelled,method='DELETE')['cancelled']
client.call('/uploads/'+cancelled,expected=410)
client.call('/operator/board',expected=401)
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
other.call('/uploads/'+uid,expected=404)
other.call('/uploads/'+uid+'/parts/1',{},expected=404)
signed=client.call('/uploads/'+uid+'/parts/1',{})['url']
with urlopen(Request(signed,data=content[:block],method='PUT'),timeout=30) as response:assert response.status==200
assert client.call('/uploads/'+uid)['parts']==[1]
client.call('/uploads/'+uid+'/complete',{},expected=409)
signed=client.call('/uploads/'+uid+'/parts/2',{})['url']
with urlopen(Request(signed,data=content[block:],method='PUT'),timeout=30) as response:assert response.status==200
client.call('/uploads/'+uid+'/complete',{})
client.call('/uploads/'+uid+'/complete',{})
wait_scan(client,uid)
client.call('/uploads/'+uid+'/parts/1',{},expected=409)
download=client.call('/operator/uploads/'+uid+'/download',operator=True)
with urlopen(download['url'],timeout=30) as response:assert hashlib.sha256(response.read()).digest()==hashlib.sha256(content).digest()
unsigned=download['url'].split('?')[0]
try:urlopen(unsigned,timeout=10);raise AssertionError('Bucket must be private')
except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
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'},
'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}]}}
client.call('/quotes',{**draft,'items':[outside]},expected=422)
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
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'},'destination':None},expected=409)
qid=quote['id']
other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
altered={**items[0],'production':{**items[0]['production'],
'sources':[{**items[0]['production']['sources'][0],'measurement':'file'}]}}
client.call('/operator/quotes/'+qid+'/approve',{'items':[altered,*items[1:]]},operator=True,expected=422)
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
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)
# Concurrent retries must produce precisely one payment/order/outbox pair.
with ThreadPoolExecutor(max_workers=4) as executor:
paid=list(executor.map(lambda _:client.call('/orders/dev-paid',{'quote_id':qid}),range(4)))
assert len({p['id'] for p in paid})==1
order=paid[0];oid=order['id']
assert order['payment']['status']=='paid' and order['snapshot']==approved
board=client.call('/operator/board',operator=True)
assert len([o for o in board['orders'] if o['quote_id']==qid])==1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':0},operator=True,expected=409)
client.call('/operator/orders/'+oid+'/move',{'state':'cor','version':0},operator=True,expected=422)
version=0
for state in ('cor','rec','tra','fil','imp','fin'):
if state=='fil':
client.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
files=[{'item_index':i,'upload_id':upload_bytes(client,b'LOCAL FINAL FIXTURE '+str(i).encode(),order_id=oid)} for i in range(4)]
result=client.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':files,'note':'Local test manual final-file approval'},operator=True)
version=result['version']
moved=client.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Local test correction' if state=='cor' else ''},operator=True)
version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
# A mistaken move can be undone one stage at a time, with an internal
# reason. The customer is not told again: going back and forward once more
# adds no messages (the outbox count below stays 8).
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
assert back['state']=='imp';version+=1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
history=client.call('/operator/orders/'+oid+'/history',operator=True)
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30
while time.monotonic()<deadline:
events=[e for e in client.call('/operator/events?order='+str(order['number']),operator=True)['events'] if e['payload']['order_id']==oid]
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1)
else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8
# Tiny sets the pickup situação from the order it is sent; the fake sale
# has no Tiny id, so the real adapter would search for it.
ready={e['provider']:e['payload'] for e in events if e['payload']['event']=='ready'}
assert ready['tiny']['order']['freight']==order['snapshot']['freight'] and 'tiny_id' in ready['tiny']
assert 'order' not in ready['whatsapp']
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid
if __name__=='__main__':run()