Files
multica/server/internal/handler/issue_view_test.go
Lambda ab624ed611 feat: add saved issue views
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:40:24 +08:00

333 lines
12 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func createIssueViewForTest(
t *testing.T,
userID string,
body map[string]any,
) IssueViewResponse {
t.Helper()
w := httptest.NewRecorder()
testHandler.CreateIssueView(w, newRequestAs(userID, http.MethodPost, "/api/views", body))
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssueView: got %d: %s", w.Code, w.Body.String())
}
var view IssueViewResponse
if err := json.NewDecoder(w.Body).Decode(&view); err != nil {
t.Fatalf("decode created view: %v", err)
}
return view
}
func TestIssueViewsPermissionsDefaultsAndPinCompatibility(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
_, _ = testPool.Exec(ctx, `DELETE FROM issue_view_preference WHERE workspace_id = $1`, testWorkspaceID)
_, _ = testPool.Exec(ctx, `DELETE FROM pinned_item WHERE workspace_id = $1 AND item_type = 'view'`, testWorkspaceID)
_, _ = testPool.Exec(ctx, `DELETE FROM issue_view WHERE workspace_id = $1`, testWorkspaceID)
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue_view_preference WHERE workspace_id = $1`, testWorkspaceID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM pinned_item WHERE workspace_id = $1 AND item_type = 'view'`, testWorkspaceID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue_view WHERE workspace_id = $1`, testWorkspaceID)
})
var memberID string
email := fmt.Sprintf("saved-view-member-%s@multica.test", t.Name())
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email) VALUES ('Saved View Member', $1)
RETURNING id
`, email).Scan(&memberID); err != nil {
t.Fatalf("create member user: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')
`, testWorkspaceID, memberID); err != nil {
t.Fatalf("create member row: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM member WHERE workspace_id = $1 AND user_id = $2`, testWorkspaceID, memberID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, memberID)
})
definition := map[string]any{
"version": 1,
"viewMode": "board",
"statusFilters": []string{"blocked"},
}
privateView := createIssueViewForTest(t, testUserID, map[string]any{
"name": "Owner private",
"icon": "bookmark",
"scope_type": "workspace",
"visibility": "private",
"definition": definition,
})
sharedView := createIssueViewForTest(t, testUserID, map[string]any{
"name": "Workspace shared",
"scope_type": "workspace",
"visibility": "workspace",
"definition": definition,
})
listW := httptest.NewRecorder()
testHandler.ListIssueViews(listW, newRequestAs(memberID, http.MethodGet, "/api/views?scope_type=workspace", nil))
if listW.Code != http.StatusOK {
t.Fatalf("ListIssueViews as member: got %d: %s", listW.Code, listW.Body.String())
}
var list struct {
Views []IssueViewResponse `json:"views"`
}
if err := json.NewDecoder(listW.Body).Decode(&list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list.Views) != 1 || list.Views[0].ID != sharedView.ID || list.Views[0].CanEdit {
t.Fatalf("member list = %+v, want only non-editable shared view", list.Views)
}
privateGetW := httptest.NewRecorder()
privateGetReq := withURLParam(
newRequestAs(memberID, http.MethodGet, "/api/views/"+privateView.ID, nil),
"id", privateView.ID,
)
testHandler.GetIssueView(privateGetW, privateGetReq)
if privateGetW.Code != http.StatusNotFound {
t.Fatalf("member private view read: got %d, want 404", privateGetW.Code)
}
memberUpdateW := httptest.NewRecorder()
memberUpdateReq := withURLParam(
newRequestAs(memberID, http.MethodPatch, "/api/views/"+sharedView.ID, map[string]any{"name": "Nope"}),
"id", sharedView.ID,
)
testHandler.UpdateIssueView(memberUpdateW, memberUpdateReq)
if memberUpdateW.Code != http.StatusForbidden {
t.Fatalf("member shared view update: got %d, want 403", memberUpdateW.Code)
}
clearIconW := httptest.NewRecorder()
clearIconReq := withURLParam(
newRequest(http.MethodPatch, "/api/views/"+privateView.ID, map[string]any{"icon": nil}),
"id", privateView.ID,
)
testHandler.UpdateIssueView(clearIconW, clearIconReq)
if clearIconW.Code != http.StatusOK {
t.Fatalf("clear view icon: got %d: %s", clearIconW.Code, clearIconW.Body.String())
}
var cleared IssueViewResponse
if err := json.NewDecoder(clearIconW.Body).Decode(&cleared); err != nil {
t.Fatalf("decode cleared view: %v", err)
}
if cleared.Icon != nil {
t.Fatalf("cleared icon = %q, want null", *cleared.Icon)
}
invalidMyW := httptest.NewRecorder()
testHandler.CreateIssueView(invalidMyW, newRequest(http.MethodPost, "/api/views", map[string]any{
"name": "Shared My Issues",
"scope_type": "my",
"visibility": "workspace",
"definition": definition,
}))
if invalidMyW.Code != http.StatusBadRequest {
t.Fatalf("shared My Issues view: got %d, want 400", invalidMyW.Code)
}
defaultW := httptest.NewRecorder()
testHandler.SetDefaultIssueView(defaultW, newRequest(http.MethodPut, "/api/views/default", map[string]any{
"scope_type": "workspace",
"view_id": sharedView.ID,
}))
if defaultW.Code != http.StatusNoContent {
t.Fatalf("SetDefaultIssueView: got %d: %s", defaultW.Code, defaultW.Body.String())
}
ownerListW := httptest.NewRecorder()
testHandler.ListIssueViews(ownerListW, newRequest(http.MethodGet, "/api/views?scope_type=workspace", nil))
var ownerList struct {
DefaultViewID *string `json:"default_view_id"`
}
if err := json.NewDecoder(ownerListW.Body).Decode(&ownerList); err != nil {
t.Fatalf("decode owner list: %v", err)
}
if ownerList.DefaultViewID == nil || *ownerList.DefaultViewID != sharedView.ID {
t.Fatalf("default_view_id = %v, want %s", ownerList.DefaultViewID, sharedView.ID)
}
createPinW := httptest.NewRecorder()
testHandler.CreatePin(createPinW, newRequest(http.MethodPost, "/api/pins", map[string]any{
"item_type": "view", "item_id": sharedView.ID,
}))
if createPinW.Code != http.StatusCreated {
t.Fatalf("CreatePin(view): got %d: %s", createPinW.Code, createPinW.Body.String())
}
legacyPinsW := httptest.NewRecorder()
testHandler.ListPins(legacyPinsW, newRequest(http.MethodGet, "/api/pins", nil))
var legacyPins []PinnedItemResponse
if err := json.NewDecoder(legacyPinsW.Body).Decode(&legacyPins); err != nil {
t.Fatalf("decode legacy pins: %v", err)
}
for _, pin := range legacyPins {
if pin.ItemType == "view" {
t.Fatal("legacy client received a view pin")
}
}
capableReq := newRequest(http.MethodGet, "/api/pins", nil)
capableReq.Header.Set("X-Client-Capabilities", protocol.AppCapabilityIssueViewPinsV1)
capablePinsW := httptest.NewRecorder()
testHandler.ListPins(capablePinsW, capableReq)
var capablePins []PinnedItemResponse
if err := json.NewDecoder(capablePinsW.Body).Decode(&capablePins); err != nil {
t.Fatalf("decode capable pins: %v", err)
}
found := false
for _, pin := range capablePins {
found = found || (pin.ItemType == "view" && pin.ItemID == sharedView.ID)
}
if !found {
t.Fatal("capable client did not receive its view pin")
}
duplicateW := httptest.NewRecorder()
duplicateReq := withURLParam(
newRequestAs(memberID, http.MethodPost, "/api/views/"+sharedView.ID+"/duplicate", map[string]any{
"name": "Member private copy", "visibility": "private",
}),
"id", sharedView.ID,
)
testHandler.DuplicateIssueView(duplicateW, duplicateReq)
if duplicateW.Code != http.StatusCreated {
t.Fatalf("DuplicateIssueView: got %d: %s", duplicateW.Code, duplicateW.Body.String())
}
var duplicate IssueViewResponse
if err := json.NewDecoder(duplicateW.Body).Decode(&duplicate); err != nil {
t.Fatalf("decode duplicate: %v", err)
}
if duplicate.CreatorID != memberID || duplicate.Visibility != "private" {
t.Fatalf("duplicate creator/visibility = %s/%s, want %s/private", duplicate.CreatorID, duplicate.Visibility, memberID)
}
deleteW := httptest.NewRecorder()
deleteReq := withURLParam(
newRequest(http.MethodDelete, "/api/views/"+sharedView.ID, nil),
"id", sharedView.ID,
)
testHandler.DeleteIssueView(deleteW, deleteReq)
if deleteW.Code != http.StatusNoContent {
t.Fatalf("DeleteIssueView: got %d: %s", deleteW.Code, deleteW.Body.String())
}
for table, query := range map[string]string{
"view": `SELECT count(*) FROM issue_view WHERE id = $1`,
"preference": `SELECT count(*) FROM issue_view_preference WHERE default_view_id = $1`,
"pin": `SELECT count(*) FROM pinned_item WHERE item_type = 'view' AND item_id = $1`,
} {
var count int
if err := testPool.QueryRow(ctx, query, sharedView.ID).Scan(&count); err != nil {
t.Fatalf("count deleted %s: %v", table, err)
}
if count != 0 {
t.Fatalf("%s remaining after view delete: %d", table, count)
}
}
}
func TestIssueViewDefinitionValidation(t *testing.T) {
cases := []struct {
name string
raw json.RawMessage
}{
{name: "empty", raw: nil},
{name: "array", raw: json.RawMessage(`[]`)},
{name: "missing version", raw: json.RawMessage(`{"viewMode":"board"}`)},
{name: "fractional version", raw: json.RawMessage(`{"version":1.5}`)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := validateIssueViewDefinition(tc.raw); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, err := validateIssueViewDefinition(json.RawMessage(`{"version":1,"viewMode":"board"}`)); err != nil {
t.Fatalf("valid definition rejected: %v", err)
}
}
func TestDeleteProjectCleansSavedViews(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
createProjectW := httptest.NewRecorder()
testHandler.CreateProject(createProjectW, newRequest(http.MethodPost, "/api/projects", map[string]any{
"title": "Saved view cleanup project",
}))
if createProjectW.Code != http.StatusCreated {
t.Fatalf("CreateProject: got %d: %s", createProjectW.Code, createProjectW.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(createProjectW.Body).Decode(&project); err != nil {
t.Fatalf("decode project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, project.ID)
})
view := createIssueViewForTest(t, testUserID, map[string]any{
"name": "Project launch",
"scope_type": "project",
"scope_id": project.ID,
"visibility": "private",
"definition": map[string]any{"version": 1, "viewMode": "board"},
})
defaultW := httptest.NewRecorder()
testHandler.SetDefaultIssueView(defaultW, newRequest(http.MethodPut, "/api/views/default", map[string]any{
"scope_type": "project", "scope_id": project.ID, "view_id": view.ID,
}))
if defaultW.Code != http.StatusNoContent {
t.Fatalf("SetDefaultIssueView: got %d: %s", defaultW.Code, defaultW.Body.String())
}
pinW := httptest.NewRecorder()
testHandler.CreatePin(pinW, newRequest(http.MethodPost, "/api/pins", map[string]any{
"item_type": "view", "item_id": view.ID,
}))
if pinW.Code != http.StatusCreated {
t.Fatalf("CreatePin: got %d: %s", pinW.Code, pinW.Body.String())
}
deleteW := httptest.NewRecorder()
deleteReq := withURLParam(
newRequest(http.MethodDelete, "/api/projects/"+project.ID, nil),
"id", project.ID,
)
testHandler.DeleteProject(deleteW, deleteReq)
if deleteW.Code != http.StatusNoContent {
t.Fatalf("DeleteProject: got %d: %s", deleteW.Code, deleteW.Body.String())
}
for table, query := range map[string]string{
"views": `SELECT count(*) FROM issue_view WHERE id = $1`,
"preferences": `SELECT count(*) FROM issue_view_preference WHERE default_view_id = $1`,
"pins": `SELECT count(*) FROM pinned_item WHERE item_type = 'view' AND item_id = $1`,
} {
var count int
if err := testPool.QueryRow(context.Background(), query, view.ID).Scan(&count); err != nil {
t.Fatalf("count %s: %v", table, err)
}
if count != 0 {
t.Fatalf("%s remaining after project delete: %d", table, count)
}
}
}