/** * Smoke test for the listing outbox (phase 1). Talks to iDempiere directly * (.env URLV1 + IDEMPIERETOKEN) to create one listing row + one queued price * change, then asks the running Nuxt dev server to flush it and prints the * resulting change / listing rows. The listing row is REAL: the price goes to * Amazon. Use a SKU you are allowed to reprice, and clean up with --cleanup. * * npx tsx reference/test-scripts/listing-outbox-smoke.mts --order-source 1000011 --sku 1000902 --price 19.99 \ * [--qty 5] [--server http://localhost:3010] [--cookie "logship_it=...; logship_organization_id=..."] [--no-flush] [--cleanup] * * Without --cookie the flush step is skipped (the route needs the browser session); * run the flush from the page ("Send now") or wait for the timer instead. */ import { readFileSync } from 'node:fs' import path from 'node:path' const args = process.argv.slice(2) const arg = (k: string, d: string | null = null) => { const i = args.indexOf(k); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d } const flag = (k: string) => args.includes(k) const env = Object.fromEntries(readFileSync(path.join(process.cwd(), '.env'), 'utf8').split('\n') .filter(l => l.includes('=') && !l.startsWith('#')).map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim().replace(/^["']|["']$/g, '')] })) const base = String(env.URLV1 || '').replace(/\/$/, '') const token = env.IDEMPIERETOKEN if (!base || !token) throw new Error('URLV1 / IDEMPIERETOKEN missing in .env') const orderSourceId = Number(arg('--order-source')) const sku = arg('--sku') const price = Number(arg('--price')) const qty = arg('--qty') != null ? Number(arg('--qty')) : null const server = arg('--server', 'http://localhost:3010')! const cookie = arg('--cookie') if (!orderSourceId || !sku || (!flag('--cleanup') && !(price > 0))) { console.log('usage: --order-source --sku --price

[--qty n] [--cookie ...] [--no-flush] [--cleanup]'); process.exit(1) } const api = async (method: string, p: string, body?: any) => { const r = await fetch(`${base}/${p}`, { method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }) const text = await r.text(); let json: any; try { json = JSON.parse(text) } catch { json = { raw: text.slice(0, 300) } } if (!r.ok) throw new Error(`${method} ${p} → ${r.status}: ${json?.detail || json?.title || text.slice(0, 200)}`) return json } const enc = encodeURIComponent const os = await api('GET', `models/c_ordersource/${orderSourceId}`) const orgId = os?.AD_Org_ID?.id console.log(`order source ${os.Name} (org ${orgId}, marketplace ${os?.Marketplace?.identifier})`) const existing = await api('GET', `models/cust_marketplacelisting?$filter=${enc(`C_OrderSource_ID eq ${orderSourceId} and ListingKey eq '${sku.replace(/'/g, "''")}'`)}&$top=1`) let listing = existing?.records?.[0] || null if (flag('--cleanup')) { if (!listing) { console.log('nothing to clean'); process.exit(0) } const ch = await api('GET', `models/cust_marketplacelistingchange?$filter=${enc(`CUST_MarketplaceListing_ID eq ${listing.id}`)}&$top=500`) for (const c of ch.records || []) await api('DELETE', `models/cust_marketplacelistingchange/${c.id}`) await api('DELETE', `models/cust_marketplacelisting/${listing.id}`) console.log(`deleted listing ${listing.id} and ${(ch.records || []).length} change row(s)`) process.exit(0) } if (!listing) { const prod = await api('GET', `models/m_product?$filter=${enc(`AD_Org_ID eq ${orgId} and Value eq '${sku.replace(/'/g, "''")}'`)}&$top=1&$select=Value,Name,asin`) const p = prod?.records?.[0] listing = await api('POST', 'models/cust_marketplacelisting', { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, C_OrderSource_ID: { id: orderSourceId, tableName: 'C_OrderSource' }, ...(p ? { M_Product_ID: { id: p.id, tableName: 'M_Product' }, externalProductId: p.asin || null } : {}), name: p?.Name || `smoke ${sku}`, sellerSKU: sku, listingKey: sku, listingStatus: { id: 'active' }, fulfillmentChannel: 'DEFAULT', price, ...(qty != null ? { qty } : {}), isPriceTaxIncluded: true, description: 'listing-outbox-smoke', tableName: 'CUST_MarketplaceListing' }) console.log(`created listing ${listing.id}${p ? ` for product ${p.Value} (${p.asin || 'no asin'})` : ' (no matching product)'}`) } else console.log(`listing ${listing.id} exists (price ${listing.Price}, sync ${listing.CUST_ListingSyncStatus?.id})`) const queue = async (field: string, oldValue: any, newValue: any) => { const open = await api('GET', `models/cust_marketplacelistingchange?$filter=${enc(`CUST_MarketplaceListing_ID eq ${listing.id} and ChangeStatus eq 'queued' and FieldName eq '${field}'`)}&$top=50`) for (const c of open.records || []) await api('PUT', `models/cust_marketplacelistingchange/${c.id}`, { changeStatus: { id: 'superseded' } }) const c = await api('POST', 'models/cust_marketplacelistingchange', { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, CUST_MarketplaceListing_ID: { id: listing.id, tableName: 'CUST_MarketplaceListing' }, C_OrderSource_ID: { id: orderSourceId, tableName: 'C_OrderSource' }, changeType: { id: 'update' }, fieldName: field, oldValue: oldValue == null ? null : String(oldValue), newValue: String(newValue), tableName: 'CUST_MarketplaceListingChange' }) console.log(`queued change ${c.id}: ${field} ${oldValue ?? '∅'} → ${newValue}`) } await queue('price', listing.Price ?? null, price) if (qty != null) await queue('qty', listing.Qty ?? null, qty) await api('PUT', `models/cust_marketplacelisting/${listing.id}`, { price, ...(qty != null ? { qty } : {}), CUST_ListingSyncStatus: { id: 'queued' }, lastChangeAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') }) if (flag('--no-flush') || !cookie) { console.log(cookie ? 'skipping flush (--no-flush)' : 'no --cookie given: not calling the flush route; the timer will deliver it (or press "Send now")') } else { const r = await fetch(`${server}/api/sales/sis/flush`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ orderSourceId }) }) console.log('flush →', r.status, JSON.stringify(await r.json().catch(() => ({})), null, 1).slice(0, 1500)) } const after = await api('GET', `models/cust_marketplacelistingchange?$filter=${enc(`CUST_MarketplaceListing_ID eq ${listing.id}`)}&$orderby=${enc('Created desc')}&$top=10`) for (const c of after.records || []) console.log(`change ${c.id}: ${c.FieldName} → ${c.NewValue} [${c.ChangeStatus?.id}] mode=${c.DeliveryMode?.id || '-'} sub=${c.SubmissionId || '-'} feed=${c.FeedId || '-'} err=${c.ErrorMsg || '-'}`) const l2 = await api('GET', `models/cust_marketplacelisting/${listing.id}`) console.log(`listing ${l2.id}: price ${l2.Price} qty ${l2.Qty ?? '-'} sync=${l2.CUST_ListingSyncStatus?.id} productType=${l2.ProductType || '-'} err=${l2.SyncError || '-'}`)