carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The analytics switch, at both ends: the CLI's telemetry PUT (which now
4// accepts the analytics half and bumps the serial, because a box acts on
5// it) and the two settings pages' owner-only forms.
6
7import (
8 "context"
9 "encoding/json"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/analytics"
18 "github.com/carlosframework/platform/internal/release"
19 "github.com/carlosframework/platform/internal/svcrole"
20 "github.com/carlosframework/platform/internal/telemetry"
21)
22
23func TestAPITelemetryPutSetsAnalytics(t *testing.T) {
24 fx, _ := instanceFixture(t, "code-analytics-set")
25 op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
26 put := func(body string) (int, string) {
27 st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body)
28 return st, b
29 }
30 if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 {
31 t.Fatalf("count=edge: %d %s", st, b)
32 }
33 r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
34 if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" {
35 t.Fatalf("resolved = %+v", r)
36 }
37 if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 {
38 t.Fatal("reserved mode must be refused for now")
39 }
40 if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 {
41 t.Fatal("bad door")
42 }
43 if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 {
44 t.Fatal("both halves in one PUT")
45 }
46 r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
47 if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" {
48 t.Fatalf("after both: %+v", r)
49 }
50 // The serial moved: boxes converge on it.
51 if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 {
52 t.Fatalf("serial not bumped: %+v %v", doc, err)
53 }
54
55 // An errors-only PUT touches no box-acted-on setting, so it must not
56 // bump the serial: boxes would converge for no reason.
57 before, _, err := release.ReadSerial(context.Background(), fx.rel)
58 if err != nil {
59 t.Fatalf("serial before: %v", err)
60 }
61 if st, b := put(`{"errors":{"log_level":"error"}}`); st != 200 {
62 t.Fatalf("errors-only: %d %s", st, b)
63 }
64 after, _, err := release.ReadSerial(context.Background(), fx.rel)
65 if err != nil {
66 t.Fatalf("serial after: %v", err)
67 }
68 if after.Serial != before.Serial {
69 t.Fatalf("errors-only PUT bumped the serial: before %+v, after %+v", before, after)
70 }
71}
72
73func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) {
74 fx, _ := instanceFixture(t, "code-analytics-page")
75 fx.setUser("paul@keymail.dev")
76 c := newBrowser(t)
77 signUp(t, fx, c, "code-analytics-signin")
78 status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
79 if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") {
80 t.Fatalf("app settings: %d", status)
81 }
82 // Post the account-level switch on; the app page shows it inherited.
83 status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {sessionToken(t, fx, c)}, "count": {"edge"}, "door": {"on"}})
84 if status != 303 {
85 t.Fatalf("account switch: %d", status)
86 }
87 _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
88 if !strings.Contains(body, "currently edge, from the account") {
89 t.Fatal("inheritance not shown")
90 }
91}
92
93// seedAnalytics writes one box's day file for the fixture's app: five page
94// views of /pricing from five addresses with a referer, one bot hit, and one
95// named event.
96//
97// The observations are stamped at MIDDAY UTC of the current day rather than
98// an hour before the run: an hour before a run that starts just after 00:00
99// UTC lands on YESTERDAY's day file, and range=today would then read an empty
100// window one hour in twenty-four. ReadRange is day-granular — `to` bounds
101// which DATES are read, never which hours inside them — so a stamp anywhere
102// in the current UTC day is counted whatever the wall clock says.
103func seedAnalytics(t *testing.T, fx *consoleFixture, when time.Time) {
104 t.Helper()
105 m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return when })
106 for i := 0; i < 5; i++ {
107 m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
108 ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari",
109 ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: when})
110 }
111 m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
112 ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: when})
113 m.Track(release.OpsAccount, "titogo", "signup", when)
114 for k, ds := range m.Drain() {
115 for _, d := range ds {
116 if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil {
117 t.Fatal(err)
118 }
119 }
120 }
121}
122
123// seedTime is the stamp seedAnalytics uses — midday of the current UTC day.
124func seedTime() time.Time { return time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) }
125
126func TestAPIAnalyticsSummary(t *testing.T) {
127 fx, token := instanceFixture(t, "code-analytics-api")
128 seedAnalytics(t, fx, seedTime())
129 status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "")
130 if status != 200 {
131 t.Fatalf("%d %s", status, body)
132 }
133 var resp analyticsResponse
134 if err := json.Unmarshal([]byte(body), &resp); err != nil {
135 t.Fatal(err)
136 }
137 if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 ||
138 resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On {
139 t.Fatalf("resp = %+v", resp)
140 }
141 if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 {
142 t.Fatal("bad range")
143 }
144 if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 {
145 t.Fatalf("7d: %d %s", st, b)
146 }
147 // Verify snake_case JSON tags
148 if !strings.Contains(body, "\"days\":[{\"date\":") {
149 t.Fatal("missing snake_case days array with date field")
150 }
151 if !strings.Contains(body, "\"pv\":") {
152 t.Fatal("missing snake_case pv field")
153 }
154 if strings.Contains(body, "\"PV\":") {
155 t.Fatal("found PascalCase PV field (should be pv)")
156 }
157}
158
159func TestAppAnalyticsPage(t *testing.T) {
160 fx, _ := instanceFixture(t, "code-analytics-tab")
161 seedAnalytics(t, fx, seedTime())
162 fx.setUser("paul@keymail.dev")
163 c := newBrowser(t)
164 signUp(t, fx, c, "code-analytics-tab-signin")
165 status, _, body := get(t, c, fx.u("/apps/titogo/analytics"))
166 if status != 200 {
167 t.Fatalf("%d %s", status, body)
168 }
169 // Off: the switch and the reading, and the numbers that exist already.
170 for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} {
171 if !strings.Contains(body, want) {
172 t.Errorf("page missing %q", want)
173 }
174 }
175 if err := telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) {
176 st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge}
177 }); err != nil {
178 t.Fatal(err)
179 }
180 _, _, body = get(t, c, fx.u("/apps/titogo/analytics?range=7d"))
181 if strings.Contains(body, "Analytics is off") || !strings.Contains(body, "daily visitors, summed") || !strings.Contains(body, "carlos analytics --app titogo") {
182 t.Fatal("on + 7d page")
183 }
184}
185
186func TestAppAnalyticsPageCountriesAndAttribution(t *testing.T) {
187 fx, _ := instanceFixture(t, "code-analytics-geo")
188 seedAnalytics(t, fx, seedTime()) // no geo state: an edge from before GeoIP
189 fx.setUser("paul@keymail.dev")
190 c := newBrowser(t)
191 signUp(t, fx, c, "code-analytics-geo-signin")
192 _, _, body := get(t, c, fx.u("/apps/titogo/analytics"))
193 if !strings.Contains(body, "No country data for this period.") {
194 t.Fatal("a range with no country data must say so")
195 }
196 when := seedTime()
197 m := analytics.NewMeter(t.TempDir(), "box2", func() time.Time { return when })
198 obs := func(country, src, state string) analytics.Obs {
199 return analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
200 ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.9", When: when,
201 Country: country, GeoSource: src, GeoState: state}
202 }
203 m.Observe(obs("GB", "DBIP-City-Lite", "hit"))
204 m.Observe(obs("GB", "DBIP-City-Lite", "hit"))
205 m.Observe(obs("", "GeoLite2-City", "miss"))
206 for k, ds := range m.Drain() {
207 for _, d := range ds {
208 if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil {
209 t.Fatal(err)
210 }
211 }
212 }
213 _, _, body = get(t, c, fx.u("/apps/titogo/analytics"))
214 for _, want := range []string{"<h3>Countries</h3>", "<td>GB</td><td>2</td>", "<td>unknown</td><td>1</td>",
215 `href="https://db-ip.com">IP Geolocation by DB-IP</a>`, "GeoLite2 data created by MaxMind"} {
216 if !strings.Contains(body, want) {
217 t.Errorf("page lacks %q", want)
218 }
219 }
220}
221
222func TestGeoAttributionsDeduplicates(t *testing.T) {
223 got := geoAttributions(map[string]int64{"GeoLite2-City": 1, "GeoLite2-Country": 1, "Custom": 1, "DBIP-City-Lite": 1})
224 if len(got) != 2 {
225 t.Fatalf("attributions = %+v, want one DB-IP and one MaxMind line", got)
226 }
227}
228