Files
wuxu 8997886eef
Frontend / quality (push) Canceled after 0s
feat: migrate account usage to CLIProxyAPI
2026-09-08 09:56:08 +08:00

493 lines
16 KiB
Go

package app
import (
"bytes"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"codex-helper/internal/security"
"codex-helper/internal/store"
)
func TestSendSMTPStopsWhenServerDoesNotRespond(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
accepted := make(chan net.Conn, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr == nil {
accepted <- conn
}
}()
host, portRaw, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portRaw)
if err != nil {
t.Fatal(err)
}
started := time.Now()
err = sendSMTPWithTimeout(SMTPSettings{Host: host, Port: port, From: "from@example.com", To: "to@example.com"}, "subject", "text", "html", 100*time.Millisecond)
if err == nil {
t.Fatal("sendSMTPWithTimeout unexpectedly succeeded")
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("SMTP timeout took %s", elapsed)
}
select {
case conn := <-accepted:
_ = conn.Close()
default:
}
}
func newReminderTestApp(t *testing.T) *App {
t.Helper()
dir := t.TempDir()
s, err := store.Open(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.DB.Close() })
vault, err := security.OpenVault(dir)
if err != nil {
t.Fatal(err)
}
return &App{store: s, vault: vault, runtimes: map[int64]*accountRuntime{}}
}
func TestTGSendKeepsOrRemovesKeyboard(t *testing.T) {
original := tgCall
t.Cleanup(func() { tgCall = original })
var calls []map[string]any
tgCall = func(_ string, method string, params any, _ any) error {
if method != "sendMessage" {
t.Fatalf("method = %q", method)
}
body, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err = json.Unmarshal(body, &decoded); err != nil {
t.Fatal(err)
}
calls = append(calls, decoded)
return nil
}
if err := tgSend(TelegramSettings{Token: "token", ChatID: 1, MenuEnabled: true}, "on"); err != nil {
t.Fatal(err)
}
if err := tgSend(TelegramSettings{Token: "token", ChatID: 1}, "off"); err != nil {
t.Fatal(err)
}
keyboard := calls[0]["reply_markup"].(map[string]any)
removed := calls[1]["reply_markup"].(map[string]any)
if keyboard["keyboard"] == nil || removed["remove_keyboard"] != true {
t.Fatalf("reply markup mismatch: %#v %#v", keyboard, removed)
}
}
func TestTelegramDeleteClearsConfigurationAndOffset(t *testing.T) {
a := newReminderTestApp(t)
enc, err := a.vault.Encrypt("secret-token")
if err != nil {
t.Fatal(err)
}
if err = a.store.Set("telegram_token", enc); err != nil {
t.Fatal(err)
}
if err = a.store.SetJSON("telegram", TelegramSettings{ChatID: 0, Enabled: true, MenuEnabled: true, Configured: true}); err != nil {
t.Fatal(err)
}
if err = a.store.Set("telegram_bind", `{"code":"123456"}`); err != nil {
t.Fatal(err)
}
if _, err = a.store.DB.Exec("UPDATE telegram_updates SET offset=42 WHERE id=1"); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
a.telegramAPI(recorder, httptest.NewRequest(http.MethodDelete, "/api/v1/settings/telegram", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
}
for _, key := range []string{"telegram", "telegram_token", "telegram_bind"} {
if _, ok := a.store.Get(key); ok {
t.Fatalf("setting %q still exists", key)
}
}
var offset int64
if err = a.store.DB.QueryRow("SELECT offset FROM telegram_updates WHERE id=1").Scan(&offset); err != nil || offset != 0 {
t.Fatalf("offset = %d err = %v", offset, err)
}
if got := a.telegramSettings(); got.Configured || got.ChatID != 0 || got.Enabled || got.MenuEnabled {
t.Fatalf("settings after delete = %#v", got)
}
}
func TestTelegramDeleteWaitsForInFlightSaveAndRemainsFinal(t *testing.T) {
a := newReminderTestApp(t)
original := tgCall
t.Cleanup(func() { tgCall = original })
getMeStarted := make(chan struct{})
releaseGetMe := make(chan struct{})
tgCall = func(_ string, method string, _ any, out any) error {
if method != "getMe" {
t.Fatalf("method = %q", method)
}
close(getMeStarted)
<-releaseGetMe
body := []byte(`{"ok":true,"result":{"first_name":"Test","username":"test_bot"}}`)
return json.Unmarshal(body, out)
}
putDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
body := bytes.NewBufferString(`{"token":"new-token","chatId":0,"enabled":true,"menuEnabled":false}`)
recorder := httptest.NewRecorder()
a.telegramAPI(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/settings/telegram", body))
putDone <- recorder
}()
<-getMeStarted
deleteDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
recorder := httptest.NewRecorder()
a.telegramAPI(recorder, httptest.NewRequest(http.MethodDelete, "/api/v1/settings/telegram", nil))
deleteDone <- recorder
}()
select {
case recorder := <-deleteDone:
t.Fatalf("delete completed before save: status=%d body=%s", recorder.Code, recorder.Body.String())
case <-time.After(50 * time.Millisecond):
}
close(releaseGetMe)
if recorder := <-putDone; recorder.Code != http.StatusOK {
t.Fatalf("PUT status = %d body = %s", recorder.Code, recorder.Body.String())
}
if recorder := <-deleteDone; recorder.Code != http.StatusOK {
t.Fatalf("DELETE status = %d body = %s", recorder.Code, recorder.Body.String())
}
if _, ok := a.store.Get("telegram_token"); ok {
t.Fatal("Telegram token was recreated after delete")
}
if _, ok := a.store.Get("telegram"); ok {
t.Fatal("Telegram settings were recreated after delete")
}
}
func TestBoundTelegramIgnoresLegacyDisabledFlags(t *testing.T) {
a := newReminderTestApp(t)
enc, err := a.vault.Encrypt("secret-token")
if err != nil {
t.Fatal(err)
}
if err = a.store.Set("telegram_token", enc); err != nil {
t.Fatal(err)
}
if err = a.store.SetJSON("telegram", TelegramSettings{ChatID: 123, Enabled: false, MenuEnabled: false, Configured: true}); err != nil {
t.Fatal(err)
}
now := time.Now()
if _, err = a.store.DB.Exec(`INSERT INTO notifications
(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at,body)
VALUES('disabled-test','configured','before','pending',0,'',?,NULL,'message')`, now.Unix()); err != nil {
t.Fatal(err)
}
original := tgCall
t.Cleanup(func() { tgCall = original })
calls := 0
tgCall = func(_ string, _ string, _ any, _ any) error {
calls++
return nil
}
a.sendPendingReminders(now)
if calls != 1 {
t.Fatalf("automatic Telegram calls = %d; want 1", calls)
}
recorder := httptest.NewRecorder()
a.telegramTest(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/settings/telegram/test", nil))
if recorder.Code != http.StatusOK || calls != 2 {
t.Fatalf("manual test status = %d calls = %d body = %s", recorder.Code, calls, recorder.Body.String())
}
settings := a.telegramSettings()
if !settings.Enabled || !settings.MenuEnabled {
t.Fatalf("effective settings = %#v", settings)
}
}
func TestLegacyBoundTelegramRestoresMenuOnce(t *testing.T) {
a := newReminderTestApp(t)
enc, err := a.vault.Encrypt("secret-token")
if err != nil {
t.Fatal(err)
}
if err = a.store.Set("telegram_token", enc); err != nil {
t.Fatal(err)
}
if err = a.store.SetJSON("telegram", TelegramSettings{ChatID: 123, Enabled: false, MenuEnabled: false, Configured: true}); err != nil {
t.Fatal(err)
}
original := tgCall
t.Cleanup(func() { tgCall = original })
calls := 0
var sent map[string]any
tgCall = func(_ string, method string, params any, _ any) error {
if method != "sendMessage" {
t.Fatalf("method = %q", method)
}
calls++
body, marshalErr := json.Marshal(params)
if marshalErr != nil {
return marshalErr
}
return json.Unmarshal(body, &sent)
}
a.syncLegacyTelegramMenu()
a.syncLegacyTelegramMenu()
if calls != 1 {
t.Fatalf("menu restore calls = %d; want 1", calls)
}
replyMarkup, ok := sent["reply_markup"].(map[string]any)
if !ok || replyMarkup["keyboard"] == nil {
t.Fatalf("send params = %#v", sent)
}
var stored TelegramSettings
if !a.store.GetJSON("telegram", &stored) || !stored.Enabled || !stored.MenuEnabled {
t.Fatalf("stored settings = %#v", stored)
}
}
func TestTelegramSaveCannotDisableFeaturesForBoundChat(t *testing.T) {
a := newReminderTestApp(t)
original := tgCall
t.Cleanup(func() { tgCall = original })
tgCall = func(_ string, method string, _ any, out any) error {
if method == "sendMessage" {
return nil
}
if method == "getMe" {
return json.Unmarshal([]byte(`{"ok":true,"result":{"first_name":"Test","username":"test_bot"}}`), out)
}
t.Fatalf("method = %q", method)
return nil
}
body := bytes.NewBufferString(`{"token":"token","chatId":123,"enabled":false,"menuEnabled":false}`)
recorder := httptest.NewRecorder()
a.telegramAPI(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/settings/telegram", body))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
}
var response TelegramSettings
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if !response.Enabled || !response.MenuEnabled {
t.Fatalf("response = %#v", response)
}
var stored TelegramSettings
if !a.store.GetJSON("telegram", &stored) || !stored.Enabled || !stored.MenuEnabled {
t.Fatalf("stored settings = %#v", stored)
}
}
func TestTelegramBindEnablesFeaturesAndSendsMenu(t *testing.T) {
a := newReminderTestApp(t)
enc, err := a.vault.Encrypt("secret-token")
if err != nil {
t.Fatal(err)
}
if err = a.store.Set("telegram_token", enc); err != nil {
t.Fatal(err)
}
if err = a.store.SetJSON("telegram", TelegramSettings{Configured: true}); err != nil {
t.Fatal(err)
}
if err = a.store.SetJSON("telegram_bind", map[string]any{"code": "123456", "expires": time.Now().Add(time.Minute).Unix()}); err != nil {
t.Fatal(err)
}
original := tgCall
t.Cleanup(func() { tgCall = original })
var sent map[string]any
tgCall = func(_ string, method string, params any, _ any) error {
if method != "sendMessage" {
t.Fatalf("method = %q", method)
}
body, marshalErr := json.Marshal(params)
if marshalErr != nil {
return marshalErr
}
return json.Unmarshal(body, &sent)
}
a.handleTG(a.telegramSecretForTest(t), 456, "/bind 123456")
settings := a.telegramSettings()
if settings.ChatID != 456 || !settings.Enabled || !settings.MenuEnabled {
t.Fatalf("settings = %#v", settings)
}
replyMarkup, ok := sent["reply_markup"].(map[string]any)
if !ok || replyMarkup["keyboard"] == nil {
t.Fatalf("send params = %#v", sent)
}
}
func (a *App) telegramSecretForTest(t *testing.T) TelegramSettings {
t.Helper()
settings, err := a.telegramSecret()
if err != nil {
t.Fatal(err)
}
return settings
}
func reminderDashboard(fetchedAt int64, used float64, resetsAt int64) Dashboard {
return Dashboard{
AccountID: 1,
DisplayName: "测试账号",
FetchedAt: fetchedAt,
Limits: []LimitBucket{{
LimitID: "codex",
WindowType: "primary",
UsedPercent: used,
WindowDurationMinutes: 300,
ResetsAt: resetsAt,
}},
}
}
func ensureReminderAccount(t *testing.T, a *App) {
t.Helper()
if _, err := a.store.CreateAccount("测试账号", "test-auth"); err != nil {
t.Fatal(err)
}
}
func notificationCount(t *testing.T, a *App) int {
t.Helper()
var count int
if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM notifications").Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
func TestStoreLimitSnapshotsDetectsEarlyReset(t *testing.T) {
a := newReminderTestApp(t)
ensureReminderAccount(t, a)
now := time.Now().Unix()
if detected, err := a.storeLimitSnapshots(reminderDashboard(now, 42, now+3600)); err != nil || detected {
t.Fatalf("initial snapshot: detected=%v err=%v", detected, err)
}
if detected, err := a.storeLimitSnapshots(reminderDashboard(now+60, 3, now+7200)); err != nil || !detected {
t.Fatalf("reset snapshot: detected=%v err=%v", detected, err)
}
var kind, body string
if err := a.store.DB.QueryRow("SELECT kind,body FROM notifications").Scan(&kind, &body); err != nil {
t.Fatal(err)
}
event, ok := decodeNotification(body)
if kind != "detected_after" || !ok || event.PreviousUsed != 42 || event.Used != 3 || event.Account != "测试账号" {
t.Fatalf("notification kind=%q body=%q", kind, body)
}
}
func TestNotificationFormatting(t *testing.T) {
if got := limitLabel(300); got != "5 小时额度" {
t.Fatalf("300 minute label = %q", got)
}
if got := limitLabel(10080); got != "7 天额度" {
t.Fatalf("7 day label = %q", got)
}
reset := time.Date(2026, 8, 20, 3, 51, 0, 0, time.UTC)
now := reset.Add(-2*time.Hour - 15*time.Minute)
event := notificationEvent{Version: 1, Kind: "before", Account: "A < B", DurationMins: 300, Remaining: 27.5, ResetsAt: reset.Unix()}
plain, telegram, subject, email := renderNotification(event, "", "Asia/Shanghai", now)
for _, value := range []string{plain, telegram, email} {
if strings.Contains(value, "codex/primary") || strings.Contains(value, "2026-08-20T03:51:00Z") {
t.Fatalf("internal label or RFC3339 leaked: %q", value)
}
}
if subject != "Codex 即将重置" || !strings.Contains(telegram, "Codex 即将重置") || !strings.Contains(telegram, "8月20日 周四 11:51") || !strings.Contains(telegram, "还有 2 小时 15 分") {
t.Fatalf("telegram = %q subject = %q", telegram, subject)
}
if !strings.Contains(telegram, "A &lt; B") || !strings.Contains(email, "multipart") && !strings.Contains(email, "Codex Helper") {
t.Fatalf("output was not escaped or rendered: telegram=%q email=%q", telegram, email)
}
}
func TestLegacyNotificationFormatting(t *testing.T) {
legacy := "旧消息 <保留>"
event, ok := decodeNotification(legacy)
if ok {
t.Fatal("legacy notification decoded as structured")
}
plain, telegram, subject, email := renderNotification(event, legacy, "UTC", time.Now())
if plain != legacy || telegram != "旧消息 &lt;保留&gt;" || subject != "Codex 额度重置提醒" || !strings.Contains(email, "旧消息 &lt;保留&gt;") {
t.Fatalf("legacy render mismatch: %q %q %q", plain, telegram, subject)
}
}
func TestStoreLimitSnapshotsUsesScheduledAfterDedupeKey(t *testing.T) {
a := newReminderTestApp(t)
ensureReminderAccount(t, a)
now := time.Now().Unix()
resetAt := now + 30
_, _ = a.storeLimitSnapshots(reminderDashboard(now, 70, resetAt))
detected, err := a.storeLimitSnapshots(reminderDashboard(now+60, 0, now+3600))
if err != nil || !detected {
t.Fatalf("detected=%v err=%v", detected, err)
}
var key, kind string
if err = a.store.DB.QueryRow("SELECT dedupe_key,kind FROM notifications").Scan(&key, &kind); err != nil {
t.Fatal(err)
}
exact := "1:codex:primary:" + strconv.FormatInt(resetAt, 10) + ":after"
if key != exact || kind != "after" {
t.Fatalf("key=%q kind=%q, want %q after", key, kind, exact)
}
}
func TestStoreLimitSnapshotsIgnoresNonResetChanges(t *testing.T) {
tests := []struct {
name string
oldUsed float64
newUsed float64
age time.Duration
notifyAfter bool
}{
{name: "increase", oldUsed: 10, newUsed: 20, age: time.Minute, notifyAfter: true},
{name: "tolerance", oldUsed: 10, newUsed: 9.995, age: time.Minute, notifyAfter: true},
{name: "old snapshot", oldUsed: 50, newUsed: 0, age: 7 * time.Hour, notifyAfter: true},
{name: "disabled", oldUsed: 50, newUsed: 0, age: time.Minute, notifyAfter: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := newReminderTestApp(t)
ensureReminderAccount(t, a)
g := defaults()
g.NotifyAfter = tt.notifyAfter
if err := a.store.SetJSON("general", g); err != nil {
t.Fatal(err)
}
now := time.Now().Unix()
_, _ = a.storeLimitSnapshots(reminderDashboard(now, tt.oldUsed, now+3600))
detected, err := a.storeLimitSnapshots(reminderDashboard(now+int64(tt.age.Seconds()), tt.newUsed, now+7200))
if err != nil || detected || notificationCount(t, a) != 0 {
t.Fatalf("detected=%v notifications=%d err=%v", detected, notificationCount(t, a), err)
}
})
}
}