Files
nezha_domains/model/online_history_test.go
Sam5440andnaiba/CloudCode 8721a15b12 feat(server): online session history with configurable stats window (#1243)
Add persisted online sessions, configurable retention and online history statistics with lifecycle, recovery, timezone and concurrency regression coverage.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-10-16 19:25:13 +08:00

221 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"math/rand"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var testZone = time.FixedZone("TEST", 8*3600)
func ts(y int, mo time.Month, d, h, mi, s int) time.Time {
return time.Date(y, mo, d, h, mi, s, 0, testZone)
}
func TestClipOnlineSessions(t *testing.T) {
windowStart := ts(2026, 10, 1, 0, 0, 0)
windowEnd := ts(2026, 10, 8, 0, 0, 0)
t.Run("session fully inside window", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 10, 2, 8, 0, 0), EndedAt: ts(2026, 10, 2, 9, 30, 0)},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.Equal(t, ts(2026, 10, 2, 8, 0, 0), periods[0].Start)
require.Equal(t, ts(2026, 10, 2, 9, 30, 0), periods[0].End)
require.False(t, periods[0].Ongoing)
})
t.Run("session started before window is clipped", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 9, 28, 12, 0, 0), EndedAt: ts(2026, 10, 1, 5, 0, 0)},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.Equal(t, windowStart, periods[0].Start)
require.Equal(t, ts(2026, 10, 1, 5, 0, 0), periods[0].End)
})
t.Run("ongoing session ends at window end", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0), Ongoing: true},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.Equal(t, windowEnd, periods[0].End)
require.True(t, periods[0].Ongoing)
})
t.Run("zero ended_at treated as ongoing", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0)},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.True(t, periods[0].Ongoing)
require.Equal(t, windowEnd, periods[0].End)
})
t.Run("session outside window yields no period", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 9, 1, 0, 0, 0), EndedAt: ts(2026, 9, 2, 0, 0, 0)},
}
require.Empty(t, ClipOnlineSessions(sessions, windowStart, windowEnd))
})
}
func TestDailyOnlineBreakdown(t *testing.T) {
loc := time.FixedZone("UTC+8", 8*3600)
windowStart := time.Date(2026, 10, 1, 10, 0, 0, 0, loc)
windowEnd := time.Date(2026, 10, 3, 14, 0, 0, 0, loc)
periods := []OnlinePeriod{
// 跨午夜:2 日 23:00 ~ 3 日 01:00
{Start: time.Date(2026, 10, 2, 23, 0, 0, 0, loc), End: time.Date(2026, 10, 3, 1, 0, 0, 0, loc)},
// 窗口前就开始的会话,裁剪后从窗口起点开始
{Start: windowStart, End: time.Date(2026, 10, 1, 12, 0, 0, 0, loc)},
}
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
require.Len(t, daily, 3)
require.Equal(t, "2026-10-01", daily[0].Date)
require.Equal(t, "2026-10-02", daily[1].Date)
require.Equal(t, "2026-10-03", daily[2].Date)
// 1 日:12:00-10:00 = 2h;2 日:23:00-24:00 = 1h;3 日:0:00-1:00 + 窗口外无 = 1h
require.Equal(t, uint64(7200), daily[0].OnlineSeconds)
require.Equal(t, uint64(3600), daily[1].OnlineSeconds)
require.Equal(t, uint64(3600), daily[2].OnlineSeconds)
// 每日合计即总时长(口径:每日累计原始时长后统一取整)
var sum uint64
for _, d := range daily {
sum += d.OnlineSeconds
}
require.Equal(t, uint64(7200+3600+3600), sum)
}
// 评审回归:取整必须按日累计原始时长后统一转换。逐段截断再累加会让亚秒
// 会话系统性偏小——同一天两个 1.5s 的会话应得 3s,而不是 1+1=2s
func TestDailyOnlineBreakdownSubSecond(t *testing.T) {
loc := testZone
windowStart := ts(2026, 10, 1, 0, 0, 0)
windowEnd := ts(2026, 10, 2, 0, 0, 0)
periods := []OnlinePeriod{
{Start: windowStart.Add(100 * time.Millisecond), End: windowStart.Add(1600 * time.Millisecond)},
{Start: windowStart.Add(2 * time.Second), End: windowStart.Add(3500 * time.Millisecond)},
}
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
require.Len(t, daily, 1)
require.Equal(t, uint64(3), daily[0].OnlineSeconds)
}
// 跨日时段按日界切分不损失精度:各日分别累计原始时长后各自取整
func TestDailyOnlineBreakdownCrossDayFraction(t *testing.T) {
loc := testZone
windowStart := ts(2026, 10, 1, 0, 0, 0)
windowEnd := ts(2026, 10, 3, 0, 0, 0)
periods := []OnlinePeriod{{
Start: time.Date(2026, 10, 1, 23, 59, 59, 250000000, loc),
End: time.Date(2026, 10, 2, 0, 0, 2, 750000000, loc),
}}
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
require.Len(t, daily, 2)
require.Equal(t, uint64(0), daily[0].OnlineSeconds) // 0.75s → 0
require.Equal(t, uint64(2), daily[1].OnlineSeconds) // 2.75s → 2
}
func TestDailyOnlineBreakdownZeroOnline(t *testing.T) {
loc := time.FixedZone("UTC+8", 8*3600)
windowStart := time.Date(2026, 10, 1, 0, 0, 0, 0, loc)
windowEnd := time.Date(2026, 10, 4, 0, 0, 0, 0, loc)
daily := DailyOnlineBreakdown(nil, windowStart, windowEnd, loc)
require.Len(t, daily, 3)
for _, d := range daily {
require.Zero(t, d.OnlineSeconds)
}
}
func TestOnlineStatsDaysBounds(t *testing.T) {
require.Equal(t, 7, DefaultOnlineStatsDays)
require.Equal(t, 7, MinOnlineStatsDays)
require.Equal(t, 3650, MaxOnlineStatsDays)
}
// F1 回归保护:指针推进实现必须与逐日朴素实现对任意不相交时段集合等价
// (单服务器会话互不重叠的约束一致),长窗口大量时段下不再随时段数平方放大。
func TestDailyOnlineBreakdownEquivalence(t *testing.T) {
loc := testZone
windowStart := ts(2026, 1, 1, 0, 0, 0)
windowEnd := windowStart.AddDate(0, 0, 90)
rng := rand.New(rand.NewSource(42))
var periods []OnlinePeriod
cursor := windowStart
for cursor.Before(windowEnd) {
// 起止带亚秒抖动:让逐段截断与按日累计两种口径可区分
start := cursor.Add(time.Duration(rng.Intn(4*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
end := start.Add(time.Duration(1+rng.Intn(6*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
if end.After(windowEnd) {
end = windowEnd
}
periods = append(periods, OnlinePeriod{Start: start, End: end})
cursor = end.Add(time.Duration(1+rng.Intn(3600)) * time.Second)
}
days := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
// 参考实现与生产同一口径:每日累计原始时长、汇总时向下取整一次
reference := map[string]time.Duration{}
for _, p := range periods {
for d := windowStart; d.Before(windowEnd); d = d.AddDate(0, 0, 1) {
overlapStart := maxTime(p.Start, d)
overlapEnd := minTime(p.End, d.AddDate(0, 0, 1))
if overlapStart.Before(overlapEnd) {
reference[d.Format("2006-01-02")] += overlapEnd.Sub(overlapStart)
}
}
}
require.Len(t, days, 90)
for _, day := range days {
require.Equal(t, uint64(reference[day.Date].Seconds()), day.OnlineSeconds, "date %s", day.Date)
}
}
func TestDailyOnlineBreakdownMidnightDSTTransition(t *testing.T) {
loc, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
start := time.Date(2018, 11, 3, 0, 0, 0, 0, loc)
end := time.Date(2018, 11, 6, 0, 0, 0, 0, loc)
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
require.Equal(t, []DailyOnlineStat{{Date: "2018-11-03", OnlineSeconds: 86400}, {Date: "2018-11-04", OnlineSeconds: 82800}, {Date: "2018-11-05", OnlineSeconds: 86400}}, days)
}
func TestDailyOnlineBreakdownRepeatedMidnight(t *testing.T) {
loc, err := time.LoadLocation("Asia/Amman")
require.NoError(t, err)
start := time.Date(2021, 10, 28, 21, 0, 0, 0, time.UTC)
end := time.Date(2021, 10, 29, 22, 0, 0, 0, time.UTC)
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
require.Equal(t, []DailyOnlineStat{{Date: "2021-10-29", OnlineSeconds: 90000}}, days)
}
func TestOnlineHistoryWindowUsesDashboardCalendar(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
for _, test := range []struct {
month time.Month
day, hours int
}{{time.March, 9, 23}, {time.November, 2, 25}} {
end := time.Date(2026, test.month, test.day, 0, 30, 0, 0, loc)
start := OnlineHistoryWindowStart(end.UTC(), 1, loc)
require.True(t, start.Equal(time.Date(2026, test.month, test.day-1, 0, 30, 0, 0, loc)))
require.Equal(t, time.Duration(test.hours)*time.Hour, end.Sub(start))
require.Len(t, DailyOnlineBreakdown(nil, start, end, loc), 2)
require.True(t, OnlineHistoryWindowStart(end.UTC(), 7, loc).Equal(end.In(loc).AddDate(0, 0, -7)))
}
}