Files
kandev/apps/backend/internal/mentions/handler_test.go
T
2026-08-08 19:59:25 +01:00

198 lines
6.7 KiB
Go

package mentions
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
apiv1 "github.com/kandev/kandev/pkg/api/v1"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type fakeSearcher struct {
search func(context.Context, SearchRequest) (*apiv1.MentionSearchResponse, error)
}
func TestHandlerRegisterRoutesUsesExistingWorkspaceWildcardName(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/v1/workspaces/:id", func(c *gin.Context) { c.Status(http.StatusNoContent) })
handler := NewHandler(fakeSearcher{search: func(_ context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) {
return &apiv1.MentionSearchResponse{Query: request.Query}, nil
}}, nil)
var recovered any
func() {
defer func() { recovered = recover() }()
handler.RegisterRoutes(router)
}()
if recovered != nil {
t.Fatalf("register mention route alongside workspace routes: %v", recovered)
}
}
func (s fakeSearcher) Search(ctx context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) {
return s.search(ctx, request)
}
func TestHandlerSearch_ReturnsNormalizedResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler(fakeSearcher{search: func(_ context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) {
if request.WorkspaceID != "workspace-1" || request.Query != "auth" || request.Limit != MaxLimit {
t.Fatalf("request = %+v", request)
}
return &apiv1.MentionSearchResponse{
Query: "auth",
Groups: []apiv1.MentionGroup{{
Source: "tasks", Provider: "kandev", Kind: "task",
DisplayName: "Kandev tasks", KindLabel: "Task", Status: StatusOK,
Results: []apiv1.EntityReference{},
}},
}, nil
}}, nil).RegisterRoutes(router)
request := httptest.NewRequest(http.MethodGet,
"/api/v1/workspaces/workspace-1/mentions/search?q=auth&limit=99", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var body apiv1.MentionSearchResponse
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Query != "auth" || len(body.Groups) != 1 || body.Groups[0].Source != "tasks" {
t.Fatalf("body = %#v", body)
}
}
func TestHandlerSearch_RejectsInvalidLimitWithoutSearching(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler(fakeSearcher{search: func(_ context.Context, _ SearchRequest) (*apiv1.MentionSearchResponse, error) {
t.Fatal("search called for invalid limit")
return nil, nil
}}, nil).RegisterRoutes(router)
request := httptest.NewRequest(http.MethodGet,
"/api/v1/workspaces/workspace-1/mentions/search?q=auth&limit=not-a-number", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusBadRequest, response.Body.String())
}
}
func TestHandlerSearch_MapsSafeErrorsWithoutLeaking(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
}{
{name: "invalid request", err: fmt.Errorf("query detail: %w", ErrInvalidRequest), wantStatus: http.StatusBadRequest},
{name: "workspace missing", err: fmt.Errorf("lookup detail: %w", ErrWorkspaceNotFound), wantStatus: http.StatusNotFound},
{name: "internal", err: errors.New("secret database address"), wantStatus: http.StatusInternalServerError},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler(fakeSearcher{search: func(_ context.Context, _ SearchRequest) (*apiv1.MentionSearchResponse, error) {
return nil, test.err
}}, nil).RegisterRoutes(router)
request := httptest.NewRequest(http.MethodGet,
"/api/v1/workspaces/workspace-1/mentions/search?q=auth", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != test.wantStatus {
t.Fatalf("status = %d, want %d; body = %s", response.Code, test.wantStatus, response.Body.String())
}
if strings.Contains(response.Body.String(), test.err.Error()) {
t.Fatalf("response leaked raw error: %s", response.Body.String())
}
})
}
}
func TestHandlerSearch_MapsClientCancellationTo499WithoutLogging(t *testing.T) {
log, observed := newMentionObserverLogger(t)
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler(fakeSearcher{search: func(context.Context, SearchRequest) (*apiv1.MentionSearchResponse, error) {
return nil, context.Canceled
}}, log).RegisterRoutes(router)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(
http.MethodGet,
"/api/v1/workspaces/workspace-1/mentions/search?q=secret-query",
nil,
))
if response.Code != 499 {
t.Fatalf("status = %d, want 499; body = %s", response.Code, response.Body.String())
}
if observed.Len() != 0 {
t.Fatalf("cancellation emitted %d log records, want none", observed.Len())
}
}
func TestHandlerSearchLogsUnexpectedFailureOnceWithSafeFields(t *testing.T) {
log, observed := newMentionObserverLogger(t)
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler(fakeSearcher{search: func(context.Context, SearchRequest) (*apiv1.MentionSearchResponse, error) {
return nil, errors.New("secret database address")
}}, log).RegisterRoutes(router)
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(
http.MethodGet,
"/api/v1/workspaces/workspace-1/mentions/search?q=secret-query",
nil,
))
if response.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", response.Code, http.StatusInternalServerError)
}
entries := observed.All()
if len(entries) != 1 || entries[0].Level != zap.ErrorLevel {
t.Fatalf("unexpected error records = %#v", entries)
}
fields := entries[0].ContextMap()
if fields["workspace_id"] != "workspace-1" || fields["failure_stage"] != "search" ||
fields["failure_class"] != "internal" {
t.Fatalf("safe failure fields = %#v", fields)
}
serialized := entries[0].Message + response.Body.String() + fmt.Sprint(entries[0].ContextMap())
if strings.Contains(serialized, "secret-query") || strings.Contains(serialized, "secret database address") {
t.Fatalf("sensitive mention-search detail leaked: %s", serialized)
}
}
func newMentionObserverLogger(t *testing.T) (*logger.Logger, *observer.ObservedLogs) {
t.Helper()
core, observed := observer.New(zap.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("create observer logger: %v", err)
}
return log, observed
}