Three routes over data the server already stores.
GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.
No cursor, deliberately. A keyset cursor needs a monotonic
server-assigned column and purchases has none; ordering by
(occurred_at, id) with a random uuid tie-break is exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.
GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.
Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.
An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
196 lines
6.7 KiB
Go
196 lines
6.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func seedSales(fs *fakeStore) {
|
|
seedUser(fs)
|
|
fs.salesRows = []Sale{
|
|
{ID: "s1", OccurredAt: "2026-09-20T10:00:00Z", SiteID: siteA, Site: "Chennai",
|
|
Amount: 1499.50, Currency: "INR", VisitorID: "v1", VisitorRef: "V-42",
|
|
VisitorLabel: "Visitor 42", Items: []string{"shirt"}, Source: "manual"},
|
|
{ID: "s2", OccurredAt: "2026-09-19T10:00:00Z", SiteID: "other-site",
|
|
Amount: 200, Currency: "INR", Items: []string{}, Source: "pos"},
|
|
}
|
|
fs.saleOwner = map[string]string{"s1": "client-acme", "s2": "client-acme"}
|
|
}
|
|
|
|
func TestSalesListsRowsTheConversionReportOnlySummed(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedSales(fs)
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out []Sale
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out) != 2 {
|
|
t.Fatalf("got %d sales, want 2", len(out))
|
|
}
|
|
// The reference the product shows people, not just the uuid.
|
|
if out[0].VisitorRef != "V-42" {
|
|
t.Errorf("visitor_ref %q, want the speakable reference", out[0].VisitorRef)
|
|
}
|
|
}
|
|
|
|
// A sale with no customer is an ordinary walk-in nobody identified, and it is
|
|
// still revenue. Joining it away would make this list disagree with the
|
|
// conversion report computed over the same table.
|
|
func TestASaleWithNoCustomerIsStillListed(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedSales(fs)
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
|
var out []Sale
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
found := false
|
|
for _, sale := range out {
|
|
if sale.ID == "s2" && sale.VisitorID == "" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("a sale with no visitor must still appear: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// An empty basket must serialise as [] and not null, or a client mapping over
|
|
// it breaks on the first sale recorded without one.
|
|
func TestEmptyItemsSerialiseAsAnArray(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedSales(fs)
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
|
if strings.Contains(rec.Body.String(), `"items":null`) {
|
|
t.Errorf("items must be [] and never null: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// The hazard this API has already been bitten by: an unknown query parameter
|
|
// is silently ignored, so a mistyped filter returns the whole estate.
|
|
func TestAnUnknownShopFilterIsRefusedRatherThanIgnored(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedSales(fs)
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/sales?site=nowhere", sess.Token, nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("got %d, want 400 - silently returning every shop's sales is "+
|
|
"a wrong number nobody would question: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSalesCanBeNarrowedToOneCustomerByReference(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedSales(fs)
|
|
fs.visitors = []Customer{{ID: "v1", Ref: "V-42", Label: "Visitor 42"}}
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/sales?customer=V-42", sess.Token, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if fs.lastSaleQuery.VisitorID != "v1" {
|
|
t.Errorf("resolved customer %q, want the uuid behind V-42",
|
|
fs.lastSaleQuery.VisitorID)
|
|
}
|
|
}
|
|
|
|
func TestAnotherTenantsSaleIs404(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedSales(fs)
|
|
fs.saleOwner["s1"] = "client-rival"
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/sales/s1", sess.Token, nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("got %d, want 404 for another tenant's sale", rec.Code)
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- dashboard
|
|
|
|
// The headline must be the server's own unique-visitor figure, never the sum
|
|
// of the buckets: a customer who came twice is one person and two
|
|
// bucket-visitors, and adding the bars up is silently too high.
|
|
func TestDashboardReportsUniquePeopleAndVisitsSeparately(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
|
|
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
|
|
fs.sites = []SiteHealth{
|
|
{SiteID: siteA, Name: "Chennai", Online: true, CamerasUp: 1, CamerasTotal: 2},
|
|
{SiteID: "s2", Name: "Mumbai", Online: false, CamerasUp: 0, CamerasTotal: 1},
|
|
}
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out DashboardSummary
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Visitors != 7 || out.Visits != 19 {
|
|
t.Errorf("got %d people / %d visits, want 7 and 19 reported separately",
|
|
out.Visitors, out.Visits)
|
|
}
|
|
if out.SitesTotal != 2 || out.SitesOnline != 1 {
|
|
t.Errorf("sites %d/%d, want 1 of 2 online", out.SitesOnline, out.SitesTotal)
|
|
}
|
|
if out.CamerasTotal != 3 || out.CamerasUp != 1 {
|
|
t.Errorf("cameras %d/%d, want 1 of 3", out.CamerasUp, out.CamerasTotal)
|
|
}
|
|
}
|
|
|
|
// The share of faces too poor to enrol is what says whether the headcount above
|
|
// is a number or a floor. It has to travel with it, on this screen too.
|
|
func TestDashboardCarriesTheConfidenceWithTheCount(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
|
|
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
|
|
var out DashboardSummary
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if out.FractionBelowGate != 0.59 || out.WorstSite == "" {
|
|
t.Errorf("a headcount without its confidence is the thing this product "+
|
|
"exists not to ship: %+v", out)
|
|
}
|
|
}
|
|
|
|
// "Today" means the shop's day. In the one market this ships to, UTC is five
|
|
// and a half hours wrong.
|
|
func TestDashboardCutsTodayInTheRequestedTimezone(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "GET", "/api/dashboard/summary?tz=Asia/Kolkata", sess.Token, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out DashboardSummary
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if out.Timezone != "Asia/Kolkata" {
|
|
t.Errorf("timezone %q, want the one asked for so the client can label it",
|
|
out.Timezone)
|
|
}
|
|
if fs.lastReport.From.Hour() != 0 {
|
|
t.Errorf("the window must start at local midnight, got %v", fs.lastReport.From)
|
|
}
|
|
}
|