1package admin23import (4 "bytes"5 "context"6 "encoding/json"7 "fmt"8 "io"9 "log"10 "net"11 "net/http"12 "os"13 "os/signal"14 "path/filepath"15 "slices"16 "strings"17 "sync"18 "testing"1920 "airwaves/internal/youtube"2122 "github.com/modelcontextprotocol/go-sdk/mcp"23)2425// agent connects to the harness's agent endpoint as an MCP client would.26func (h *harness) agent() *mcp.ClientSession {27 h.t.Helper()28 c := mcp.NewClient(&mcp.Implementation{Name: "test-agent", Version: "1"}, nil)29 s, err := c.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: h.url + mcpPath}, nil)30 if err != nil {31 h.t.Fatal(err)32 }33 h.t.Cleanup(func() { s.Close() })34 return s35}3637// call calls a tool, returning its answer decoded, as text, and whether38// it's an error.39func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) (map[string]any, string, bool) {40 t.Helper()41 res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})42 if err != nil {43 t.Fatalf("%s: %v", name, err)44 }45 var text strings.Builder46 for _, c := range res.Content {47 if tc, ok := c.(*mcp.TextContent); ok {48 text.WriteString(tc.Text)49 }50 }51 var m map[string]any52 _ = json.Unmarshal([]byte(text.String()), &m)53 return m, text.String(), res.IsError54}5556// ok calls a tool that should work.57func ok(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) map[string]any {58 t.Helper()59 m, text, isErr := call(t, s, name, args)60 if isErr || m == nil {61 t.Fatalf("%s %v: %s", name, args, text)62 }63 return m64}6566// fails calls a tool that should fail, saying want.67func fails(t *testing.T, s *mcp.ClientSession, name string, args map[string]any, want string) {68 t.Helper()69 if _, text, isErr := call(t, s, name, args); !isErr || !strings.Contains(text, want) {70 t.Errorf("%s %v: want an error with %q, got %v %s", name, args, want, isErr, text)71 }72}7374func list(v any) []any { l, _ := v.([]any); return l }7576func TestAgentToolList(t *testing.T) {77 h := newHarness(t)78 res, err := h.agent().ListTools(context.Background(), nil)79 if err != nil {80 t.Fatal(err)81 }82 var names []string83 for _, tool := range res.Tools {84 names = append(names, tool.Name)85 if len(tool.Description) < 80 || tool.Title == "" || tool.Annotations == nil {86 t.Errorf("%s: describe it for agents: %q", tool.Name, tool.Description)87 }88 raw, _ := json.Marshal(tool.InputSchema)89 var schema map[string]any90 json.Unmarshal(raw, &schema)91 if schema["type"] != "object" || schema["additionalProperties"] != false {92 t.Errorf("%s: schema %s", tool.Name, raw)93 }94 for prop, p := range schema["properties"].(map[string]any) {95 if d, _ := p.(map[string]any)["description"].(string); d == "" {96 t.Errorf("%s.%s has no description", tool.Name, prop)97 }98 }99 readOnly := tool.Annotations.ReadOnlyHint100 if want := strings.HasPrefix(tool.Name, "list_") || strings.HasPrefix(tool.Name, "get_") || strings.HasPrefix(tool.Name, "search_") ||101 strings.HasPrefix(tool.Name, "lookup_") || strings.HasPrefix(tool.Name, "browse_"); readOnly != want {102 t.Errorf("%s: read-only %v", tool.Name, readOnly)103 }104 if destructive := tool.Annotations.DestructiveHint; (tool.Name == "delete_channel") != (destructive != nil && *destructive) {105 t.Errorf("%s: destructive %v", tool.Name, destructive)106 }107 }108 slices.Sort(names)109 want := []string{"browse_jellyfin", "create_backup", "create_jellyfin_channel", "create_youtube_channel", "delete_channel", "get_channel", "get_schedule",110 "list_antenna_channels", "list_backups", "list_channels", "lookup_youtube_channel", "search_youtube_channels", "set_channel_details",111 "update_jellyfin_channel", "update_youtube_channel"}112 if !slices.Equal(names, want) {113 t.Errorf("tools: %v", names)114 }115 if init := h.agent().InitializeResult(); init == nil || !strings.Contains(init.Instructions, "major.minor") {116 t.Errorf("instructions: %+v", init)117 }118}119120func TestAgentJellyfinChannels(t *testing.T) {121 h := newHarness(t)122 h.admin.Antenna = func(context.Context) map[string]string {123 return map[string]string{"1.1": "WGBH", "1.3": "WBZ", "4.1": "WBZ"}124 }125 s := h.agent()126127 // Before there's an account, browsing says so.128 fails(t, s, "browse_jellyfin", map[string]any{"kind": "series"}, "set up the Jellyfin account first")129 if code, _, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL, "user": "viewer", "password": "s3cret-pw"}); code != 200 {130 t.Fatalf("account: %s", raw)131 }132133 st := ok(t, s, "list_channels", nil)134 if chans := list(st["channels"]); len(chans) != 1 || chans[0].(map[string]any)["plays"] != "0 video files" {135 t.Errorf("channels: %v", st["channels"])136 }137 if st["nextFree"] != "1.4" || st["antennaChannels"] != 3.0 || st["jellyfinAccount"].(map[string]any)["ok"] != true {138 t.Errorf("list_channels: %v", st)139 }140 if strings.Contains(fmt.Sprint(st), "s3cret") {141 t.Error("the password came back")142 }143 if ant := list(ok(t, s, "list_antenna_channels", nil)["antenna"]); len(ant) != 3 || ant[0].(map[string]any)["number"] != "1.1" {144 t.Errorf("antenna: %v", ant)145 }146147 // Browsing, filtered and paged.148 b := ok(t, s, "browse_jellyfin", map[string]any{"kind": "series", "text": "FUTUR"})149 items := list(b["items"])150 if b["total"] != 2.0 || b["matched"] != 1.0 || len(items) != 1 || items[0].(map[string]any)["key"] != "Futurama (1999)" ||151 items[0].(map[string]any)["episodes"] != 2.0 || len(list(b["genres"])) != 2 {152 t.Errorf("browse futurama: %v", b)153 }154 if b := ok(t, s, "browse_jellyfin", map[string]any{"kind": "series", "genre": "game show"}); list(b["items"])[0].(map[string]any)["key"] != "Family Feud (1976)" {155 t.Errorf("by genre: %v", b)156 }157 if b := ok(t, s, "browse_jellyfin", map[string]any{"kind": "series", "yearTo": 1990}); b["matched"] != 1.0 {158 t.Errorf("by year: %v", b)159 }160 if b := ok(t, s, "browse_jellyfin", map[string]any{"kind": "series", "limit": 1}); len(list(b["items"])) != 1 || !strings.Contains(b["more"].(string), "offset 1") {161 t.Errorf("paged: %v", b)162 }163 fails(t, s, "browse_jellyfin", map[string]any{"kind": "music"}, "kind")164165 // A new channel; a name that matches nothing is pointed out.166 c := ok(t, s, "create_jellyfin_channel", map[string]any{167 "number": "1.4", "name": "Cartoons", "series": []string{"Futurama (1999)", "Nonesuch"}, "callSign": "TOON", "category": "Kids",168 })169 ch := c["channel"].(map[string]any)170 if ch["kind"] != "jellyfin" || ch["folder"] != "1.4 Cartoons" || ch["callSign"] != "TOON" || !slices.Contains(list(ch["unmatched"]), "Nonesuch") ||171 !strings.Contains(fmt.Sprint(c["notes"]), "matched nothing") {172 t.Fatalf("create: %v", c)173 }174 fails(t, s, "create_jellyfin_channel", map[string]any{"number": "1.4", "name": "Again"}, "1.4 is taken by Cartoons")175 fails(t, s, "create_jellyfin_channel", map[string]any{"number": "one", "name": "x"}, "the number should look like 1.4")176177 // Edits change only what's given.178 c = ok(t, s, "update_jellyfin_channel", map[string]any{179 "number": "1.4", "remove": map[string]any{"series": []string{"nonesuch"}}, "add": map[string]any{"movies": []string{"The Iron Giant"}}, "order": "aired",180 })181 cfg := c["channel"].(map[string]any)["config"].(map[string]any)182 if fmt.Sprint(cfg["series"]) != "[Futurama (1999)]" || fmt.Sprint(cfg["movies"]) != "[The Iron Giant]" || cfg["order"] != "aired" {183 t.Errorf("update: %v", cfg)184 }185 fails(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "remove": map[string]any{"series": []string{"Seinfeld"}}}, `no series named "Seinfeld"`)186 fails(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4"}, "nothing to change")187 fails(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "shows": []string{"x"}}, "shows")188 fails(t, s, "update_youtube_channel", map[string]any{"number": "1.4", "maxHeight": 480}, "use update_jellyfin_channel")189 fails(t, s, "update_jellyfin_channel", map[string]any{"number": "1.2", "order": "aired"}, "only its details change here")190191 g := ok(t, s, "get_channel", map[string]any{"number": "01.4"})192 if g["channel"].(map[string]any)["number"] != "1.4" || len(list(g["next"])) == 0 {193 t.Errorf("get_channel: %v", g)194 }195 if p := list(ok(t, s, "get_schedule", map[string]any{"number": "1.4"})["programs"]); len(p) == 0 {196 t.Error("no schedule")197 }198 fails(t, s, "get_channel", map[string]any{"number": "9.9"}, "there's no channel 9.9")199200 // Details, a new number among them; the picks stay.201 c = ok(t, s, "set_channel_details", map[string]any{"number": "1.4", "newNumber": "1.5", "description": "Saturday mornings.", "enabled": false})202 ch = c["channel"].(map[string]any)203 if ch["number"] != "1.5" || ch["description"] != "Saturday mornings." || ch["enabled"] != false || ch["callSign"] != "TOON" {204 t.Errorf("details: %v", ch)205 }206 if raw, _ := os.ReadFile(filepath.Join(h.root, "1.5 Cartoons", channelFile)); !strings.Contains(string(raw), "Futurama (1999)") || !strings.Contains(string(raw), `"aired"`) {207 t.Errorf("jellyfin.json after details: %s", raw)208 }209 if c := ok(t, s, "set_channel_details", map[string]any{"number": "1.2", "callSign": "CATS"}); c["channel"].(map[string]any)["callSign"] != "CATS" {210 t.Errorf("folder details: %v", c)211 }212 fails(t, s, "set_channel_details", map[string]any{"number": "1.2", "category": "Cats"}, "category")213 fails(t, s, "set_channel_details", map[string]any{"number": "1.2", "callSign": "TOO LONG A SIGN"}, "the call sign")214 fails(t, s, "set_channel_details", map[string]any{"number": "1.2", "logo": "source"}, "only Jellyfin and YouTube channels have a picture")215 fails(t, s, "set_channel_details", map[string]any{"number": "1.2"}, "nothing to change")216217 // Deleting takes a confirmation.218 fails(t, s, "delete_channel", map[string]any{"number": "1.5"}, "confirm")219 fails(t, s, "delete_channel", map[string]any{"number": "1.5", "confirm": false}, "can't be undone")220 fails(t, s, "delete_channel", map[string]any{"number": "1.2", "confirm": true}, "only Jellyfin and YouTube channels are deleted here")221 if d := ok(t, s, "delete_channel", map[string]any{"number": "1.5", "confirm": true}); d["deleted"].(map[string]any)["name"] != "Cartoons" {222 t.Errorf("delete: %v", d)223 }224 if _, err := os.Stat(filepath.Join(h.root, "1.5 Cartoons")); !os.IsNotExist(err) {225 t.Error("the deleted channel's folder is still there")226 }227}228229func TestAgentYouTubeChannels(t *testing.T) {230 h := newHarness(t)231 s := h.agent()232233 res := ok(t, s, "search_youtube_channels", map[string]any{"query": "boiler room"})234 found := list(res["channels"])235 if len(found) != 2 || found[0].(map[string]any)["handle"] != "@boilerroom" || found[0].(map[string]any)["image"] != nil {236 t.Errorf("search: %v", res)237 }238 fails(t, s, "search_youtube_channels", map[string]any{"query": "broken"}, "Sign in to confirm")239 if nl := ok(t, s, "lookup_youtube_channel", map[string]any{"channel": "@Northernlion"}); nl["videos"] != 20000.0 || nl["id"] != "UC3tNpTOHsTnkmbwztCs30sA" {240 t.Errorf("lookup: %v", nl)241 }242 fails(t, s, "lookup_youtube_channel", map[string]any{"channel": "https://youtu.be/dQw4w9WgXcQ"}, "link to a video")243244 c := ok(t, s, "create_youtube_channel", map[string]any{245 "number": "1.9", "name": "DJ Sets", "channels": []string{"@boilerroom"}, "maxMinutes": 0, "rerunMix": "recent", "maxAgeDays": 90, "category": "Music",246 })247 if ch := c["channel"].(map[string]any); ch["kind"] != "youtube" || ch["category"] != "Music" || ch["youtube"].(map[string]any)["maxMinutes"] != 0.0 {248 t.Fatalf("create: %v", c)249 }250 h.settle("1.9")251 data, _ := os.ReadFile(filepath.Join(h.root, "1.9 DJ Sets", youtubeFile))252 if !strings.Contains(string(data), `"maxAgeDays": 90`) || !strings.Contains(string(data), `"rerunMix": "recent"`) || !strings.Contains(string(data), `"repeatDays": 30`) {253 t.Errorf("youtube.json: %s", data)254 }255 fails(t, s, "create_youtube_channel", map[string]any{"number": "1.10", "name": "x", "channels": []string{"@boilerroom"}, "maxHeight": 900}, "maxHeight")256 fails(t, s, "create_youtube_channel", map[string]any{"number": "1.10", "name": "x", "channels": []string{"https://youtu.be/dQw4w9WgXcQ"}}, "link to a video")257 fails(t, s, "create_youtube_channel", map[string]any{"number": "1.10", "name": "x", "channels": []string{"@a"}, "minMinutes": 20, "maxMinutes": 10}, "longer than the shortest")258259 c = ok(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "addChannels": []string{"youtube.com/@Northernlion"}, "maxHeight": 1080})260 yt := c["channel"].(map[string]any)["youtube"].(map[string]any)261 if fmt.Sprint(yt["channels"]) != "[https://www.youtube.com/@boilerroom https://www.youtube.com/@Northernlion]" || yt["maxHeight"] != 1080.0 || yt["rerunMix"] != "recent" {262 t.Errorf("add: %v", yt)263 }264 h.settle("1.9")265 c = ok(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "removeChannels": []string{"boilerroom"}, "name": "Northernlion"})266 if ch := c["channel"].(map[string]any); ch["folder"] != "1.9 Northernlion" || len(list(ch["youtube"].(map[string]any)["channels"])) != 1 {267 t.Errorf("remove: %v", ch)268 }269 h.settle("1.9")270 fails(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "removeChannels": []string{"@nobody"}}, `doesn't play "@nobody"`)271 fails(t, s, "update_youtube_channel", map[string]any{"number": "1.9", "rerunMix": "newest"}, "rerunMix")272273 if l := list(ok(t, s, "list_channels", nil)["channels"]); len(l) != 2 || l[1].(map[string]any)["plays"] != "@Northernlion on YouTube" {274 t.Errorf("list: %v", l)275 }276 ok(t, s, "delete_channel", map[string]any{"number": "1.9", "confirm": true})277 if _, err := os.Stat(filepath.Join(h.root, "1.9 Northernlion")); !os.IsNotExist(err) {278 t.Error("the deleted channel's folder is still there")279 }280}281282// TestAgentSchedule: schedules through the Jellyfin tools and283// set_channel_details, shown by list_channels and get_channel.284func TestAgentSchedule(t *testing.T) {285 h := newHarness(t)286 s := h.agent()287 if code, _, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL, "user": "viewer", "password": "s3cret-pw"}); code != 200 {288 t.Fatalf("account: %s", raw)289 }290 nightly := map[string]any{"start": "2026-10-04", "blocks": []any{map[string]any{"at": "20:00", "count": 4}}}291 c := ok(t, s, "create_jellyfin_channel", map[string]any{"number": "1.4", "name": "Futurama", "series": []string{"Futurama (1999)"}, "order": "aired",292 "schedule": nightly})293 if sch, _ := c["channel"].(map[string]any)["schedule"].(map[string]any); sch["start"] != "2026-10-04" {294 t.Fatalf("create: %v", c)295 }296 g := ok(t, s, "get_channel", map[string]any{"number": "1.4"})297 if items := list(g["items"]); len(items) != 2 || items[0] != "1. Futurama S1 E1: Space Pilot 3000 (22 min)" {298 t.Errorf("items: %v", g["items"])299 }300 if l := list(ok(t, s, "list_channels", nil)["channels"]); l[1].(map[string]any)["schedule"] == nil {301 t.Errorf("list: %v", l)302 }303304 c = ok(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "schedule": map[string]any{"start": "2026-10-04T21:00", "first": 2}})305 if sch, _ := c["channel"].(map[string]any)["schedule"].(map[string]any); sch["first"] != 2.0 || sch["blocks"] != nil {306 t.Errorf("update: %v", c)307 }308 c = ok(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "order": "shuffle"})309 if c["channel"].(map[string]any)["schedule"] == nil {310 t.Errorf("left out, the schedule went: %v", c)311 }312 fails(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "schedule": map[string]any{"start": "2026-10-04", "blocks": []any{map[string]any{"at": "20:00"}}}},313 "the schedule: block 1 needs a count or a time to stop")314 fails(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "schedule": map[string]any{"start": "2026-10-04", "blocks": []any{map[string]any{"at": "20:00", "count": 0}}}},315 "count")316 if c := ok(t, s, "update_jellyfin_channel", map[string]any{"number": "1.4", "schedule": nil}); c["channel"].(map[string]any)["schedule"] != nil {317 t.Errorf("null: %v", c)318 }319320 // A folder channel's, through set_channel_details.321 c = ok(t, s, "set_channel_details", map[string]any{"number": "1.2", "schedule": nightly})322 if c["channel"].(map[string]any)["schedule"] == nil {323 t.Errorf("folder: %v", c)324 }325 if g := ok(t, s, "get_channel", map[string]any{"number": "1.2"}); g["items"] == nil || len(list(g["items"])) != 0 {326 t.Errorf("an empty folder's items: %v", g["items"])327 }328 fails(t, s, "set_channel_details", map[string]any{"number": "1.2", "schedule": map[string]any{"start": "soon"}}, "the schedule: the start")329 if c := ok(t, s, "set_channel_details", map[string]any{"number": "1.2", "schedule": nil}); c["channel"].(map[string]any)["schedule"] != nil {330 t.Errorf("folder null: %v", c)331 }332}333334// TestAgentEndpoint: the page's WebMCP calls (a plain JSON-RPC POST, no335// session), what's logged, and other sites' requests refused.336func TestAgentEndpoint(t *testing.T) {337 h := newHarness(t)338 var logged bytes.Buffer339 var mu sync.Mutex340 log.SetOutput(writerFunc(func(p []byte) (int, error) { mu.Lock(); defer mu.Unlock(); return logged.Write(p) }))341 t.Cleanup(func() { log.SetOutput(os.Stderr) })342343 post := func(body string, header map[string]string) (int, string) {344 req, _ := http.NewRequest("POST", h.url+mcpPath, strings.NewReader(body))345 req.Header.Set("Content-Type", "application/json")346 req.Header.Set("Accept", "application/json, text/event-stream")347 for k, v := range header {348 req.Header.Set(k, v)349 }350 resp, err := http.DefaultClient.Do(req)351 if err != nil {352 t.Fatal(err)353 }354 defer resp.Body.Close()355 raw, _ := io.ReadAll(resp.Body)356 return resp.StatusCode, string(raw)357 }358 code, raw := post(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_youtube_channels","arguments":{"query":"secret query"}}}`,359 map[string]string{viaHeader: "webmcp"})360 var res struct {361 Result mcp.CallToolResult `json:"result"`362 }363 if err := json.Unmarshal([]byte(raw), &res); code != 200 || err != nil || res.Result.IsError || len(res.Result.Content) != 1 {364 t.Fatalf("page call: %d %s", code, raw)365 }366 if code, raw := post(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, nil); code != 200 || !strings.Contains(raw, `"delete_channel"`) {367 t.Errorf("tools/list: %d %.200s", code, raw)368 }369 mu.Lock()370 got := logged.String()371 mu.Unlock()372 if !strings.Contains(got, "admin: agent: search_youtube_channels (through the page)") || strings.Contains(got, "secret query") {373 t.Errorf("log: %q", got)374 }375376 if code, raw := post(`{"jsonrpc":"2.0","id":3,"method":"tools/list"}`, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://example.com"}); code != http.StatusForbidden {377 t.Errorf("another site's request: %d %s", code, raw)378 }379 resp, err := http.Get(h.url + mcpPath)380 if err != nil {381 t.Fatal(err)382 }383 resp.Body.Close()384 if resp.StatusCode != http.StatusMethodNotAllowed {385 t.Errorf("GET: %d", resp.StatusCode)386 }387}388389type writerFunc func([]byte) (int, error)390391func (f writerFunc) Write(p []byte) (int, error) { return f(p) }392393// TestAgentDemoServer serves the admin page and its agent endpoint with a394// fake Jellyfin server, a weather channel, a live YouTube channel and a395// few antenna channels, for trying agents and the page's WebMCP by hand:396//397// AIRWAVES_ADMIN_DEMO=127.0.0.1:8790 go test ./internal/admin -run TestAgentDemoServer -timeout 0398//399// With AIRWAVES_YTDLP naming a yt-dlp, YouTube searches and channels are400// real; otherwise they come from testdata. It runs until interrupted.401func TestAgentDemoServer(t *testing.T) {402 addr := os.Getenv("AIRWAVES_ADMIN_DEMO")403 if addr == "" {404 t.Skip("set AIRWAVES_ADMIN_DEMO to an address to serve a demo admin page")405 }406 h := newHarness(t)407 if tool := os.Getenv("AIRWAVES_YTDLP"); tool != "" {408 h.lib.YtDlp = &youtube.YtDlp{Path: tool, HTTP: http.DefaultClient}409 }410 h.withWeather()411 h.admin.Antenna = func(context.Context) map[string]string {412 return map[string]string{"2.1": "WGBH", "4.1": "WBZ", "5.1": "WCVB", "7.1": "WHDH", "44.1": "WGBX"}413 }414 seedYouTube(t, filepath.Join(h.root, "1.8 Northernlion"))415 h.lib.Rescan()416 if code, _, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL, "user": "viewer", "password": "s3cret-pw"}); code != 200 {417 t.Fatalf("account: %s", raw)418 }419 ln, err := net.Listen("tcp", addr)420 if err != nil {421 t.Fatal(err)422 }423 srv := &http.Server{Handler: h.admin.Handler()}424 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)425 defer stop()426 go func() {427 <-ctx.Done()428 srv.Close()429 }()430 fmt.Printf("demo admin page on http://%s/admin/, agents at http://%s%s, channels in %s\n", addr, addr, mcpPath, h.root)431 if err := srv.Serve(ln); err != http.ErrServerClosed {432 t.Fatal(err)433 }434}