Files
PilotVault/API Server/internal/plugins/builtin/openweather/openweather_test.go
T
tajniak81andClaude Opus 4.8 285551fe8a Let OpenWeather Units fall through the settings cascade
The Units field carried a Default of "metric" and had no blank option, so
the global panel layer always set a value and locked organizations and
users out of changing it. Mirror the fix already applied to OpenSky's plan.

- Plugin descriptor: drop the "metric" Default and add a blank "Not set"
  option; runtime still falls back to metric when no layer sets it.
- Web App: add a "Not set" choice to the Units control and stop forcing
  metric back into the form on load.
- Test guards that the units field has no Default and offers a blank option.
- Rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:31:42 +02:00

208 lines
6.2 KiB
Go

package openweather
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"pilotvault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
p := &Plugin{}
d := p.Descriptor()
if d.Name != "openweather" {
t.Fatalf("name = %q, want openweather", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if d.Category != plugins.CategoryAPIsExternal {
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryAPIsExternal)
}
if d.AuthType != plugins.AuthAPIKey {
t.Fatalf("authType = %q, want %q", d.AuthType, plugins.AuthAPIKey)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
// The apiKey field must be flagged so the manager masks it, and no field may
// be Required (so the plugin can be enabled as an empty master switch).
for _, f := range d.ConfigFields {
if f.Key == "apiKey" && !f.Secret {
t.Errorf("config field %q must be Secret", f.Key)
}
if f.Required {
t.Errorf("config field %q must not be Required", f.Key)
}
// Units must be blank-able so the value can fall through the settings
// cascade: a blank "" option and no Default, otherwise the global layer
// always sets it and locks organizations/users out.
if f.Key == "units" {
if f.Default != "" {
t.Errorf("units field must not carry a Default (got %q) — it would lock lower cascade layers", f.Default)
}
hasBlank := false
for _, o := range f.Options {
if o.Value == "" {
hasBlank = true
}
}
if !hasBlank {
t.Error("units field must offer a blank \"\" (Not set) option so it can fall through the cascade")
}
}
}
}
func TestInitDefaults(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), nil); err != nil {
t.Fatal(err)
}
if p.units != "metric" {
t.Errorf("default units = %q, want metric", p.units)
}
if p.lat != defaultLat || p.lon != defaultLon {
t.Errorf("default location = %q,%q, want %q,%q", p.lat, p.lon, defaultLat, defaultLon)
}
if p.client == nil {
t.Error("Init must build an http client")
}
}
func TestRequestURL(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{
"apiKey": "secret", "units": "imperial", "lang": "pl",
})
got := p.requestURL("/data/2.5/weather", p.pointQuery("10", "20"))
for _, want := range []string{"lat=10", "lon=20", "units=imperial", "lang=pl", "appid=secret", "/data/2.5/weather?"} {
if !strings.Contains(got, want) {
t.Errorf("requestURL %q missing %q", got, want)
}
}
}
// TestRequestURLNoKey confirms the appid param is omitted when no key is set, so
// a key is never leaked as an empty value.
func TestRequestURLNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if got := p.requestURL("/data/2.5/weather", p.pointQuery("", "")); strings.Contains(got, "appid") {
t.Errorf("requestURL %q must not contain appid when key is unset", got)
}
}
// TestHealthCheckNoKey confirms a missing key is reported as down, not a panic.
func TestHealthCheckNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
}
}
func TestRegistered(t *testing.T) {
m := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, ok := m.Get("openweather"); !ok {
t.Fatal("openweather not registered in the plugin manager")
}
}
// fakeOW is a minimal OpenWeather stand-in: it validates the appid and echoes a
// small current-weather body, and returns 401 for a wrong key.
func newFakeOW(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("appid") != "good-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/data/2.5/weather":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"Warsaw","main":{"temp":12.5},"weather":[{"description":"clear sky"}]}`))
case "/data/2.5/forecast":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"cnt":40,"list":[]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestHealthCheckOK(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
p.base = srv.URL
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusOK {
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
}
if !strings.Contains(h.Detail, "Warsaw") || !strings.Contains(h.Detail, "clear sky") {
t.Errorf("detail = %q, want it to summarise the current conditions", h.Detail)
}
}
func TestHealthCheckAuthFailure(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "wrong"})
p.base = srv.URL
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
}
}
func TestInvoke(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
p.base = srv.URL
ctx := context.Background()
// current weather with an explicit location override
raw, err := p.Invoke(ctx, "weather.current", json.RawMessage(`{"lat":"51.5","lon":"-0.12"}`))
if err != nil {
t.Fatalf("weather.current: %v", err)
}
var cur struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &cur); err != nil || cur.Name != "Warsaw" {
t.Fatalf("weather.current body = %s (err=%v)", raw, err)
}
// 5-day forecast using the default location
if _, err := p.Invoke(ctx, "forecast.5day", nil); err != nil {
t.Fatalf("forecast.5day: %v", err)
}
// unknown action
if _, err := p.Invoke(ctx, "nope", nil); err == nil {
t.Error("expected error for unknown action")
}
}
// TestInvokeNoKey confirms Invoke refuses to call upstream without a key.
func TestInvokeNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if _, err := p.Invoke(context.Background(), "weather.current", nil); err == nil {
t.Error("expected errNoKey when no API key is configured")
}
}