| 1 | package analytics |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | ) |
| 11 | |
| 12 | func delta(box string, seq int64, pv int64) Delta { |
| 13 | h := &HLL{} |
| 14 | h.Add(h64(box + itoa(int(seq)))) |
| 15 | p := NewTopK() |
| 16 | p.Add("/", pv) |
| 17 | return Delta{Date: "2026-09-07", Box: box, Seq: seq, Hours: map[string]HourRow{"09": {PV: pv, Bytes: pv * 100}}, |
| 18 | Visitors: h, Pages: p, Referers: NewTopK(), Events: NewTopK()} |
| 19 | } |
| 20 | |
| 21 | func TestDayMergeIsSeqIdempotent(t *testing.T) { |
| 22 | var d Day |
| 23 | if ok, err := d.Merge(delta("box-a", 5, 10)); !ok || err != nil { |
| 24 | t.Fatalf("first merge: %v %v", ok, err) |
| 25 | } |
| 26 | if ok, _ := d.Merge(delta("box-a", 5, 10)); ok { |
| 27 | t.Fatal("replaying the same seq must be a no-op") |
| 28 | } |
| 29 | if ok, _ := d.Merge(delta("box-a", 4, 10)); ok { |
| 30 | t.Fatal("an older seq must be a no-op") |
| 31 | } |
| 32 | if ok, _ := d.Merge(delta("box-b", 1, 7)); !ok { |
| 33 | t.Fatal("another box's seq is independent") |
| 34 | } |
| 35 | if d.Hours["09"].PV != 17 || d.Pages["/"] != 17 || d.Seq["box-a"] != 5 || d.Seq["box-b"] != 1 || d.V != 1 || d.Date != "2026-09-07" { |
| 36 | t.Fatalf("merged = %+v", d) |
| 37 | } |
| 38 | vis, _ := DecodeHLL(d.Visitors) |
| 39 | if c := vis.Count(); c < 1.5 || c > 2.5 { |
| 40 | t.Fatalf("visitors ≈ 2, got %.2f", c) |
| 41 | } |
| 42 | b, _ := json.Marshal(d) |
| 43 | for _, want := range []string{`"v":1`, `"hours"`, `"visitors"`, `"pages"`, `"referers"`, `"events"`, `"seq"`, `"dropped"`, `"countries"`} { |
| 44 | if !contains(string(b), want) { |
| 45 | t.Errorf("wire missing %s in %s", want, b) |
| 46 | } |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestDayMergeReplacesCorruptSketch(t *testing.T) { |
| 51 | d := Day{Visitors: "garbage!"} |
| 52 | ok, err := d.Merge(delta("box-a", 1, 5)) |
| 53 | if !ok || err != nil { |
| 54 | t.Fatalf("merge with corrupt sketch: %v %v", ok, err) |
| 55 | } |
| 56 | if d.Hours["09"].PV != 5 || d.Pages["/"] != 5 { |
| 57 | t.Fatalf("day = %+v", d) |
| 58 | } |
| 59 | vis, err := DecodeHLL(d.Visitors) |
| 60 | if err != nil { |
| 61 | t.Fatalf("decode replaced sketch: %v", err) |
| 62 | } |
| 63 | if c := vis.Count(); c < 0.5 { |
| 64 | t.Fatalf("expected at least one visitor recorded, got %.2f", c) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestUpsertDayCASAcrossBoxes(t *testing.T) { |
| 69 | ctx := context.Background() |
| 70 | s := release.NewDirStore(t.TempDir()) |
| 71 | if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 1, 3)); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { // retry of an ambiguous put |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | raw, _, err := s.Get(ctx, must(t)(DayKey("bab", "web", "box-a", "2026-09-07"))) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | var d Day |
| 85 | json.Unmarshal(raw, &d) |
| 86 | if d.Hours["09"].PV != 7 || d.Seq["box-a"] != 2 { |
| 87 | t.Fatalf("day = %+v", d) |
| 88 | } |
| 89 | if k := must(t)(DayKey("bab", "web", "box-a", "2026-09-07")); k != "apps/bab/web/analytics/box-a/days/2026-09-07.json" { |
| 90 | t.Fatal(k) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func contains(s, sub string) bool { return strings.Contains(s, sub) } |
| 95 | |
| 96 | // must unwraps a key builder's (key, error) for fixtures whose names are |
| 97 | // known good: must(t)(ChunkKey(...)). |
| 98 | func must(t testing.TB) func(string, error) string { |
| 99 | return func(k string, err error) string { |
| 100 | t.Helper() |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | return k |
| 105 | } |
| 106 | } |
| 107 | |