docs: add maintainer guidance and API contract
This commit is contained in:
@@ -19,6 +19,10 @@ import (
|
||||
func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/api/v1/")
|
||||
if p == "system/status" {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonOut(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
return
|
||||
}
|
||||
connected := false
|
||||
a.mu.RLock()
|
||||
for _, rt := range a.runtimes {
|
||||
@@ -97,6 +101,12 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
rt.syncing.Lock()
|
||||
d := rt.dash
|
||||
rt.syncing.Unlock()
|
||||
if d.Limits == nil {
|
||||
d.Limits = []LimitBucket{}
|
||||
}
|
||||
if d.Usage == nil {
|
||||
d.Usage = []UsagePoint{}
|
||||
}
|
||||
jsonOut(w, 200, d)
|
||||
}
|
||||
case p == "sync" && r.Method == "POST":
|
||||
|
||||
@@ -96,22 +96,38 @@ func (a *App) smtpTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
const smtpTimeout = 35 * time.Second
|
||||
|
||||
func sendSMTP(s SMTPSettings, subject, textBody, htmlBody string) error {
|
||||
return sendSMTPWithTimeout(s, subject, textBody, htmlBody, smtpTimeout)
|
||||
}
|
||||
|
||||
func sendSMTPWithTimeout(s SMTPSettings, subject, textBody, htmlBody string, timeout time.Duration) error {
|
||||
addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port))
|
||||
var c *smtp.Client
|
||||
var e error
|
||||
if s.Security == "tls" {
|
||||
conn, x := tls.Dial("tcp", addr, &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12})
|
||||
if x != nil {
|
||||
return x
|
||||
}
|
||||
c, e = smtp.NewClient(conn, s.Host)
|
||||
} else {
|
||||
c, e = smtp.Dial(addr)
|
||||
}
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, e := dialer.Dial("tcp", addr)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = conn.SetDeadline(time.Now().Add(timeout)); e != nil {
|
||||
_ = conn.Close()
|
||||
return e
|
||||
}
|
||||
var c *smtp.Client
|
||||
if s.Security == "tls" {
|
||||
tlsConn := tls.Client(conn, &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12})
|
||||
if e = tlsConn.Handshake(); e != nil {
|
||||
_ = conn.Close()
|
||||
return e
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
c, e = smtp.NewClient(conn, s.Host)
|
||||
if e != nil {
|
||||
_ = conn.Close()
|
||||
return e
|
||||
}
|
||||
defer c.Close()
|
||||
if s.Security == "starttls" {
|
||||
if e = c.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); e != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,6 +10,42 @@ import (
|
||||
"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()
|
||||
s, err := store.Open(t.TempDir())
|
||||
|
||||
@@ -2,12 +2,53 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codex-helper/internal/security"
|
||||
)
|
||||
|
||||
func TestSystemStatusRejectsNonGETMethods(t *testing.T) {
|
||||
a := newReminderTestApp(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/system/status", nil)
|
||||
a.api(recorder, request)
|
||||
if recorder.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
|
||||
a := newReminderTestApp(t)
|
||||
a.runtimes[1] = &accountRuntime{}
|
||||
_, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil)
|
||||
request.AddCookie(&http.Cookie{Name: "session", Value: "test-session"})
|
||||
a.api(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Limits == nil || body.Usage == nil {
|
||||
t.Fatalf("nil lists in response: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCodexClient struct {
|
||||
mu sync.Mutex
|
||||
connected bool
|
||||
|
||||
Reference in New Issue
Block a user