File size: 4,792 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package panel

import (
	"encoding/json"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"

	"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
	"github.com/linguo2625469/workbuddy2api-panel/internal/reqlog"
	"github.com/linguo2625469/workbuddy2api-panel/internal/usage"
)

// parseTimeParam 是「今天 / 自定义」区间的唯一入口:前端默认发 unix 秒,手工
// 调接口时可以用 RFC3339 / datetime-local;非法值与空串一律回落"不设界",
// 不能让一个拼错的参数把整页用量打成 500。
func TestParseTimeParam(t *testing.T) {
	want := time.Date(2026, 9, 30, 14, 5, 0, 0, time.Local)
	cases := []struct {
		name string
		in   string
		want time.Time
	}{
		{"空串", "", time.Time{}},
		{"空白", "   ", time.Time{}},
		{"unix 秒", "1786000000", time.Unix(1786000000, 0)},
		{"unix 毫秒", "1786000000000", time.UnixMilli(1786000000000)},
		{"零值按不设界", "0", time.Time{}},
		{"负数按不设界", "-5", time.Time{}},
		{"RFC3339", want.Format(time.RFC3339), want},
		{"本地 datetime-local", "2026-09-30T14:05", want},
		{"非法", "not-a-time", time.Time{}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := parseTimeParam(c.in)
			if !got.Equal(c.want) {
				t.Fatalf("parseTimeParam(%q) = %v, want %v", c.in, got, c.want)
			}
		})
	}
}

// usage 接口的区间参数必须真的作用到聚合上:from/to 生效时 hours 被忽略,
// 且响应回显 window_from/window_to 供面板确认口径。
func TestUsageHandlerAcceptsExplicitRange(t *testing.T) {
	rec := usage.New("")
	base := time.Now().Truncate(time.Hour).Add(-5 * time.Hour)
	for i := 0; i < 6; i++ {
		rec.Add(base.Add(time.Duration(i)*time.Hour), "cn", "u1", "glm-5.2",
			usage.Delta{PromptTokens: 10, HasPromptTokens: true}, true)
	}
	p := New(Config{Version: "test", APIKey: "k", Usage: rec, Pool: pool.New("")})

	get := func(query string) map[string]any {
		t.Helper()
		req := httptest.NewRequest("GET", "/panel/api/usage"+query, nil)
		req.Header.Set("Authorization", "Bearer k")
		rr := httptest.NewRecorder()
		p.ServeHTTP(rr, req)
		if rr.Code != 200 {
			t.Fatalf("usage%s -> %d %s", query, rr.Code, rr.Body)
		}
		var out map[string]any
		if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
			t.Fatal(err)
		}
		return out
	}

	from := base.Add(2 * time.Hour).Unix()
	to := base.Add(3 * time.Hour).Unix()
	// 同时带上 hours=720:区间参数应优先,否则会被算成 30 天全量。
	got := get("?hours=720&from=" + strconv.FormatInt(from, 10) + "&to=" + strconv.FormatInt(to, 10))
	totals := got["totals"].(map[string]any)
	if reqs := totals["requests"].(float64); reqs != 2 {
		t.Fatalf("区间请求数 = %v, want 2(from/to 应优先于 hours)", reqs)
	}
	if got["window_from"] == nil || got["window_to"] == nil {
		t.Fatalf("响应应回显 window_from/window_to: %+v", got)
	}

	// 无区间参数时回落默认 72h,且不回显区间。
	def := get("")
	if def["window_from"] != nil || def["window_to"] != nil {
		t.Fatalf("默认窗口不应回显区间: %+v", def)
	}
	if r := def["totals"].(map[string]any)["requests"].(float64); r != 6 {
		t.Fatalf("默认窗口请求数 = %v, want 6", r)
	}

	// hours=0(全部历史)同样不回显区间。
	all := get("?hours=0")
	if r := all["totals"].(map[string]any)["requests"].(float64); r != 6 {
		t.Fatalf("全部历史请求数 = %v, want 6", r)
	}
}

// 归档为空时接口必须回 [],而不是 JSON null:前端把 null 与"归档关闭"混在一起
// 会走错分支,把不满足时间区间的最近请求显示出来。
func TestRequestLogsEmptyRangeReturnsArray(t *testing.T) {
	rec := reqlog.New(reqlog.Config{Enabled: true, Dir: t.TempDir(), MaxBytes: 1 << 20, RetentionDays: 7})
	rec.Record(reqlog.Event{Time: time.Now().Add(-48 * time.Hour), RequestID: "old",
		Status: 200, OK: true, Outcome: reqlog.OutcomeSuccess})
	rec.Close()

	p := New(Config{Version: "test", APIKey: "k", Pool: pool.New(""), RequestLog: rec})
	future := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
	req := httptest.NewRequest("GET", "/panel/api/request_logs?from="+future, nil)
	req.Header.Set("Authorization", "Bearer k")
	rr := httptest.NewRecorder()
	p.ServeHTTP(rr, req)
	if rr.Code != 200 {
		t.Fatalf("code=%d body=%s", rr.Code, rr.Body)
	}
	var out struct {
		Entries []reqlog.Event `json:"entries"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
		t.Fatal(err)
	}
	if out.Entries == nil {
		t.Fatalf("空区间应回 [],得到 null: %s", rr.Body)
	}
	if len(out.Entries) != 0 {
		t.Fatalf("未来区间不该命中任何记录: %+v", out.Entries)
	}
}