Every other thing in this product a person refers to already had a readable reference: a shop is chennai, a camera cam1, a customer V-42, a person their email. An audit of every list response found exactly one gap, and it was the row people look at most - the arrivals feed showed a visit as 36 hex characters. 012 argued no route takes a visit id so none was needed. That is true of routing and false of everything else: it is what the feed shows, what a support conversation quotes, and what somebody reading an API response judges the product by. Migration 014 mirrors the visitor scheme exactly - per client, so it discloses no platform-wide volume, and beside the uuid rather than instead of it. A stored counter is affordable on the busiest table because visits from one tenant are already serialised by the consumer's SetOrderMatters(true), so it adds no contention that was not already there. A derived reference was the alternative and does not work: several people through one door share occurred_at to the microsecond, which is the collision 004 exists to handle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
158 lines
5.8 KiB
Go
158 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// A visit reference is what a person quotes; the uuid is what a machine
|
|
// de-duplicates on. Both travel, neither replaces the other.
|
|
func TestVisitRefIsReadableAndAbsentWhenUnnumbered(t *testing.T) {
|
|
if got := VisitRef(1042); got != "#1042" {
|
|
t.Errorf("VisitRef(1042) = %q, want #1042", got)
|
|
}
|
|
// Visits recorded before 014 have no number. Absent, never "#0" - a
|
|
// reference that looks real and is not is worse than none.
|
|
for _, n := range []int64{0, -1} {
|
|
if got := VisitRef(n); got != "" {
|
|
t.Errorf("VisitRef(%d) = %q, want empty", n, got)
|
|
}
|
|
}
|
|
}
|