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") } }