gitlink-cli/shortcuts/wiki/wiki_test.go

664 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package wiki
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func resetProjectIDCache() {
projectIDCache = sync.Map{}
}
func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("writeJSON: %v", err)
}
}
// ---- unwrapGatewayResponse tests ----
func TestUnwrapGatewayResponse_Success(t *testing.T) {
env := output.SuccessEnvelope(map[string]interface{}{
"code": float64(200),
"data": map[string]interface{}{"id": float64(1)},
}, nil)
result, err := unwrapGatewayResponse(env)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, ok := result.Data.(map[string]interface{})
if !ok {
t.Fatal("expected map data")
}
if data["id"] != float64(1) {
t.Fatalf("got %v, want 1", data["id"])
}
}
func TestUnwrapGatewayResponse_Code201(t *testing.T) {
env := output.SuccessEnvelope(map[string]interface{}{
"code": float64(201),
"data": "ok",
}, nil)
result, err := unwrapGatewayResponse(env)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Data != "ok" {
t.Fatalf("got %v, want ok", result.Data)
}
}
func TestUnwrapGatewayResponse_BusinessError(t *testing.T) {
env := output.SuccessEnvelope(map[string]interface{}{
"code": float64(500),
"msg": "内部错误",
}, nil)
_, err := unwrapGatewayResponse(env)
if err == nil {
t.Fatal("expected error, got nil")
}
// 必须是 *clierrors.CLIError否则 TryPrintError 无法按 envelope 输出
var cliErr *clierrors.CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err)
}
if cliErr.Kind != clierrors.KindServer {
t.Errorf("Kind = %q, want %q", cliErr.Kind, clierrors.KindServer)
}
if cliErr.Message != "内部错误" {
t.Errorf("Message = %q, want %q", cliErr.Message, "内部错误")
}
}
func TestUnwrapGatewayResponse_NoDataField(t *testing.T) {
env := output.SuccessEnvelope(map[string]interface{}{
"code": float64(200),
}, nil)
result, err := unwrapGatewayResponse(env)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should return original envelope since no "data" field to extract
if result != env {
t.Fatal("expected original envelope when no data field")
}
}
func TestUnwrapGatewayResponse_NonMapData(t *testing.T) {
env := output.SuccessEnvelope("plain text", nil)
result, err := unwrapGatewayResponse(env)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Data != "plain text" {
t.Fatalf("got %v, want plain text", result.Data)
}
}
// ---- resolveProjectID tests ----
func TestResolveProjectID_Success(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/owner1/repo1/detail.json" {
writeJSON(t, w, map[string]interface{}{"project_id": float64(123)})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner1",
Repo: "repo1",
}
pid, err := resolveProjectID(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pid != "123" {
t.Fatalf("got %q, want %q", pid, "123")
}
}
func TestResolveProjectID_Float64(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/owner2/repo2/detail.json" {
writeJSON(t, w, map[string]interface{}{"project_id": float64(456)})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner2",
Repo: "repo2",
}
pid, err := resolveProjectID(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pid != "456" {
t.Fatalf("got %q, want %q", pid, "456")
}
}
func TestResolveProjectID_CacheHit(t *testing.T) {
resetProjectIDCache()
callCount := 0
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
if r.URL.Path == "/owner3/repo3/detail.json" {
writeJSON(t, w, map[string]interface{}{"project_id": float64(789)})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner3",
Repo: "repo3",
}
pid1, err := resolveProjectID(ctx)
if err != nil {
t.Fatalf("first call: %v", err)
}
if pid1 != "789" {
t.Fatalf("first call: got %q, want %q", pid1, "789")
}
pid2, err := resolveProjectID(ctx)
if err != nil {
t.Fatalf("second call: %v", err)
}
if pid2 != "789" {
t.Fatalf("second call: got %q, want %q", pid2, "789")
}
if callCount != 1 {
t.Fatalf("API called %d times, want 1 (cache miss)", callCount)
}
}
func TestResolveProjectID_MissingProjectID(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{"name": "no-project-id"})
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner4",
Repo: "repo4",
}
_, err := resolveProjectID(ctx)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestResolveProjectID_APIError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner5",
Repo: "repo5",
}
_, err := resolveProjectID(ctx)
if err == nil {
t.Fatal("expected error, got nil")
}
}
// ---- callWikiAPI HTTP request path tests ----
func TestCallWikiAPI_Success(t *testing.T) {
resetProjectIDCache()
var receivedPath, receivedMethod string
var receivedBody []byte
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
receivedMethod = r.Method
if r.Body != nil {
buf := make([]byte, 1024)
n, _ := r.Body.Read(buf)
receivedBody = buf[:n]
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedMethod != "POST" {
t.Errorf("method = %q, want POST", receivedMethod)
}
if receivedPath != "/wiki/open/test" {
t.Errorf("path = %q, want /wiki/open/test", receivedPath)
}
if !strings.Contains(string(receivedBody), `"foo"`) {
t.Errorf("body should contain foo: %s", string(receivedBody))
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map data, got %T", env.Data)
}
if data["id"] != float64(42) {
t.Errorf("data[id] = %v, want 42", data["id"])
}
}
func TestCallWikiAPI_BusinessError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
// 必须是 *clierrors.CLIErrorcode=400 对应 KindForbidden401/403或 KindServer
// (其他非 200/404 错误),这里 400 走 KindServer 分支
var cliErr *clierrors.CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err)
}
if cliErr.Message != "bad request" {
t.Errorf("Message = %q, want %q", cliErr.Message, "bad request")
}
if cliErr.Suggestion == "" {
t.Errorf("Suggestion should not be empty (helps user recover)")
}
}
func TestCallWikiAPI_GatewayHTTPError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`upstream error`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error on 502")
}
}
func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) {
// Verifies the SkipJSONSuffix path is correctly taken for gateway:
// the URL should NOT have a .json appended.
resetProjectIDCache()
var receivedPath string
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
w.Write([]byte(`{"code":200,"data":{}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedPath != "/wiki/open/list" {
t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath)
}
if strings.HasSuffix(receivedPath, ".json") {
t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath)
}
}
func TestCallWikiAPI_ConnectionRefused(t *testing.T) {
resetProjectIDCache()
// Use an unbound port to simulate connection failure
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"},
GatewayBaseURL: "http://127.0.0.1:1",
GatewayHTTPClient: &http.Client{},
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected connection error")
}
}
// ---- runLint / check rules tests ----
func TestIsCheckEnabled_Empty(t *testing.T) {
if !isCheckEnabled("", "any") {
t.Error("empty filter should enable all checks")
}
if !isCheckEnabled("empty,headings", "empty") {
t.Error("should enable 'empty' in filter list")
}
if isCheckEnabled("headings", "empty") {
t.Error("should not enable 'empty' when not in filter list")
}
if !isCheckEnabled(" empty , headings ", "empty") {
t.Error("should trim whitespace")
}
}
func TestCheckEmpty(t *testing.T) {
issues := checkEmpty("p1", "")
if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" {
t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues)
}
if issues := checkEmpty("p1", "some content"); issues != nil {
t.Errorf("non-empty content should not produce issues, got %+v", issues)
}
if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 {
t.Errorf("whitespace-only content should be empty, got %+v", issues)
}
}
func TestCheckHeading(t *testing.T) {
// missing H1
if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 {
t.Errorf("expected 1 missing-heading issue, got %+v", issues)
}
// has H1
if issues := checkHeading("p1", "# Title\nbody"); issues != nil {
t.Errorf("H1 should not produce issues, got %+v", issues)
}
// empty content skipped
if issues := checkHeading("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
// whitespace prefix
if issues := checkHeading("p1", " \n# Real Title"); issues != nil {
t.Errorf("H1 after whitespace should not produce issues, got %+v", issues)
}
}
func TestCheckShort(t *testing.T) {
if issues := checkShort("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
if issues := checkShort("p1", "short"); len(issues) != 1 {
t.Errorf("expected 1 short issue, got %+v", issues)
}
long := strings.Repeat("a", 100)
if issues := checkShort("p1", long); issues != nil {
t.Errorf("long content should not produce issues, got %+v", issues)
}
// exactly 49 chars triggers
if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 {
t.Errorf("49-char content should be 'short', got %+v", issues)
}
}
func TestCheckDeadLinks(t *testing.T) {
known := map[string]bool{"Home": true, "Guide": true}
// All known: no issues
if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil {
t.Errorf("all-known should not produce issues, got %+v", issues)
}
// Unknown link
issues := checkDeadLinks("p1", "[Unknown](Unknown)", known)
if len(issues) != 1 || issues[0].Check != "links" {
t.Errorf("expected 1 dead link issue, got %+v", issues)
}
// External links skipped
if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil {
t.Errorf("external links should be skipped, got %+v", issues)
}
// Anchor links skipped
if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil {
t.Errorf("anchor links should be skipped, got %+v", issues)
}
// Mixed
issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known)
if len(issues) != 1 {
t.Errorf("expected 1 dead link in mixed, got %+v", issues)
}
// Empty content
if issues := checkDeadLinks("p1", "", known); issues != nil {
t.Errorf("empty content should not produce issues, got %+v", issues)
}
}
func TestCheckImages(t *testing.T) {
// Mock image server
imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "HEAD" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
})
defer imgServer.Close()
brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
defer brokenServer.Close()
httpClient := imgServer.Client()
// Valid image (200)
if issues := checkImages("p1", "![ok]("+imgServer.URL+"/img.png)", httpClient); issues != nil {
t.Errorf("200 image should not produce issues, got %+v", issues)
}
// Broken image (404)
issues := checkImages("p1", "![bad]("+brokenServer.URL+"/missing.png)", httpClient)
if len(issues) != 1 {
t.Errorf("expected 1 broken image issue, got %+v", issues)
}
// No images
if issues := checkImages("p1", "no images here", httpClient); issues != nil {
t.Errorf("no images should not produce issues, got %+v", issues)
}
// Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse)
if issues := checkImages("p1", "![bad](http://[)", httpClient); len(issues) != 1 {
t.Errorf("expected 1 invalid-URL issue, got %+v", issues)
}
}
func TestRunLint_Integration(t *testing.T) {
resetProjectIDCache()
// Mock main API (project detail) and gateway (wiki list + get)
mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/detail.json") {
writeJSON(t, w, map[string]interface{}{"project_id": float64(999)})
return
}
t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path)
})
defer mainServer.Close()
// Page content (base64 encoded)
goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3)))
wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/wiki/open/wikiPages":
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": []map[string]interface{}{
{"title": "Good", "sub_url": "Good"},
{"title": "Empty", "sub_url": "Empty"},
{"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped
},
})
case "/wiki/open/getWiki":
pageName := r.URL.Query().Get("pageName")
var content string
if pageName == "Empty" {
content = "" // empty page
} else {
content = goodContent
}
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": map[string]interface{}{"content_base64": content},
})
default:
t.Errorf("unexpected wiki path: %s", r.URL.Path)
}
})
defer wikiServer.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL},
Owner: "owner1",
Repo: "repo1",
Format: "json",
GatewayBaseURL: wikiServer.URL,
GatewayHTTPClient: wikiServer.Client(),
}
if err := runLint(ctx); err != nil {
t.Fatalf("runLint: %v", err)
}
// _Sidebar is skipped, so TotalPages=2 (Good, Empty)
// Empty page produces 1 "empty" error
// We can't directly inspect the output envelope, but if no error, the function ran end-to-end
}
// ---- resolveContent tests ----
func TestResolveContent_FromArg(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"content": "inline content"},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "inline content" {
t.Errorf("got %q, want %q", got, "inline content")
}
}
func TestResolveContent_FromFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
want := "# Title\n\nBody content from file"
if err := os.WriteFile(path, []byte(want), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{"file": path},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestResolveContent_ArgTakesPrecedence(t *testing.T) {
// When both --content and --file are set, --content wins
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
if err := os.WriteFile(path, []byte("from file"), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{
"content": "from arg",
"file": path,
},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "from arg" {
t.Errorf("arg should take precedence; got %q", got)
}
}
func TestResolveContent_Missing(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error when neither --content nor --file is provided")
}
if !strings.Contains(err.Error(), "required") {
t.Errorf("err = %q, want to mention 'required'", err.Error())
}
}
func TestResolveContent_FileNotFound(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}