import { describe, expect, it } from 'vitest' import { attributableLots, formatCost, formatQuantity, formatUnitCost, lotState, lotsByPlant, safeExternalUrl, summarizeLots, type SeedLot, } from './seedLots' function lot(over: Partial = {}): SeedLot { return { id: 1, ownerId: 1, plantId: 1, vendor: '', sourceUrl: '', sku: '', lotCode: '', quantity: 100, unit: 'seeds', notes: '', version: 1, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', used: 0, remaining: 100, ...over, } } describe('safeExternalUrl', () => { // The server already scheme-checks this, but a link is rendered from whatever // the client was handed. "The backend validated it" is not a reason to hand // javascript: to an anchor tag. it('accepts http and https', () => { expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x') expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/') }) it('refuses everything else', () => { for (const bad of [ '', 'javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,', 'file:///etc/passwd', '//evil.example.com', '/seeds/garlic', 'not a url', ]) { expect(safeExternalUrl(bad)).toBeNull() } }) }) describe('lotState', () => { it('reads the state, not just the number', () => { expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok') expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low') expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty') // Planting more than you recorded buying is real, and worth showing plainly // rather than clamping to zero. expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over') }) it('has nothing to say about a lot with no recorded quantity', () => { expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown') }) }) describe('summarizeLots', () => { it('totals remaining and reports the worst state', () => { const s = summarizeLots([ lot({ id: 1, quantity: 100, remaining: 80 }), lot({ id: 2, quantity: 50, remaining: 5 }), ]) expect(s.remaining).toBe(85) expect(s.unit).toBe('seeds') expect(s.state).toBe('low') // the one that needs attention wins }) it("drops the unit when lots don't agree, rather than summing dishonestly", () => { const s = summarizeLots([ lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }), lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }), ]) expect(s.unit).toBeNull() }) it('says nothing for a plant with no lots', () => { expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' }) }) }) describe('cost formatting', () => { it('renders whole currency from cents', () => { expect(formatCost(499)).toBe('$4.99') expect(formatCost(undefined)).toBeNull() }) it('gives per-unit cost extra precision when it would round to nothing', () => { expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed') expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet') }) it('has no per-unit cost without both halves', () => { expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull() expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull() }) }) describe('lotsByPlant', () => { it('groups, and copes with nothing', () => { const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })]) expect(grouped.get(7)?.length).toBe(2) expect(grouped.get(9)?.length).toBe(1) expect(lotsByPlant(undefined).size).toBe(0) }) }) describe('attributableLots', () => { // An exhausted lot is still offered: the count records what was written down, // not a fact about the packet, so refusing to attribute a planting because the // number says zero would make a wrong number harder to correct, not easier. it('offers exhausted lots, just not first', () => { const empty = lot({ id: 1, remaining: 0 }) const full = lot({ id: 2, remaining: 100 }) expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1]) expect(attributableLots([empty]).length).toBe(1) }) it("doesn't mutate its input", () => { const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })] attributableLots(lots) expect(lots.map((l) => l.id)).toEqual([1, 2]) }) }) describe('formatQuantity', () => { it('keeps whole counts whole and fractional ones readable', () => { expect(formatQuantity(100)).toBe('100') expect(formatQuantity(12.5)).toBe('12.5') expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative }) })