package console // The analytics switch, at both ends: the CLI's telemetry PUT (which now // accepts the analytics half and bumps the serial, because a box acts on // it) and the two settings pages' owner-only forms. import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/analytics" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" "github.com/carlosframework/platform/internal/telemetry" ) func TestAPITelemetryPutSetsAnalytics(t *testing.T) { fx, _ := instanceFixture(t, "code-analytics-set") op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) put := func(body string) (int, string) { st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body) return st, b } if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 { t.Fatalf("count=edge: %d %s", st, b) } r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" { t.Fatalf("resolved = %+v", r) } if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 { t.Fatal("reserved mode must be refused for now") } if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 { t.Fatal("bad door") } if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 { t.Fatal("both halves in one PUT") } r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" { t.Fatalf("after both: %+v", r) } // The serial moved: boxes converge on it. if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 { t.Fatalf("serial not bumped: %+v %v", doc, err) } // An errors-only PUT touches no box-acted-on setting, so it must not // bump the serial: boxes would converge for no reason. before, _, err := release.ReadSerial(context.Background(), fx.rel) if err != nil { t.Fatalf("serial before: %v", err) } if st, b := put(`{"errors":{"log_level":"error"}}`); st != 200 { t.Fatalf("errors-only: %d %s", st, b) } after, _, err := release.ReadSerial(context.Background(), fx.rel) if err != nil { t.Fatalf("serial after: %v", err) } if after.Serial != before.Serial { t.Fatalf("errors-only PUT bumped the serial: before %+v, after %+v", before, after) } } func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) { fx, _ := instanceFixture(t, "code-analytics-page") fx.setUser("paul@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-analytics-signin") status, _, body := get(t, c, fx.u("/apps/titogo/settings")) if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") { t.Fatalf("app settings: %d", status) } // Post the account-level switch on; the app page shows it inherited. status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {sessionToken(t, fx, c)}, "count": {"edge"}, "door": {"on"}}) if status != 303 { t.Fatalf("account switch: %d", status) } _, _, body = get(t, c, fx.u("/apps/titogo/settings")) if !strings.Contains(body, "currently edge, from the account") { t.Fatal("inheritance not shown") } } // seedAnalytics writes one box's day file for the fixture's app: five page // views of /pricing from five addresses with a referer, one bot hit, and one // named event. // // The observations are stamped at MIDDAY UTC of the current day rather than // an hour before the run: an hour before a run that starts just after 00:00 // UTC lands on YESTERDAY's day file, and range=today would then read an empty // window one hour in twenty-four. ReadRange is day-granular — `to` bounds // which DATES are read, never which hours inside them — so a stamp anywhere // in the current UTC day is counted whatever the wall clock says. func seedAnalytics(t *testing.T, fx *consoleFixture, when time.Time) { t.Helper() m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return when }) for i := 0; i < 5; i++ { m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: when}) } m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: when}) m.Track(release.OpsAccount, "titogo", "signup", when) for k, ds := range m.Drain() { for _, d := range ds { if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil { t.Fatal(err) } } } } // seedTime is the stamp seedAnalytics uses — midday of the current UTC day. func seedTime() time.Time { return time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) } func TestAPIAnalyticsSummary(t *testing.T) { fx, token := instanceFixture(t, "code-analytics-api") seedAnalytics(t, fx, seedTime()) status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "") if status != 200 { t.Fatalf("%d %s", status, body) } var resp analyticsResponse if err := json.Unmarshal([]byte(body), &resp); err != nil { t.Fatal(err) } if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 || resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On { t.Fatalf("resp = %+v", resp) } if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 { t.Fatal("bad range") } if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 { t.Fatalf("7d: %d %s", st, b) } // Verify snake_case JSON tags if !strings.Contains(body, "\"days\":[{\"date\":") { t.Fatal("missing snake_case days array with date field") } if !strings.Contains(body, "\"pv\":") { t.Fatal("missing snake_case pv field") } if strings.Contains(body, "\"PV\":") { t.Fatal("found PascalCase PV field (should be pv)") } } func TestAppAnalyticsPage(t *testing.T) { fx, _ := instanceFixture(t, "code-analytics-tab") seedAnalytics(t, fx, seedTime()) fx.setUser("paul@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-analytics-tab-signin") status, _, body := get(t, c, fx.u("/apps/titogo/analytics")) if status != 200 { t.Fatalf("%d %s", status, body) } // Off: the switch and the reading, and the numbers that exist already. for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "Countries", "GB2", "unknown1", `href="https://db-ip.com">IP Geolocation by DB-IP`, "GeoLite2 data created by MaxMind"} { if !strings.Contains(body, want) { t.Errorf("page lacks %q", want) } } } func TestGeoAttributionsDeduplicates(t *testing.T) { got := geoAttributions(map[string]int64{"GeoLite2-City": 1, "GeoLite2-Country": 1, "Custom": 1, "DBIP-City-Lite": 1}) if len(got) != 2 { t.Fatalf("attributions = %+v, want one DB-IP and one MaxMind line", got) } }