// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package db import ( "context" "testing" "time" "github.com/stretchr/testify/require" ) func TestBoxViews(t *testing.T) { db, cleanup := newTestDB(t) ctx := context.Background() store := NewBoxViewsStore(db) for _, tc := range []struct { name string test func(t *testing.T, ctx context.Context, store BoxViewsStore) }{ {"BatchMergesSameKey", testBoxViewsBatchMergesSameKey}, {"AccumulatesAcrossCalls", testBoxViewsAccumulatesAcrossCalls}, {"DailyAndTotals", testBoxViewsDailyAndTotals}, {"PurgeKeepsRecent", testBoxViewsPurgeKeepsRecent}, } { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { if err := cleanup("box_views"); err != nil { t.Fatal(err) } }) tc.test(t, ctx, store) }) } } // 同一批里同 (user, day, visitor) 出现多次时,必须先在内存合并再写。 // // PostgreSQL 的 ON CONFLICT DO UPDATE 不允许同一条 INSERT 两次命中同一行 // ("ON CONFLICT DO UPDATE command cannot affect row a second time"), // 不合并的话记录器一次 flush 就会整批失败 —— 而 flush 里恰好可能包含 // 同一访客在 2 秒内刷新三次的记录。 func testBoxViewsBatchMergesSameKey(t *testing.T, ctx context.Context, store BoxViewsStore) { now := time.Date(2026, 10, 29, 15, 4, 5, 0, time.Local) // 同一访客三次 + 另一个访客一次,全在同一批里 err := store.RecordBatch(ctx, []RecordBoxViewOptions{ {UserID: 7, ViewedOn: now, Visitor: "aaaa"}, {UserID: 7, ViewedOn: now, Visitor: "aaaa"}, {UserID: 7, ViewedOn: now, Visitor: "aaaa"}, {UserID: 7, ViewedOn: now, Visitor: "bbbb"}, }) require.NoError(t, err) pv, uv, err := store.Totals(ctx, 7) require.NoError(t, err) require.Equal(t, int64(4), pv, "三次重复访问应累加进 views") require.Equal(t, int64(2), uv, "同一访客当天只算一个 UV") } func testBoxViewsAccumulatesAcrossCalls(t *testing.T, ctx context.Context, store BoxViewsStore) { day := time.Date(2026, 10, 28, 9, 0, 0, 0, time.Local) require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"})) require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"})) require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "y"})) pv, uv, err := store.Totals(ctx, 3) require.NoError(t, err) require.Equal(t, int64(3), pv) require.Equal(t, int64(2), uv) } func testBoxViewsDailyAndTotals(t *testing.T, ctx context.Context, store BoxViewsStore) { base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local) // 20 日:2 访客共 3 次;21 日:1 访客 1 次;22 日:无记录 require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{ {UserID: 11, ViewedOn: base, Visitor: "a"}, {UserID: 11, ViewedOn: base, Visitor: "a"}, {UserID: 11, ViewedOn: base, Visitor: "b"}, {UserID: 11, ViewedOn: base.AddDate(0, 0, 1), Visitor: "a"}, })) // 另一个箱主的数据不能串进来 require.NoError(t, store.Record(ctx, RecordBoxViewOptions{ UserID: 12, ViewedOn: base, Visitor: "a", })) days, err := store.Daily(ctx, 11, base, base.AddDate(0, 0, 2)) require.NoError(t, err) require.Len(t, days, 2, "没有记录的那天不返回,由上层补零") require.Equal(t, "2026-10-20", days[0].Day.Format("2006-01-02")) require.Equal(t, int64(3), days[0].PV) require.Equal(t, int64(2), days[0].UV) require.Equal(t, "2026-10-21", days[1].Day.Format("2006-01-02")) require.Equal(t, int64(1), days[1].PV) require.Equal(t, int64(1), days[1].UV) pv, uv, err := store.Totals(ctx, 11) require.NoError(t, err) require.Equal(t, int64(4), pv) require.Equal(t, int64(3), uv) first, err := store.FirstDay(ctx, 11) require.NoError(t, err) require.Equal(t, "2026-10-20", first.Format("2006-01-02")) } func testBoxViewsPurgeKeepsRecent(t *testing.T, ctx context.Context, store BoxViewsStore) { old := time.Now().AddDate(0, 0, -30) recent := time.Now() require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{ {UserID: 5, ViewedOn: old, Visitor: "a"}, {UserID: 5, ViewedOn: recent, Visitor: "b"}, })) n, err := store.Purge(ctx, time.Now().AddDate(0, 0, -7)) require.NoError(t, err) require.Equal(t, int64(1), n) pv, uv, err := store.Totals(ctx, 5) require.NoError(t, err) require.Equal(t, int64(1), pv, "只保留保留期内的行") require.Equal(t, int64(1), uv) }