feat: rewrite to stateless web app with IndexedDB frontend
Replace CLI + SQLite architecture with a Go web server + vanilla JS frontend using IndexedDB for all client-side data storage. - Remove: cli, store, report, static packages - Add: compute engine (BuildDashboard), server package, web UI - Add: setup page with CRUD for profiles, rooms, devices, occupants, AC - Add: dashboard with SVG temperature timeline, risk analysis, care checklist - Add: i18n support (English/German) with server-side Go templates - Add: LLM provider selection UI with client-side API key storage - Add: per-room indoor temperature, edit buttons, language-aware AI summary
This commit is contained in:
168
internal/server/api.go
Normal file
168
internal/server/api.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/compute"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/weather"
|
||||
)
|
||||
|
||||
func (s *Server) handleComputeDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req compute.ComputeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := compute.BuildDashboard(req)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, data)
|
||||
}
|
||||
|
||||
type forecastRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req forecastRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider := weather.NewOpenMeteo(nil)
|
||||
resp, err := provider.FetchForecast(ctx, req.Lat, req.Lon, req.Timezone)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, resp)
|
||||
}
|
||||
|
||||
type warningsRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWeatherWarnings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req warningsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider := weather.NewDWDWFS(nil)
|
||||
warnings, err := provider.FetchWarnings(ctx, req.Lat, req.Lon)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]any{"warnings": warnings})
|
||||
}
|
||||
|
||||
type summarizeRequest struct {
|
||||
llm.SummaryInput
|
||||
Provider string `json:"provider,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLLMSummarize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req summarizeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use client-provided credentials if present, otherwise fall back to server config
|
||||
provider := s.llmProvider
|
||||
if req.Provider != "" && req.APIKey != "" {
|
||||
switch req.Provider {
|
||||
case "anthropic":
|
||||
provider = llm.NewAnthropic(req.APIKey, req.Model, nil)
|
||||
case "openai":
|
||||
provider = llm.NewOpenAI(req.APIKey, req.Model, nil)
|
||||
case "gemini":
|
||||
provider = llm.NewGemini(req.APIKey, req.Model, nil)
|
||||
}
|
||||
}
|
||||
|
||||
if provider.Name() == "none" {
|
||||
jsonResponse(w, map[string]string{"summary": ""})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
summary, err := provider.Summarize(ctx, req.SummaryInput)
|
||||
if err != nil {
|
||||
jsonError(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]string{"summary": summary})
|
||||
}
|
||||
|
||||
func (s *Server) handleLLMConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
available := s.llmProvider.Name() != "none"
|
||||
jsonResponse(w, map[string]any{
|
||||
"provider": s.cfg.LLM.Provider,
|
||||
"model": s.cfg.LLM.Model,
|
||||
"available": available,
|
||||
})
|
||||
}
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, msg string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
6
internal/server/embed.go
Normal file
6
internal/server/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package server
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// WebFS is set by the main package to provide the embedded web/ filesystem.
|
||||
var WebFS fs.FS
|
||||
100
internal/server/i18n.go
Normal file
100
internal/server/i18n.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// translations holds the loaded translation maps keyed by language code.
|
||||
type translations struct {
|
||||
langs map[string]map[string]any // e.g. {"en": {...}, "de": {...}}
|
||||
}
|
||||
|
||||
func loadTranslations(enJSON, deJSON []byte) (*translations, error) {
|
||||
t := &translations{langs: make(map[string]map[string]any)}
|
||||
|
||||
var en map[string]any
|
||||
if err := json.Unmarshal(enJSON, &en); err != nil {
|
||||
return nil, fmt.Errorf("parse en.json: %w", err)
|
||||
}
|
||||
t.langs["en"] = en
|
||||
|
||||
var de map[string]any
|
||||
if err := json.Unmarshal(deJSON, &de); err != nil {
|
||||
return nil, fmt.Errorf("parse de.json: %w", err)
|
||||
}
|
||||
t.langs["de"] = de
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// get resolves a dotted key (e.g. "setup.rooms.title") from the translation map.
|
||||
func (t *translations) get(lang, key string) string {
|
||||
m, ok := t.langs[lang]
|
||||
if !ok {
|
||||
m = t.langs["en"]
|
||||
}
|
||||
if m == nil {
|
||||
return key
|
||||
}
|
||||
return resolve(m, key)
|
||||
}
|
||||
|
||||
func resolve(m map[string]any, key string) string {
|
||||
parts := strings.Split(key, ".")
|
||||
var current any = m
|
||||
for _, p := range parts {
|
||||
cm, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
current, ok = cm[p]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
}
|
||||
s, ok := current.(string)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// supportedLangs are the available languages.
|
||||
var supportedLangs = []string{"en", "de"}
|
||||
|
||||
// detectLanguage determines the active language from query param, cookie, or Accept-Language header.
|
||||
func detectLanguage(r *http.Request) string {
|
||||
// 1. Query parameter
|
||||
if lang := r.URL.Query().Get("lang"); isSupported(lang) {
|
||||
return lang
|
||||
}
|
||||
|
||||
// 2. Cookie
|
||||
if c, err := r.Cookie("heatguard_lang"); err == nil && isSupported(c.Value) {
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// 3. Accept-Language header
|
||||
accept := r.Header.Get("Accept-Language")
|
||||
for _, part := range strings.Split(accept, ",") {
|
||||
lang := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
lang = strings.SplitN(lang, "-", 2)[0]
|
||||
if isSupported(lang) {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
func isSupported(lang string) bool {
|
||||
for _, l := range supportedLangs {
|
||||
if l == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
205
internal/server/server.go
Normal file
205
internal/server/server.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/config"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
||||
)
|
||||
|
||||
// Server holds the HTTP server state.
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
trans *translations
|
||||
cfg config.Config
|
||||
llmProvider llm.Provider
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// Options configures the server.
|
||||
type Options struct {
|
||||
Port int
|
||||
DevMode bool
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
// New creates a new Server and sets up routes.
|
||||
func New(opts Options) (*Server, error) {
|
||||
s := &Server{
|
||||
mux: http.NewServeMux(),
|
||||
cfg: opts.Config,
|
||||
devMode: opts.DevMode,
|
||||
}
|
||||
|
||||
// Load translations
|
||||
var enJSON, deJSON []byte
|
||||
var err error
|
||||
if opts.DevMode {
|
||||
enJSON, err = os.ReadFile(filepath.Join("web", "i18n", "en.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read en.json: %w", err)
|
||||
}
|
||||
deJSON, err = os.ReadFile(filepath.Join("web", "i18n", "de.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read de.json: %w", err)
|
||||
}
|
||||
} else {
|
||||
if WebFS == nil {
|
||||
return nil, fmt.Errorf("WebFS not set — call server.WebFS = ... before server.New()")
|
||||
}
|
||||
enJSON, err = fs.ReadFile(WebFS, "i18n/en.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded en.json: %w", err)
|
||||
}
|
||||
deJSON, err = fs.ReadFile(WebFS, "i18n/de.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded de.json: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.trans, err = loadTranslations(enJSON, deJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load translations: %w", err)
|
||||
}
|
||||
|
||||
// Set up LLM provider
|
||||
s.llmProvider = buildLLMProvider(s.cfg)
|
||||
|
||||
// Static assets
|
||||
if opts.DevMode {
|
||||
s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("web"))))
|
||||
} else {
|
||||
s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(WebFS))))
|
||||
}
|
||||
|
||||
// Page routes
|
||||
s.mux.HandleFunc("/", s.handleDashboard)
|
||||
s.mux.HandleFunc("/setup", s.handleSetup)
|
||||
s.mux.HandleFunc("/guide", s.handleGuide)
|
||||
|
||||
// API routes
|
||||
s.mux.HandleFunc("/api/compute/dashboard", s.handleComputeDashboard)
|
||||
s.mux.HandleFunc("/api/weather/forecast", s.handleWeatherForecast)
|
||||
s.mux.HandleFunc("/api/weather/warnings", s.handleWeatherWarnings)
|
||||
s.mux.HandleFunc("/api/llm/summarize", s.handleLLMSummarize)
|
||||
s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
// ListenAndServe starts the server.
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
return http.ListenAndServe(addr, s.mux)
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Lang string
|
||||
Page string
|
||||
Title string
|
||||
}
|
||||
|
||||
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page, templateFile string) {
|
||||
lang := detectLanguage(r)
|
||||
|
||||
// Set language cookie if query param was provided
|
||||
if qLang := r.URL.Query().Get("lang"); isSupported(qLang) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "heatguard_lang",
|
||||
Value: qLang,
|
||||
Path: "/",
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"t": func(key string) string {
|
||||
return s.trans.get(lang, key)
|
||||
},
|
||||
}
|
||||
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
|
||||
if s.devMode {
|
||||
tmpl, err = template.New("layout.html").Funcs(funcMap).ParseFiles(
|
||||
filepath.Join("web", "templates", "layout.html"),
|
||||
filepath.Join("web", "templates", templateFile),
|
||||
)
|
||||
} else {
|
||||
tmpl, err = template.New("layout.html").Funcs(funcMap).ParseFS(WebFS,
|
||||
"templates/layout.html",
|
||||
"templates/"+templateFile,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("template error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
title := s.trans.get(lang, "nav."+page)
|
||||
data := pageData{
|
||||
Lang: lang,
|
||||
Page: page,
|
||||
Title: title,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "dashboard", "dashboard.html")
|
||||
}
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderPage(w, r, "setup", "setup.html")
|
||||
}
|
||||
|
||||
func (s *Server) handleGuide(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderPage(w, r, "guide", "guide.html")
|
||||
}
|
||||
|
||||
func buildLLMProvider(cfg config.Config) llm.Provider {
|
||||
switch cfg.LLM.Provider {
|
||||
case "anthropic":
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewAnthropic(key, cfg.LLM.Model, nil)
|
||||
case "openai":
|
||||
key := os.Getenv("OPENAI_API_KEY")
|
||||
if key == "" {
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewOpenAI(key, cfg.LLM.Model, nil)
|
||||
case "gemini":
|
||||
key := os.Getenv("GEMINI_API_KEY")
|
||||
if key == "" {
|
||||
return llm.NewNoop()
|
||||
}
|
||||
return llm.NewGemini(key, cfg.LLM.Model, nil)
|
||||
case "ollama":
|
||||
return llm.NewOllama(cfg.LLM.Model, cfg.LLM.Endpoint, nil)
|
||||
default:
|
||||
return llm.NewNoop()
|
||||
}
|
||||
}
|
||||
167
internal/server/server_test.go
Normal file
167
internal/server/server_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/compute"
|
||||
"github.com/cnachtigall/heatwave-autopilot/internal/config"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Tests run from the package directory. Set WebFS to point to web/ dir.
|
||||
WebFS = os.DirFS("../../web")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func testServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s, err := New(Options{
|
||||
Port: 0,
|
||||
DevMode: false,
|
||||
Config: config.DefaultConfig(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDashboardPage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("got content-type %q, want text/html", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupPage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/setup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuidePage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/guide", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageDetection(t *testing.T) {
|
||||
s := testServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/?lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte(`lang="de"`)) {
|
||||
t.Error("expected lang=de in response")
|
||||
}
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "heatguard_lang" && c.Value == "de" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected heatguard_lang cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAPI_NoForecasts(t *testing.T) {
|
||||
s := testServer(t)
|
||||
|
||||
reqBody := compute.ComputeRequest{
|
||||
Profile: compute.Profile{Name: "Test", Timezone: "UTC"},
|
||||
Date: "2025-07-15",
|
||||
}
|
||||
b, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/compute/dashboard", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAPI_MethodNotAllowed(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/compute/dashboard", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("got status %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMConfigAPI(t *testing.T) {
|
||||
s := testServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/llm/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Available {
|
||||
t.Errorf("expected available=false for noop provider, got provider=%q model=%q available=%v", resp.Provider, resp.Model, resp.Available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMSummarize_Noop(t *testing.T) {
|
||||
s := testServer(t)
|
||||
body := `{"date":"2025-07-15","peakTempC":35,"riskLevel":"high"}`
|
||||
req := httptest.NewRequest("POST", "/api/llm/summarize", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["summary"] != "" {
|
||||
t.Errorf("expected empty summary for noop, got %q", resp["summary"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user