feat: approve priced carts at checkout so customers can pay at once
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
Every quote waited for an operator before it could be paid, so an order placed at night waited for the morning. A cart the Site priced is now approved when the quote is created, through the same server pricing the operator's approval uses (app/quote_review.py). Orders above QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site could not analyse still wait for review; the Kanban shows which quotes were approved automatically and why the others wait. The grade is still computed in the browser (roadmap 3.2, 3.9), so the discount remains a customer-supplied value until the server computes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,7 +97,8 @@ try{
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
await site.click('#bPagar');
|
||||
try{
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
// A cart the Site can price is approved at once: the customer pays now.
|
||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000);
|
||||
}catch(error){
|
||||
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
|
||||
throw error;
|
||||
@@ -107,16 +108,15 @@ try{
|
||||
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()');
|
||||
// Quotes live in their own tab; the review pane shows the one picked.
|
||||
// Quotes live in their own tab; an automatic approval is listed as such.
|
||||
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
|
||||
await kanban.click('[data-tab="quotes"]');
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
|
||||
await kanban.click(`[data-quote-pick="${qid}"]`);
|
||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
||||
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
||||
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');
|
||||
await kanban.click('[data-tab="quotes"]');
|
||||
await waitFor(()=>kanban.eval('document.querySelectorAll("#quote-filters button").length===2'),'quote filters');
|
||||
await kanban.eval('document.querySelectorAll("#quote-filters button")[1].click()');
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'approved quote listed on Kanban');
|
||||
assert.equal(await kanban.eval(`document.querySelector('[data-quote-pick="${qid}"]').textContent.includes('Aprovada automaticamente')`),true);
|
||||
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');
|
||||
@@ -188,7 +188,7 @@ try{
|
||||
assert.equal(stored,0);
|
||||
assert.deepEqual(portal.errors,[]);
|
||||
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
|
||||
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||
console.log('PASS: browser Site upload → automatic approval → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
|
||||
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
|
||||
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
|
||||
|
||||
@@ -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, item_spec, with_host
|
||||
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
|
||||
|
||||
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
||||
|
||||
@@ -42,8 +42,7 @@ def reviewed_quote():
|
||||
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
||||
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
||||
'items': [item], 'freight': {'service': 'pickup'}})
|
||||
approved = customer.call('/operator/quotes/' + quote['id'] + '/approve',
|
||||
{'items': [item]}, operator=True)
|
||||
approved = approved_quote(customer, quote, [item])
|
||||
return customer, quote['id'], approved['total_cents']
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from uuid import uuid4
|
||||
from PIL import Image
|
||||
|
||||
from tests.payment_test import deliver
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, approved_quote as approval, upload_bytes
|
||||
|
||||
PT_PER_CM = 72 / 2.54
|
||||
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
||||
@@ -53,7 +53,7 @@ def loose_item(uid, copies=2):
|
||||
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)
|
||||
approved = approval(client, quote, [item])
|
||||
return quote['id'], approved['total_cents']
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ def item_spec(mode, metres, grade, uid):
|
||||
'quality_status':'unverified' if grade==0 else 'ok',
|
||||
'quality_acknowledged':False}
|
||||
|
||||
def approved_quote(client, quote, items):
|
||||
"""The approval a customer pays against: automatic, or by the operator."""
|
||||
if quote['status']=='approved':
|
||||
return client.call('/quotes/'+quote['id'])['approved']
|
||||
return client.call('/operator/quotes/'+quote['id']+'/approve',{'items':items},operator=True)
|
||||
|
||||
def run():
|
||||
client=Client();other=Client()
|
||||
config=client.call('/session');other.call('/session')
|
||||
@@ -127,7 +133,8 @@ def run():
|
||||
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')]
|
||||
# Above QUOTE_AUTO_MAX_METRES (50 m by default), so a person reviews it.
|
||||
items=[item_spec(m,'13',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',
|
||||
@@ -146,6 +153,8 @@ def run():
|
||||
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 quote['status']=='pending_review'
|
||||
assert client.call('/quotes/'+quote['id'])['review_reason']=='Pedido acima de 50 m'
|
||||
assert client.call('/quotes',draft)['id']==quote['id']
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
|
||||
qid=quote['id']
|
||||
@@ -159,9 +168,26 @@ def run():
|
||||
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['total_cents']==2189+32370+90870+109070+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)
|
||||
assert not client.call('/quotes/'+qid)['auto_approved']
|
||||
# A cart the Site priced is approved at once and can be paid straight away,
|
||||
# at the server's own prices; no operator can then change it.
|
||||
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
|
||||
auto=client.call('/quotes',small)
|
||||
assert auto['status']=='approved'
|
||||
seen=client.call('/quotes/'+auto['id'])
|
||||
assert seen['auto_approved'] and seen['review_reason'] is None
|
||||
assert seen['approved']['total_cents']==6972+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||
assert client.call('/quotes',small)['status']=='approved'
|
||||
client.call('/operator/quotes/'+auto['id']+'/approve',{'items':small['items']},operator=True,expected=409)
|
||||
# The Site grades only art it analysed; a discount on unanalysed art waits for a person.
|
||||
claimed={**item_spec('avulsa','2.75',0,uid),'grade':90}
|
||||
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
|
||||
assert held['status']=='pending_review'
|
||||
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
|
||||
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
|
||||
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.
|
||||
|
||||
38
tests/test_quote_review.py
Normal file
38
tests/test_quote_review.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Which quotes the Site approves at checkout and which wait for a person."""
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from app.quote_review import review_reason
|
||||
|
||||
|
||||
def item(mode='avulsa', metres='2', grade=90, quality='ok', version=2):
|
||||
return {'mode': mode, 'metres': metres, 'grade': grade, 'quality_status': quality,
|
||||
'quality_acknowledged': quality == 'warning', 'production': {'version': version}}
|
||||
|
||||
|
||||
class ReviewReasonTest(unittest.TestCase):
|
||||
def reason(self, *items, **env):
|
||||
with mock.patch.dict(os.environ, env):
|
||||
return review_reason({'items': list(items)})
|
||||
|
||||
def test_a_priced_cart_is_approved(self):
|
||||
self.assertIsNone(self.reason(item()))
|
||||
# Accepted resolution warnings and unanalysed art at the full rate too.
|
||||
self.assertIsNone(self.reason(item(quality='warning'), item(grade=0, quality='unverified')))
|
||||
|
||||
def test_large_orders_wait_for_review(self):
|
||||
self.assertIsNone(self.reason(item(metres='30'), item(metres='20')))
|
||||
self.assertEqual(self.reason(item(metres='30'), item(metres='20.1')), 'Pedido acima de 50 m')
|
||||
self.assertEqual(self.reason(item(metres='6'), QUOTE_AUTO_MAX_METRES='5'), 'Pedido acima de 5 m')
|
||||
|
||||
def test_a_discount_the_site_could_not_have_given_waits(self):
|
||||
self.assertEqual(self.reason(item(grade=90, quality='unverified')), 'Nota informada sem análise da arte')
|
||||
|
||||
def test_old_layouts_and_switching_it_off(self):
|
||||
self.assertEqual(self.reason(item(version=1)), 'Montagem antiga')
|
||||
self.assertEqual(self.reason(item(), QUOTE_AUTO_APPROVE='false'), 'Aprovação automática desligada')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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, item_spec
|
||||
from tests.smoke_test import Client, approved_quote, upload_bytes, item_spec
|
||||
|
||||
def run():
|
||||
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
||||
@@ -9,7 +9,7 @@ def run():
|
||||
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)
|
||||
approved_quote(customer,q,[item])
|
||||
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
|
||||
before=list(customer.jar)[0].value
|
||||
password='local-test-password-'+uuid4().hex
|
||||
|
||||
Reference in New Issue
Block a user