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) } }