Files
backend_fiesta/repositories/posRepository_test.go
Suriya e3459a0f1c Ingest counter sales from the POS terminals, over MQTT and HTTP
A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.

Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.

Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.

Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.

Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.

Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:48:00 +05:30

98 lines
2.9 KiB
Go

package repositories
import "testing"
// The terminal holds a unique index on barcode, so this rule decides whether a
// catalogue import succeeds at all. Measured against live data when it was
// written: 6,245 products, 93 distinct SKUs, and "1" used by 5,794 of them.
func TestPosBarcodeFallsBackToProductIdWhenTheSkuIsNotScannable(t *testing.T) {
cases := []struct {
name string
productID int
sku string
want string
}{
{"the SKU almost every product shares", 844, "1", "844"},
{"blank SKU", 845, "", "845"},
{"whitespace only", 846, " ", "846"},
{"too short to be a barcode", 847, "1234567", "847"},
{"too long to be a barcode", 848, "123456789012345", "848"},
{"not digits", 849, "SKU-ABC-123", "849"},
{"digits with a space", 850, "1234 5678", "850"},
// Real scannable codes are used as-is, so the day the catalogue carries
// them scanning starts working with no code change.
{"EAN-8", 851, "12345678", "12345678"},
{"UPC-A", 852, "012345678905", "012345678905"},
{"EAN-13", 853, "8901030865278", "8901030865278"},
{"padded EAN-13", 854, " 8901030865278 ", "8901030865278"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posBarcode(c.productID, c.sku); got != c.want {
t.Errorf("posBarcode(%d, %q) = %q, want %q", c.productID, c.sku, got, c.want)
}
})
}
}
func TestPosBarcodesAreUniqueAcrossACatalogueOfSharedSkus(t *testing.T) {
// The failure this exists to prevent: a whole catalogue collapsing onto one
// barcode and the import being rejected by the terminal's unique index.
seen := make(map[string]int)
for id := 844; id < 844+500; id++ {
barcode := posBarcode(id, "1")
if first, clash := seen[barcode]; clash {
t.Fatalf("products %d and %d both produced barcode %q", first, id, barcode)
}
seen[barcode] = id
}
}
func TestRoundStockQtyNeverUnderDeducts(t *testing.T) {
// productstocks.quantity is an integer column and a counter sells 1.5 kg of
// onions. Rounding up keeps recorded stock at or below what is on the shelf;
// truncating would let the shop oversell a little more with every sale.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 2},
{0.25, 1},
{2.0, 2},
{2.01, 3},
{0, 1},
{-1, 1},
}
for _, c := range cases {
if got := roundStockQty(c.quantity); got != c.want {
t.Errorf("roundStockQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}
func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
// App orders have always truncated, and that behaviour is deliberately
// preserved rather than corrected — changing it would silently alter stock
// deduction for every order already flowing through createOrderTx.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 1},
{0.5, 1},
{3.9, 3},
{0, 1},
}
for _, c := range cases {
if got := legacyOrderQty(c.quantity); got != c.want {
t.Errorf("legacyOrderQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}