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 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
Moves: an order can go back one stage (BACK in app/runtime.py) with an internal reason, flagged in the history as movements.back. The customer is not notified and approved finals stay; "production started" and "ready" are now enqueued once per order, so undoing and redoing a move sends nothing twice. Dragging only goes forward and highlights the allowed column. Move errors are in Portuguese. Lists: the send log, payments (open, resolved as history, all) and quotes are paged on the server with a total, 20 rows by default (10/20/50/100), first/previous/page/next/last. The send log filters by destination, status, event and order. Older finished orders load on demand. The board no longer carries the send log or payment rows, only the open-payment count. Kanban: Pagamentos and Integrações are separate tabs; messages are brief, bottom notifications that clear themselves; wording is shorter. Full CI integration sequence passes locally, with new checks for undo, paging and filters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
13 KiB
Python
211 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
|
|
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
|
|
return oid
|
|
|
|
if __name__=='__main__':run()
|