fix: harden week-two ordering, artwork and operations
This commit is contained in:
@@ -13,6 +13,30 @@ const html=await readFile('web/index.html','utf8');
|
||||
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
|
||||
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
|
||||
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
|
||||
function pdfFixture({pages=1,media='[0 0 720 360]',crop='',rotate=0,unit=1}={}){
|
||||
const objects=[
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids ['+Array.from({length:pages},(_,i)=>i+3+' 0 R').join(' ')+
|
||||
'] /Count '+pages+' /MediaBox '+media+' >>',
|
||||
...Array.from({length:pages},()=> '<< /Type /Page /Parent 2 0 R'+
|
||||
(crop?' /CropBox '+crop:'')+(rotate?' /Rotate '+rotate:'')+
|
||||
(unit!==1?' /UserUnit '+unit:'')+' >>')
|
||||
];
|
||||
const chunks=['%PDF-1.6\n%\xE2\xE3\xCF\xD3\n'], offsets=[0];
|
||||
let length=Buffer.byteLength(chunks[0],'latin1');
|
||||
for(let i=0;i<objects.length;i++){
|
||||
offsets.push(length);
|
||||
const part=(i+1)+' 0 obj\n'+objects[i]+'\nendobj\n';
|
||||
chunks.push(part);length+=Buffer.byteLength(part,'latin1');
|
||||
}
|
||||
const xref=length;
|
||||
chunks.push('xref\n0 '+(objects.length+1)+'\n0000000000 65535 f \n');
|
||||
for(const offset of offsets.slice(1))chunks.push(String(offset).padStart(10,'0')+' 00000 n \n');
|
||||
chunks.push('trailer\n<< /Size '+(objects.length+1)+
|
||||
' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF\n');
|
||||
return Buffer.from(chunks.join(''),'latin1');
|
||||
}
|
||||
const pdfData=options=>JSON.stringify(pdfFixture(options).toString('base64'));
|
||||
const server=createServer(async(req,res)=>{
|
||||
if(req.url.startsWith('/api/')){
|
||||
res.setHeader('Content-Type','application/json');
|
||||
@@ -27,7 +51,7 @@ const server=createServer(async(req,res)=>{
|
||||
// Serve any script the page asks for, resolved inside web/, rather than
|
||||
// a hardcoded list: the Site's behaviour is split across several files and a
|
||||
// list would silently 404 the next one added.
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url)){
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url) || /^\/vendor\/pdf\.(worker\.)?min\.js$/.test(req.url)){
|
||||
try{
|
||||
const body=await readFile(resolve('web'+req.url));
|
||||
res.setHeader('Content-Type','text/javascript');res.end(body);return;
|
||||
@@ -39,6 +63,7 @@ await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
let stderr='',ws,next=0;
|
||||
@@ -128,6 +153,9 @@ try{
|
||||
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
|
||||
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
|
||||
{shown:true,on:['folha']});
|
||||
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:128*1048576+1});sel([f]);})()`);
|
||||
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('128 MB')`),true,
|
||||
'files beyond the scanner limit are rejected before browser analysis');
|
||||
await evaluate(`pickTipo('avulsa')`);
|
||||
assert.equal(await evaluate('modo'),'avulsa');
|
||||
await evaluate('sendImage()');
|
||||
@@ -135,6 +163,14 @@ try{
|
||||
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
|
||||
await fill('[data-cm]',20);await fill('[data-q]',6);
|
||||
let layout=await packed(6);
|
||||
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.copies===6`),'per-file production record');
|
||||
assert.deepEqual(await evaluate(`({version:itemAtual.production.version,source:itemAtual.production.sources[0]})`),
|
||||
{version:2,source:{kind:'artwork',width_cm:20,length_cm:40,copies:6,
|
||||
rotation_degrees:0,mirrored:false,measurement:'file'}});
|
||||
assert.equal(await evaluate('itemAtual.production.placements.length'),6);
|
||||
assert.equal(await evaluate('itemAtual.production.height_cm'),121);
|
||||
assert.deepEqual(await evaluate('itemAtual.production.placements.map(p=>[p.source_index,p.copy_index,p.x_cm,p.y_cm])'),
|
||||
[[0,0,0,0],[0,1,20.5,0],[0,2,0,40.5],[0,3,20.5,40.5],[0,4,0,81],[0,5,20.5,81]]);
|
||||
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
|
||||
assert.equal(layout.billed,1.3);
|
||||
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
|
||||
@@ -149,6 +185,32 @@ try{
|
||||
await click('[data-esp]');
|
||||
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
|
||||
assert.equal(await evaluate('metros'),0.235);
|
||||
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.mirrored===true`),'transforms in production record');
|
||||
assert.deepEqual(await evaluate(`({rotation:itemAtual.production.sources[0].rotation_degrees,copies:itemAtual.production.sources[0].copies})`),
|
||||
{rotation:90,copies:9});
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='rejected'`),'low resolution refusal');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
await fill('[data-cm]',3);
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='ok'`),'rotated DPI uses image height');
|
||||
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),339);
|
||||
await click('[data-giro]');
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'unrotated resolution warning');
|
||||
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),169);
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
await click('#cienteOk');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),true);
|
||||
await fill('[data-q]',8);
|
||||
assert.equal(await evaluate('itemAtual===null'),true,'editing invalidates the old cart immediately');
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'edited warning');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false,'acknowledgement does not survive an edit');
|
||||
await fill('[data-cm]',58);
|
||||
await waitFor(()=>evaluate(`itemAtual===null && artes[0].cm===58`),'oversized width rejected');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
assert.equal(await evaluate(`(()=>{try{encaixar([{w:58,h:10,img:null}],57);return false}catch(error){return error instanceof RangeError}})()`),true,
|
||||
'packing engine must not shrink an oversized source');
|
||||
await click('[data-rm]');
|
||||
assert.equal(await evaluate(`artes.length===0 && itemAtual===null && !cartPodeEnviar()`),true,
|
||||
'removed artwork cannot remain in the cart');
|
||||
// A transparent asymmetric image exposes masks that ignore user transforms.
|
||||
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
|
||||
assert.equal(await evaluate(`(()=>{
|
||||
@@ -169,6 +231,8 @@ try{
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
|
||||
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
|
||||
await evaluate(`(()=>{folhas.push({f:new File(['manual'],'manual.cdr'),med:null,rep:1,m:1,an:null});pintaFolha();})()`);
|
||||
await waitFor(()=>evaluate(`itemAtual?.nota===0 && itemAtual?.unit===TABELA[modo]`),'mixed analyzed and manual sheets use table pricing');
|
||||
// An image too small to span the film is refused, never silently repriced.
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate('sendImage()');
|
||||
@@ -182,6 +246,33 @@ try{
|
||||
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
|
||||
await evaluate(`folhas[0].semAnalise='<img src=x onerror=alert(1)>';pintaFolha()`);
|
||||
assert.equal(await evaluate(`!$('lista').querySelector('img') && $('lista').textContent.includes('<img src=x')`),true,
|
||||
'PDF parser errors are text, never executable markup');
|
||||
// Parsed geometry honors inherited MediaBox, CropBox, rotation and UserUnit.
|
||||
// Extra pages and unreadable PDFs must never fall through to manual pricing.
|
||||
const pdfFile=(data,name='sheet.pdf')=>`new File([Uint8Array.from(atob(${data}),c=>c.charCodeAt(0))],${JSON.stringify(name)},{type:'application/pdf'})`;
|
||||
const rotated=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))})`);
|
||||
assert.deepEqual(rotated,{larg:50.8,alt:25.4,fonte:'página do PDF · UserUnit 2'});
|
||||
const rendered=await evaluate(`rasterizarPdf(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))},50.8,25.4).then(r=>({ok:!!r.tela,error:r.erro||null}))`);
|
||||
assert.deepEqual(rendered,{ok:true,error:null});
|
||||
const multi=await evaluate(`medirFolha(${pdfFile(pdfData({pages:2}))})`);
|
||||
assert.equal(multi.rejected,true);assert.match(multi.reason,/2 páginas/);
|
||||
const beyondSpec=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 20000 720]'}))})`);
|
||||
assert.equal(beyondSpec.rejected,true);assert.match(beyondSpec.reason,/508 cm/);
|
||||
const broken=await evaluate(`medirFolha(new File(['broken'],'broken.pdf',{type:'application/pdf'}))`);
|
||||
assert.equal(broken.rejected,true);
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate(`sel([${pdfFile(pdfData({pages:2}),'two-pages.pdf')}])`);
|
||||
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].measurementError`),'multipage PDF refusal');
|
||||
assert.equal(await evaluate(`avaliar().pronto`),false);
|
||||
assert.equal(await evaluate(`cartPodeEnviar()`),false);
|
||||
assert.match(await evaluate(`$('lista').textContent`),/não pode ser orçado/);
|
||||
await click('#bVoltar');await click('[data-modo="avulsa"]');
|
||||
await evaluate(`sel([new File(['not an image'],'broken.png',{type:'image/png'})])`);
|
||||
await waitFor(()=>evaluate('artes.length===1 && artes[0].decodeError===true'),'failed image decode');
|
||||
await fill('[data-cm]',20);
|
||||
await waitFor(()=>evaluate('itemAtual===null && !cartPodeEnviar()'),'undecodable image cannot be quoted');
|
||||
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
|
||||
// from a by-metre product is one declared click, and it is reversible.
|
||||
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
|
||||
@@ -201,6 +292,21 @@ try{
|
||||
await fill('[data-q]',7);
|
||||
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
|
||||
await packed(7);
|
||||
const timeoutCleanup=await evaluate(`(async()=>{
|
||||
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;
|
||||
let destroyed=false, fail;
|
||||
carregarPdfJs=async()=>({getDocument:()=>({
|
||||
promise:new Promise((_,reject)=>{fail=reject}),
|
||||
destroy:()=>{destroyed=true;fail(new Error('cancelled'));return Promise.resolve()}
|
||||
})});
|
||||
temWorker=true;
|
||||
window.setTimeout=(fn,ms)=>realTimer(fn,ms===30000?1:ms);
|
||||
try{
|
||||
const result=await rasterizarPdf({arrayBuffer:async()=>new ArrayBuffer(1)},10,10);
|
||||
return {timedOut:result.erro==='demorou demais neste navegador',destroyed};
|
||||
}finally{carregarPdfJs=realLoader;window.setTimeout=realTimer;temWorker=realWorker;}
|
||||
})()`);
|
||||
assert.deepEqual(timeoutCleanup,{timedOut:true,destroyed:true});
|
||||
// Inspect the supplied local artwork, when requested, using the real file input.
|
||||
if(process.env.ARTWORK_FILE){
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
|
||||
@@ -14,6 +14,7 @@ try {
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
@@ -41,7 +42,9 @@ try{
|
||||
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
|
||||
}
|
||||
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
|
||||
const siteOrigin=process.env.SITE_BROWSER_ORIGIN||'http://localhost:'+(process.env.SITE_PORT||8080);
|
||||
const kanbanOrigin=process.env.KANBAN_BROWSER_ORIGIN||'http://localhost:'+(process.env.KANBAN_PORT||8081);
|
||||
const site=await page(siteOrigin);
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
|
||||
// Prove escaping itself, independently of the CSP's second line of defense.
|
||||
await site.call('Page.setBypassCSP',{enabled:true});
|
||||
@@ -74,9 +77,14 @@ try{
|
||||
assert.equal(await site.eval('pedido[0].total'),21.89);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
await site.click('#bPagar');
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
try{
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
}catch(error){
|
||||
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
|
||||
throw error;
|
||||
}
|
||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||
const kanban=await page(kanbanOrigin);
|
||||
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
@@ -85,6 +93,14 @@ try{
|
||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
||||
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
||||
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
|
||||
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
|
||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
|
||||
await site.eval('pedido[0].production.sources[0].copies=1;pintaPedido()');
|
||||
await site.fill('#fMail','changed-browser@example.test');
|
||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'quote invalidated by cart edit');
|
||||
assert.equal(await site.eval('[...document.querySelectorAll("button")].some(x=>x.textContent==="Criar pedido de teste")'),false);
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
await site.eval('window.dtfCheckout()');
|
||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
|
||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
|
||||
@@ -92,6 +108,8 @@ try{
|
||||
await kanban.click('#refresh');
|
||||
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
|
||||
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
||||
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
|
||||
{kind:'sheet',copies:1,length:101,height:101,placed:1});
|
||||
// Click real transition buttons, including rerender after each move.
|
||||
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
|
||||
if(state==='fil'){
|
||||
@@ -115,7 +133,7 @@ try{
|
||||
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
|
||||
await site.screenshot('output/local/site.png');
|
||||
await kanban.screenshot('output/local/kanban.png');
|
||||
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid);
|
||||
const portal=await page(siteOrigin+'/portal.html?order='+oid);
|
||||
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
|
||||
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
|
||||
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
|
||||
@@ -127,7 +145,7 @@ try{
|
||||
// A logout must clear draft file blobs and metadata, including other open Site tabs.
|
||||
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
|
||||
await portal.click('#logout');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout")?.hidden===true'),'customer logout');
|
||||
let stored;
|
||||
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
|
||||
assert.equal(stored,0);
|
||||
|
||||
@@ -12,7 +12,7 @@ from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from tests.smoke_test import BASE, Client, upload_bytes, with_host
|
||||
from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host
|
||||
|
||||
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
||||
|
||||
@@ -37,7 +37,7 @@ def reviewed_quote():
|
||||
customer = Client()
|
||||
customer.call('/session')
|
||||
uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST')
|
||||
item = {'mode': 'file', 'metres': '1.01', 'grade': 0, 'uploads': [uid]}
|
||||
item = item_spec('file', '1.01', 0, uid)
|
||||
profile = {'cnpj': '11222333000181', 'zap': '11999999999',
|
||||
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
||||
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
||||
@@ -62,7 +62,13 @@ def run():
|
||||
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total - 100})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order'
|
||||
print('PASS: an amount that disagrees with the reviewed quote is refused')
|
||||
deliver({'event_id': 'missing-amount-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved'})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'missing paid amount created an order'
|
||||
deliver({'event_id': 'invalid-amount-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': str(total)})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'non-integer paid amount created an order'
|
||||
print('PASS: a missing, invalid or mismatched paid amount is refused')
|
||||
|
||||
# The real thing, then the same delivery again, and a second event for the
|
||||
# same quote: a provider does all three.
|
||||
@@ -81,6 +87,12 @@ def run():
|
||||
assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared'
|
||||
print('PASS: one order from a repeated and re-sent approval')
|
||||
|
||||
assert customer.call('/orders/dev-paid', {'quote_id': quote_id})['id'] == order['id']
|
||||
other = Client()
|
||||
other.call('/session')
|
||||
other.call('/orders/dev-paid', {'quote_id': quote_id}, expected=404)
|
||||
print('PASS: another customer cannot retrieve the paid order by quote id')
|
||||
|
||||
# The customer is told once, not once per delivery.
|
||||
board = Client()
|
||||
events = board.call('/operator/board', operator=True)['events']
|
||||
|
||||
60
tests/quote_pagination_test.py
Normal file
60
tests/quote_pagination_test.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""The 101st pending quote and older approved quotes remain reachable on the board."""
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.core import db
|
||||
from tests.smoke_test import Client
|
||||
|
||||
|
||||
def run():
|
||||
owner = uuid4()
|
||||
pending_ids = [uuid4() for _ in range(105)]
|
||||
approved_ids = [uuid4() for _ in range(22)]
|
||||
created = pending_ids + approved_ids
|
||||
draft = {'customer': {'mail': 'pagination-fixture@example.test'},
|
||||
'items': [], 'freight': {'service': 'pickup'}}
|
||||
try:
|
||||
with db.connect() as c:
|
||||
for uid in pending_ids:
|
||||
c.execute('''INSERT INTO dtf_local.quotes
|
||||
(id,owner,request_key,request_hash,draft,created_at)
|
||||
VALUES(%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft)))
|
||||
for uid in approved_ids:
|
||||
c.execute('''INSERT INTO dtf_local.quotes
|
||||
(id,owner,request_key,request_hash,draft,approved,approved_at,created_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now(),now()+interval '1 hour')''',
|
||||
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft),
|
||||
Jsonb({'items': [], 'total_cents': 0})))
|
||||
|
||||
client = Client()
|
||||
board = client.call('/operator/board', operator=True)
|
||||
assert board['pending_total'] >= 105
|
||||
assert board['approved_total'] >= 22
|
||||
for kind, fixture_ids in [('pending', pending_ids), ('approved', approved_ids)]:
|
||||
first = [q for q in board['quotes'] if (q['approved'] is None) == (kind == 'pending')]
|
||||
seen = {q['id'] for q in first}
|
||||
assert len(first) == (100 if kind == 'pending' else 20)
|
||||
last = first[-1]
|
||||
for _ in range(5):
|
||||
path = '/operator/quotes?' + urlencode({
|
||||
'kind': kind, 'limit': 50,
|
||||
'before_created_at': last['created_at'], 'before_id': last['id']})
|
||||
page = client.call(path, operator=True)
|
||||
assert page['quotes'], 'An older quote page disappeared'
|
||||
assert not seen.intersection(q['id'] for q in page['quotes']), 'Quote page repeated rows'
|
||||
seen.update(q['id'] for q in page['quotes'])
|
||||
if set(map(str, fixture_ids)) <= seen:
|
||||
break
|
||||
last = page['quotes'][-1]
|
||||
assert set(map(str, fixture_ids)) <= seen, f'{kind} quotes were hidden by the board limit'
|
||||
print('PASS: 105 pending and 22 approved quotes remain reachable across board pages')
|
||||
finally:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM dtf_local.quotes WHERE id=ANY(%s)', (created,))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
|
||||
from uuid import uuid4
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, upload_bytes, item_spec
|
||||
|
||||
def run():
|
||||
customer=Client();customer.call('/session')
|
||||
@@ -9,7 +9,7 @@ def run():
|
||||
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
|
||||
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
|
||||
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
|
||||
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409)
|
||||
'items':[item_spec('file','1',0,uid)],'freight':{'service':'pickup'}},expected=409)
|
||||
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
|
||||
customer.call('/operator/uploads/'+clean+'/download',operator=True)
|
||||
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')
|
||||
|
||||
@@ -37,14 +37,15 @@ class Client:
|
||||
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):
|
||||
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)
|
||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
|
||||
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)
|
||||
@@ -78,10 +79,32 @@ def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected
|
||||
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']
|
||||
@@ -104,10 +127,15 @@ def run():
|
||||
except HTTPError as exc:assert exc.code==403
|
||||
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
|
||||
|
||||
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')]
|
||||
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'}}
|
||||
client.call('/quotes',{**draft,'total_cents':1},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']
|
||||
@@ -116,6 +144,9 @@ def run():
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
||||
from uuid import uuid4
|
||||
from urllib.request import urlopen
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, upload_bytes, item_spec
|
||||
|
||||
def run():
|
||||
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
||||
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
|
||||
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]}
|
||||
item=item_spec('file','1.01',0,uid)
|
||||
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
|
||||
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
|
||||
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
|
||||
@@ -47,6 +47,10 @@ def run():
|
||||
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
version=move('fil',version);version=move('imp',version);version=move('cor',version)
|
||||
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
premature_final=upload_bytes(customer,b'FINAL BEFORE CUSTOMER CORRECTION',order_id=oid)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',
|
||||
{'version':version,'files':[{'item_index':0,'upload_id':premature_final}],
|
||||
'note':'Prepared before customer sent the new correction'},operator=True)['version']
|
||||
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
|
||||
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
|
||||
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
|
||||
@@ -54,6 +58,7 @@ def run():
|
||||
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
|
||||
files=customer.call('/operator/orders/'+oid+'/files',operator=True)
|
||||
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
|
||||
assert not any(f['kind']=='final' and f['active'] for f in files), 'new correction kept a stale final active'
|
||||
version=move('tra',version)
|
||||
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)
|
||||
|
||||
Reference in New Issue
Block a user