mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
333 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|