package e2e_test import ( "encoding/json" "fmt" "io" "net/http" "os" "strings" "testing" "time" "github.com/sartoopjj/thefeed/internal/client" "github.com/sartoopjj/thefeed/internal/protocol" "github.com/sartoopjj/thefeed/internal/web" ) func TestE2E_WebAPI_ConfigAndStatus(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) resp, err := http.Get(base + "/api/status") if err != nil { t.Fatalf("GET /api/status: %v", err) } defer resp.Body.Close() var status map[string]any json.NewDecoder(resp.Body).Decode(&status) if status["configured"] != false { t.Errorf("expected configured=false, got %v", status["configured"]) } resp2, err := http.Get(base + "/api/config") if err != nil { t.Fatalf("GET /api/config: %v", err) } defer resp2.Body.Close() var cfgResp map[string]any json.NewDecoder(resp2.Body).Decode(&cfgResp) if cfgResp["configured"] != false { t.Errorf("expected configured=false on GET config, got %v", cfgResp["configured"]) } cfg := `{"domain":"test.example.com","key":"testpass","resolvers":["127.0.0.1:9999"],"queryMode":"single","rateLimit":10}` resp3, err := http.Post(base+"/api/config", "application/json", strings.NewReader(cfg)) if err != nil { t.Fatalf("POST /api/config: %v", err) } defer resp3.Body.Close() if resp3.StatusCode != 200 { body, _ := io.ReadAll(resp3.Body) t.Fatalf("POST /api/config status=%d body=%s", resp3.StatusCode, body) } resp4, err := http.Get(base + "/api/status") if err != nil { t.Fatalf("GET /api/status after config: %v", err) } defer resp4.Body.Close() var status2 map[string]any json.NewDecoder(resp4.Body).Decode(&status2) if status2["configured"] != true { t.Errorf("expected configured=true, got %v", status2["configured"]) } if status2["domain"] != "test.example.com" { t.Errorf("domain = %v, want test.example.com", status2["domain"]) } } func TestE2E_WebAPI_InvalidConfig(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) resp, err := http.Post(base+"/api/config", "application/json", strings.NewReader(`{"domain":"x"}`)) if err != nil { t.Fatalf("POST: %v", err) } defer resp.Body.Close() if resp.StatusCode != 400 { t.Errorf("expected 400, got %d", resp.StatusCode) } resp2, err := http.Post(base+"/api/config", "application/json", strings.NewReader(`not json`)) if err != nil { t.Fatalf("POST: %v", err) } defer resp2.Body.Close() if resp2.StatusCode != 400 { t.Errorf("expected 400 for invalid json, got %d", resp2.StatusCode) } } func TestE2E_WebAPI_Channels(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) resp, err := http.Get(base + "/api/channels") if err != nil { t.Fatalf("GET /api/channels: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if string(body) != "null\n" && string(body) != "[]\n" { t.Logf("channels response: %q (acceptable)", string(body)) } } func TestE2E_WebAPI_Messages(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) resp, err := http.Get(base + "/api/messages/1") if err != nil { t.Fatalf("GET /api/messages/1: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("expected 200, got %d", resp.StatusCode) } // Response must be MessagesResult format var result map[string]any json.NewDecoder(resp.Body).Decode(&result) if _, ok := result["messages"]; !ok { t.Error("expected 'messages' key in response") } resp2, err := http.Get(base + "/api/messages/abc") if err != nil { t.Fatalf("GET /api/messages/abc: %v", err) } defer resp2.Body.Close() if resp2.StatusCode != 400 { t.Errorf("expected 400 for invalid channel, got %d", resp2.StatusCode) } } func TestE2E_WebAPI_IndexPage(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) resp, err := http.Get(base + "/") if err != nil { t.Fatalf("GET /: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html", ct) } } func TestE2E_WebAPI_NotFound(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) resp, err := http.Get(base + "/nonexistent") if err != nil { t.Fatalf("GET /nonexistent: %v", err) } defer resp.Body.Close() if resp.StatusCode != 404 { t.Errorf("expected 404, got %d", resp.StatusCode) } } func TestE2E_WebAPI_MethodNotAllowed(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) req, _ := http.NewRequest(http.MethodPut, base+"/api/config", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("PUT /api/config: %v", err) } defer resp.Body.Close() if resp.StatusCode != 405 { t.Errorf("expected 405, got %d", resp.StatusCode) } resp2, err := http.Get(base + "/api/refresh") if err != nil { t.Fatalf("GET /api/refresh: %v", err) } defer resp2.Body.Close() if resp2.StatusCode != 405 { t.Errorf("expected 405 for GET /api/refresh, got %d", resp2.StatusCode) } } func TestE2E_WebAPI_ConfigPersistence(t *testing.T) { dataDir := t.TempDir() port1 := findFreePort(t, "tcp") srv1, err := web.New(dataDir, port1, "127.0.0.1", "") 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) cfg := `{"domain":"persist.example.com","key":"persistkey","resolvers":["127.0.0.1:9999"]}` resp, err := http.Post(base1+"/api/config", "application/json", strings.NewReader(cfg)) if err != nil { t.Fatalf("POST config: %v", err) } resp.Body.Close() configPath := dataDir + "/config.json" if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Fatal("config.json was not persisted to disk") } port2 := findFreePort(t, "tcp") srv2, err := web.New(dataDir, port2, "127.0.0.1", "") 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) resp2, err := http.Get(base2 + "/api/status") if err != nil { t.Fatalf("GET /api/status on second instance: %v", err) } defer resp2.Body.Close() var status map[string]any json.NewDecoder(resp2.Body).Decode(&status) if status["configured"] != true { t.Error("second instance should have loaded config, got configured=false") } if status["domain"] != "persist.example.com" { t.Errorf("domain = %v, want persist.example.com", status["domain"]) } } // TestE2E_FullRoundTrip tests DNS server -> client fetcher -> web API end to end. func TestE2E_FullRoundTrip(t *testing.T) { domain := "roundtrip.example.com" passphrase := "full-roundtrip-key" channels := []string{"general", "alerts"} msgs := map[int][]protocol.Message{ 1: { {ID: 1, Timestamp: 1700000000, Text: "General message 1"}, {ID: 2, Timestamp: 1700000001, Text: "General message 2"}, }, 2: { {ID: 10, Timestamp: 1700000010, Text: "Alert!"}, }, } resolver, cancel := startDNSServer(t, domain, passphrase, channels, msgs) defer cancel() dataDir := t.TempDir() port := findFreePort(t, "tcp") srv, err := web.New(dataDir, port, "127.0.0.1", "") if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`, domain, passphrase, resolver) resp, err := http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON)) if err != nil { t.Fatalf("POST /api/config: %v", err) } resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("config POST status=%d", resp.StatusCode) } time.Sleep(2 * time.Second) respRefresh1, err := http.Post(base+"/api/refresh?channel=1", "application/json", nil) if err != nil { t.Fatalf("POST /api/refresh?channel=1: %v", err) } respRefresh1.Body.Close() time.Sleep(1500 * time.Millisecond) resp2, err := http.Get(base + "/api/channels") if err != nil { t.Fatalf("GET /api/channels: %v", err) } defer resp2.Body.Close() var chList []protocol.ChannelInfo json.NewDecoder(resp2.Body).Decode(&chList) if len(chList) != 2 { t.Fatalf("expected 2 channels, got %d", len(chList)) } if chList[0].Name != "general" || chList[1].Name != "alerts" { t.Errorf("channels = %v, want [general, alerts]", chList) } resp3, err := http.Get(base + "/api/messages/1") if err != nil { t.Fatalf("GET /api/messages/1: %v", err) } defer resp3.Body.Close() var result1 client.MessagesResult json.NewDecoder(resp3.Body).Decode(&result1) if len(result1.Messages) != 2 { t.Fatalf("expected 2 messages for channel 1, got %d", len(result1.Messages)) } if result1.Messages[0].Text != "General message 1" { t.Errorf("msg[0].Text = %q, want %q", result1.Messages[0].Text, "General message 1") } respRefresh2, err := http.Post(base+"/api/refresh?channel=2", "application/json", nil) if err != nil { t.Fatalf("POST /api/refresh?channel=2: %v", err) } respRefresh2.Body.Close() time.Sleep(1500 * time.Millisecond) resp4, err := http.Get(base + "/api/messages/2") if err != nil { t.Fatalf("GET /api/messages/2: %v", err) } defer resp4.Body.Close() var result2 client.MessagesResult json.NewDecoder(resp4.Body).Decode(&result2) if len(result2.Messages) != 1 { t.Fatalf("expected 1 message for channel 2, got %d", len(result2.Messages)) } if result2.Messages[0].Text != "Alert!" { t.Errorf("msg[0].Text = %q, want %q", result2.Messages[0].Text, "Alert!") } } func TestE2E_WebAPI_GlobalAuth(t *testing.T) { dataDir := t.TempDir() port := findFreePort(t, "tcp") password := "webpass123" srv, err := web.New(dataDir, port, "127.0.0.1", password) if err != nil { t.Fatalf("create web server: %v", err) } go srv.Run() time.Sleep(200 * time.Millisecond) base := fmt.Sprintf("http://127.0.0.1:%d", port) endpoints := []struct { method string path string }{ {"GET", "/"}, {"GET", "/api/status"}, {"GET", "/api/config"}, {"GET", "/api/channels"}, {"GET", "/api/messages/1"}, {"GET", "/api/events"}, } for _, ep := range endpoints { req, _ := http.NewRequest(ep.method, base+ep.path, nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", ep.method, ep.path, err) } resp.Body.Close() if resp.StatusCode != 401 { t.Errorf("%s %s without auth: expected 401, got %d", ep.method, ep.path, resp.StatusCode) } } for _, ep := range endpoints[:5] { req, _ := http.NewRequest(ep.method, base+ep.path, nil) req.SetBasicAuth("", password) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", ep.method, ep.path, err) } resp.Body.Close() if resp.StatusCode == 401 { t.Errorf("%s %s with correct auth: got 401", ep.method, ep.path) } } req, _ := http.NewRequest("GET", base+"/api/status", nil) req.SetBasicAuth("", "wrongpass") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("GET /api/status wrong pw: %v", err) } resp.Body.Close() if resp.StatusCode != 401 { t.Errorf("wrong password: expected 401, got %d", resp.StatusCode) } } // TestE2E_NewMsgSeparator_TimestampBased verifies the index.html uses // timestamp-based (not ID-based) comparison for the "new messages" separator. // This is critical because X/Twitter post IDs are CRC32 hashes that don't // increase monotonically, so ID-based comparison would place the separator // in wrong positions. func TestE2E_NewMsgSeparator_TimestampBased(t *testing.T) { base, _ := startWebServer(t) resp := getJSON(t, base+"/") defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) html := string(body) // The separator must compare against timestamp, not message ID. // Look for the timestamp-based lastSeen check in the new-msg separator logic. checks := []struct { name string needle string wantHas bool }{ {"uses timestamp for lastSeen storage", "thefeed_seen_ts_", true}, {"compares msgTs > lastSeenTs", "msgTs > lastSeenTs", true}, {"tracks maxTimestamp", "maxTimestamp", true}, {"no old ID-based seen key", "thefeed_seen_'", false}, {"no old id > lastSeen comparison", "id > lastSeen", false}, } for _, c := range checks { has := strings.Contains(html, c.needle) if has != c.wantHas { if c.wantHas { t.Errorf("%s: expected %q in index.html but not found", c.name, c.needle) } else { t.Errorf("%s: found %q in index.html but should have been removed", c.name, c.needle) } } } // Also verify that first-visit logic stores timestamp, not ID if !strings.Contains(html, "setLastSeenTimestamp") { t.Error("expected setLastSeenTimestamp function in index.html") } if !strings.Contains(html, "getLastSeenTimestamp") { t.Error("expected getLastSeenTimestamp function in index.html") } // Verify wasAtBottom updates lastSeen on re-renders (prevents stale separator) if !strings.Contains(html, "wasAtBottom && maxTimestamp > 0") { t.Error("expected wasAtBottom to update lastSeen timestamp on re-render") } } // TestE2E_MessagesHaveTimestamps verifies that the messages API response // includes Timestamp fields needed for the new-messages separator. func TestE2E_MessagesHaveTimestamps(t *testing.T) { base, _ := startWebServer(t) resp := getJSON(t, base+"/api/messages/1") defer resp.Body.Close() var result struct { Messages []struct { ID uint32 `json:"ID"` Timestamp uint32 `json:"Timestamp"` Text string `json:"Text"` } `json:"messages"` } body, _ := io.ReadAll(resp.Body) if err := json.Unmarshal(body, &result); err != nil { t.Fatalf("decode messages: %v", err) } // With no messages configured, the array should be empty or nil — but the // response format must still be valid JSON with a "messages" key. // This verifies the API structure supports the timestamp-based separator. t.Logf("messages response contains %d messages", len(result.Messages)) } // TestE2E_ActiveResolversAPI verifies the /api/resolvers/active endpoint. func TestE2E_ActiveResolversAPI(t *testing.T) { base, _ := startWebServer(t) // Before config: should return empty resolvers resp, err := http.Get(base + "/api/resolvers/active") if err != nil { t.Fatalf("GET /api/resolvers/active: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var result map[string]any json.NewDecoder(resp.Body).Decode(&result) resolvers, ok := result["resolvers"].([]any) if !ok { t.Fatal("expected 'resolvers' key in response") } t.Logf("active resolvers: %d", len(resolvers)) // Method not allowed resp2, err := http.Post(base+"/api/resolvers/active", "application/json", nil) if err != nil { t.Fatalf("POST /api/resolvers/active: %v", err) } defer resp2.Body.Close() if resp2.StatusCode != 405 { t.Errorf("expected 405 for POST, got %d", resp2.StatusCode) } } // TestE2E_WebUI_NewFeatures verifies the index.html includes new UI elements. func TestE2E_WebUI_NewFeatures(t *testing.T) { base, _ := startWebServer(t) resp, err := http.Get(base + "/") if err != nil { t.Fatalf("GET /: %v", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) html := string(bodyBytes) checks := map[string]string{ "message search bar": "msgSearchBar", "search input": "msgSearchInput", "export modal": "exportModal", "resolvers modal": "resolversModal", "background image": "bgImageInput", "dns timeout field": "peTimeout", "scanner clear button": "scanner_clear_targets", "search function": "doMsgSearch", "export function": "doExport", "bg image function": "applyBgImage", "resolvers function": "openResolversModal", "sidebar toolbar": "sidebar-toolbar", "resolvers badge": "resolversBadge", "normalize function": "normalizeArabicPersian", } for name, needle := range checks { if !strings.Contains(html, needle) { t.Errorf("%s: expected HTML to contain %q", name, needle) } } } // ===== RESOLVER BANK TESTS ===== func TestE2E_ResolverBank_EmptyByDefault(t *testing.T) { base, _ := startWebServer(t) resp := getJSON(t, base+"/api/resolvers/bank") m := decodeJSON(t, resp) if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } count, _ := m["count"].(float64) if count != 0 { t.Errorf("expected 0 bank resolvers, got %v", count) } } func TestE2E_ResolverBank_AddResolvers(t *testing.T) { base, _ := startWebServer(t) body := `{"resolvers":["8.8.8.8","1.1.1.1","8.8.8.8"]}` resp := postJSON(t, base+"/api/resolvers/bank", body) m := decodeJSON(t, resp) if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } // 8.8.8.8 appears twice but should be deduplicated added, _ := m["added"].(float64) total, _ := m["total"].(float64) if added != 2 { t.Errorf("expected 2 added, got %v", added) } if total != 2 { t.Errorf("expected 2 total, got %v", total) } // Verify via GET resp2 := getJSON(t, base+"/api/resolvers/bank") m2 := decodeJSON(t, resp2) count, _ := m2["count"].(float64) if count != 2 { t.Errorf("GET bank: expected 2, got %v", count) } } func TestE2E_ResolverBank_DeleteResolvers(t *testing.T) { base, _ := startWebServer(t) // Add some resolvers first postJSON(t, base+"/api/resolvers/bank", `{"resolvers":["8.8.8.8","1.1.1.1","4.4.4.4"]}`).Body.Close() // Delete one req, _ := http.NewRequest(http.MethodDelete, base+"/api/resolvers/bank", strings.NewReader(`{"addrs":["8.8.8.8:53"]}`)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("DELETE: %v", err) } defer resp.Body.Close() var m map[string]any json.NewDecoder(resp.Body).Decode(&m) if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } removed, _ := m["removed"].(float64) remaining, _ := m["remaining"].(float64) if removed != 1 { t.Errorf("expected 1 removed, got %v", removed) } if remaining != 2 { t.Errorf("expected 2 remaining, got %v", remaining) } } func TestE2E_ResolverBank_CleanupDryRun(t *testing.T) { base, _ := startWebServer(t) // Add resolvers postJSON(t, base+"/api/resolvers/bank", `{"resolvers":["8.8.8.8","1.1.1.1"]}`).Body.Close() // Dry-run cleanup with high threshold (should remove all, since they have no stats → score 0.2) resp := postJSON(t, base+"/api/resolvers/bank/cleanup", `{"minScore":0.5,"dryRun":true}`) m := decodeJSON(t, resp) if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } removed, _ := m["removed"].(float64) remaining, _ := m["remaining"].(float64) if removed != 2 { t.Errorf("dryRun: expected 2 removed, got %v", removed) } if remaining != 0 { t.Errorf("dryRun: expected 0 remaining, got %v", remaining) } // Verify bank is unchanged (dry run) resp2 := getJSON(t, base+"/api/resolvers/bank") m2 := decodeJSON(t, resp2) count, _ := m2["count"].(float64) if count != 2 { t.Errorf("bank should still have 2 after dry run, got %v", count) } } func TestE2E_ResolverBank_CleanupApply(t *testing.T) { base, _ := startWebServer(t) // Add resolvers postJSON(t, base+"/api/resolvers/bank", `{"resolvers":["8.8.8.8","1.1.1.1"]}`).Body.Close() // Apply cleanup with threshold below 0.2 → nothing removed (default score is 0.2) resp := postJSON(t, base+"/api/resolvers/bank/cleanup", `{"minScore":0.1}`) m := decodeJSON(t, resp) removed, _ := m["removed"].(float64) remaining, _ := m["remaining"].(float64) if removed != 0 { t.Errorf("expected 0 removed with 0.1 threshold, got %v", removed) } if remaining != 2 { t.Errorf("expected 2 remaining, got %v", remaining) } } func TestE2E_ResolverBank_MigrationFromProfile(t *testing.T) { base, _ := startWebServer(t) // Create a profile with resolvers — they should be migrated to the bank body := `{"action":"create","profile":{"id":"","nickname":"TestMigrate","config":{"domain":"test.example","key":"mypass","resolvers":["127.0.0.1:9999"],"queryMode":"single","rateLimit":5}}}` resp := postJSON(t, base+"/api/profiles", body) m := decodeJSON(t, resp) if m["ok"] != true { t.Fatalf("create profile: ok=%v", m["ok"]) } // The resolvers should now be in the bank resp2 := getJSON(t, base+"/api/resolvers/bank") m2 := decodeJSON(t, resp2) count, _ := m2["count"].(float64) if count < 1 { t.Errorf("expected at least 1 resolver in bank after migration, got %v", count) } // The profile should no longer have resolvers resp3 := getJSON(t, base+"/api/profiles") m3 := decodeJSON(t, resp3) profs := m3["profiles"].([]any) cfg := profs[0].(map[string]any)["config"].(map[string]any) resolvers := cfg["resolvers"] if resolvers != nil { r, ok := resolvers.([]any) if ok && len(r) > 0 { t.Errorf("expected profile resolvers to be empty after migration, got %v", resolvers) } } } func TestE2E_ResolverBank_ConfigAddsToBank(t *testing.T) { base, _ := startWebServer(t) // POST /api/config with resolvers should add them to the bank cfg := `{"domain":"test.example.com","key":"testpass","resolvers":["127.0.0.1:19999"],"queryMode":"single","rateLimit":10}` resp := postJSON(t, base+"/api/config", cfg) defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) t.Fatalf("POST /api/config status=%d body=%s", resp.StatusCode, body) } // Check that bank has the resolver resp2 := getJSON(t, base+"/api/resolvers/bank") m2 := decodeJSON(t, resp2) count, _ := m2["count"].(float64) if count < 1 { t.Errorf("expected at least 1 resolver in bank after config POST, got %v", count) } } func TestE2E_ResolverBank_MethodNotAllowed(t *testing.T) { base, _ := startWebServer(t) req, _ := http.NewRequest(http.MethodPut, base+"/api/resolvers/bank", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("PUT: %v", err) } defer resp.Body.Close() if resp.StatusCode != 405 { t.Errorf("expected 405, got %d", resp.StatusCode) } } func TestE2E_ResolverBank_CleanupBadRequest(t *testing.T) { base, _ := startWebServer(t) // Missing or invalid minScore resp := postJSON(t, base+"/api/resolvers/bank/cleanup", `{"minScore":0}`) defer resp.Body.Close() if resp.StatusCode != 400 { t.Errorf("expected 400 for minScore=0, got %d", resp.StatusCode) } }