package wikijs import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" ) func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) client, err := New(srv.URL, "test-key", WithUserAgent("wikijs-mcp/test")) if err != nil { t.Fatalf("New() error = %v", err) } return client } func decodeRequest(t *testing.T, r *http.Request) graphqlRequest { t.Helper() var req graphqlRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("decode request: %v", err) } return req } func writeJSON(t *testing.T, w http.ResponseWriter, status int, body string) { t.Helper() w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if _, err := w.Write([]byte(body)); err != nil { t.Errorf("write response: %v", err) } } func TestNewNormalizesURL(t *testing.T) { tests := []struct { base string wantBase string wantEndpoint string }{ {base: "https://wiki.example.com", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"}, {base: "https://wiki.example.com/", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"}, {base: "https://wiki.example.com/graphql", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"}, {base: "https://wiki.example.com/graphql/", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"}, {base: "https://wiki.example.com/wiki", wantBase: "https://wiki.example.com/wiki", wantEndpoint: "https://wiki.example.com/wiki/graphql"}, } for _, tt := range tests { t.Run(tt.base, func(t *testing.T) { c, err := New(tt.base, "key") if err != nil { t.Fatalf("New() error = %v", err) } if c.baseURL != tt.wantBase { t.Errorf("baseURL = %q, want %q", c.baseURL, tt.wantBase) } if c.endpoint != tt.wantEndpoint { t.Errorf("endpoint = %q, want %q", c.endpoint, tt.wantEndpoint) } }) } } func TestNewRejectsInvalidURL(t *testing.T) { for _, raw := range []string{"", "wiki.example.com", "ftp://wiki.example.com", "/relative", "https://wiki.example.com/?a=1", "https://wiki.example.com/#x"} { if _, err := New(raw, "key"); err == nil { t.Errorf("New(%q) error = nil, want error", raw) } } } func TestPageByID(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want { t.Errorf("Authorization = %q, want %q", got, want) } if got, want := r.Header.Get("User-Agent"), "wikijs-mcp/test"; got != want { t.Errorf("User-Agent = %q, want %q", got, want) } if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { t.Errorf("Content-Type = %q, want %q", got, want) } req := decodeRequest(t, r) if !strings.Contains(req.Query, "single(id: $id)") { t.Errorf("query does not contain single query: %s", req.Query) } if got := req.Variables["id"]; got != float64(42) { t.Errorf("variables[id] = %v, want 42", got) } writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":{ "id":42,"path":"/dev/go","title":"Go","description":"desc", "tags":[{"tag":"go","title":"Go"}],"content":"body", "createdAt":"2025-01-01T00:00:00Z","updatedAt":"2025-01-02T00:00:00Z", "locale":"de"}}}}`) }) page, err := client.PageByID(context.Background(), 42) if err != nil { t.Fatalf("PageByID() error = %v", err) } if page == nil { t.Fatal("PageByID() = nil, want page") } if page.ID != 42 || page.Title != "Go" { t.Errorf("page = %+v", page) } if got, want := page.URL, client.baseURL+"/de/dev/go"; got != want { t.Errorf("URL = %q, want %q", got, want) } if len(page.Tags) != 1 || page.Tags[0].Tag != "go" { t.Errorf("Tags = %+v", page.Tags) } } func TestPageByIDEnglishHasNoLocalePrefix(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":{ "id":1,"path":"/start","title":"Start","description":"", "tags":null,"content":"","createdAt":"","updatedAt":"","locale":"en"}}}}`) }) page, err := client.PageByID(context.Background(), 1) if err != nil { t.Fatalf("PageByID() error = %v", err) } if got, want := page.URL, client.baseURL+"/start"; got != want { t.Errorf("URL = %q, want %q", got, want) } if page.Tags == nil { t.Error("Tags = nil, want empty slice") } } func TestPageByIDNotFound(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":null}}}`) }) page, err := client.PageByID(context.Background(), 99) if err != nil { t.Fatalf("PageByID() error = %v", err) } if page != nil { t.Errorf("PageByID() = %+v, want nil", page) } } func TestPageByPath(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) if !strings.Contains(req.Query, "singleByPath") { t.Errorf("query = %s", req.Query) } if got := req.Variables["path"]; got != "/start" { t.Errorf("variables[path] = %v", got) } if got := req.Variables["locale"]; got != "de-DE" { t.Errorf("variables[locale] = %v", got) } writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"singleByPath":{ "id":7,"path":"/start","title":"Start","description":"desc", "tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":"de-DE"}}}}`) }) page, err := client.PageByPath(context.Background(), "/start", "de-DE") if err != nil { t.Fatalf("PageByPath() error = %v", err) } if got, want := page.URL, client.baseURL+"/de-DE/start"; got != want { t.Errorf("URL = %q, want %q", got, want) } } func TestSearchPages(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) if got := req.Variables["query"]; got != "mcp" { t.Errorf("variables[query] = %v", got) } if got := req.Variables["path"]; got != "/dev" { t.Errorf("variables[path] = %v", got) } if _, ok := req.Variables["locale"]; ok { t.Errorf("variables[locale] = %v, want omitted", req.Variables["locale"]) } writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"search":{ "results":[ {"title":"A","description":"a","path":"/a","locale":"en"}, null, {"title":"B","description":"b","path":"/b","locale":"de"} ], "suggestions":["s1"],"totalHits":3}}}}`) }) res, err := client.SearchPages(context.Background(), "mcp", "/dev", "") if err != nil { t.Fatalf("SearchPages() error = %v", err) } if got, want := res.TotalHits, 3; got != want { t.Errorf("TotalHits = %d, want %d", got, want) } if len(res.Results) != 2 { t.Fatalf("Results = %d, want 2", len(res.Results)) } if got, want := res.Results[0].URL, client.baseURL+"/a"; got != want { t.Errorf("URL = %q, want %q", got, want) } if got, want := res.Results[1].URL, client.baseURL+"/de/b"; got != want { t.Errorf("URL = %q, want %q", got, want) } if len(res.Suggestions) != 1 || res.Suggestions[0] != "s1" { t.Errorf("Suggestions = %v", res.Suggestions) } } func TestSearchPagesEmpty(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"search":null}}}`) }) res, err := client.SearchPages(context.Background(), "nothing", "", "") if err != nil { t.Fatalf("SearchPages() error = %v", err) } if res.Results == nil || res.Suggestions == nil { t.Errorf("empty slices must not be nil: %+v", res) } if res.TotalHits != 0 { t.Errorf("TotalHits = %d, want 0", res.TotalHits) } } func TestListPages(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) if got := req.Variables["limit"]; got != float64(10) { t.Errorf("variables[limit] = %v", got) } if got := req.Variables["locale"]; got != "de" { t.Errorf("variables[locale] = %v", got) } tags, ok := req.Variables["tags"].([]any) if !ok || len(tags) != 2 || tags[0] != "go" { t.Errorf("variables[tags] = %v", req.Variables["tags"]) } writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"list":[ {"id":1,"path":"/a","locale":"de","title":"A","description":"a", "createdAt":"c","updatedAt":"u","tags":["go"]}, null, {"id":2,"path":"/b","locale":"en","title":"B","description":"b", "createdAt":"c","updatedAt":"u","tags":null} ]}}}`) }) limit := 10 pages, err := client.ListPages(context.Background(), ListOptions{ Limit: &limit, Locale: "de", Tags: []string{"go", "mcp"}, }) if err != nil { t.Fatalf("ListPages() error = %v", err) } if len(pages) != 2 { t.Fatalf("ListPages() returned %d pages, want 2", len(pages)) } if got, want := pages[0].URL, client.baseURL+"/de/a"; got != want { t.Errorf("URL = %q, want %q", got, want) } if pages[1].Tags == nil { t.Error("Tags = nil, want empty slice") } } func TestListPagesOmitsEmptyFilters(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) for _, key := range []string{"limit", "locale", "tags"} { if _, ok := req.Variables[key]; ok { t.Errorf("variables[%s] present, want omitted", key) } } writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"list":[]}}}`) }) if _, err := client.ListPages(context.Background(), ListOptions{}); err != nil { t.Fatalf("ListPages() error = %v", err) } } func TestCreatePage(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) if !strings.Contains(req.Query, "mutation CreatePage") { t.Errorf("query = %s", req.Query) } if got := req.Variables["tags"].([]any); len(got) != 1 || got[0] != "go" { t.Errorf("variables[tags] = %v", req.Variables["tags"]) } if got := req.Variables["isPublished"]; got != true { t.Errorf("variables[isPublished] = %v", got) } writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"create":{"responseResult":{ "succeeded":true,"errorCode":0,"slug":"new-page","message":"Page created"}}}}}`) }) res, err := client.CreatePage(context.Background(), CreatePageInput{ Title: "New", Content: "body", Description: "desc", Path: "new-page", Editor: "markdown", IsPublished: true, Locale: "en", Tags: []string{"go"}, }) if err != nil { t.Fatalf("CreatePage() error = %v", err) } if !res.Succeeded || res.Message != "Page created" || res.Slug != "new-page" { t.Errorf("result = %+v", res) } } func TestUpdatePageFailureIsReturned(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"update":{"responseResult":{ "succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`) }) res, err := client.UpdatePage(context.Background(), UpdatePageInput{ID: 1}) if err != nil { t.Fatalf("UpdatePage() error = %v", err) } if res.Succeeded { t.Error("Succeeded = true, want false") } if res.ErrorCode != 6001 || res.Message != "Page not found" { t.Errorf("result = %+v", res) } } func TestGraphQLErrors(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"errors":[{"message":"Unauthorized"},{"message":"Forbidden"}]}`) }) _, err := client.PageByID(context.Background(), 1) if err == nil { t.Fatal("PageByID() error = nil, want error") } var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("error type = %T, want *APIError", err) } if got := apiErr.Error(); !strings.Contains(got, "Unauthorized") || !strings.Contains(got, "Forbidden") { t.Errorf("error = %q", got) } } func TestHTTPError(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "server exploded", http.StatusInternalServerError) }) _, err := client.PageByID(context.Background(), 1) if err == nil { t.Fatal("PageByID() error = nil, want error") } var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("error type = %T, want *APIError", err) } if apiErr.Status != http.StatusInternalServerError { t.Errorf("Status = %d, want 500", apiErr.Status) } if !strings.Contains(err.Error(), "server exploded") { t.Errorf("error = %q", err) } } func TestMissingData(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":null}`) }) _, err := client.PageByID(context.Background(), 1) if err == nil { t.Fatal("PageByID() error = nil, want error") } } func TestContextCancellation(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("handler must not be called") }) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := client.PageByID(ctx, 1); err == nil { t.Fatal("PageByID() error = nil, want context error") } } func TestListAssetFoldersRoot(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) if !strings.Contains(req.Query, "folders(parentFolderId") { t.Errorf("query = %s", req.Query) } if got := req.Variables["parentFolderId"]; got != float64(0) { t.Errorf("variables[parentFolderId] = %v, want 0 (root)", got) } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[ {"id":5,"slug":"images","name":"Images"}, {"id":6,"slug":"docs","name":"Docs"} ]}}}`) }) folders, err := client.ListAssetFolders(context.Background(), "") if err != nil { t.Fatalf("ListAssetFolders() error = %v", err) } if len(folders) != 2 { t.Fatalf("folders = %d, want 2", len(folders)) } if got, want := folders[0].Path, "images"; got != want { t.Errorf("Path = %q, want %q", got, want) } } func TestResolveAssetFolderNested(t *testing.T) { var calls []map[string]any client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) calls = append(calls, req.Variables) switch len(calls) { case 1: if got := req.Variables["parentFolderId"]; got != float64(0) { t.Errorf("call 1 parentFolderId = %v, want 0", got) } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":5,"slug":"images"}]}}}`) case 2: if got := req.Variables["parentFolderId"]; got != float64(5) { t.Errorf("call 2 parentFolderId = %v, want 5", got) } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens"}]}}}`) case 3: if got := req.Variables["folderId"]; got != float64(9) { t.Errorf("call 3 folderId = %v, want 9", got) } if got := req.Variables["kind"]; got != "ALL" { t.Errorf("call 3 kind = %v, want ALL", got) } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"list":[ {"id":1,"filename":"shot.png","ext":"png","kind":"IMAGE","mime":"image/png", "fileSize":12,"createdAt":"c","updatedAt":"u", "folder":{"id":9,"slug":"screens","name":"Screens"}} ]}}}`) default: t.Errorf("unexpected call %d", len(calls)) } }) assets, err := client.ListAssets(context.Background(), "images/screens") if err != nil { t.Fatalf("ListAssets() error = %v", err) } if len(assets) != 1 { t.Fatalf("assets = %d, want 1", len(assets)) } if got, want := assets[0].Folder.Path, "images/screens"; got != want { t.Errorf("Folder.Path = %q, want %q", got, want) } if got, want := assets[0].URL, client.baseURL+"/images/screens/shot.png"; got != want { t.Errorf("URL = %q, want %q", got, want) } } func TestListAssetsRootOmitsFolderResolution(t *testing.T) { requests := 0 client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { requests++ req := decodeRequest(t, r) if got := req.Variables["folderId"]; got != float64(0) { t.Errorf("folderId = %v, want 0", got) } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"list":[ {"id":2,"filename":"logo.png","ext":"png","kind":"IMAGE","mime":"image/png", "fileSize":9,"createdAt":"c","updatedAt":"u","folder":null} ]}}}`) }) assets, err := client.ListAssets(context.Background(), "") if err != nil { t.Fatalf("ListAssets() error = %v", err) } if requests != 1 { t.Errorf("requests = %d, want 1", requests) } if got, want := assets[0].URL, client.baseURL+"/logo.png"; got != want { t.Errorf("URL = %q, want %q", got, want) } if assets[0].Folder == nil || assets[0].Folder.ID != 0 { t.Errorf("Folder = %+v, want root folder", assets[0].Folder) } } func TestResolveAssetFolderNotFound(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[]}}}`) }) if _, err := client.ListAssets(context.Background(), "nope"); err == nil { t.Fatal("ListAssets() error = nil, want error") } else if !strings.Contains(err.Error(), "not found") { t.Errorf("error = %q", err) } } func TestCreateAssetFolder(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { req := decodeRequest(t, r) if !strings.Contains(req.Query, "mutation CreateAssetFolder") { t.Errorf("query = %s", req.Query) } if got := req.Variables["parentFolderId"]; got != float64(0) { t.Errorf("parentFolderId = %v, want 0", got) } if got, want := req.Variables["slug"], "vacation"; got != want { t.Errorf("slug = %v, want %q", got, want) } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"createFolder":{"responseResult":{ "succeeded":true,"errorCode":0,"slug":"vacation","message":"ok"}}}}}`) }) res, err := client.CreateAssetFolder(context.Background(), "", "vacation", "Vacation 2025") if err != nil { t.Fatalf("CreateAssetFolder() error = %v", err) } if !res.Succeeded { t.Errorf("result = %+v", res) } } func TestCreateAssetFolderFailure(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"createFolder":{"responseResult":{ "succeeded":false,"errorCode":2002,"slug":"","message":"AssetFolderExists"}}}}}`) }) res, err := client.CreateAssetFolder(context.Background(), "", "vacation", "Vacation") if err != nil { t.Fatalf("CreateAssetFolder() error = %v", err) } if res.Succeeded || res.ErrorCode != 2002 { t.Errorf("result = %+v", res) } } func TestUploadAssetRoot(t *testing.T) { var meta, file string var filename, contentType, auth string var contents []byte client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { // Best-effort site config probe arrives before the multipart upload. if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { writeJSON(t, w, http.StatusOK, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`) return } auth = r.Header.Get("Authorization") mr, err := r.MultipartReader() if err != nil { t.Fatalf("multipart reader: %v", err) } for i := 0; ; i++ { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Fatalf("part %d: %v", i, err) } if got := part.FormName(); got != "mediaUpload" { t.Errorf("part %d FormName() = %q, want mediaUpload", i, got) } data, err := io.ReadAll(part) if err != nil { t.Fatalf("read part %d: %v", i, err) } if part.FileName() == "" { meta = string(data) } else { file = string(data) filename = part.FileName() contentType = part.Header.Get("Content-Type") contents = data } } _, _ = w.Write([]byte("ok")) }) res, uploaded, err := client.UploadAsset(context.Background(), "", "Report 2025; Final.pdf", []byte("payload"), "") if err != nil { t.Fatalf("UploadAsset() error = %v", err) } if auth != "Bearer test-key" { t.Errorf("Authorization = %q", auth) } if meta != `{"folderId":0}` { t.Errorf("metadata = %q, want folderId 0", meta) } if file != "payload" || string(contents) != "payload" { t.Errorf("file part = %q", file) } if filename != "report_2025_final.pdf" { t.Errorf("filename = %q, want sanitized", filename) } if contentType != "application/pdf" { t.Errorf("Content-Type = %q, want guessed application/pdf", contentType) } if !res.Succeeded { t.Errorf("result = %+v", res) } if got, want := uploaded.Filename, filename; got != want { t.Errorf("uploaded.Filename = %q, want %q", got, want) } if got, want := uploaded.URL, client.baseURL+"/report_2025_final.pdf"; got != want { t.Errorf("uploaded.URL = %q, want %q", got, want) } } func TestUploadAssetNestedFolder(t *testing.T) { var calls []map[string]any client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { _, _ = w.Write([]byte("ok")) return } req := decodeRequest(t, r) if strings.Contains(req.Query, "query SiteConfig") { writeJSON(t, w, http.StatusOK, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`) return } calls = append(calls, req.Variables) if len(calls) == 1 { writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":5,"slug":"images"}]}}}`) return } writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens"}]}}}`) }) res, uploaded, err := client.UploadAsset( context.Background(), "images/screens", "shot.png", []byte("png"), "image/png", ) if err != nil { t.Fatalf("UploadAsset() error = %v", err) } if len(calls) != 2 { t.Errorf("folder calls = %d, want 2", len(calls)) } if !res.Succeeded { t.Errorf("result = %+v", res) } if got, want := uploaded.URL, client.baseURL+"/images/screens/shot.png"; got != want { t.Errorf("uploaded.URL = %q, want %q", got, want) } } func TestUploadAssetForbidden(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, `{"succeeded":false,"message":"You are not authorized to upload files."}`) }) res, _, err := client.UploadAsset(context.Background(), "", "x.png", []byte("png"), "") if err != nil { t.Fatalf("UploadAsset() error = %v", err) } if res.Succeeded { t.Error("Succeeded = true, want false") } if res.Message != "You are not authorized to upload files." { t.Errorf("Message = %q", res.Message) } } func TestUploadAssetUnexpectedResponse(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("proxy error")) }) if _, _, err := client.UploadAsset(context.Background(), "", "x.png", []byte("png"), ""); err == nil { t.Fatal("UploadAsset() error = nil, want error") } } func TestUploadAssetInvalidFilename(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Error("handler must not be called") }) if _, _, err := client.UploadAsset(context.Background(), "", " ", []byte("png"), ""); err == nil { t.Fatal("UploadAsset() error = nil, want error") } } func TestSanitizeAssetFilename(t *testing.T) { tests := []struct{ in, want string }{ {in: "Report 2025; Final.pdf", want: "report_2025_final.pdf"}, {in: "a b.png", want: "a_b.png"}, {in: " A/B\\C.png ", want: "abc.png"}, {in: "a,;#b.txt", want: "a_b.txt"}, {in: "images/shot.png", want: "imagesshot.png"}, } for _, tt := range tests { if got := sanitizeAssetFilename(tt.in); got != tt.want { t.Errorf("sanitizeAssetFilename(%q) = %q, want %q", tt.in, got, tt.want) } } }