package v1 import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strconv" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) func newCaptainSkillHandlerTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *model.CaptainAssistant) { t.Helper() gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file:"+filepath.Join(t.TempDir(), "captain-skill-handler.db")+"?_busy_timeout=5000&_journal_mode=WAL"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(2) t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) require.NoError(t, db.AutoMigrate(&model.CaptainAssistant{}, &model.CaptainSkill{}, &model.CaptainSkillReference{}, &model.CaptainAssistantSkill{})) assistant := &model.CaptainAssistant{AccountID: 1, Name: "Assistant", Status: model.AssistantStatusActive} require.NoError(t, db.Create(assistant).Error) handler := NewCaptainSkillHandler(service.NewCaptainSkillService(repository.NewCaptainSkillRepo(db))) router := gin.New() router.Use(func(c *gin.Context) { role := c.GetHeader("X-Test-Role") if role == "" { role = "administrator" } c.Set("policy_context", auth.NewPolicyContext(1, 1, role, 0, nil)) c.Next() }) captain := router.Group("/api/v1/accounts/:account_id/captain", middleware.RoleCheckAny("administrator", "super_admin")) skills := captain.Group("/skills") skills.GET("", handler.List) skills.POST("", handler.Create) skills.GET("/:skill_id", handler.Get) skills.PUT("/:skill_id", handler.Update) skills.DELETE("/:skill_id", handler.Delete) assistants := captain.Group("/assistants/:assistant_id/skills") assistants.POST("/:skill_id", handler.Bind) assistants.DELETE("/:skill_id", handler.Unbind) return router, db, assistant } func captainSkillRequest(t *testing.T, router http.Handler, method, path, role string, body any) *httptest.ResponseRecorder { t.Helper() var raw []byte if body != nil { raw, _ = json.Marshal(body) } req := httptest.NewRequest(method, path, bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") if role != "" { req.Header.Set("X-Test-Role", role) } w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } func captainSkillBody(status string) map[string]any { return map[string]any{"skill": map[string]any{ "name": "refund-policy", "description": "Refund rules", "instructions_md": "Use the policy.", "status": status, "references": []map[string]any{{"reference_key": "standard-policy", "content_md": "Standard terms."}}, }} } func TestCaptainSkillHandlerManagementContract(t *testing.T) { router, _, assistant := newCaptainSkillHandlerTestRouter(t) base := "/api/v1/accounts/1/captain" w := captainSkillRequest(t, router, http.MethodPost, base+"/skills", "agent", captainSkillBody("active")) assert.Equal(t, http.StatusForbidden, w.Code) w = captainSkillRequest(t, router, http.MethodPost, base+"/skills", "super_admin", captainSkillBody("active")) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var created map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) skillID := strconv.Itoa(int(created["id"].(float64))) assert.Equal(t, "active", created["status"]) require.Len(t, created["references"], 1) w = captainSkillRequest(t, router, http.MethodPost, base+"/assistants/"+strconv.Itoa(int(assistant.ID))+"/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) w = captainSkillRequest(t, router, http.MethodGet, base+"/skills?assistant_id="+strconv.Itoa(int(assistant.ID)), "administrator", nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var listed map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listed)) payload := listed["payload"].([]any) require.Len(t, payload, 1) assert.Equal(t, true, payload[0].(map[string]any)["bound"]) assert.NotContains(t, payload[0].(map[string]any), "instructions_md") w = captainSkillRequest(t, router, http.MethodGet, base+"/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusOK, w.Code) updatedBody := captainSkillBody("active") updatedBody["skill"].(map[string]any)["description"] = "Updated refund rules" updatedBody["skill"].(map[string]any)["expected_version"] = 1 w = captainSkillRequest(t, router, http.MethodPut, base+"/skills/"+skillID, "administrator", updatedBody) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var updated map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) assert.Equal(t, float64(2), updated["version"]) updatedBody["skill"].(map[string]any)["description"] = "Stale update" w = captainSkillRequest(t, router, http.MethodPut, base+"/skills/"+skillID, "administrator", updatedBody) assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) w = captainSkillRequest(t, router, http.MethodDelete, base+"/assistants/"+strconv.Itoa(int(assistant.ID))+"/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusNoContent, w.Code) w = captainSkillRequest(t, router, http.MethodGet, "/api/v1/accounts/2/captain/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusNotFound, w.Code) w = captainSkillRequest(t, router, http.MethodPost, base+"/assistants/999/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusNotFound, w.Code) } func TestCaptainSkillHandlerValidationAndConflictStatuses(t *testing.T) { router, _, assistant := newCaptainSkillHandlerTestRouter(t) base := "/api/v1/accounts/1/captain" body := captainSkillBody("active") body["skill"].(map[string]any)["references"] = []map[string]any{{"reference_key": "../unsafe", "content_md": "x"}} w := captainSkillRequest(t, router, http.MethodPost, base+"/skills", "administrator", body) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) w = captainSkillRequest(t, router, http.MethodGet, base+"/skills?assistant_id=bad", "administrator", nil) assert.Equal(t, http.StatusBadRequest, w.Code) w = captainSkillRequest(t, router, http.MethodPost, base+"/skills", "administrator", captainSkillBody("draft")) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var created map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) skillID := strconv.Itoa(int(created["id"].(float64))) w = captainSkillRequest(t, router, http.MethodPost, base+"/assistants/"+strconv.Itoa(int(assistant.ID))+"/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusConflict, w.Code) w = captainSkillRequest(t, router, http.MethodDelete, base+"/skills/"+skillID, "administrator", nil) assert.Equal(t, http.StatusNoContent, w.Code) } func TestCaptainSkillHandlerConcurrentUpdateCASConflict(t *testing.T) { router, db, _ := newCaptainSkillHandlerTestRouter(t) base := "/api/v1/accounts/1/captain/skills" w := captainSkillRequest(t, router, http.MethodPost, base, "administrator", captainSkillBody("active")) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var created map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) ready := make(chan struct{}, 2) release := make(chan struct{}) rowsAffected := make(chan int64, 2) barrierName := "test:captain_skill_handler_cas_barrier" rowsName := "test:captain_skill_handler_cas_rows" require.NoError(t, db.Callback().Update().Before("gorm:update").Register(barrierName, func(tx *gorm.DB) { if tx.Statement.Table == "captain_skills" { ready <- struct{}{} <-release } })) require.NoError(t, db.Callback().Update().After("gorm:update").Register(rowsName, func(tx *gorm.DB) { if tx.Statement.Table == "captain_skills" { rowsAffected <- tx.RowsAffected } })) defer func() { require.NoError(t, db.Callback().Update().Remove(barrierName)) require.NoError(t, db.Callback().Update().Remove(rowsName)) }() responses := make(chan *httptest.ResponseRecorder, 2) for i := range 2 { body := captainSkillBody("active") body["skill"].(map[string]any)["description"] = "Concurrent update " + strconv.Itoa(i) body["skill"].(map[string]any)["expected_version"] = created["version"] go func() { responses <- captainSkillRequest(t, router, http.MethodPut, base+"/"+strconv.Itoa(int(created["id"].(float64))), "administrator", body) }() } for range 2 { select { case <-ready: case <-time.After(5 * time.Second): close(release) t.Fatal("both HTTP updates did not reach the repository CAS") } } close(release) statuses := make([]int, 0, 2) for range 2 { response := <-responses statuses = append(statuses, response.Code) if response.Code == http.StatusConflict { assert.Contains(t, response.Body.String(), repository.ErrCaptainSkillVersionConflict.Error()) } } assert.ElementsMatch(t, []int{http.StatusOK, http.StatusConflict}, statuses) assert.ElementsMatch(t, []int64{1, 0}, []int64{<-rowsAffected, <-rowsAffected}) }