From 5f01ad7ff372f9185674ba7d3ff8bedcbf421f4c Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Tue, 12 May 2026 05:41:03 +0000 Subject: [PATCH] =?UTF-8?q?test(platform/handlers):=20add=20instructions?= =?UTF-8?q?=5Ftest.go=20=E2=80=94=20full=20CRUD=20+=20resolve=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers InstructionsHandler (workspace-server/internal/handlers/instructions.go): - List: empty result, global scope filter, workspace_id filter, DB error - Create: success, missing scope, invalid scope (team), workspace without scope_target, empty scope_target, content >8192 chars, title >200 chars, insert error, workspace scope with valid target, exact limit cases - Update: success, not found, content/title too long, exec error, all fields - Delete: success, not found, exec error - Resolve: empty, global only, workspace only, both scopes, global before workspace (ORDER BY guarantee), query error, missing workspace ID, scan error continues (rows.Err covered) - scanInstructions: scan error skips row, returns valid remaining rows - Multiple per-scope instructions with correct scope header ordering sqlmock patterns: \$1 escaping for regex mode (v1.5.2), UPDATE WithArgs ordered to match handler's (id, title, content, priority, enabled) call. Co-Authored-By: Claude Opus 4.7 --- .../internal/handlers/instructions_test.go | 966 ++++++++++++++++++ 1 file changed, 966 insertions(+) create mode 100644 workspace-server/internal/handlers/instructions_test.go diff --git a/workspace-server/internal/handlers/instructions_test.go b/workspace-server/internal/handlers/instructions_test.go new file mode 100644 index 00000000..0a7a3cad --- /dev/null +++ b/workspace-server/internal/handlers/instructions_test.go @@ -0,0 +1,966 @@ +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) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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) + } +}