mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-09 10:55:27 +08:00
refactor backend API structure
This commit is contained in:
63
internal/httpapi/admin/shared/deps.go
Normal file
63
internal/httpapi/admin/shared/deps.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
)
|
||||
|
||||
type ConfigStore interface {
|
||||
Snapshot() config.Config
|
||||
Keys() []string
|
||||
Accounts() []config.Account
|
||||
FindAccount(identifier string) (config.Account, bool)
|
||||
UpdateAccountToken(identifier, token string) error
|
||||
UpdateAccountTestStatus(identifier, status string) error
|
||||
AccountTestStatus(identifier string) (string, bool)
|
||||
Update(mutator func(*config.Config) error) error
|
||||
ExportJSONAndBase64() (string, string, error)
|
||||
IsEnvBacked() bool
|
||||
IsEnvWritebackEnabled() bool
|
||||
HasEnvConfigSource() bool
|
||||
ConfigPath() string
|
||||
SetVercelSync(hash string, ts int64) error
|
||||
AdminPasswordHash() string
|
||||
AdminJWTExpireHours() int
|
||||
AdminJWTValidAfterUnix() int64
|
||||
RuntimeAccountMaxInflight() int
|
||||
RuntimeAccountMaxQueue(defaultSize int) int
|
||||
RuntimeGlobalMaxInflight(defaultSize int) int
|
||||
RuntimeTokenRefreshIntervalHours() int
|
||||
AutoDeleteMode() string
|
||||
HistorySplitEnabled() bool
|
||||
HistorySplitTriggerAfterTurns() int
|
||||
CompatStripReferenceMarkers() bool
|
||||
AutoDeleteSessions() bool
|
||||
}
|
||||
|
||||
type PoolController interface {
|
||||
Reset()
|
||||
Status() map[string]any
|
||||
ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMaxInflight int)
|
||||
}
|
||||
|
||||
type OpenAIChatCaller interface {
|
||||
ChatCompletions(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type DeepSeekCaller interface {
|
||||
Login(ctx context.Context, acc config.Account) (string, error)
|
||||
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
|
||||
GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error)
|
||||
DeleteAllSessionsForToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
var _ ConfigStore = (*config.Store)(nil)
|
||||
var _ PoolController = (*account.Pool)(nil)
|
||||
var _ DeepSeekCaller = (*dsclient.Client)(nil)
|
||||
403
internal/httpapi/admin/shared/helpers.go
Normal file
403
internal/httpapi/admin/shared/helpers.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
var intFrom = util.IntFrom
|
||||
|
||||
var WriteJSON = util.WriteJSON
|
||||
var IntFrom = util.IntFrom
|
||||
|
||||
func ReverseAccounts(a []config.Account) { reverseAccounts(a) }
|
||||
func IntFromQuery(r *http.Request, key string, d int) int {
|
||||
return intFromQuery(r, key, d)
|
||||
}
|
||||
func NilIfEmpty(s string) any { return nilIfEmpty(s) }
|
||||
func NilIfZero(v int64) any { return nilIfZero(v) }
|
||||
func MaskSecretPreview(secret string) string {
|
||||
return maskSecretPreview(secret)
|
||||
}
|
||||
func ToStringSlice(v any) ([]string, bool) { return toStringSlice(v) }
|
||||
func ToAccount(m map[string]any) config.Account {
|
||||
return toAccount(m)
|
||||
}
|
||||
func ToAPIKeys(v any) ([]config.APIKey, bool) {
|
||||
return toAPIKeys(v)
|
||||
}
|
||||
func NormalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
|
||||
return normalizeAPIKeyForStorage(item)
|
||||
}
|
||||
func APIKeyHasMetadata(item config.APIKey) bool {
|
||||
return apiKeyHasMetadata(item)
|
||||
}
|
||||
func MergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
|
||||
return mergeAPIKeysPreferStructured(existing, incoming)
|
||||
}
|
||||
func MergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey {
|
||||
return mergeAPIKeyRecord(existing, incoming)
|
||||
}
|
||||
func FieldString(m map[string]any, key string) string {
|
||||
return fieldString(m, key)
|
||||
}
|
||||
func FieldStringOptional(m map[string]any, key string) (string, bool) {
|
||||
return fieldStringOptional(m, key)
|
||||
}
|
||||
func StatusOr(v int, d int) int { return statusOr(v, d) }
|
||||
func AccountMatchesIdentifier(acc config.Account, identifier string) bool {
|
||||
return accountMatchesIdentifier(acc, identifier)
|
||||
}
|
||||
func NormalizeAccountForStorage(acc config.Account) config.Account {
|
||||
return normalizeAccountForStorage(acc)
|
||||
}
|
||||
func ToProxy(m map[string]any) config.Proxy {
|
||||
return toProxy(m)
|
||||
}
|
||||
func FindProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
|
||||
return findProxyByID(c, proxyID)
|
||||
}
|
||||
func AccountDedupeKey(acc config.Account) string { return accountDedupeKey(acc) }
|
||||
func NormalizeAndDedupeAccounts(accounts []config.Account) []config.Account {
|
||||
return normalizeAndDedupeAccounts(accounts)
|
||||
}
|
||||
func FindAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) {
|
||||
return findAccountByIdentifier(store, identifier)
|
||||
}
|
||||
|
||||
func ComputeSyncHash(store ConfigStore) string {
|
||||
if store == nil {
|
||||
return ""
|
||||
}
|
||||
snap := store.Snapshot().Clone()
|
||||
snap.ClearAccountTokens()
|
||||
snap.VercelSyncHash = ""
|
||||
snap.VercelSyncTime = 0
|
||||
b, _ := json.Marshal(snap)
|
||||
sum := md5.Sum(b)
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
func SyncHashForJSON(s string) string {
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
cfg.VercelSyncHash = ""
|
||||
cfg.VercelSyncTime = 0
|
||||
cfg.ClearAccountTokens()
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := md5.Sum(b)
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
func reverseAccounts(a []config.Account) {
|
||||
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
}
|
||||
|
||||
func intFromQuery(r *http.Request, key string, d int) int {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nilIfZero(v int64) any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func maskSecretPreview(secret string) string {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return ""
|
||||
}
|
||||
if len(secret) <= 4 {
|
||||
return strings.Repeat("*", len(secret))
|
||||
}
|
||||
return secret[:2] + "****" + secret[len(secret)-2:]
|
||||
}
|
||||
|
||||
func toStringSlice(v any) ([]string, bool) {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
out = append(out, strings.TrimSpace(fmt.Sprintf("%v", item)))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func toAccount(m map[string]any) config.Account {
|
||||
email := fieldString(m, "email")
|
||||
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
|
||||
return config.Account{
|
||||
Name: fieldString(m, "name"),
|
||||
Remark: fieldString(m, "remark"),
|
||||
Email: email,
|
||||
Mobile: mobile,
|
||||
Password: fieldString(m, "password"),
|
||||
ProxyID: fieldString(m, "proxy_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func toAPIKeys(v any) ([]config.APIKey, bool) {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]config.APIKey, 0, len(arr))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range arr {
|
||||
switch x := item.(type) {
|
||||
case map[string]any:
|
||||
key := fieldString(x, "key")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, config.APIKey{
|
||||
Key: key,
|
||||
Name: fieldString(x, "name"),
|
||||
Remark: fieldString(x, "remark"),
|
||||
})
|
||||
default:
|
||||
key := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, config.APIKey{Key: key})
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func normalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
|
||||
return config.APIKey{
|
||||
Key: strings.TrimSpace(item.Key),
|
||||
Name: strings.TrimSpace(item.Name),
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyHasMetadata(item config.APIKey) bool {
|
||||
return strings.TrimSpace(item.Name) != "" || strings.TrimSpace(item.Remark) != ""
|
||||
}
|
||||
|
||||
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
|
||||
if len(existing) == 0 && len(incoming) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
merged := make([]config.APIKey, 0, len(existing)+len(incoming))
|
||||
index := make(map[string]int, len(existing)+len(incoming))
|
||||
for _, item := range existing {
|
||||
item = normalizeAPIKeyForStorage(item)
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[item.Key]; ok {
|
||||
continue
|
||||
}
|
||||
index[item.Key] = len(merged)
|
||||
merged = append(merged, item)
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, item := range incoming {
|
||||
item = normalizeAPIKeyForStorage(item)
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
if idx, ok := index[item.Key]; ok {
|
||||
keep := merged[idx]
|
||||
next := mergeAPIKeyRecord(keep, item)
|
||||
if next != keep {
|
||||
merged[idx] = next
|
||||
imported++
|
||||
}
|
||||
continue
|
||||
}
|
||||
index[item.Key] = len(merged)
|
||||
merged = append(merged, item)
|
||||
imported++
|
||||
}
|
||||
|
||||
if len(merged) == 0 {
|
||||
return nil, imported
|
||||
}
|
||||
return merged, imported
|
||||
}
|
||||
|
||||
func mergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey {
|
||||
existing = normalizeAPIKeyForStorage(existing)
|
||||
incoming = normalizeAPIKeyForStorage(incoming)
|
||||
if existing.Key == "" {
|
||||
return incoming
|
||||
}
|
||||
if apiKeyHasMetadata(existing) {
|
||||
return existing
|
||||
}
|
||||
if apiKeyHasMetadata(incoming) {
|
||||
return incoming
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func fieldString(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
func fieldStringOptional(m map[string]any, key string) (string, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v)), true
|
||||
}
|
||||
|
||||
func statusOr(v int, d int) int {
|
||||
if v == 0 {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func accountMatchesIdentifier(acc config.Account, identifier string) bool {
|
||||
id := strings.TrimSpace(identifier)
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(acc.Email) == id {
|
||||
return true
|
||||
}
|
||||
if mobileKey := config.CanonicalMobileKey(id); mobileKey != "" && mobileKey == config.CanonicalMobileKey(acc.Mobile) {
|
||||
return true
|
||||
}
|
||||
return acc.Identifier() == id
|
||||
}
|
||||
|
||||
func normalizeAccountForStorage(acc config.Account) config.Account {
|
||||
acc.Name = strings.TrimSpace(acc.Name)
|
||||
acc.Remark = strings.TrimSpace(acc.Remark)
|
||||
acc.Email = strings.TrimSpace(acc.Email)
|
||||
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
|
||||
acc.ProxyID = strings.TrimSpace(acc.ProxyID)
|
||||
return acc
|
||||
}
|
||||
|
||||
func toProxy(m map[string]any) config.Proxy {
|
||||
return config.NormalizeProxy(config.Proxy{
|
||||
ID: fieldString(m, "id"),
|
||||
Name: fieldString(m, "name"),
|
||||
Type: fieldString(m, "type"),
|
||||
Host: fieldString(m, "host"),
|
||||
Port: intFrom(m["port"]),
|
||||
Username: fieldString(m, "username"),
|
||||
Password: fieldString(m, "password"),
|
||||
})
|
||||
}
|
||||
|
||||
func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
|
||||
id := strings.TrimSpace(proxyID)
|
||||
if id == "" {
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
for _, proxy := range c.Proxies {
|
||||
proxy = config.NormalizeProxy(proxy)
|
||||
if proxy.ID == id {
|
||||
return proxy, true
|
||||
}
|
||||
}
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
|
||||
func accountDedupeKey(acc config.Account) string {
|
||||
if email := strings.TrimSpace(acc.Email); email != "" {
|
||||
return "email:" + email
|
||||
}
|
||||
if mobile := config.CanonicalMobileKey(acc.Mobile); mobile != "" {
|
||||
return "mobile:" + mobile
|
||||
}
|
||||
if id := strings.TrimSpace(acc.Identifier()); id != "" {
|
||||
return "id:" + id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAndDedupeAccounts(accounts []config.Account) []config.Account {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]config.Account, 0, len(accounts))
|
||||
seen := make(map[string]struct{}, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
acc = normalizeAccountForStorage(acc)
|
||||
key := accountDedupeKey(acc)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, acc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) {
|
||||
id := strings.TrimSpace(identifier)
|
||||
if id == "" {
|
||||
return config.Account{}, false
|
||||
}
|
||||
if acc, ok := store.FindAccount(id); ok {
|
||||
return acc, true
|
||||
}
|
||||
accounts := store.Snapshot().Accounts
|
||||
for _, acc := range accounts {
|
||||
if accountMatchesIdentifier(acc, id) {
|
||||
return acc, true
|
||||
}
|
||||
}
|
||||
return config.Account{}, false
|
||||
}
|
||||
240
internal/httpapi/admin/shared/helpers_edge_test.go
Normal file
240
internal/httpapi/admin/shared/helpers_edge_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
// ─── reverseAccounts ─────────────────────────────────────────────────
|
||||
|
||||
func TestReverseAccountsEmpty(t *testing.T) {
|
||||
a := []config.Account{}
|
||||
reverseAccounts(a)
|
||||
if len(a) != 0 {
|
||||
t.Fatal("expected empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseAccountsTwoElements(t *testing.T) {
|
||||
a := []config.Account{
|
||||
{Email: "a@test.com"},
|
||||
{Email: "b@test.com"},
|
||||
}
|
||||
reverseAccounts(a)
|
||||
if a[0].Email != "b@test.com" || a[1].Email != "a@test.com" {
|
||||
t.Fatalf("unexpected order after reverse: %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseAccountsThreeElements(t *testing.T) {
|
||||
a := []config.Account{
|
||||
{Email: "1@test.com"},
|
||||
{Email: "2@test.com"},
|
||||
{Email: "3@test.com"},
|
||||
}
|
||||
reverseAccounts(a)
|
||||
if a[0].Email != "3@test.com" || a[1].Email != "2@test.com" || a[2].Email != "1@test.com" {
|
||||
t.Fatalf("unexpected order: %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── intFromQuery edge cases ─────────────────────────────────────────
|
||||
|
||||
func TestIntFromQueryPresent(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?limit=5", nil)
|
||||
if got := intFromQuery(req, "limit", 10); got != 5 {
|
||||
t.Fatalf("expected 5, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntFromQueryMissing(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if got := intFromQuery(req, "limit", 10); got != 10 {
|
||||
t.Fatalf("expected default 10, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntFromQueryInvalid(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?limit=abc", nil)
|
||||
if got := intFromQuery(req, "limit", 10); got != 10 {
|
||||
t.Fatalf("expected default 10 for invalid, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntFromQueryNegative(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?limit=-3", nil)
|
||||
if got := intFromQuery(req, "limit", 10); got != -3 {
|
||||
t.Fatalf("expected -3, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntFromQueryZero(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?limit=0", nil)
|
||||
if got := intFromQuery(req, "limit", 10); got != 0 {
|
||||
t.Fatalf("expected 0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── nilIfEmpty ──────────────────────────────────────────────────────
|
||||
|
||||
func TestNilIfEmptyEmpty(t *testing.T) {
|
||||
if nilIfEmpty("") != nil {
|
||||
t.Fatal("expected nil for empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmptyNonEmpty(t *testing.T) {
|
||||
if nilIfEmpty("hello") != "hello" {
|
||||
t.Fatal("expected 'hello'")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── nilIfZero ───────────────────────────────────────────────────────
|
||||
|
||||
func TestNilIfZeroZero(t *testing.T) {
|
||||
if nilIfZero(0) != nil {
|
||||
t.Fatal("expected nil for zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfZeroNonZero(t *testing.T) {
|
||||
if nilIfZero(42) != int64(42) {
|
||||
t.Fatal("expected 42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfZeroNegative(t *testing.T) {
|
||||
if nilIfZero(-1) != int64(-1) {
|
||||
t.Fatal("expected -1")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── toStringSlice ───────────────────────────────────────────────────
|
||||
|
||||
func TestToStringSliceFromAnySlice(t *testing.T) {
|
||||
input := []any{"a", "b", "c"}
|
||||
got, ok := toStringSlice(input)
|
||||
if !ok || len(got) != 3 {
|
||||
t.Fatalf("expected 3 strings, got %#v ok=%v", got, ok)
|
||||
}
|
||||
if got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
||||
t.Fatalf("unexpected values: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSliceFromMixed(t *testing.T) {
|
||||
input := []any{"hello", 42, true}
|
||||
got, ok := toStringSlice(input)
|
||||
if !ok {
|
||||
t.Fatal("expected ok for mixed types")
|
||||
}
|
||||
if got[0] != "hello" || got[1] != "42" || got[2] != "true" {
|
||||
t.Fatalf("unexpected values: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSliceFromNonSlice(t *testing.T) {
|
||||
_, ok := toStringSlice("not a slice")
|
||||
if ok {
|
||||
t.Fatal("expected not ok for string input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSliceFromNil(t *testing.T) {
|
||||
_, ok := toStringSlice(nil)
|
||||
if ok {
|
||||
t.Fatal("expected not ok for nil input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSliceEmpty(t *testing.T) {
|
||||
got, ok := toStringSlice([]any{})
|
||||
if !ok {
|
||||
t.Fatal("expected ok for empty slice")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty result, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStringSliceTrimsWhitespace(t *testing.T) {
|
||||
got, ok := toStringSlice([]any{" hello ", " world "})
|
||||
if !ok {
|
||||
t.Fatal("expected ok")
|
||||
}
|
||||
if got[0] != "hello" || got[1] != "world" {
|
||||
t.Fatalf("expected trimmed values, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── toAccount edge cases ────────────────────────────────────────────
|
||||
|
||||
func TestToAccountAllFields(t *testing.T) {
|
||||
acc := toAccount(map[string]any{
|
||||
"email": "user@test.com",
|
||||
"mobile": "13800138000",
|
||||
"password": "secret",
|
||||
"token": "tok123",
|
||||
})
|
||||
if acc.Email != "user@test.com" {
|
||||
t.Fatalf("unexpected email: %q", acc.Email)
|
||||
}
|
||||
if acc.Mobile != "+8613800138000" {
|
||||
t.Fatalf("unexpected mobile: %q", acc.Mobile)
|
||||
}
|
||||
if acc.Password != "secret" {
|
||||
t.Fatalf("unexpected password: %q", acc.Password)
|
||||
}
|
||||
if acc.Token != "" {
|
||||
t.Fatalf("expected token to be ignored, got %q", acc.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToAccountNumericValues(t *testing.T) {
|
||||
acc := toAccount(map[string]any{
|
||||
"email": 12345,
|
||||
})
|
||||
if acc.Email != "12345" {
|
||||
t.Fatalf("expected numeric converted to string, got %q", acc.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── fieldString edge cases ──────────────────────────────────────────
|
||||
|
||||
func TestFieldStringNonString(t *testing.T) {
|
||||
got := fieldString(map[string]any{"key": 42}, "key")
|
||||
if got != "42" {
|
||||
t.Fatalf("expected '42' for int, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldStringBool(t *testing.T) {
|
||||
got := fieldString(map[string]any{"key": true}, "key")
|
||||
if got != "true" {
|
||||
t.Fatalf("expected 'true', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldStringWhitespace(t *testing.T) {
|
||||
got := fieldString(map[string]any{"key": " hello "}, "key")
|
||||
if got != "hello" {
|
||||
t.Fatalf("expected trimmed 'hello', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── statusOr ────────────────────────────────────────────────────────
|
||||
|
||||
func TestStatusOrZeroReturnsDefault(t *testing.T) {
|
||||
if got := statusOr(0, http.StatusOK); got != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d", http.StatusOK, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusOrNonZeroReturnsValue(t *testing.T) {
|
||||
if got := statusOr(http.StatusBadRequest, http.StatusOK); got != http.StatusBadRequest {
|
||||
t.Fatalf("expected %d, got %d", http.StatusBadRequest, got)
|
||||
}
|
||||
}
|
||||
31
internal/httpapi/admin/shared/request_error.go
Normal file
31
internal/httpapi/admin/shared/request_error.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package shared
|
||||
|
||||
import "errors"
|
||||
|
||||
type requestError struct {
|
||||
detail string
|
||||
}
|
||||
|
||||
func (e *requestError) Error() string {
|
||||
return e.detail
|
||||
}
|
||||
|
||||
func newRequestError(detail string) error {
|
||||
return &requestError{detail: detail}
|
||||
}
|
||||
|
||||
func NewRequestError(detail string) error {
|
||||
return newRequestError(detail)
|
||||
}
|
||||
|
||||
func requestErrorDetail(err error) (string, bool) {
|
||||
var reqErr *requestError
|
||||
if errors.As(err, &reqErr) {
|
||||
return reqErr.detail, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func RequestErrorDetail(err error) (string, bool) {
|
||||
return requestErrorDetail(err)
|
||||
}
|
||||
35
internal/httpapi/admin/shared/settings_validation.go
Normal file
35
internal/httpapi/admin/shared/settings_validation.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func normalizeSettingsConfig(c *config.Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.Admin.PasswordHash = strings.TrimSpace(c.Admin.PasswordHash)
|
||||
c.Embeddings.Provider = strings.TrimSpace(c.Embeddings.Provider)
|
||||
}
|
||||
|
||||
func NormalizeSettingsConfig(c *config.Config) {
|
||||
normalizeSettingsConfig(c)
|
||||
}
|
||||
|
||||
func validateSettingsConfig(c config.Config) error {
|
||||
return config.ValidateConfig(c)
|
||||
}
|
||||
|
||||
func ValidateSettingsConfig(c config.Config) error {
|
||||
return validateSettingsConfig(c)
|
||||
}
|
||||
|
||||
func validateRuntimeSettings(runtime config.RuntimeConfig) error {
|
||||
return config.ValidateRuntimeConfig(runtime)
|
||||
}
|
||||
|
||||
func ValidateRuntimeSettings(runtime config.RuntimeConfig) error {
|
||||
return validateRuntimeSettings(runtime)
|
||||
}
|
||||
Reference in New Issue
Block a user