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:
Sarto
2026-04-03 22:08:54 +03:30
parent d1b3fb94ec
commit 271e6f569e
5 changed files with 1505 additions and 1170 deletions
@@ -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
}
}
+10
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+307 -10
View File
@@ -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
View File
@@ -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)
}
}