Files
arcrun-collector/cloudquota_test.go
T

189 lines
8.9 KiB
Go
Raw 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 collector
// cloudquota_test.go — arcrun-rag#197:雲端資料庫(D1)免費額度用完時,
// ① 認得出來 ② 整個帳號不再打雲端 ③ 用戶看得到白話 ④ 恢復後自己接上。
//
// /health 的回應原文照抄 2026-09-13 20:3x 對 youlin1.4.63)實打的結果,不是編的。
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
const youlinHealthD1ReadExhausted = `{"ok":true,"status":"degraded","data_layer":{"ok":false,"summary":"資料層探測失敗——連 schema 都讀不到(D1_ERROR: Your account has exceeded D1's free tier daily row read limit. Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. See https://developers.cloudflare.com/d1/platform/limits/ for more details.)。這台的知識庫現在不可用。","expected_generation":9,"actual_generation":-1,"behind_by":-1,"missing":[],"legacy_tables":[],"remedy":"確認 D1 bindingDB)指向正確的 arcrun-kbdb,並重跑安裝器或 ` + "`acr update`" + `。","probe_error":"D1_ERROR: Your account has exceeded D1's free tier daily row read limit. Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. See https://developers.cloudflare.com/d1/platform/limits/ for more details."},"bundle_version":"1.4.63","bundle_commit":"be6bff7cf10a","auth_store":{"console":{"home":"sessions-kv","writable":true,"legacy_secrets_present":false},"portal_users":{"home":"kbdb","writable":true,"legacy_secrets_present":false}},"mail_relay_configured":true}`
// geek6688 同一時間(1.4.46,沒有 data_layer)的原文:認不出來就不編故事。
const geekHealthOld = `{"ok":true,"bundle_version":"1.4.46","auth_store":{"console":{"home":"sessions-kv","writable":true,"legacy_secrets_present":false},"portal_users":{"home":"kbdb","writable":true,"legacy_secrets_present":false}},"mail_relay_configured":true}`
const healthyNew = `{"ok":true,"status":"ok","data_layer":{"ok":true,"summary":"ok"},"bundle_version":"1.4.64"}`
func TestD1QuotaKind(t *testing.T) {
cases := map[string]string{
"D1_ERROR: Your account has exceeded D1's free tier daily row read limit.": QuotaKindD1Read,
"D1_ERROR: Your account has exceeded D1's free tier daily row write limit.": QuotaKindD1Write,
"D1_ERROR: no such table: entries": "",
"4006: you have used up your daily free allocation of 10,000 neurons": "", // Workers AI,不是 D1
"": "",
}
for in, want := range cases {
if got := d1QuotaKind(in); got != want {
t.Errorf("d1QuotaKind(%q)=%q,要 %q", in, got, want)
}
}
if got := d1QuotaFromHealth([]byte(youlinHealthD1ReadExhausted)); got != QuotaKindD1Read {
t.Fatalf("youlin 實打的 /health 應認出讀取額度用完,got %q", got)
}
if got := d1QuotaFromHealth([]byte(geekHealthOld)); got != "" {
t.Fatalf("舊版雲端沒有 data_layer,不准猜,got %q", got)
}
if got := d1QuotaFromHealth([]byte(healthyNew)); got != "" {
t.Fatalf("資料層正常不准報額度,got %q", got)
}
}
func TestD1QuotaNotice_TellsUserEverything(t *testing.T) {
// 台北 2026-09-13 20:30 UTC 12:30 ⇒ 下一次重置是台北 09-14 08:00(明天)
now := time.Date(2026, 9, 13, 12, 30, 0, 0, time.UTC)
n := buildD1QuotaNotice(QuotaKindD1Read, now, nextQuotaResetTaiwan(now))
all := n.Headline + n.Usage + n.Guarantee + n.ExitOptions
for _, must := range []string{"讀取", "額度用完", "500 萬", "已經用到上限", "台北時間明天早上 8:00", "自動接著傳", "不用做任何事"} {
if !strings.Contains(all, must) {
t.Errorf("訊息少了「%s」:%+v", must, n)
}
}
for _, banned := range []string{"D1_ERROR", "HTTP", "credential", "UTC"} {
if strings.Contains(all, banned) {
t.Errorf("用戶看得到的字不准出現 %q%+v", banned, n)
}
}
if n.ResumeAt != "2026-09-14T08:00:00+08:00" {
t.Errorf("ResumeAt=%s", n.ResumeAt)
}
w := buildD1QuotaNotice(QuotaKindD1Write, now, nextQuotaResetTaiwan(now))
if !strings.Contains(w.Headline, "寫入") || !strings.Contains(w.Usage, "10 萬") {
t.Errorf("寫入額度要講寫入與 10 萬:%+v", w)
}
// 台北凌晨 02:00UTC 前一天 18:00)⇒ 重置是「今天」早上 8 點
early := time.Date(2026, 9, 13, 18, 0, 0, 0, time.UTC)
if e := buildD1QuotaNotice(QuotaKindD1Read, early, nextQuotaResetTaiwan(early)); !strings.Contains(e.Guarantee, "今天早上 8:00") {
t.Errorf("凌晨要說今天:%s", e.Guarantee)
}
// 被擋下的檔要帶著原因上畫面(explainsWhySkipped 認得)
resetD1Quota()
defer resetD1Quota()
noteD1Quota("https://x.example", QuotaKindD1Read, now)
if note := d1QuotaNote("https://x.example", now); !explainsWhySkipped(note) {
t.Errorf("擋下的訊息畫面認不出原因:%s", note)
}
}
// 核心驗收:額度用完期間,整輪同步對雲端**零寫入**;/health 說恢復了就自己接上。
func TestD1Quota_NoCloudWritesUntilRecovered(t *testing.T) {
resetD1Quota()
resetCloudChecks()
cloudRoutes.reset()
defer func() { resetD1Quota(); resetCloudChecks(); cloudRoutes.reset() }()
var healthBody atomic.Value
healthBody.Store(youlinHealthD1ReadExhausted)
var posts, healths int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/health" {
atomic.AddInt64(&healths, 1)
_, _ = w.Write([]byte(healthBody.Load().(string)))
return
}
atomic.AddInt64(&posts, 1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"success":true,"data":{"success":true}}`))
}))
defer srv.Close()
origFetch := fetchCloudVersion
fetchCloudVersion = fetchBundleVersion // 走真的 /health 解析
defer func() { fetchCloudVersion = origFetch }()
if _, ok := cloudVersionThrottled(srv.URL, false); !ok {
t.Fatal("/health 應可達")
}
cfg := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k"}
url := srv.URL + "/webhooks/named/ns/rag_ingest_card/trigger"
for i := 0; i < 5; i++ {
_, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{"i": i})
if err == nil || !isRouteBackoff(err) {
t.Fatalf("第 %d 發:額度用完應被擋成『沒打出去』,got %v", i, err)
}
if !strings.Contains(err.Error(), "額度用完") {
t.Fatalf("擋下的原因要講額度:%v", err)
}
}
// 按「立刻同步」也不打(但會重問 /health)
force := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k", ForceSync: true}
cloudVersionThrottled(srv.URL, true)
if _, _, err := force.postJSON(stepIngestCard, url, map[string]any{}); err == nil {
t.Fatal("額度沒恢復時立刻同步也不該打出去")
}
if got := atomic.LoadInt64(&posts); got != 0 {
t.Fatalf("額度用完期間打了 %d 發寫入,要 0", got)
}
// 雲端恢復:下一次強制重問 /health 就放行
healthBody.Store(healthyNew)
cloudVersionThrottled(srv.URL, true)
if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); err != nil {
t.Fatalf("恢復後應自動接上,got %v", err)
}
if got := atomic.LoadInt64(&posts); got != 1 {
t.Fatalf("恢復後應打出 1 發,got %d", got)
}
}
// 寫入那一發自己帶回 D1 額度原文(不等下一分鐘的 /health)⇒ 下一發起就停。
func TestD1Quota_LearnsFromFailedWrite(t *testing.T) {
resetD1Quota()
cloudRoutes.reset()
defer func() { resetD1Quota(); cloudRoutes.reset() }()
var posts int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&posts, 1)
w.WriteHeader(500)
_, _ = w.Write([]byte(`{"success":false,"error":"D1_ERROR: Your account has exceeded D1's free tier daily row write limit."}`))
}))
defer srv.Close()
cfg := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k"}
url := srv.URL + "/webhooks/named/ns/rag_ingest_card/trigger"
if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); err == nil || isRouteBackoff(err) {
t.Fatalf("第一發應真的打出去並失敗,got %v", err)
}
for i := 0; i < 3; i++ {
if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); !isRouteBackoff(err) {
t.Fatalf("認出寫入額度用完後應停打,got %v", err)
}
}
if got := atomic.LoadInt64(&posts); got != 1 {
t.Fatalf("只該打出 1 發,got %d", got)
}
if st, ok := activeD1Quota(srv.URL, directNow()); !ok || st.kind != QuotaKindD1Write {
t.Fatalf("應記成寫入額度,got %+v %v", st, ok)
}
}
// 過了重置時間(台北 08:00)舊紀錄一定失效——就算那之後 /health 一次都沒打到。
func TestD1Quota_ExpiresAtReset(t *testing.T) {
resetD1Quota()
defer resetD1Quota()
now := time.Date(2026, 9, 13, 12, 30, 0, 0, time.UTC)
noteD1Quota("https://x.example", QuotaKindD1Read, now)
if _, ok := activeD1Quota("https://x.example", now.Add(11*time.Hour)); !ok {
t.Fatal("重置前應仍有效")
}
if _, ok := activeD1Quota("https://x.example", time.Date(2026, 9, 14, 0, 0, 1, 0, time.UTC)); ok {
t.Fatal("過了 00:00 UTC 應失效")
}
}