parsePosSaleDate needed no change — RFC3339Nano already accepts the offset the
terminal now sends, and Format("2006-01-02") on a zoned time still yields the
till's own trading day rather than UTC's. But the ordering of those layouts is
load-bearing and nothing said so, and the two bare layouts are a legacy that
should be recognisable as one: they exist for terminals built before the offset,
whose bills record an instant wrong by the offset with nothing in the payload to
recover it from. Two tests pin both halves, including the case that motivated
this — 00:30 IST, where UTC has not yet rolled into the same day.
The GST script writes only the four packaged lines at 1185, which sat at 0 and
were being billed with no tax at all.
It deliberately does not touch the produce at 1135. That was written believing
every rate was 0 — read from a field name that does not exist in the response,
so the check silently returned nothing. The rows in fact hold 8, 12 and 18, and
under Indian GST fresh unbranded fruit and chilled fish are nil-rated, so
several look like overcharging. Every correction there is a reduction of a live
rate, which belongs to whoever signs the returns rather than to a script. They
are reported as REVIEW and left as found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
8.9 KiB
Go
254 lines
8.9 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// billedat and businessdate are derived from the same parsed value and pull in
|
|
// opposite directions, so they are tested together.
|
|
//
|
|
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
|
|
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
|
|
// half hours after it was received. The till was sending a naive local
|
|
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
|
|
// clock was recorded as though read in London.
|
|
//
|
|
// The daily figures survived it by luck: businessdate comes off the wall clock
|
|
// either way, and the wall clock was always the till's own. Anything comparing
|
|
// billedat against real time did not.
|
|
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
|
|
// Coimbatore, late enough that UTC has not yet rolled into the same day.
|
|
const ist = "2026-08-05T00:30:00+05:30"
|
|
|
|
at, err := parsePosSaleDate(ist)
|
|
if err != nil {
|
|
t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
|
|
}
|
|
|
|
// The instant. 00:30 IST is 19:00 UTC the previous evening.
|
|
wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
|
|
if !at.UTC().Equal(wantInstant) {
|
|
t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
|
|
}
|
|
|
|
// The trading day. This is the one that must NOT follow UTC — the shop rang
|
|
// this sale on the 5th and its takings belong to the 5th. Deriving the
|
|
// business date from UTC would file it under the 4th and leave two days
|
|
// wrong: one short, one over.
|
|
if got := at.Format("2006-01-02"); got != "2026-08-05" {
|
|
t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
|
|
}
|
|
}
|
|
|
|
// Terminals built before the offset was added send a bare local timestamp, and
|
|
// they are still in the field. Parsing must not start refusing them.
|
|
//
|
|
// The instant such a bill records is wrong by the offset and cannot be
|
|
// recovered — there is nothing in the payload that says which zone it was read
|
|
// in. Its business date is still right, which is why the daily figures held up,
|
|
// and why this stays a tolerated legacy rather than a rejection.
|
|
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
|
|
at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
|
|
if err != nil {
|
|
t.Fatalf("a pre-offset terminal must not be refused: %v", err)
|
|
}
|
|
|
|
if got := at.Format("2006-01-02"); got != "2026-08-05" {
|
|
t.Errorf("businessdate = %s, want 2026-08-05", got)
|
|
}
|
|
}
|