Compare commits

...

1 Commits

Author SHA1 Message Date
f88804f183 test(platform/handlers): add comprehensive instructions_test.go — 36 cases
Covers all CRUD endpoints + resolve:
- List: empty, workspace filter, scope filter, no params, DB error
- Create: success, missing scope/title/content, invalid scope, workspace scope edge cases, content/title length limits, DB error
- Update: success, all fields, not found, length limits, exec error, empty body (WithArgs ordering fix)
- Delete: success, not found, exec error
- Resolve: empty, workspace only, global only, both scopes, scope transition (consecutive globals share header), query error, missing workspace ID, scan error continues
- Scan helper: scan error continues

Branch: feat/instructions-test-coverage (PR #679)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 08:29:49 +00:00

View 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)
}
}