|
| 1 | +/** |
| 2 | + * @fileoverview Tests for FirebaseService.calculateStock chronological replay. |
| 3 | + * |
| 4 | + * These tests pin the two bugs that the stock page suffered from before the |
| 5 | + * refactor: |
| 6 | + * - bug #1: stockAdjustments are loaded `orderBy('date','desc')`. Iterating |
| 7 | + * them in array order applies the *newest* first, inverting the meaning of |
| 8 | + * any `set` adjustment whenever older `add`/`remove` adjustments exist. |
| 9 | + * - bug #2: a `set` adjustment used `adj.newStock` (a stale snapshot of stock |
| 10 | + * at save time). After the snapshot, all later purchases/sales were |
| 11 | + * re-applied first and then silently overwritten by the snapshot — every |
| 12 | + * event recorded after the adjustment vanished from the displayed stock. |
| 13 | + * |
| 14 | + * They also cover a smaller fix: |
| 15 | + * - bug #3: a sale whose key didn't match any purchase was silently dropped, |
| 16 | + * so the displayed stock of a *different* item was incorrectly inflated. |
| 17 | + * Sales now create the entry on demand. |
| 18 | + * |
| 19 | + * The test imports the real `FirebaseService` and shares the singleton |
| 20 | + * `AppState` module with the production code, so we exercise the exact code |
| 21 | + * path that runs in the browser. |
| 22 | + */ |
| 23 | + |
| 24 | +import { FirebaseService } from '../firebase/firestore-service.js'; |
| 25 | +import { AppState } from '../utils/state.js'; |
| 26 | + |
| 27 | +const ITEM = { id: 'item_piyar', name: 'piyar dana', hindiName: 'पियर दाना' }; |
| 28 | + |
| 29 | +/** Reset the slices of AppState that calculateStock reads. */ |
| 30 | +function resetState() { |
| 31 | + AppState.items = [ITEM]; |
| 32 | + AppState.purchaseHistory = []; |
| 33 | + AppState.salesHistory = []; |
| 34 | + AppState.retailSalesHistory = []; |
| 35 | + AppState.stockAdjustments = []; |
| 36 | +} |
| 37 | + |
| 38 | +/** Build a purchase document with a single line item. */ |
| 39 | +function purchase(ts, qty, rate) { |
| 40 | + return { |
| 41 | + id: `purchase_${ts}`, |
| 42 | + timestamp: ts, |
| 43 | + date: new Date(ts).toLocaleString('en-IN'), |
| 44 | + items: [{ itemId: ITEM.id, name: ITEM.name, qty, rate }] |
| 45 | + }; |
| 46 | +} |
| 47 | + |
| 48 | +/** Build a wholesale sale document with a single line item. */ |
| 49 | +function sale(ts, qty, rate) { |
| 50 | + return { |
| 51 | + id: `sale_${ts}`, |
| 52 | + timestamp: ts, |
| 53 | + date: new Date(ts).toLocaleString('en-IN'), |
| 54 | + items: [{ itemId: ITEM.id, name: ITEM.name, qty, rate }] |
| 55 | + }; |
| 56 | +} |
| 57 | + |
| 58 | +/** Build a stock adjustment document. */ |
| 59 | +function adjustment(ts, adjustType, quantity, opts = {}) { |
| 60 | + return { |
| 61 | + id: `adj_${ts}`, |
| 62 | + timestamp: ts, |
| 63 | + date: new Date(ts).toLocaleString('en-IN'), |
| 64 | + itemId: ITEM.id, |
| 65 | + itemName: ITEM.name, |
| 66 | + adjustType, |
| 67 | + quantity, |
| 68 | + rate: opts.rate || 0, |
| 69 | + newStock: opts.newStock, |
| 70 | + reason: opts.reason || '' |
| 71 | + }; |
| 72 | +} |
| 73 | + |
| 74 | +describe('FirebaseService.calculateStock - chronological replay', () => { |
| 75 | + beforeEach(() => { |
| 76 | + resetState(); |
| 77 | + }); |
| 78 | + |
| 79 | + it('sums simple purchases with weighted-average rate', async () => { |
| 80 | + AppState.purchaseHistory = [ |
| 81 | + purchase(1000, 10, 100), |
| 82 | + purchase(2000, 10, 200) |
| 83 | + ]; |
| 84 | + const stock = await FirebaseService.calculateStock(); |
| 85 | + expect(stock[ITEM.id].quantity).toBe(20); |
| 86 | + expect(stock[ITEM.id].rate).toBeCloseTo(150); |
| 87 | + }); |
| 88 | + |
| 89 | + it('subtracts sales using the running weighted-average rate', async () => { |
| 90 | + AppState.purchaseHistory = [purchase(1000, 10, 100), purchase(2000, 10, 200)]; |
| 91 | + AppState.salesHistory = [sale(3000, 5, 250)]; |
| 92 | + const stock = await FirebaseService.calculateStock(); |
| 93 | + expect(stock[ITEM.id].quantity).toBe(15); |
| 94 | + // After 20kg @ avg 150, selling 5 at avg=150 leaves totalValue=2250 / 15 = 150 |
| 95 | + expect(stock[ITEM.id].rate).toBeCloseTo(150); |
| 96 | + }); |
| 97 | + |
| 98 | + // ---------- Bug #1 ---------------------------------------------------- |
| 99 | + it('applies adjustments in chronological order even when stored newest-first', async () => { |
| 100 | + // Simulate the production load order: orderBy('date','desc') |
| 101 | + AppState.stockAdjustments = [ |
| 102 | + adjustment(3000, 'set', 100), // newest - last chronologically |
| 103 | + adjustment(2000, 'remove', 5), |
| 104 | + adjustment(1000, 'add', 50) // oldest - first chronologically |
| 105 | + ]; |
| 106 | + |
| 107 | + const stock = await FirebaseService.calculateStock(); |
| 108 | + |
| 109 | + // Expected chronological replay: |
| 110 | + // t=1000 add 50 -> 50 |
| 111 | + // t=2000 remove 5 -> 45 |
| 112 | + // t=3000 set 100 -> 100 (the user's last word) |
| 113 | + expect(stock[ITEM.id].quantity).toBe(100); |
| 114 | + }); |
| 115 | + |
| 116 | + // ---------- Bug #2 ---------------------------------------------------- |
| 117 | + it('preserves purchases recorded AFTER a set adjustment', async () => { |
| 118 | + AppState.stockAdjustments = [adjustment(2000, 'set', 20, { rate: 100 })]; |
| 119 | + AppState.purchaseHistory = [purchase(3000, 5, 200)]; |
| 120 | + |
| 121 | + const stock = await FirebaseService.calculateStock(); |
| 122 | + |
| 123 | + // Replay: |
| 124 | + // t=2000 set 20 @ 100 -> qty=20, value=2000 |
| 125 | + // t=3000 purchase 5 @ 200 -> qty=25, value=3000 |
| 126 | + expect(stock[ITEM.id].quantity).toBe(25); |
| 127 | + expect(stock[ITEM.id].rate).toBeCloseTo(120); |
| 128 | + }); |
| 129 | + |
| 130 | + it('uses adj.quantity (target) for set adjustments, not the stale newStock snapshot', async () => { |
| 131 | + // newStock snapshot says 999 but quantity (the user-entered target) is 50. |
| 132 | + // With purchases happening before, the production bug would have used |
| 133 | + // the snapshot. The fix uses adj.quantity. |
| 134 | + AppState.purchaseHistory = [purchase(1000, 10, 100)]; |
| 135 | + AppState.stockAdjustments = [adjustment(2000, 'set', 50, { rate: 100, newStock: 999 })]; |
| 136 | + |
| 137 | + const stock = await FirebaseService.calculateStock(); |
| 138 | + |
| 139 | + expect(stock[ITEM.id].quantity).toBe(50); |
| 140 | + }); |
| 141 | + |
| 142 | + it('handles set + later remove correctly', async () => { |
| 143 | + AppState.stockAdjustments = [ |
| 144 | + adjustment(2000, 'remove', 5), // newest first (DESC load order) |
| 145 | + adjustment(1000, 'set', 30, { rate: 100 }) |
| 146 | + ]; |
| 147 | + |
| 148 | + const stock = await FirebaseService.calculateStock(); |
| 149 | + |
| 150 | + // t=1000 set 30, t=2000 remove 5 -> 25 |
| 151 | + expect(stock[ITEM.id].quantity).toBe(25); |
| 152 | + }); |
| 153 | + |
| 154 | + // ---------- Bug #3 ---------------------------------------------------- |
| 155 | + it('creates a stock entry for sales of an item that was never purchased', async () => { |
| 156 | + AppState.salesHistory = [sale(1000, 5, 200)]; |
| 157 | + const stock = await FirebaseService.calculateStock(); |
| 158 | + // The entry is created so its negative balance is visible, instead of |
| 159 | + // being silently dropped (which previously caused other items' |
| 160 | + // displayed stock to look wrong). |
| 161 | + expect(stock[ITEM.id]).toBeDefined(); |
| 162 | + expect(stock[ITEM.id].quantity).toBe(-5); |
| 163 | + }); |
| 164 | + |
| 165 | + // ---------- Cross-source ordering ------------------------------------ |
| 166 | + it('interleaves purchases, sales and adjustments in true chronological order', async () => { |
| 167 | + AppState.purchaseHistory = [purchase(1000, 10, 100), purchase(4000, 5, 300)]; |
| 168 | + AppState.salesHistory = [sale(2000, 3, 150)]; |
| 169 | + AppState.stockAdjustments = [ |
| 170 | + adjustment(5000, 'remove', 2), // newest - DESC load order |
| 171 | + adjustment(3000, 'set', 20, { rate: 200 }) |
| 172 | + ]; |
| 173 | + |
| 174 | + const stock = await FirebaseService.calculateStock(); |
| 175 | + |
| 176 | + // Chronological replay: |
| 177 | + // t=1000 purchase 10 @ 100 -> qty=10, value=1000 |
| 178 | + // t=2000 sale 3 @ avg(100) -> qty=7, value=700 |
| 179 | + // t=3000 set 20 @ 200 -> qty=20, value=4000 |
| 180 | + // t=4000 purchase 5 @ 300 -> qty=25, value=5500 |
| 181 | + // t=5000 remove 2 (5/25=20%)-> qty=23, value=5060 |
| 182 | + expect(stock[ITEM.id].quantity).toBe(23); |
| 183 | + expect(stock[ITEM.id].rate).toBeCloseTo(220); |
| 184 | + }); |
| 185 | + |
| 186 | + it('falls back to parsing the locale date string when timestamp is absent', async () => { |
| 187 | + // Two purchases with no numeric timestamp - only a parseable d/m/yyyy date. |
| 188 | + // First chronologically is the 1 Jan one even though the array lists Feb first. |
| 189 | + const p1 = { id: 'p1', date: '01/02/2026, 10:00:00 am', items: [{ itemId: ITEM.id, name: ITEM.name, qty: 5, rate: 200 }] }; |
| 190 | + const p2 = { id: 'p2', date: '01/01/2026, 10:00:00 am', items: [{ itemId: ITEM.id, name: ITEM.name, qty: 5, rate: 100 }] }; |
| 191 | + AppState.purchaseHistory = [p1, p2]; |
| 192 | + |
| 193 | + const stock = await FirebaseService.calculateStock(); |
| 194 | + expect(stock[ITEM.id].quantity).toBe(10); |
| 195 | + expect(stock[ITEM.id].rate).toBeCloseTo(150); |
| 196 | + }); |
| 197 | + |
| 198 | + // ---------- Bug #4: case-insensitive name matching -------------------- |
| 199 | + it('collapses legacy records without itemId to the same bucket regardless of name casing or whitespace', async () => { |
| 200 | + // A purchase using the canonical lower-case name + itemId, and a sale |
| 201 | + // recorded against the same item by legacy data that has no itemId and |
| 202 | + // a differently-cased / whitespace-padded name. Pre-fix, the sale was |
| 203 | + // bucketed under a separate orphan key ("Piyar Dana") and the purchase |
| 204 | + // bucket showed the full quantity uncorrected. |
| 205 | + AppState.purchaseHistory = [purchase(1000, 100, 200)]; |
| 206 | + AppState.salesHistory = [ |
| 207 | + { |
| 208 | + id: 'legacy_sale', |
| 209 | + timestamp: 2000, |
| 210 | + date: new Date(2000).toLocaleString('en-IN'), |
| 211 | + items: [{ name: ' Piyar Dana ', qty: 30, rate: 250 }] |
| 212 | + } |
| 213 | + ]; |
| 214 | + |
| 215 | + const stock = await FirebaseService.calculateStock(); |
| 216 | + |
| 217 | + expect(stock[ITEM.id]).toBeDefined(); |
| 218 | + expect(stock[ITEM.id].quantity).toBe(70); |
| 219 | + // No orphan bucket should exist under the legacy raw name. |
| 220 | + expect(stock[' Piyar Dana ']).toBeUndefined(); |
| 221 | + expect(stock['Piyar Dana']).toBeUndefined(); |
| 222 | + expect(stock['piyar dana']).toBeUndefined(); |
| 223 | + }); |
| 224 | + |
| 225 | + it('matches by hindiName case-insensitively for legacy records without itemId', async () => { |
| 226 | + // The catalogue Hindi name is "पियर दाना" — exact match. Legacy data |
| 227 | + // commonly carries the same string with stray whitespace. Stripping + |
| 228 | + // case-folding should still produce a single canonical bucket. |
| 229 | + AppState.purchaseHistory = [purchase(1000, 50, 200)]; |
| 230 | + AppState.salesHistory = [ |
| 231 | + { |
| 232 | + id: 'legacy_sale_hindi', |
| 233 | + timestamp: 2000, |
| 234 | + date: new Date(2000).toLocaleString('en-IN'), |
| 235 | + items: [{ name: ' पियर दाना ', qty: 10, rate: 250 }] |
| 236 | + } |
| 237 | + ]; |
| 238 | + |
| 239 | + const stock = await FirebaseService.calculateStock(); |
| 240 | + expect(stock[ITEM.id].quantity).toBe(40); |
| 241 | + }); |
| 242 | +}); |
0 commit comments