Files
PilotVault/API Server/internal/api/integrations_openweather_test.go
tajniak81andClaude Opus 4.8 5960fb4806 Show current weather on the Overview
Add a weather card to the Web App Overview, wired like the OpenSky live-map
overlay and backed by the OpenWeather plugin.

- API Server: GET /api/integrations/openweather/current resolves the
  caller's cascade, gates it (master/org/personal opt-in/key), and returns
  trimmed current conditions for the configured or a supplied ?lat=&lon=
  point; off/keyless returns {unavailable, detail} so the card degrades.
- BFF relay (forwards the location override) + route; api.js client.
- Dashboard: a Weather card stacked above Schedule showing an emoji
  condition, temperature in the resolved unit, description, location, and a
  feels-like/wind/humidity/cloud grid. Location follows the same cascade as
  the map (drone -> phone -> browser -> default) without prompting for geo;
  refreshes every 10 min while on Overview.
- validLatLon test; rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:34:01 +02:00

161 lines
5.8 KiB
Go

package api
import (
"context"
"encoding/json"
"testing"
"pilotvault/apiserver/internal/plugins"
)
// newOWServer builds a minimal Server whose plugin manager has openweather
// enabled with the given global config. admin is left nil (not configured), so
// the org layer is skipped and the cascade covers global + user only.
func newOWServer(t *testing.T, global map[string]string) *Server {
t.Helper()
mgr := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, err := mgr.Upsert(context.Background(), openWeatherPlugin, true, global); err != nil {
t.Fatalf("enable openweather: %v", err)
}
return &Server{plugins: mgr}
}
// userRaw builds a pluginSettings blob with an openweather user layer.
func userRaw(t *testing.T, cfg owConfig, enabled bool) json.RawMessage {
t.Helper()
b, err := json.Marshal(owSettingsDoc{OpenWeather: owStored{Config: cfg, Enabled: enabled}})
if err != nil {
t.Fatal(err)
}
return b
}
func TestResolveOpenWeatherCascade(t *testing.T) {
// Global supplies the API key + units and leaves the location blank so the
// user layer fills it in. (A value set globally locks the lower layers — that
// is verified separately by the masked/locked apiKey below.)
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser} // org-less
uRaw := userRaw(t, owConfig{Lat: "51.5", Lon: "-0.12"}, true)
res := s.resolveOpenWeather(context.Background(), who, uRaw)
if !res.available {
t.Error("available should be true when the plugin is enabled")
}
if !res.enabled {
t.Error("enabled should reflect the user's personal opt-in")
}
// API key + units come from global (user left them blank).
if res.eff.APIKey != "GLOBAL-KEY" || res.source["apiKey"] != "global" {
t.Errorf("apiKey = %q src %q, want GLOBAL-KEY/global", res.eff.APIKey, res.source["apiKey"])
}
if res.eff.Units != "metric" || res.source["units"] != "global" {
t.Errorf("units = %q src %q, want metric/global", res.eff.Units, res.source["units"])
}
// Location comes from the user layer (top-wins over the global default).
if res.eff.Lat != "51.5" || res.source["lat"] != "user" {
t.Errorf("lat = %q src %q, want 51.5/user", res.eff.Lat, res.source["lat"])
}
if res.eff.Lon != "-0.12" || res.source["lon"] != "user" {
t.Errorf("lon = %q src %q, want -0.12/user", res.eff.Lon, res.source["lon"])
}
}
// A field set globally locks the lower layers: even when the user supplies units,
// the global value stays in force and is sourced to "global".
func TestResolveOpenWeatherGlobalLocksUser(t *testing.T) {
s := newOWServer(t, map[string]string{"apiKey": "K", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser}
uRaw := userRaw(t, owConfig{Units: "imperial"}, false)
res := s.resolveOpenWeather(context.Background(), who, uRaw)
if res.eff.Units != "metric" || res.source["units"] != "global" {
t.Errorf("units = %q src %q, want metric/global (global must lock the user's imperial)", res.eff.Units, res.source["units"])
}
}
// The view must never leak the concrete API key — only presence, masked.
func TestOpenWeatherViewMasksKey(t *testing.T) {
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser}
res := s.resolveOpenWeather(context.Background(), who, nil)
view := s.openWeatherView(who, res)
scopes := view["scopes"].(map[string]any)
user := scopes["user"].(map[string]any)
fields := user["fields"].(map[string]osFieldView)
if got := fields["apiKey"].Effective; got != openSkySecretMask {
t.Errorf("apiKey effective = %q, want the mask (never the raw key)", got)
}
if fields["apiKey"].Source != "global" || !fields["apiKey"].Locked {
t.Errorf("apiKey field = %+v, want source global + locked for a plain user", fields["apiKey"])
}
// A non-secret field is shown in the clear.
if fields["units"].Effective != "metric" {
t.Errorf("units effective = %q, want metric", fields["units"].Effective)
}
}
func TestValidLatLon(t *testing.T) {
ok := [][2]string{{"0", "0"}, {"52.2297", "21.0122"}, {"-90", "180"}, {"90", "-180"}}
for _, c := range ok {
if !validLatLon(c[0], c[1]) {
t.Errorf("validLatLon(%q,%q) = false, want true", c[0], c[1])
}
}
bad := [][2]string{{"", ""}, {"91", "0"}, {"0", "181"}, {"-91", "0"}, {"abc", "0"}, {"0", "x"}}
for _, c := range bad {
if validLatLon(c[0], c[1]) {
t.Errorf("validLatLon(%q,%q) = true, want false", c[0], c[1])
}
}
}
// mergeOpenWeather must preserve sibling plugin keys (opensky/webdav) untouched.
func TestMergeOpenWeatherPreservesSiblings(t *testing.T) {
existing := json.RawMessage(`{"opensky":{"enabled":true},"webdav":{"config":{"baseURL":"https://x"}}}`)
out := mergeOpenWeather(existing, func(ow *owStored) {
ow.Config.APIKey = "K"
ow.Enabled = true
})
var doc map[string]json.RawMessage
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
if _, ok := doc["opensky"]; !ok {
t.Error("opensky key was dropped by mergeOpenWeather")
}
if _, ok := doc["webdav"]; !ok {
t.Error("webdav key was dropped by mergeOpenWeather")
}
var ow owStored
if err := json.Unmarshal(doc["openweather"], &ow); err != nil {
t.Fatal(err)
}
if ow.Config.APIKey != "K" || !ow.Enabled {
t.Errorf("openweather entry = %+v, want APIKey K + enabled", ow)
}
}
// A saved secret left at the mask on PUT must not be re-checked here, but the
// merge/keep logic lives in the handler; this guards the field/secret metadata
// the handler relies on stays consistent with the plugin descriptor.
func TestOpenWeatherFieldMetadata(t *testing.T) {
if !owSecretKeys["apiKey"] {
t.Error("apiKey must be a secret key")
}
// Every declared field must round-trip through owGet/owSet.
var c owConfig
for _, k := range owFields {
owSet(&c, k, "v-"+k)
}
for _, k := range owFields {
if owGet(c, k) != "v-"+k {
t.Errorf("owGet/owSet mismatch for %q", k)
}
}
}