Files
backend_fiesta/repositories/posRepository_test.go
Suriya ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

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

200 lines
6.6 KiB
Go

package repositories
import (
"testing"
"time"
)
// 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)
}
}
}
// A catalogue revision is the terminal's memory of when it last pulled. If it
// does not survive a round trip, every pull silently becomes a full snapshot —
// or worse, a filtered result gets labelled as one and the shop's shelf empties.
func TestPosRevisionRoundTrips(t *testing.T) {
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
revision := posRevisionFor(1135, at)
if revision != "loc1135-20260803T123045Z" {
t.Fatalf("revision = %q, want loc1135-20260803T123045Z", revision)
}
got := posRevisionCutoff(1135, revision)
if !got.Equal(at) {
t.Errorf("cutoff = %v, want %v", got, at)
}
}
func TestAnUnusableRevisionFallsBackToAFullSnapshot(t *testing.T) {
// A zero cutoff means "send everything", and the caller turns that into
// is_delta:false. Falling back the other way — answering an unreadable
// revision with a change set — would leave a terminal permanently missing
// every change it had not already seen, with nothing to show for it.
cases := []struct {
name string
location int
revision string
}{
{"empty", 1135, ""},
{"whitespace", 1135, " "},
{"no prefix", 1135, "20260803T123045Z"},
{"malformed timestamp", 1135, "loc1135-not-a-time"},
{"truncated timestamp", 1135, "loc1135-20260803"},
{"another outlet's revision", 1135, "loc1097-20260803T123045Z"},
{"prefix collision", 113, "loc1135-20260803T123045Z"},
{"garbage", 1135, "../../etc/passwd"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posRevisionCutoff(c.location, c.revision); !got.IsZero() {
t.Errorf("cutoff = %v, want zero (full snapshot) for %q", got, c.revision)
}
})
}
}
func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
// loc1135 and loc113 share a textual prefix. Matching loosely would let one
// shop's cutoff silently scope another shop's delta.
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
revision := posRevisionFor(1135, at)
if got := posRevisionCutoff(1135, revision); got.IsZero() {
t.Error("the issuing outlet could not read back its own revision")
}
for _, other := range []int{113, 11350, 1097, 1} {
if got := posRevisionCutoff(other, revision); !got.IsZero() {
t.Errorf("outlet %d accepted outlet 1135's revision (cutoff %v)", other, got)
}
}
}
// The bug this covers reached production and stayed invisible for a day.
//
// The MQTT consumer backfills a missing terminal code from the topic, but it
// wrote it onto the *batch* while the row was built from the *order*, so the
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
// The result: 16 of 17 live bills carried an empty terminalid while their own
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
// summary grouped almost everything under "".
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
cases := []struct {
name string
orderTerminal string
batchTerminal string
want string
}{
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
{"neither knows", "", "", ""},
// Whitespace is not a terminal code. Treating it as one would file
// bills under a blank that reads identically to the missing value
// this fallback exists to prevent.
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
{"batch sends whitespace", "", " ", ""},
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
c.orderTerminal, c.batchTerminal, got, c.want)
}
})
}
}