/** * Releasing several products at once must not give them all one price. * * `publishproduct` writes `products.retailprice` as well as the per-outlet * price, at every branch. So publishing the seventeen products R mart imported * on 2026-08-31 — ₹395 rice, ₹30 crisps, ₹105 cleaner — with the single figure * a dialog prefilled from the first row would have set all seventeen to ₹395 and * left nothing to restore the real prices from. * * Each product now has its own field, so these tests pin two things: that a * figure typed into one row stays in that row, and that nothing is released * without a price at all. */ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { needingPrice, planPublishEach, priceFieldFor, taxFieldFor, unpricedIn, type TypedPrice, } from './publishPlan'; import type { Product } from '@/api/types'; const product = (id: number, price: number, retail = 0, tax?: number) => ({ productid: id, price, retailprice: retail, ...(tax === undefined ? {} : { taxpercent: tax }), }) as Product; /** What the drawer holds once every row has been prefilled and none edited. */ const asOpened = (products: Product[]): Map => new Map( products.map((p) => [p.productid, { price: priceFieldFor(p), taxpercent: taxFieldFor(p) }]), ); /* ── A typed figure stays in the row it was typed into ────────────────────── */ test('each product is released at its own price', () => { const products = [product(7089, 395), product(7103, 30), product(7104, 105)]; const plan = planPublishEach(products, asOpened(products)); assert.deepEqual( plan.map((line) => line.price), [395, 30, 105], ); }); // The failure this guards: three products released together, all set to // whichever figure prefilled from the first. test('editing one row does not move the other prices', () => { const products = [product(1, 395), product(2, 30), product(3, 105)]; const typed = asOpened(products); typed.set(1, { price: '999', taxpercent: '5' }); const plan = planPublishEach(products, typed); assert.deepEqual( plan.map((line) => line.price), [999, 30, 105], ); }); // One product is an edit of that product: the merchant is looking at its price, // has typed another, and means it. `own > 0 ? own : typed` made that impossible // — a price could be set once and never corrected. test('a typed price replaces the one the product already had', () => { const products = [product(7, 395)]; const typed = new Map([[7, { price: '250', taxpercent: '12' }]]); const [line] = planPublishEach(products, typed); assert.equal(line?.price, 250); assert.equal(line?.taxpercent, 12); }); /* ── Prefill: what a row starts at ────────────────────────────────────────── */ // The per-outlet price and the tenant master are two columns and either can hold // the figure — an imported product carries both, a hand-priced one may carry // only the master. `effectivePrice` resolves that, and the prefill has to use // the same resolution or it would ask for a price that already exists. test('the master price counts as having a price', () => { assert.equal(priceFieldFor(product(1, 0, 175)), '175'); assert.equal(priceFieldFor(product(2, 0)), '', 'nothing to prefill, so the field is empty'); }); // Zero-rated goods are real — unpackaged staples are 0% GST — so 0 must show as // a rate rather than as an empty field meaning "not set". test('a tax rate of zero is shown, not treated as missing', () => { assert.equal(taxFieldFor(product(1, 100, 0, 0)), '0'); const plan = planPublishEach([product(1, 100, 0, 0)], asOpened([product(1, 100, 0, 0)])); assert.equal(plan[0]?.taxpercent, 0); }); // An empty tax field is zero-rated, which is a real answer. An empty PRICE // field is not — see below. test('an empty tax field releases at zero per cent rather than blocking', () => { const plan = planPublishEach([product(1, 100)], new Map([[1, { price: '100', taxpercent: '' }]])); assert.equal(plan[0]?.taxpercent, 0); }); /* ── Nothing is released without a price ──────────────────────────────────── */ test('a product with no price of its own is named as needing one', () => { assert.equal(needingPrice([product(1, 395), product(2, 0), product(3, 0, 88)]).length, 1); }); test('an unpriced row blocks the release until it is filled', () => { const products = [product(1, 395), product(2, 0)]; const typed = asOpened(products); assert.deepEqual( unpricedIn(products, typed).map((p) => p.productid), [2], ); typed.set(2, { price: '60', taxpercent: '5' }); assert.deepEqual(unpricedIn(products, typed), []); }); // Clearing a field is not "keep what it had". What the row shows is what gets // written, so an emptied field is no price and the release stops. test('clearing a prefilled price blocks the release rather than reverting', () => { const products = [product(1, 395)]; const typed = new Map([[1, { price: '', taxpercent: '5' }]]); assert.equal(unpricedIn(products, typed).length, 1); }); test('zero and a negative are refused as prices, not sent', () => { const products = [product(1, 0), product(2, 0)]; const typed = new Map([ [1, { price: '0', taxpercent: '0' }], [2, { price: '-5', taxpercent: '0' }], ]); assert.equal(unpricedIn(products, typed).length, 2, 'a till cannot ring up ₹0 or less'); }); test('a price that is not a number at all is refused', () => { const products = [product(1, 0)]; assert.equal(unpricedIn(products, new Map([[1, { price: 'abc', taxpercent: '0' }]])).length, 1); });