Files
backend_fiesta/repositories/posRepository_test.go
Suriya fc81df14e4 Answer the catalogue as a delta when the terminal sends a revision
Every pull was a full snapshot, so a shop with a thousand products
re-sent all of them to correct one price. The response now carries a
revision the terminal stores and hands back, and a pull that supplies
one gets only what moved: the product row, its row at that outlet, or
its stock ledger. Stock is included because a shop's count drifts from a
till's on every sale rung at another counter, and a delta that ignored
it would let that drift persist until someone forced a full pull.

The dangerous part is the flag, not the filter. A response marked
is_delta:false tells the terminal to withdraw every product it does not
mention — so a filtered result carrying that label empties the shelf.
Both are now derived from one value, and there is no path through the
function that filters without also setting the flag.

Everything ambiguous resolves toward the snapshot. A revision that is
malformed, empty, or issued to another outlet yields a zero cutoff and a
complete response; the opposite would leave a terminal permanently
missing changes with nothing to show for it. The revision advances only
on the final page, so a terminal that abandons a paginated pull cannot
end up holding one that claims it saw pages it never received. And the
stamp is taken a second in the past, because a product written during
the same second the query ran would otherwise fall on the wrong side of
the next cutoff and be skipped for good.

A delta still cannot withdraw a deleted product — removing a row from
productlocations leaves no tombstone — so a periodic pull without a
revision is what collects those.

Verified against the live outlet: a full pull of 12, a delta returning
only the one product whose price had changed, and pagination that stays
exact now that productid <= 0 is excluded in SQL rather than after the
LIMIT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:26:01 +05:30

163 lines
5.2 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)
}
}
}