mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(issue-status): board fetches by category; archive retires without migration (MUL-6243) Product decisions from the issue thread: 1. Board fetches by CATEGORY, not per status. PAGINATED_STATUSES becomes PAGINATED_CATEGORIES and the cache buckets by category, so the board's fan-out stays fixed at 7 parallel requests however many custom statuses a workspace defines — instead of one more request per status added. Adds status_category / status_categories filters to the issue list endpoints, backed by the existing issue_effective_status() SQL function. 3. Archiving retires a status from FUTURE use only. It no longer refuses while issues still carry it: those issues keep their status and keep resolving to their category's behavior (Effective ignores archived_at), while Resolve still rejects the status so nothing new can be assigned to it. The exclusive catalog lock stays — it is what makes "no NEW assignment after archive" exact rather than approximate. Also lands the frontend catalog foundation: packages/core/issue-statuses with useIssueStatuses(wsId) (React Query, keyed by wsId per the state rules) and a pure buildIssueStatusCatalog for non-React callers, plus IssueStatusCategory as a distinct type from the status key and status_category on issue payloads. NOT included, and deliberately: widening IssueStatus to string. That change forces every Record<IssueStatus, …> presentation lookup in packages/views to resolve a key to its category first, and it is the first task of the follow-up PR. Everything here is the groundwork that makes it mechanical. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): restore the status index, make category authoritative, keep archived resolvable Review round 1 on #7084 (MUL-6243): 1. Category filtering wrapped issue.status in issue_effective_status(), which makes the (workspace_id, status) index unusable — and the board switched to it unconditionally, so every workspace paid for it immediately. Categories are now expanded to concrete status keys (issuestatus.ExpandCategories) and filtered with an indexed status = ANY(...). Measured on a 100k-row table with the production index: seq scan cost 27013 -> index-only scan cost 6.07. A workspace with no custom statuses expands each category to exactly its own key, reproducing the pre-feature query. Archived keys are included, since issues left on them still belong in their category's column. 2. status_category was only filled for built-ins, so a custom-status issue arriving over the websocket was silently dropped by addIssueToBuckets and a cross-category update was skipped by the cache coordinator, drifting off-window totals permanently. List, grouped, get, create, update and batch responses now carry an authoritative category via a per-request Resolver (archived included), and the client invalidates instead of no-oping when a category is unresolvable. 3. The catalog was fetched without archived entries, so after archiving a status the issues still on it lost their name and colour and had their category guessed as todo — contradicting the decision that archiving leaves existing issues untouched. Resolution now includes archived entries; activeStatuses / inCategory expose only assignable ones to pickers. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): category on every payload exit, one resolver per request, no same-category duplication Review round 2 on #7084 (MUL-6243): 1. Category fill was incomplete and N+1. The create WEBSOCKET payload was unfilled — only the creator's HTTP response was, which is too late for the other tabs the event exists to serve. GetIssue, the open_only list, search, the duplicate-conflict response, the table rows, the GitHub PR update and the onboarding shim were also unfilled. Grouped and batch called the single-response helper per row, so each custom-status row hit the catalog separately. Every exit now goes through newStatusCategoryFiller, a request-scoped closure over ONE Resolver: built-in statuses still cost no query, and a page of custom-status rows costs one catalog read rather than one per row. 2. A status KEY change within one category duplicated the card and inflated the total. patchIssueInBuckets compared patch.status (a key) against loc.status (a bucket CATEGORY), so in_review -> a custom human_review took the cross-bucket path, then deleted from and re-inserted into the same bucket from a pre-delete snapshot. It now resolves the destination category first and compares category to category; same-category changes replace in place and leave the total alone. The destination category is resolved from the patch alone — the merged issue inherits the previous status_category, which would have kept a genuinely moved card in its old column. patchNeedsInvalidation lets the loaded branch of the cache coordinator invalidate instead of silently no-oping on a status it cannot resolve. Also drops the unrelated share_link.sql.go churn from an sqlc version difference, per the review nit. The four cache cases were verified to fail against the previous logic (card duplicated, total +1) before the fix was restored. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): normalize status_category on every cache write; one filler per create Review round 3 on #7084 (MUL-6243): Must-fix — a cached entity kept the STALE status_category after a status change. The server now sends a category on every issue, so a cached entity is {status: "todo", status_category: "todo"}; an optimistic patch carries only {status: "done"}, and a bare {...issue, ...patch} moved the card to the done bucket while the entity still claimed todo. A single update self-heals from the server response, but the batch API returns only {updated: n} and does not refetch bucketed lists, so the inconsistency was permanent — and the next off-window count then decremented the wrong bucket, recreating total drift. normalizeStatusPatch now rewrites status_category to match status before any cache write, applied at applyIssueChange's entry (covering the flat, table and detail spreads), in patchIssueInBuckets, and in the children mutation. An unresolvable custom key drops the inherited category rather than keeping it; patchNeedsInvalidation still routes it to a refetch. Test fixtures now carry status_category for built-ins, matching what the server actually sends — without that the regression was invisible. Non-blocking items also addressed: - CreateIssue builds one filler and shares it between the broadcast payload and the HTTP response, so a custom-status create reads the catalog once. - Background events via IssueToMap now resolve an authoritative category (IssueToMapWithCategory) for the task, issue and autopilot publishers. The channel router keeps plain IssueToMap and says why: it always creates with the built-in "todo", where key IS category. The "one resolver serves a whole page" assertion was REMOVED rather than kept: pg_stat counters lag, so a delta around one request passes even with a per-row filler. Verified that directly before dropping it. The comment now states what is and is not pinned instead of implying coverage that does not exist. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): address review nits - Normalize the single-update children optimistic patch too, matching the batch path. It self-healed on onSettled, but it broke the entity/bucket invariant for the duration of the request. - Rename TestListEndpointsCarryStatusCategory: the old name claimed it covered "without per row queries", which the test explicitly does not assert. - Drop the unrelated gofmt churn in empty_claim_cache.go. - Add a background-event assertion: IssueToMap leaves a custom status uncategorized, IssueToMapWithCategory resolves it — the reason the publishers use the latter. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
616 lines
20 KiB
Go
616 lines
20 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func createIssueViewForTest(t *testing.T, body map[string]any) (IssueViewResponse, int, string) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issue-views", body)
|
|
testHandler.CreateIssueView(w, req)
|
|
var view IssueViewResponse
|
|
if w.Code == http.StatusCreated {
|
|
json.NewDecoder(w.Body).Decode(&view)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_view WHERE id = $1`, view.ID)
|
|
})
|
|
}
|
|
return view, w.Code, w.Body.String()
|
|
}
|
|
|
|
func TestCreateAndListIssueView(t *testing.T) {
|
|
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Needs review",
|
|
"scope_type": "workspace",
|
|
"visibility": "workspace",
|
|
"query": map[string]any{"statusFilters": []string{"in_review"}},
|
|
"display": map[string]any{"viewMode": "board"},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("create: expected 201, got %d: %s", code, body)
|
|
}
|
|
if view.Revision != 1 || view.DefinitionVersion != 1 {
|
|
t.Fatalf("expected revision=1 definition_version=1, got %d/%d", view.Revision, view.DefinitionVersion)
|
|
}
|
|
if view.Visibility != "workspace" {
|
|
t.Fatalf("expected workspace visibility, got %s", view.Visibility)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListIssueViews(w, newRequest("GET", "/api/issue-views?scope_type=workspace", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var views []IssueViewResponse
|
|
json.NewDecoder(w.Body).Decode(&views)
|
|
found := false
|
|
for _, v := range views {
|
|
if v.ID == view.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("created view %s missing from list", view.ID)
|
|
}
|
|
}
|
|
|
|
func TestMyIssueViewForcedPrivate(t *testing.T) {
|
|
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "My assigned",
|
|
"scope_type": "my",
|
|
"scope_variant": "assigned",
|
|
"visibility": "workspace", // must be overridden
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
if view.Visibility != "private" {
|
|
t.Fatalf("my view must be forced private, got %s", view.Visibility)
|
|
}
|
|
if view.ScopeVariant == nil || *view.ScopeVariant != "assigned" {
|
|
t.Fatalf("scope_variant not persisted: %+v", view.ScopeVariant)
|
|
}
|
|
}
|
|
|
|
func TestIssueViewPrivateInvisibleToOthers(t *testing.T) {
|
|
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Mine only",
|
|
"scope_type": "workspace",
|
|
"visibility": "private",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
|
|
// A second member of the same workspace must neither list nor fetch it.
|
|
otherID := createSecondWorkspaceMember(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issue-views?scope_type=workspace", nil)
|
|
req.Header.Set("X-User-ID", otherID)
|
|
testHandler.ListIssueViews(w, req)
|
|
var views []IssueViewResponse
|
|
json.NewDecoder(w.Body).Decode(&views)
|
|
for _, v := range views {
|
|
if v.ID == view.ID {
|
|
t.Fatalf("private view leaked into another member's list")
|
|
}
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issue-views/"+view.ID, nil)
|
|
req.Header.Set("X-User-ID", otherID)
|
|
req = withURLParam(req, "id", view.ID)
|
|
testHandler.GetIssueViewByID(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 for foreign private view, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestUpdateIssueViewRevisionConflict(t *testing.T) {
|
|
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Conflict target",
|
|
"scope_type": "workspace",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
|
|
patch := func(rev int32, name string) int {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PATCH", "/api/issue-views/"+view.ID, map[string]any{
|
|
"name": name,
|
|
"expected_revision": rev,
|
|
})
|
|
req = withURLParam(req, "id", view.ID)
|
|
testHandler.UpdateIssueView(w, req)
|
|
return w.Code
|
|
}
|
|
|
|
if got := patch(1, "First rename"); got != http.StatusOK {
|
|
t.Fatalf("first patch: expected 200, got %d", got)
|
|
}
|
|
// Same expected_revision again → the row is at revision 2 now.
|
|
if got := patch(1, "Stale rename"); got != http.StatusConflict {
|
|
t.Fatalf("stale patch: expected 409, got %d", got)
|
|
}
|
|
if got := patch(2, "Second rename"); got != http.StatusOK {
|
|
t.Fatalf("fresh patch: expected 200, got %d", got)
|
|
}
|
|
}
|
|
|
|
// createSecondWorkspaceMember inserts another user + membership in the test
|
|
// workspace and returns the user id.
|
|
func createSecondWorkspaceMember(t *testing.T) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var userID string
|
|
email := fmt.Sprintf("issue-view-other-%[email protected]", t.Name())
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO "user" (email, name) VALUES ($1, 'View Other')
|
|
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING id
|
|
`, email).Scan(&userID); err != nil {
|
|
t.Fatalf("create second user: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')
|
|
ON CONFLICT DO NOTHING
|
|
`, testWorkspaceID, userID); err != nil {
|
|
t.Fatalf("create second membership: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM member WHERE workspace_id = $1 AND user_id = $2`, testWorkspaceID, userID)
|
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID)
|
|
})
|
|
return userID
|
|
}
|
|
|
|
func TestWorkspaceIssueViewVariant(t *testing.T) {
|
|
|
|
// Save from the Agents tab: variant persists.
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Agent work",
|
|
"scope_type": "workspace",
|
|
"scope_variant": "agents",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
if view.ScopeVariant == nil || *view.ScopeVariant != "agents" {
|
|
t.Fatalf("workspace variant not persisted: %+v", view.ScopeVariant)
|
|
}
|
|
|
|
// "all" normalizes to NULL (no restriction).
|
|
view2, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Everything",
|
|
"scope_type": "workspace",
|
|
"scope_variant": "all",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
if view2.ScopeVariant != nil {
|
|
t.Fatalf("'all' must normalize to NULL, got %v", *view2.ScopeVariant)
|
|
}
|
|
|
|
// My-only variants are rejected on workspace views.
|
|
_, code, _ = createIssueViewForTest(t, map[string]any{
|
|
"name": "Bad variant",
|
|
"scope_type": "workspace",
|
|
"scope_variant": "assigned",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for my-variant on workspace view, got %d", code)
|
|
}
|
|
|
|
// Switching the variant via PATCH works; the scope_type stays fixed.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PATCH", "/api/issue-views/"+view.ID, map[string]any{
|
|
"scope_variant": "members",
|
|
"expected_revision": 1,
|
|
})
|
|
req = withURLParam(req, "id", view.ID)
|
|
testHandler.UpdateIssueView(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("patch variant: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var updated IssueViewResponse
|
|
json.NewDecoder(w.Body).Decode(&updated)
|
|
if updated.ScopeVariant == nil || *updated.ScopeVariant != "members" {
|
|
t.Fatalf("variant switch lost: %+v", updated.ScopeVariant)
|
|
}
|
|
}
|
|
|
|
func TestProjectIssueViewVariant(t *testing.T) {
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "variant project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create project: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), req)
|
|
})
|
|
|
|
// Save from the project page's Members tab: variant persists.
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Member work",
|
|
"scope_type": "project",
|
|
"scope_id": project.ID,
|
|
"scope_variant": "members",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
if view.ScopeVariant == nil || *view.ScopeVariant != "members" {
|
|
t.Fatalf("project variant not persisted: %+v", view.ScopeVariant)
|
|
}
|
|
|
|
// "all" normalizes to NULL, same as workspace views.
|
|
view2, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Whole project",
|
|
"scope_type": "project",
|
|
"scope_id": project.ID,
|
|
"scope_variant": "all",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
if view2.ScopeVariant != nil {
|
|
t.Fatalf("'all' must normalize to NULL, got %v", *view2.ScopeVariant)
|
|
}
|
|
|
|
// My-only variants are rejected on project views.
|
|
_, code, _ = createIssueViewForTest(t, map[string]any{
|
|
"name": "Bad variant",
|
|
"scope_type": "project",
|
|
"scope_id": project.ID,
|
|
"scope_variant": "involved",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for my-variant on project view, got %d", code)
|
|
}
|
|
}
|
|
|
|
func TestPinIssueView(t *testing.T) {
|
|
|
|
shared, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Pinnable",
|
|
"scope_type": "workspace",
|
|
"visibility": "workspace",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
private, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Secret",
|
|
"scope_type": "workspace",
|
|
"visibility": "private",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM pinned_item WHERE item_type = 'view'`)
|
|
})
|
|
|
|
pinView := func(userID, viewID string) int {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/pins", map[string]any{
|
|
"item_type": "view",
|
|
"item_id": viewID,
|
|
})
|
|
if userID != "" {
|
|
req.Header.Set("X-User-ID", userID)
|
|
}
|
|
testHandler.CreatePin(w, req)
|
|
return w.Code
|
|
}
|
|
|
|
// Shared view: any member can pin it.
|
|
if got := pinView("", shared.ID); got != http.StatusCreated {
|
|
t.Fatalf("pin shared view: expected 201, got %d", got)
|
|
}
|
|
// Own private view: pinnable by its owner.
|
|
if got := pinView("", private.ID); got != http.StatusCreated {
|
|
t.Fatalf("pin own private view: expected 201, got %d", got)
|
|
}
|
|
// Foreign private view: 404 — a pin must not confirm its existence.
|
|
otherID := createSecondWorkspaceMember(t)
|
|
if got := pinView(otherID, private.ID); got != http.StatusNotFound {
|
|
t.Fatalf("pin foreign private view: expected 404, got %d", got)
|
|
}
|
|
// Unknown type still rejected.
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreatePin(w, newRequest("POST", "/api/pins", map[string]any{
|
|
"item_type": "label",
|
|
"item_id": shared.ID,
|
|
}))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for unknown item_type, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Old clients (built before saved views) classify every non-issue pin as a
|
|
// project pin and permanently auto-unpin it on a 404 — the legacy list
|
|
// contract must therefore never expose view pins without the capability
|
|
// opt-in.
|
|
func TestListPinsHidesViewPinsWithoutOptIn(t *testing.T) {
|
|
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Pinned for compat",
|
|
"scope_type": "workspace",
|
|
"visibility": "workspace",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM pinned_item WHERE item_type = 'view'`)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreatePin(w, newRequest("POST", "/api/pins", map[string]any{
|
|
"item_type": "view",
|
|
"item_id": view.ID,
|
|
}))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("pin view: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
listPins := func(path string) []PinnedItemResponse {
|
|
rec := httptest.NewRecorder()
|
|
testHandler.ListPins(rec, newRequest("GET", path, nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("list pins: expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var pins []PinnedItemResponse
|
|
json.NewDecoder(rec.Body).Decode(&pins)
|
|
return pins
|
|
}
|
|
|
|
// Legacy contract: the view pin must be invisible.
|
|
for _, p := range listPins("/api/pins") {
|
|
if p.ItemType == "view" {
|
|
t.Fatalf("view pin leaked into the legacy list contract")
|
|
}
|
|
}
|
|
// Opted-in contract: the view pin is present.
|
|
found := false
|
|
for _, p := range listPins("/api/pins?include=view") {
|
|
if p.ItemType == "view" && p.ItemID == view.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("view pin missing from the opted-in list")
|
|
}
|
|
}
|
|
|
|
func TestIssueViewWriteLimits(t *testing.T) {
|
|
|
|
// JSON null for a definition blob is rejected up front, not left to
|
|
// become a DB CHECK 500.
|
|
_, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Null query",
|
|
"scope_type": "workspace",
|
|
"query": nil,
|
|
})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("null query: expected 400, got %d: %s", code, body)
|
|
}
|
|
|
|
// Oversized body: a query blob past the byte cap is a 400.
|
|
big := strings.Repeat("x", issueViewBodyMaxBytes)
|
|
_, code, _ = createIssueViewForTest(t, map[string]any{
|
|
"name": "Huge",
|
|
"scope_type": "workspace",
|
|
"query": map[string]any{"blob": big},
|
|
})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("oversized body: expected 400, got %d", code)
|
|
}
|
|
|
|
// Per-owner quota: fill to the cap directly, then one more create fails.
|
|
ctx := context.Background()
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue_view (workspace_id, owner_id, name, scope_type, visibility, definition_version, query, display)
|
|
SELECT $1::uuid, $2::uuid, 'quota filler ' || g, 'workspace', 'private', 1, '{}', '{}'
|
|
FROM generate_series(1, $3::int) AS g
|
|
`, testWorkspaceID, testUserID, issueViewsPerOwnerMax); err != nil {
|
|
t.Fatalf("seed quota rows: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_view WHERE name LIKE 'quota filler %'`)
|
|
})
|
|
_, code, body = createIssueViewForTest(t, map[string]any{
|
|
"name": "One too many",
|
|
"scope_type": "workspace",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("quota: expected 400, got %d: %s", code, body)
|
|
}
|
|
if !strings.Contains(body, "limit") {
|
|
t.Fatalf("quota error should mention the limit, got: %s", body)
|
|
}
|
|
}
|
|
|
|
// Member removal must sweep the departed member's private views and their
|
|
// view-bar preferences in the revoke transaction; shared views stay for the
|
|
// workspace. (The migration comments promise exactly this.)
|
|
func TestRevokeMemberSweepsPrivateViewsAndPreferences(t *testing.T) {
|
|
|
|
ctx := context.Background()
|
|
otherID := createSecondWorkspaceMember(t)
|
|
|
|
seedView := func(name, visibility string) {
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue_view (workspace_id, owner_id, name, scope_type, visibility, definition_version, query, display)
|
|
VALUES ($1, $2, $3, 'workspace', $4, 1, '{}', '{}')
|
|
`, testWorkspaceID, otherID, name, visibility); err != nil {
|
|
t.Fatalf("seed view: %v", err)
|
|
}
|
|
}
|
|
seedView("revoke private", "private")
|
|
seedView("revoke shared", "workspace")
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue_view_preference (workspace_id, user_id, scope_type, scope_id, prefs)
|
|
VALUES ($1, $2, 'workspace', $1, '{"order": []}')
|
|
ON CONFLICT (workspace_id, user_id, scope_type, scope_id) DO UPDATE SET prefs = EXCLUDED.prefs
|
|
`, testWorkspaceID, otherID); err != nil {
|
|
t.Fatalf("seed preference: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO pinned_item (workspace_id, user_id, item_type, item_id, position)
|
|
SELECT $1::uuid, $2::uuid, 'view', id, 1 FROM issue_view WHERE name = 'revoke private'
|
|
`, testWorkspaceID, otherID); err != nil {
|
|
t.Fatalf("seed pin: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM issue_view WHERE name LIKE 'revoke %'`)
|
|
testPool.Exec(ctx, `DELETE FROM issue_view_preference WHERE user_id = $1`, otherID)
|
|
})
|
|
|
|
var memberID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM member WHERE workspace_id = $1 AND user_id = $2
|
|
`, testWorkspaceID, otherID).Scan(&memberID); err != nil {
|
|
t.Fatalf("load member id: %v", err)
|
|
}
|
|
if _, err := testHandler.revokeAndRemoveMember(
|
|
ctx, parseUUID(testWorkspaceID), parseUUID(otherID), parseUUID(memberID), parseUUID(testUserID),
|
|
); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
|
|
var privateCount, sharedCount, prefCount, pinCount int
|
|
testPool.QueryRow(ctx, `SELECT COUNT(*) FROM issue_view WHERE name = 'revoke private'`).Scan(&privateCount)
|
|
testPool.QueryRow(ctx, `SELECT COUNT(*) FROM issue_view WHERE name = 'revoke shared'`).Scan(&sharedCount)
|
|
testPool.QueryRow(ctx, `SELECT COUNT(*) FROM issue_view_preference WHERE user_id = $1`, otherID).Scan(&prefCount)
|
|
if privateCount != 0 {
|
|
t.Fatal("private view survived member removal")
|
|
}
|
|
if sharedCount != 1 {
|
|
t.Fatal("shared view must survive member removal")
|
|
}
|
|
if prefCount != 0 {
|
|
t.Fatal("view-bar preferences survived member removal")
|
|
}
|
|
testPool.QueryRow(ctx, `SELECT COUNT(*) FROM pinned_item WHERE item_type = 'view' AND user_id = $1`, otherID).Scan(&pinCount)
|
|
if pinCount != 0 {
|
|
t.Fatal("pins on the removed member's private views survived")
|
|
}
|
|
}
|
|
|
|
// Deleting a view — directly, or via its project's deletion — must sweep the
|
|
// sidebar pins that point at it: view pins never auto-unpin client-side, so a
|
|
// surviving row would be invisible and unremovable forever.
|
|
func TestDeletingViewsSweepsTheirPins(t *testing.T) {
|
|
|
|
ctx := context.Background()
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM pinned_item WHERE item_type = 'view'`)
|
|
})
|
|
countViewPins := func() int {
|
|
var n int
|
|
testPool.QueryRow(ctx, `SELECT COUNT(*) FROM pinned_item WHERE item_type = 'view'`).Scan(&n)
|
|
return n
|
|
}
|
|
pinView := func(viewID string) {
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreatePin(w, newRequest("POST", "/api/pins", map[string]any{
|
|
"item_type": "view",
|
|
"item_id": viewID,
|
|
}))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("pin view: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 1) Direct handler delete.
|
|
view, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Sweep on delete",
|
|
"scope_type": "workspace",
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
pinView(view.ID)
|
|
w := httptest.NewRecorder()
|
|
req := withURLParam(newRequest("DELETE", "/api/issue-views/"+view.ID, nil), "id", view.ID)
|
|
testHandler.DeleteIssueView(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("delete view: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if n := countViewPins(); n != 0 {
|
|
t.Fatalf("pin survived direct view delete: %d rows", n)
|
|
}
|
|
|
|
// 2) Project deletion cascades through its scoped views.
|
|
w = httptest.NewRecorder()
|
|
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "pin sweep project",
|
|
}))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create project: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&project)
|
|
|
|
projView, code, body := createIssueViewForTest(t, map[string]any{
|
|
"name": "Project pinned view",
|
|
"scope_type": "project",
|
|
"scope_id": project.ID,
|
|
"query": map[string]any{},
|
|
})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", code, body)
|
|
}
|
|
pinView(projView.ID)
|
|
|
|
w = httptest.NewRecorder()
|
|
req = withURLParam(newRequest("DELETE", "/api/projects/"+project.ID, nil), "id", project.ID)
|
|
testHandler.DeleteProject(w, req)
|
|
if w.Code >= 300 {
|
|
t.Fatalf("delete project: got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if n := countViewPins(); n != 0 {
|
|
t.Fatalf("pin survived project deletion: %d rows", n)
|
|
}
|
|
}
|