You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
68 lines
3.5 KiB
TypeScript
68 lines
3.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { openDb, type DB } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { commitBill } from '../src/repos'
|
|
|
|
/**
|
|
* M5 (red-team): commitBill's store/counter lookup is tenant-scoped — `WHERE id=? AND tenant_id=?`
|
|
* for both — and an unknown-for-tenant store or counter is rejected 400 (E-1110) BEFORE any money
|
|
* logic, which also removes the old undefined-deref 500. (E-1110, not the task's suggested E-1109,
|
|
* because E-1109 is already the "payments do not sum to payable" code — reusing it would be
|
|
* ambiguous.) The /bills and /bills/offline routes both funnel through commitBill, so both paths
|
|
* are covered. Hermetic in-memory DB; Carry Bag (108) has master sale 500, no MRP, GST18.
|
|
*/
|
|
function fresh(): DB {
|
|
const db = openDb(':memory:')
|
|
seedIfEmpty(db)
|
|
return db
|
|
}
|
|
const carryBag = (db: DB): string =>
|
|
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='108'").get() as { id: string }).id
|
|
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
|
const base = (db: DB): Omit<Parameters<typeof commitBill>[1], 'storeId'> => ({
|
|
tenantId: 't1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
|
|
lines: [{ itemId: carryBag(db), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
|
payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
|
|
})
|
|
const thrown = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
|
|
try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
|
|
}
|
|
|
|
describe('M5 — commitBill store/counter are tenant-scoped', () => {
|
|
it('a nonexistent storeId is rejected 400 (E-1110) — no 500, nothing committed', () => {
|
|
const db = fresh()
|
|
const err = thrown(() => commitBill(db, { ...base(db), storeId: 'does-not-exist' }))
|
|
expect(err).toBeDefined()
|
|
expect(err!.status).toBe(400)
|
|
expect(err!.code).toBe('E-1110')
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
|
|
it('a nonexistent counterId is rejected 400 (E-1110)', () => {
|
|
const db = fresh()
|
|
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's1', counterId: 'nope' }))
|
|
expect(err!.status).toBe(400)
|
|
expect(err!.code).toBe('E-1110')
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
|
|
it('a store/counter belonging to ANOTHER tenant is rejected (true cross-tenant scoping)', () => {
|
|
const db = fresh()
|
|
db.prepare("INSERT INTO tenant (id, code, name, gst_scheme, fy_start_month, created_at) VALUES ('t2','T2','Other','regular',4,?)").run(new Date().toISOString())
|
|
db.prepare("INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s2','t2','ST9','Other Store','29')").run()
|
|
db.prepare("INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('cc2','t2','s2','C1','C')").run()
|
|
// commit as tenant t1 but point at t2's store/counter → must reject, never bill against t2's store
|
|
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's2', counterId: 'cc2' }))
|
|
expect(err!.code).toBe('E-1110')
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
|
|
it('the honest same-tenant store/counter still commits (4-digit-FY doc no)', () => {
|
|
const db = fresh()
|
|
const out = commitBill(db, { ...base(db), storeId: 's1' })
|
|
expect(out.docNo).toMatch(/^ST1C22026-/)
|
|
expect(out.docNo.length).toBeLessThanOrEqual(16)
|
|
expect(billCount(db)).toBe(1)
|
|
})
|
|
})
|