Compare commits
1 Commits
main
...
feat/instr
| Author | SHA1 | Date | |
|---|---|---|---|
| f88804f183 |
991
workspace-server/internal/handlers/instructions_test.go
Normal file
991
workspace-server/internal/handlers/instructions_test.go
Normal file
@ -0,0 +1,991 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newInstructionsHandler() *InstructionsHandler {
|
||||
return NewInstructionsHandler()
|
||||
}
|
||||
|
||||
func instructionsGet(t *testing.T, h *InstructionsHandler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", path, nil)
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/workspaces/"):
|
||||
// Resolve: extract workspace ID from path /workspaces/:id/instructions/resolve
|
||||
c.Params = []gin.Param{{Key: "id", Value: strings.Split(strings.TrimPrefix(path, "/workspaces/"), "/")[0]}}
|
||||
h.Resolve(c)
|
||||
default:
|
||||
// List: /instructions
|
||||
h.List(c)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func instructionsPost(t *testing.T, h *InstructionsHandler, path string, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
b, _ := json.Marshal(body)
|
||||
c.Request = httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
h.Create(c)
|
||||
return w
|
||||
}
|
||||
|
||||
func instructionsPut(t *testing.T, h *InstructionsHandler, path string, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
b, _ := json.Marshal(body)
|
||||
c.Request = httptest.NewRequest("PUT", path, bytes.NewReader(b))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = []gin.Param{{Key: "id", Value: strings.TrimPrefix(path, "/instructions/")}}
|
||||
h.Update(c)
|
||||
return w
|
||||
}
|
||||
|
||||
func instructionsDelete(t *testing.T, h *InstructionsHandler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("DELETE", path, nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: strings.TrimPrefix(path, "/instructions/")}}
|
||||
h.Delete(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsList_Empty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"})
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/instructions")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 instructions, got %d", len(result))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_WithGlobalScope(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
rows := sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"}).
|
||||
AddRow("inst-1", "global", nil, "Be careful with deletions", "Always confirm before deleting.", 10, true, now, now).
|
||||
AddRow("inst-2", "global", nil, "Naming convention", "Use snake_case for files.", 5, true, now, now)
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
|
||||
WithArgs().
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/instructions?scope=global")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2 instructions, got %d", len(result))
|
||||
}
|
||||
if result[0].Scope != "global" {
|
||||
t.Errorf("expected scope 'global', got %q", result[0].Scope)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_WorkspaceFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-uuid-123"
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
rows := sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"}).
|
||||
AddRow("inst-3", "workspace", &wsID, "Workspace-specific rule", "Follow team naming.", 20, true, now, now)
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/instructions?workspace_id="+wsID)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Errorf("expected 1 instruction, got %d", len(result))
|
||||
}
|
||||
if result[0].Scope != "workspace" {
|
||||
t.Errorf("expected scope 'workspace', got %q", result[0].Scope)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := instructionsGet(t, h, "/instructions")
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Test instruction", "Follow this rule.", 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-id"))
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Test instruction",
|
||||
"content": "Follow this rule.",
|
||||
"priority": 5,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["id"] != "new-inst-id" {
|
||||
t.Errorf("expected id 'new-inst-id', got %v", resp["id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingScope(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"title": "Missing scope",
|
||||
"content": "This should fail.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp["error"].(string), "scope") {
|
||||
t.Errorf("expected error about scope, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_InvalidScope(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "team",
|
||||
"title": "Team scope",
|
||||
"content": "Not supported yet.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp["error"].(string), "scope") {
|
||||
t.Errorf("expected error about scope, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeWithoutTarget(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
// workspace scope without scope_target
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"title": "Workspace rule",
|
||||
"content": "Follow workspace policy.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp["error"].(string), "scope_target") {
|
||||
t.Errorf("expected error about scope_target, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeWithEmptyTarget(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
empty := ""
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": empty,
|
||||
"title": "Workspace rule",
|
||||
"content": "Follow workspace policy.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
longContent := strings.Repeat("x", 8193)
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Long content",
|
||||
"content": longContent,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp["error"].(string), "8192") {
|
||||
t.Errorf("expected error about 8192 char limit, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
longTitle := strings.Repeat("x", 201)
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": longTitle,
|
||||
"content": "Short content.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp["error"].(string), "200") {
|
||||
t.Errorf("expected error about 200 char limit, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_InsertError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Insert error",
|
||||
"content": "This will fail.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeWithTarget(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-target-456"
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("workspace", &wsID, "Workspace rule", "Follow this.", 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-ws"))
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": wsID,
|
||||
"title": "Workspace rule",
|
||||
"content": "Follow this.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["id"] != "new-inst-ws" {
|
||||
t.Errorf("expected id 'new-inst-ws', got %v", resp["id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsUpdate_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
// Handler passes (id, title, content, priority, enabled) — 5 params matching $1–$5.
|
||||
// title is non-nil here; content/priority/enabled are nil (COALESCE means no-op).
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs("inst-upd-1", "Updated title", nil, nil, nil).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
newTitle := "Updated title"
|
||||
w := instructionsPut(t, h, "/instructions/inst-upd-1", map[string]interface{}{
|
||||
"title": newTitle,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["status"] != "updated" {
|
||||
t.Errorf("expected status 'updated', got %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
// All fields nil except title — COALESCE(id=nil → uses current, others=no-op).
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs("nonexistent", nil, nil, nil, nil).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := instructionsPut(t, h, "/instructions/nonexistent", map[string]interface{}{
|
||||
"title": "Update nonexistent",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_ContentTooLong(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
longContent := strings.Repeat("x", 8193)
|
||||
w := instructionsPut(t, h, "/instructions/inst-1", map[string]interface{}{
|
||||
"content": longContent,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp["error"].(string), "8192") {
|
||||
t.Errorf("expected error about 8192 limit, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_TitleTooLong(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
longTitle := strings.Repeat("x", 201)
|
||||
w := instructionsPut(t, h, "/instructions/inst-1", map[string]interface{}{
|
||||
"title": longTitle,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_ExecError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "inst-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := instructionsPut(t, h, "/instructions/inst-err", map[string]interface{}{
|
||||
"title": "Error update",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstructionsUpdate_EmptyBody verifies that sending an empty JSON body
|
||||
// to UPDATE results in a no-op update (all fields left unchanged via COALESCE).
|
||||
// The fix in mc#669 corrects WithArgs ordering: args are ($1=id, $2=title,
|
||||
// $3=content, $4=priority, $5=enabled) matching the SQL:
|
||||
// UPDATE ... SET title=COALESCE($2,...), content=COALESCE($3,...), ...
|
||||
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
instID := "inst-empty-update"
|
||||
|
||||
mock.ExpectExec(`UPDATE platform_instructions SET`).
|
||||
WithArgs(instID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := instructionsPut(t, h, "/instructions/"+instID, map[string]interface{}{})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsDelete_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = \$1").
|
||||
WithArgs("inst-del-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := instructionsDelete(t, h, "/instructions/inst-del-1")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["status"] != "deleted" {
|
||||
t.Errorf("expected status 'deleted', got %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = \$1").
|
||||
WithArgs("nonexistent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := instructionsDelete(t, h, "/instructions/nonexistent")
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_ExecError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = \$1").
|
||||
WithArgs("inst-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := instructionsDelete(t, h, "/instructions/inst-err")
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w := instructionsDelete(t, h, "/instructions/inst-del-1")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["status"] != "deleted" {
|
||||
t.Errorf("expected status 'deleted', got %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = \$1").
|
||||
WithArgs("nonexistent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := instructionsDelete(t, h, "/instructions/nonexistent")
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_ExecError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = \$1").
|
||||
WithArgs("inst-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := instructionsDelete(t, h, "/instructions/inst-err")
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Resolve tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsResolve_Empty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-resolve-empty"
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"})
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["workspace_id"] != wsID {
|
||||
t.Errorf("expected workspace_id %q, got %v", wsID, resp["workspace_id"])
|
||||
}
|
||||
if resp["instructions"] != "" {
|
||||
t.Errorf("expected empty instructions, got %q", resp["instructions"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_GlobalOnly(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-global-only"
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Platform rule", "Always review before merging.")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
instr := resp["instructions"].(string)
|
||||
if !strings.Contains(instr, "Platform-Wide Rules") {
|
||||
t.Errorf("expected 'Platform-Wide Rules' header, got: %s", instr)
|
||||
}
|
||||
if !strings.Contains(instr, "Platform rule") {
|
||||
t.Errorf("expected instruction title 'Platform rule', got: %s", instr)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_WorkspaceOnly(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-workspace-only"
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("workspace", "Workspace rule", "Follow workspace conventions.")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
instr := resp["instructions"].(string)
|
||||
if !strings.Contains(instr, "Role-Specific Rules") {
|
||||
t.Errorf("expected 'Role-Specific Rules' header, got: %s", instr)
|
||||
}
|
||||
if !strings.Contains(instr, "Workspace rule") {
|
||||
t.Errorf("expected instruction title 'Workspace rule', got: %s", instr)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_BothScopes(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-both-scopes"
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Platform rule", "Global instruction text.").
|
||||
AddRow("workspace", "Workspace rule", "Workspace instruction text.")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
instr := resp["instructions"].(string)
|
||||
if !strings.Contains(instr, "Platform-Wide Rules") {
|
||||
t.Errorf("expected 'Platform-Wide Rules' header, got: %s", instr)
|
||||
}
|
||||
if !strings.Contains(instr, "Role-Specific Rules") {
|
||||
t.Errorf("expected 'Role-Specific Rules' header, got: %s", instr)
|
||||
}
|
||||
// Global should come before workspace (ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'workspace' THEN 2 END)
|
||||
globalIdx := strings.Index(instr, "Platform-Wide Rules")
|
||||
wsIdx := strings.Index(instr, "Role-Specific Rules")
|
||||
if globalIdx > wsIdx {
|
||||
t.Errorf("global should appear before workspace; got global at %d, workspace at %d", globalIdx, wsIdx)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-resolve-err"
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces//instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_ScanErrorContinues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-scan-err"
|
||||
// Row that can be partially scanned (last col fails)
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
// scope and title scan fine, content is sql.ErrPtrAlign (simulated scan error)
|
||||
RowError(0, sql.ErrConnDone).
|
||||
AddRow("global", "Good title", "Good content")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
// Handler uses "continue" on scan error, so should still return 200
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 even with scan error, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
// Should still return empty rather than crashing
|
||||
if resp["workspace_id"] != wsID {
|
||||
t.Errorf("expected workspace_id %q, got %v", wsID, resp["workspace_id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// scanInstructions edge cases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScanInstructions_ScanErrorContinues(t *testing.T) {
|
||||
// Simulate a scan that errors on the first row but has valid subsequent rows
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
rows := sqlmock.NewRows([]string{"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at"}).
|
||||
RowError(0, sql.ErrConnDone). // first row errors on scan
|
||||
AddRow("id-2", "global", nil, "Title 2", "Content 2", 1, true, now, now).
|
||||
AddRow("id-3", "global", nil, "Title 3", "Content 3", 2, true, now, now)
|
||||
|
||||
result := scanInstructions(rows)
|
||||
// Handler skips rows with scan errors and continues
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2 results (scan error skipped), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Content edge cases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_ContentExactly8192(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
// Exactly at the limit — should succeed
|
||||
content8192 := strings.Repeat("x", 8192)
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Exact limit", content8192, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("exact-limit-id"))
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Exact limit",
|
||||
"content": content8192,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201 for content at 8192 limit, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_TitleExactly200(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
// Exactly at the title limit — should succeed
|
||||
title200 := strings.Repeat("x", 200)
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, title200, "Short content.", 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("title-200-id"))
|
||||
|
||||
w := instructionsPost(t, h, "/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": title200,
|
||||
"content": "Short content.",
|
||||
})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201 for title at 200 limit, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_AllFields(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
// Handler passes (id, title, content, priority, enabled) in order matching $1–$5.
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs("inst-all-1", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
newTitle := "New title"
|
||||
newContent := "New content"
|
||||
newPriority := 99
|
||||
newEnabled := false
|
||||
w := instructionsPut(t, h, "/instructions/inst-all-1", map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"content": newContent,
|
||||
"priority": newPriority,
|
||||
"enabled": newEnabled,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp["status"] != "updated" {
|
||||
t.Errorf("expected status 'updated', got %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_MultiplePerScope(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := newInstructionsHandler()
|
||||
|
||||
wsID := "ws-multi-per-scope"
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Rule A", "Global A.").
|
||||
AddRow("global", "Rule B", "Global B.").
|
||||
AddRow("workspace", "WS Rule 1", "Workspace 1.").
|
||||
AddRow("workspace", "WS Rule 2", "Workspace 2.")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := instructionsGet(t, h, "/workspaces/"+wsID+"/instructions/resolve")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
instr := resp["instructions"].(string)
|
||||
// Within a scope, multiple instructions appear
|
||||
if !strings.Contains(instr, "Rule A") || !strings.Contains(instr, "Rule B") {
|
||||
t.Errorf("expected both Rule A and Rule B in instructions, got: %s", instr)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user