package api import ( "net/http" "strings" "testing" ) func TestVisitorRefRoundTrips(t *testing.T) { for _, n := range []int64{1, 42, 999999} { ref := VisitorRef(n) got, ok := ParseVisitorRef(ref) if !ok || got != n { t.Fatalf("VisitorRef(%d) = %q, parsed back as (%d, %v)", n, ref, got, ok) } } if got := VisitorRef(0); got != "" { t.Fatalf("an unnumbered visitor must render as no reference, got %q", got) } } // A shop assistant reading "Visitor 42" off a screen types 42. Refusing that // teaches them the field is unreliable, so bare digits are accepted - and there // is nothing for them to collide with, because a uuid is checked first and is // never all digits. func TestVisitorRefAcceptsWhatSomebodyWouldActuallyType(t *testing.T) { for _, in := range []string{"V-42", "v-42", "42", " V-42 "} { n, ok := ParseVisitorRef(in) if !ok || n != 42 { t.Fatalf("ParseVisitorRef(%q) = (%d, %v), want 42", in, n, ok) } } for _, in := range []string{"", "V-", "V-0", "-1", "V-x", "abc", "4 2", "3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c"} { if _, ok := ParseVisitorRef(in); ok { t.Fatalf("ParseVisitorRef(%q) accepted a reference it should not", in) } } } func TestLooksLikeNameRejectsJunkBeforeItReachesSQL(t *testing.T) { for _, ok := range []string{"chennai", "Office1", "cam_2", "a.b-c"} { if !looksLikeName(ok) { t.Fatalf("%q should be a usable name", ok) } } for _, bad := range []string{"", "a b", "a/b", "a'b", "../etc", "%", string(make([]byte, 65))} { if looksLikeName(bad) { t.Fatalf("%q should not reach a query", bad) } } } // The point of the whole scheme: a customer is addressable by the number staff // read on screen, and the uuid that clients already stored keeps working. func TestACustomerIsReachableByNumberAndByUUID(t *testing.T) { const id = "3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c" srv, fs := newServer(t) seedUser(fs) fs.visitors = []Customer{{ID: id, Ref: "V-42", Label: "Visitor 42"}} fs.history = []VisitRow{{Site: "Chennai"}} tok := login(t, srv, "manager@acme.com", "correct horse battery").Token for _, path := range []string{"/api/visitors/" + id + "/history", "/api/visitors/V-42/history", "/api/visitors/42/history"} { rec := do(t, srv, http.MethodGet, path, tok, nil) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", path, rec.Code) } } } // A reference that names nobody is 404 on a path, and a filter that names no // shop is 400 on a query string. The difference matters: `/api/visits` answered // fine and what was wrong was the filter, so a 404 there would send somebody // looking for a missing arrivals feed. func TestAnUnknownReferenceIs404OnAPathAnd400OnAFilter(t *testing.T) { srv, fs := newServer(t) seedUser(fs) tok := login(t, srv, "manager@acme.com", "correct horse battery").Token rec := do(t, srv, http.MethodGet, "/api/visitors/V-999/history", tok, nil) if rec.Code != http.StatusNotFound { t.Fatalf("unknown customer = %d, want 404", rec.Code) } rec = do(t, srv, http.MethodGet, "/api/visits?site=nowhere", tok, nil) if rec.Code != http.StatusBadRequest { t.Fatalf("unknown shop filter = %d, want 400", rec.Code) } } // Reports have always taken `site` and the arrivals feed `site_id`. Both work // everywhere now, because an unknown query parameter is silently ignored - so // getting it the wrong way round returned the whole estate rather than an // error, which is a wrong number nobody would question. func TestBothSiteParameterNamesAreAccepted(t *testing.T) { srv, fs := newServer(t) seedUser(fs) fs.sites = []SiteHealth{{SiteID: "11111111-1111-4111-8111-111111111111", Slug: "chennai"}} tok := login(t, srv, "manager@acme.com", "correct horse battery").Token for _, q := range []string{"site=chennai", "site_id=chennai"} { fs.arrivalQ = ArrivalQuery{} if rec := do(t, srv, http.MethodGet, "/api/visits?"+q, tok, nil); rec.Code != http.StatusOK { t.Fatalf("GET /api/visits?%s = %d", q, rec.Code) } if got := fs.arrivalQ.SiteID; got != "11111111-1111-4111-8111-111111111111" { t.Fatalf("%s resolved to %q", q, got) } } } // An arrival names its shop three ways, and the one a client can filter by must // be among them. Reading "TeNext Chennai" off a row and then having no way to // ask for that shop except by uuid is the exact gap the reference scheme // exists to close. func TestAnArrivalCarriesTheShopReferenceItCanBeFilteredBy(t *testing.T) { srv, fs := newServer(t) seedUser(fs) fs.arrivals = []Arrival{{ VisitID: "c64dc53f-c7f0-4e61-a3b4-9a230f52b3a3", Seq: 265, SiteID: "7c9bb456-e0c6-436d-ba01-5d6cc4e3f466", Site: "TeNext Chennai", SiteSlug: "chennai", }} tok := login(t, srv, "manager@acme.com", "correct horse battery").Token rec := do(t, srv, http.MethodGet, "/api/visits", tok, nil) if rec.Code != http.StatusOK { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, `"site_slug":"chennai"`) { t.Fatalf("an arrival must carry the shop's reference: %s", body) } // seq is a plain bigserial, so it counts every visit on the PLATFORM. It // must not travel: that is the total footfall of every customer we have, // on every row of every tenant's feed. if strings.Contains(body, `"seq"`) { t.Fatalf("the platform-wide visit counter leaked into the feed: %s", body) } }