mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 11:14:35 +03:00
Implement profiles management and settings API
- Added Profile and ProfileList structs to manage user profiles with unique IDs and nicknames. - Introduced endpoints for CRUD operations on profiles: `/api/profiles` for managing profiles and `/api/profiles/switch` for switching active profiles. - Implemented settings management with an endpoint `/api/settings` to handle user preferences like font size and debug mode. - Enhanced the server to load and save profiles from a `profiles.json` file. - Updated the fetcher initialization to respect the active profile's configuration. - Added comprehensive end-to-end tests for profiles and settings APIs to ensure functionality and persistence.
This commit is contained in:
@@ -65,7 +65,7 @@ class MainActivity : ComponentActivity() {
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
if (url != null && url.startsWith("http://127.0.0.1")) {
|
||||
txtStatus.text = "Connected"
|
||||
txtStatus.text = ""
|
||||
retryCount = 0
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val MAX_RETRIES = 25
|
||||
private const val RETRY_DELAY_MS = 2000L
|
||||
private const val INITIAL_DELAY_MS = 5000L
|
||||
private const val RETRY_DELAY_MS = 10000L
|
||||
private const val INITIAL_DELAY_MS = 15000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,18 @@ func (rc *ResolverChecker) SetLogFunc(fn LogFunc) {
|
||||
// An initial check runs immediately; subsequent checks happen every 10 minutes.
|
||||
// ctx controls the lifetime — cancel it to stop the checker.
|
||||
func (rc *ResolverChecker) Start(ctx context.Context) {
|
||||
rc.StartAndNotify(ctx, nil)
|
||||
}
|
||||
|
||||
// StartAndNotify is like Start but calls onFirstDone (if non-nil) after the
|
||||
// initial health-check pass finishes, before the periodic ticker begins.
|
||||
// This lets callers sequence "DNS scan → metadata fetch" without races.
|
||||
func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func()) {
|
||||
go func() {
|
||||
rc.CheckNow()
|
||||
if onFirstDone != nil {
|
||||
onFirstDone()
|
||||
}
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
|
||||
+884
-1156
File diff suppressed because it is too large
Load Diff
+307
-10
@@ -2,8 +2,10 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -34,8 +36,22 @@ type Config struct {
|
||||
// Timeout is the per-query DNS timeout in seconds (0 = default 5 s).
|
||||
// Also used as the resolver health-check probe timeout.
|
||||
Timeout float64 `json:"timeout,omitempty"`
|
||||
// Debug enables verbose query logging (shows generated DNS query names).
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
// Profile wraps a Config with a user-chosen nickname and a unique ID.
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Config Config `json:"config"`
|
||||
}
|
||||
|
||||
// ProfileList is the on-disk structure for profiles.json.
|
||||
type ProfileList struct {
|
||||
Active string `json:"active"` // ID of active profile
|
||||
Profiles []Profile `json:"profiles"`
|
||||
// FontSize stores user's preferred font size (0 = default 14).
|
||||
FontSize int `json:"fontSize,omitempty"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
// Server is the web UI server for thefeed client.
|
||||
@@ -55,6 +71,9 @@ type Server struct {
|
||||
lastMsgIDs map[int]uint32 // last seen message IDs per channel
|
||||
lastHashes map[int]uint32 // last seen content hashes per channel
|
||||
|
||||
// checker is the active resolver health-checker; set by initFetcher.
|
||||
checker *client.ResolverChecker
|
||||
|
||||
// fetcherCtx/fetcherCancel control the lifetime of the active fetcher's
|
||||
// background goroutines (rate limiter, noise, resolver checker).
|
||||
// They are cancelled and recreated each time the config changes.
|
||||
@@ -119,6 +138,9 @@ func (s *Server) Run() error {
|
||||
mux.HandleFunc("/api/send", s.handleSend)
|
||||
mux.HandleFunc("/api/admin", s.handleAdmin)
|
||||
mux.HandleFunc("/api/events", s.handleSSE)
|
||||
mux.HandleFunc("/api/profiles", s.handleProfiles)
|
||||
mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch)
|
||||
mux.HandleFunc("/api/settings", s.handleSettings)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
||||
@@ -126,7 +148,7 @@ func (s *Server) Run() error {
|
||||
fmt.Printf("\n Open in browser: http://%s\n\n", addr)
|
||||
|
||||
if s.fetcher != nil {
|
||||
go s.refreshMetadataOnly()
|
||||
s.startCheckerThenRefresh()
|
||||
}
|
||||
|
||||
var handler http.Handler = mux
|
||||
@@ -220,7 +242,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
|
||||
return
|
||||
}
|
||||
go s.refreshMetadataOnly()
|
||||
s.startCheckerThenRefresh()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
|
||||
default:
|
||||
@@ -489,7 +511,12 @@ func (s *Server) initFetcher() error {
|
||||
if cfg.QueryMode == "double" {
|
||||
fetcher.SetQueryMode(protocol.QueryMultiLabel)
|
||||
}
|
||||
fetcher.SetDebug(cfg.Debug)
|
||||
// Use global debug preference from profiles.json.
|
||||
var debug bool
|
||||
if pl, err := s.loadProfiles(); err == nil {
|
||||
debug = pl.Debug
|
||||
}
|
||||
fetcher.SetDebug(debug)
|
||||
if cfg.RateLimit > 0 {
|
||||
fetcher.SetRateLimit(cfg.RateLimit)
|
||||
}
|
||||
@@ -512,18 +539,35 @@ func (s *Server) initFetcher() error {
|
||||
// Start rate limiter and noise goroutines.
|
||||
fetcher.Start(ctx)
|
||||
|
||||
// Start periodic resolver health checks (runs first check in background immediately).
|
||||
// Initialise resolver health-checker; start it (with initial scan → then refresh)
|
||||
// via startCheckerThenRefresh, called by every initFetcher call site.
|
||||
checker := client.NewResolverChecker(fetcher, timeout)
|
||||
checker.SetLogFunc(func(msg string) {
|
||||
s.addLog(msg)
|
||||
})
|
||||
checker.Start(ctx)
|
||||
s.checker = checker
|
||||
|
||||
s.fetcher = fetcher
|
||||
s.cache = cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// startCheckerThenRefresh runs the resolver health-check pass synchronously
|
||||
// (in a new goroutine), then starts the periodic checker and fetches metadata.
|
||||
// This ensures fresh resolver data is used for the very first metadata query.
|
||||
func (s *Server) startCheckerThenRefresh() {
|
||||
s.mu.RLock()
|
||||
checker := s.checker
|
||||
ctx := s.fetcherCtx
|
||||
s.mu.RUnlock()
|
||||
if checker == nil {
|
||||
return
|
||||
}
|
||||
checker.StartAndNotify(ctx, func() {
|
||||
s.refreshMetadataOnly()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) refreshMetadataOnly() {
|
||||
// Cancel any in-progress refresh and start a new cancellable one.
|
||||
s.refreshMu.Lock()
|
||||
@@ -659,7 +703,7 @@ func (s *Server) refreshChannel(channelNum int) {
|
||||
s.mu.RUnlock()
|
||||
if prevID > 0 && ch.LastMsgID == prevID && ch.ContentHash == prevHash {
|
||||
s.addLog(fmt.Sprintf("Channel %s: no changes (last ID: %d)", ch.Name, prevID))
|
||||
s.broadcast("event: update\ndata: \"messages\"\n\n")
|
||||
s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -671,7 +715,7 @@ func (s *Server) refreshChannel(channelNum int) {
|
||||
s.lastHashes[channelNum] = ch.ContentHash
|
||||
s.mu.Unlock()
|
||||
s.addLog(fmt.Sprintf("Updated %s: 0 messages", ch.Name))
|
||||
s.broadcast("event: update\ndata: \"messages\"\n\n")
|
||||
s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -697,7 +741,7 @@ func (s *Server) refreshChannel(channelNum int) {
|
||||
}
|
||||
|
||||
s.addLog(fmt.Sprintf("Updated %s: %d messages", ch.Name, len(msgs)))
|
||||
s.broadcast("event: update\ndata: \"messages\"\n\n")
|
||||
s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
|
||||
}
|
||||
|
||||
func (s *Server) loadConfig() (*Config, error) {
|
||||
@@ -726,3 +770,256 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (s *Server) loadProfiles() (*ProfileList, error) {
|
||||
path := filepath.Join(s.dataDir, "profiles.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pl ProfileList
|
||||
if err := json.Unmarshal(data, &pl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pl, nil
|
||||
}
|
||||
|
||||
func (s *Server) saveProfiles(pl *ProfileList) error {
|
||||
path := filepath.Join(s.dataDir, "profiles.json")
|
||||
data, err := json.MarshalIndent(pl, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// handleProfiles manages CRUD for config profiles.
|
||||
// GET: returns profile list. POST: create/update/delete profiles.
|
||||
func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
pl, err := s.loadProfiles()
|
||||
if err != nil {
|
||||
// Migrate existing config.json into a profile
|
||||
pl = &ProfileList{}
|
||||
if s.config != nil {
|
||||
p := Profile{
|
||||
ID: generateID(),
|
||||
Nickname: s.config.Domain,
|
||||
Config: *s.config,
|
||||
}
|
||||
pl.Profiles = []Profile{p}
|
||||
pl.Active = p.ID
|
||||
_ = s.saveProfiles(pl)
|
||||
}
|
||||
}
|
||||
writeJSON(w, pl)
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Action string `json:"action"` // "create", "update", "delete", "reorder"
|
||||
Profile Profile `json:"profile"`
|
||||
Order []string `json:"order"` // for reorder
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
pl, _ := s.loadProfiles()
|
||||
if pl == nil {
|
||||
pl = &ProfileList{}
|
||||
}
|
||||
|
||||
switch req.Action {
|
||||
case "create":
|
||||
req.Profile.ID = generateID()
|
||||
if req.Profile.Nickname == "" {
|
||||
req.Profile.Nickname = req.Profile.Config.Domain
|
||||
}
|
||||
pl.Profiles = append(pl.Profiles, req.Profile)
|
||||
if len(pl.Profiles) == 1 {
|
||||
pl.Active = req.Profile.ID
|
||||
}
|
||||
|
||||
case "update":
|
||||
for i, p := range pl.Profiles {
|
||||
if p.ID == req.Profile.ID {
|
||||
pl.Profiles[i] = req.Profile
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "delete":
|
||||
for i, p := range pl.Profiles {
|
||||
if p.ID == req.Profile.ID {
|
||||
pl.Profiles = append(pl.Profiles[:i], pl.Profiles[i+1:]...)
|
||||
if pl.Active == req.Profile.ID {
|
||||
pl.Active = ""
|
||||
if len(pl.Profiles) > 0 {
|
||||
pl.Active = pl.Profiles[0].ID
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "reorder":
|
||||
if len(req.Order) > 0 {
|
||||
ordered := make([]Profile, 0, len(pl.Profiles))
|
||||
byID := make(map[string]Profile)
|
||||
for _, p := range pl.Profiles {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
for _, id := range req.Order {
|
||||
if p, ok := byID[id]; ok {
|
||||
ordered = append(ordered, p)
|
||||
}
|
||||
}
|
||||
pl.Profiles = ordered
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, "unknown action", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.saveProfiles(pl); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save profiles: %v", err), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// If active profile changed, update config.json and re-init the fetcher.
|
||||
if pl.Active != "" {
|
||||
for _, p := range pl.Profiles {
|
||||
if p.ID == pl.Active {
|
||||
_ = s.saveConfig(&p.Config)
|
||||
s.mu.Lock()
|
||||
s.config = &p.Config
|
||||
s.mu.Unlock()
|
||||
if err := s.initFetcher(); err != nil {
|
||||
log.Printf("[web] re-init fetcher after profile change: %v", err)
|
||||
} else {
|
||||
s.startCheckerThenRefresh()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"ok": true, "profiles": pl})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
}
|
||||
|
||||
// handleProfileSwitch switches the active profile and re-initializes the fetcher.
|
||||
func (s *Server) handleProfileSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
pl, err := s.loadProfiles()
|
||||
if err != nil || pl == nil {
|
||||
http.Error(w, "no profiles", 400)
|
||||
return
|
||||
}
|
||||
var found *Profile
|
||||
for i, p := range pl.Profiles {
|
||||
if p.ID == req.ID {
|
||||
found = &pl.Profiles[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
http.Error(w, "profile not found", 404)
|
||||
return
|
||||
}
|
||||
pl.Active = found.ID
|
||||
if err := s.saveProfiles(pl); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save: %v", err), 500)
|
||||
return
|
||||
}
|
||||
if err := s.saveConfig(&found.Config); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save config: %v", err), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset state
|
||||
s.mu.Lock()
|
||||
s.config = &found.Config
|
||||
s.channels = nil
|
||||
s.messages = make(map[int][]protocol.Message)
|
||||
s.lastMsgIDs = make(map[int]uint32)
|
||||
s.lastHashes = make(map[int]uint32)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.initFetcher(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
|
||||
return
|
||||
}
|
||||
s.startCheckerThenRefresh()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleSettings manages user preferences (font size etc.).
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
pl, _ := s.loadProfiles()
|
||||
if pl == nil {
|
||||
pl = &ProfileList{}
|
||||
}
|
||||
writeJSON(w, map[string]any{"fontSize": pl.FontSize, "debug": pl.Debug})
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
FontSize int `json:"fontSize"`
|
||||
Debug bool `json:"debug"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
if req.FontSize < 10 {
|
||||
req.FontSize = 0
|
||||
}
|
||||
if req.FontSize > 24 {
|
||||
req.FontSize = 24
|
||||
}
|
||||
pl, _ := s.loadProfiles()
|
||||
if pl == nil {
|
||||
pl = &ProfileList{}
|
||||
}
|
||||
pl.FontSize = req.FontSize
|
||||
pl.Debug = req.Debug
|
||||
if err := s.saveProfiles(pl); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save: %v", err), 500)
|
||||
return
|
||||
}
|
||||
// Apply debug to the current fetcher session immediately.
|
||||
s.mu.RLock()
|
||||
f := s.fetcher
|
||||
s.mu.RUnlock()
|
||||
if f != nil {
|
||||
f.SetDebug(req.Debug)
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
}
|
||||
|
||||
+301
-1
@@ -233,7 +233,9 @@ func TestE2E_WrongPassphrase(t *testing.T) {
|
||||
t.Fatalf("create fetcher: %v", err)
|
||||
}
|
||||
|
||||
_, err = fetcher.FetchMetadata(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = fetcher.FetchMetadata(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error with wrong passphrase, got nil")
|
||||
}
|
||||
@@ -791,3 +793,301 @@ func TestE2E_AdminNoManage(t *testing.T) {
|
||||
t.Error("expected error when server has allow-manage disabled, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profiles API Tests ---
|
||||
|
||||
func startWebServer(t *testing.T) (string, *web.Server) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
port := findFreePort(t, "tcp")
|
||||
srv, err := web.New(dataDir, port, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create web server: %v", err)
|
||||
}
|
||||
go srv.Run()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", port), srv
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url, body string) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func getJSON(t *testing.T, url string) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_GetEmpty(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := getJSON(t, base+"/api/profiles")
|
||||
m := decodeJSON(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if m["profiles"] != nil {
|
||||
t.Errorf("expected profiles=null on fresh server, got %v", m["profiles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_CreateAndGet(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
body := `{"action":"create","profile":{"id":"","nickname":"Test","config":{"domain":"test.example","key":"mypass","resolvers":["8.8.8.8"],"queryMode":"single","rateLimit":5}}}`
|
||||
resp := postJSON(t, base+"/api/profiles", body)
|
||||
m := decodeJSON(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("create profile: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if m["ok"] != true {
|
||||
t.Errorf("expected ok=true, got %v", m["ok"])
|
||||
}
|
||||
|
||||
// GET should now return the created profile
|
||||
resp2 := getJSON(t, base+"/api/profiles")
|
||||
m2 := decodeJSON(t, resp2)
|
||||
profs, ok := m2["profiles"].([]any)
|
||||
if !ok || len(profs) != 1 {
|
||||
t.Fatalf("expected 1 profile, got %v", m2["profiles"])
|
||||
}
|
||||
p := profs[0].(map[string]any)
|
||||
if p["nickname"] != "Test" {
|
||||
t.Errorf("nickname = %v, want Test", p["nickname"])
|
||||
}
|
||||
cfg := p["config"].(map[string]any)
|
||||
if cfg["domain"] != "test.example" {
|
||||
t.Errorf("domain = %v, want test.example", cfg["domain"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_CreateSetsActive(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
body := `{"action":"create","profile":{"id":"","nickname":"First","config":{"domain":"first.example","key":"k1","resolvers":["1.1.1.1"],"queryMode":"single","rateLimit":0}}}`
|
||||
resp := postJSON(t, base+"/api/profiles", body)
|
||||
decodeJSON(t, resp)
|
||||
|
||||
resp2 := getJSON(t, base+"/api/profiles")
|
||||
m2 := decodeJSON(t, resp2)
|
||||
active, _ := m2["active"].(string)
|
||||
profs := m2["profiles"].([]any)
|
||||
firstID := profs[0].(map[string]any)["id"].(string)
|
||||
if active != firstID {
|
||||
t.Errorf("first profile should be active, active=%q id=%q", active, firstID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_UpdateNickname(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
// Create
|
||||
createBody := `{"action":"create","profile":{"id":"","nickname":"OldName","config":{"domain":"upd.example","key":"k1","resolvers":["1.1.1.1"],"queryMode":"single","rateLimit":0}}}`
|
||||
postJSON(t, base+"/api/profiles", createBody).Body.Close()
|
||||
|
||||
// Get the ID
|
||||
m := decodeJSON(t, getJSON(t, base+"/api/profiles"))
|
||||
id := m["profiles"].([]any)[0].(map[string]any)["id"].(string)
|
||||
|
||||
updateBody := fmt.Sprintf(`{"action":"update","profile":{"id":%q,"nickname":"NewName","config":{"domain":"upd.example","key":"k1","resolvers":["1.1.1.1"],"queryMode":"single","rateLimit":0}}}`, id)
|
||||
resp := postJSON(t, base+"/api/profiles", updateBody)
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("update: expected 200, got %d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
m2 := decodeJSON(t, getJSON(t, base+"/api/profiles"))
|
||||
nick := m2["profiles"].([]any)[0].(map[string]any)["nickname"].(string)
|
||||
if nick != "NewName" {
|
||||
t.Errorf("nickname after update = %q, want NewName", nick)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_Delete(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
postJSON(t, base+"/api/profiles", `{"action":"create","profile":{"id":"","nickname":"ToDelete","config":{"domain":"del.example","key":"k","resolvers":["1.1.1.1"],"queryMode":"single","rateLimit":0}}}`).Body.Close()
|
||||
m := decodeJSON(t, getJSON(t, base+"/api/profiles"))
|
||||
id := m["profiles"].([]any)[0].(map[string]any)["id"].(string)
|
||||
|
||||
delBody := fmt.Sprintf(`{"action":"delete","profile":{"id":%q}}`, id)
|
||||
resp := postJSON(t, base+"/api/profiles", delBody)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("delete: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
m2 := decodeJSON(t, getJSON(t, base+"/api/profiles"))
|
||||
if profs := m2["profiles"]; profs != nil {
|
||||
if list, ok := profs.([]any); ok && len(list) != 0 {
|
||||
t.Errorf("expected 0 profiles after delete, got %d", len(list))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_Switch(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
postJSON(t, base+"/api/profiles", `{"action":"create","profile":{"id":"","nickname":"A","config":{"domain":"a.example","key":"k","resolvers":["1.1.1.1"],"queryMode":"single","rateLimit":0}}}`).Body.Close()
|
||||
postJSON(t, base+"/api/profiles", `{"action":"create","profile":{"id":"","nickname":"B","config":{"domain":"b.example","key":"k","resolvers":["1.1.1.1"],"queryMode":"single","rateLimit":0}}}`).Body.Close()
|
||||
|
||||
m := decodeJSON(t, getJSON(t, base+"/api/profiles"))
|
||||
profs := m["profiles"].([]any)
|
||||
if len(profs) < 2 {
|
||||
t.Fatalf("expected 2 profiles, got %d", len(profs))
|
||||
}
|
||||
idB := profs[1].(map[string]any)["id"].(string)
|
||||
|
||||
switchBody := fmt.Sprintf(`{"id":%q}`, idB)
|
||||
resp := postJSON(t, base+"/api/profiles/switch", switchBody)
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("switch: expected 200, got %d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
m2 := decodeJSON(t, getJSON(t, base+"/api/profiles"))
|
||||
if m2["active"] != idB {
|
||||
t.Errorf("active after switch = %v, want %q", m2["active"], idB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_InvalidAction(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := postJSON(t, base+"/api/profiles", `{"action":"bogus","profile":{}}`)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("bogus action: expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Profiles_SwitchNotFound(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := postJSON(t, base+"/api/profiles/switch", `{"id":"nonexistent-id"}`)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 && resp.StatusCode != 404 {
|
||||
t.Errorf("switch nonexistent: expected 400/404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings API Tests ---
|
||||
|
||||
func TestE2E_Settings_GetDefault(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := getJSON(t, base+"/api/settings")
|
||||
m := decodeJSON(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/settings: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
// fontSize defaults to 0 (use browser default), debug defaults to false
|
||||
if _, ok := m["fontSize"]; !ok {
|
||||
t.Error("expected 'fontSize' key in settings response")
|
||||
}
|
||||
if _, ok := m["debug"]; !ok {
|
||||
t.Error("expected 'debug' key in settings response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Settings_SaveAndRead(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := postJSON(t, base+"/api/settings", `{"fontSize":16,"debug":true}`)
|
||||
m := decodeJSON(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST /api/settings: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if m["ok"] != true {
|
||||
t.Errorf("expected ok=true, got %v", m["ok"])
|
||||
}
|
||||
|
||||
m2 := decodeJSON(t, getJSON(t, base+"/api/settings"))
|
||||
if m2["fontSize"] != float64(16) {
|
||||
t.Errorf("fontSize = %v, want 16", m2["fontSize"])
|
||||
}
|
||||
if m2["debug"] != true {
|
||||
t.Errorf("debug = %v, want true", m2["debug"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Settings_FontSizeClamped(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
// Below minimum
|
||||
postJSON(t, base+"/api/settings", `{"fontSize":1}`).Body.Close()
|
||||
m := decodeJSON(t, getJSON(t, base+"/api/settings"))
|
||||
if want := float64(0); m["fontSize"] != want {
|
||||
t.Errorf("fontSize below min: got %v, want %v", m["fontSize"], want)
|
||||
}
|
||||
|
||||
// Above maximum (24)
|
||||
postJSON(t, base+"/api/settings", `{"fontSize":99}`).Body.Close()
|
||||
m2 := decodeJSON(t, getJSON(t, base+"/api/settings"))
|
||||
if m2["fontSize"] != float64(24) {
|
||||
t.Errorf("fontSize above max: got %v, want 24", m2["fontSize"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Settings_Persistence(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
|
||||
port1 := findFreePort(t, "tcp")
|
||||
srv1, err := web.New(dataDir, port1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create web server: %v", err)
|
||||
}
|
||||
go srv1.Run()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
base1 := fmt.Sprintf("http://127.0.0.1:%d", port1)
|
||||
postJSON(t, base1+"/api/settings", `{"fontSize":18,"debug":false}`).Body.Close()
|
||||
|
||||
port2 := findFreePort(t, "tcp")
|
||||
srv2, err := web.New(dataDir, port2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create second web server: %v", err)
|
||||
}
|
||||
go srv2.Run()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
base2 := fmt.Sprintf("http://127.0.0.1:%d", port2)
|
||||
|
||||
m := decodeJSON(t, getJSON(t, base2+"/api/settings"))
|
||||
if m["fontSize"] != float64(18) {
|
||||
t.Errorf("persisted fontSize = %v, want 18", m["fontSize"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Settings_MethodNotAllowed(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, base+"/api/settings", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DELETE /api/settings: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 405 {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user