mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-15 00:04:58 +02:00
When an agent created a sub-issue while working on a human's behalf, that human received no notifications for it at all. issue_subscriber modelled ACTOR identity, so an agent-created, agent-assigned issue had a full subscriber list and zero members to deliver to. The platform already knew who the work was for (agent_task_queue.originator_user_id, MUL-4302); notification never asked. - attribution.DelegatedSubscriber: one shared rule over the same origin waterfall ClassifyDirect uses. agent_create subscribes the originator as 'delegated'; quick_create keeps the direct 'creator' tier; autopilot and degraded attribution subscribe nobody. - Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus failures and mentions. Routine churn is suppressed, and the parent bubble cannot re-deliver what the tier dropped. - Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later rule passes, and opt_out_scope distinguishes "this issue" from "this subtree" so a narrow opt-out no longer silently suppresses future children. - Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly against an older backend (Go drops unknown fields); an unknown route 404s, which the UI now surfaces with a distinct message. - Eligibility and the write share one statement under a (workspace, user) advisory lock that subtree unsubscribe and member revoke also take, closing the check-then-insert races. Revoke additionally clears the departing member's subscriptions in the same tx. - UI explains a delegated subscription and offers both unsubscribe scopes. Migrations 249/250 add the delegated reason, the opt-out tombstone, and the opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does not scan issue_subscriber under an exclusive lock. Reviewed across eight rounds; an earlier write-time subtree roll-up was built and then removed in full once it proved unfixable without serializing every topology mutation. The parent's own status transition already carries that signal. Closes MUL-5483.
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
import { setApiInstance } from "@multica/core/api";
|
|
import { ApiError } from "@multica/core/api/client";
|
|
import type { ApiClient } from "@multica/core/api/client";
|
|
|
|
const toastError = vi.fn();
|
|
vi.mock("sonner", () => ({ toast: { error: (msg: string) => toastError(msg) } }));
|
|
|
|
// Return the key path so an assertion can tell the two failure messages apart
|
|
// without depending on the English copy.
|
|
vi.mock("../../i18n", () => ({
|
|
useT: () => ({
|
|
t: (sel: (d: Record<string, Record<string, string>>) => string) =>
|
|
sel(
|
|
new Proxy(
|
|
{},
|
|
{
|
|
get: (_t, section: string) =>
|
|
new Proxy(
|
|
{},
|
|
{ get: (_s, key: string) => `${section}.${key}` },
|
|
),
|
|
},
|
|
) as Record<string, Record<string, string>>,
|
|
),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@multica/core/realtime", () => ({
|
|
useWSEvent: () => undefined,
|
|
useWSReconnect: () => undefined,
|
|
}));
|
|
|
|
import { useIssueSubscribers } from "./use-issue-subscribers";
|
|
|
|
function wrapper(queryClient: QueryClient) {
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
};
|
|
}
|
|
|
|
function renderSubscribers() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
return renderHook(() => useIssueSubscribers("issue-1", "user-1"), {
|
|
wrapper: wrapper(queryClient),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subtree unsubscribe is not optimistic — nothing on screen changes when it
|
|
* fails — so without an explicit message a failure is indistinguishable from a
|
|
* dead button. The 404 case specifically means the backend predates the
|
|
* feature (web/desktop staging deploys on merge, backend by hand), which the
|
|
* user can only wait out, so it must not read as a generic error
|
|
* (MUL-5483 review round 7).
|
|
*/
|
|
describe("useIssueSubscribers subtree unsubscribe failures", () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
toastError.mockClear();
|
|
});
|
|
|
|
it("tells the user when the backend has no subtree route yet", async () => {
|
|
setApiInstance({
|
|
listIssueSubscribers: async () => [],
|
|
unsubscribeFromIssueSubtree: async () => {
|
|
// chi answers an unknown route with plain text, so parseErrorBody
|
|
// leaves body undefined. That is the deploy-skew signature.
|
|
throw new ApiError("not found", 404, "Not Found");
|
|
},
|
|
} as unknown as ApiClient);
|
|
|
|
const { result } = renderSubscribers();
|
|
result.current.unsubscribeFromSubtree();
|
|
|
|
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
|
|
expect(toastError).toHaveBeenCalledWith(
|
|
"detail.unsubscribe_subtree_unsupported",
|
|
);
|
|
});
|
|
|
|
it("does not blame deploy skew for a structured 404 from a current backend", async () => {
|
|
setApiInstance({
|
|
listIssueSubscribers: async () => [],
|
|
unsubscribeFromIssueSubtree: async () => {
|
|
// The route exists; the ISSUE is gone or not visible. Telling this user
|
|
// to wait for the next deploy would be wrong advice.
|
|
throw new ApiError("issue not found", 404, "Not Found", {
|
|
error: "issue not found",
|
|
});
|
|
},
|
|
} as unknown as ApiClient);
|
|
|
|
const { result } = renderSubscribers();
|
|
result.current.unsubscribeFromSubtree();
|
|
|
|
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
|
|
expect(toastError).toHaveBeenCalledWith("detail.unsubscribe_subtree_failed");
|
|
});
|
|
|
|
it("falls back to a generic failure for any other error", async () => {
|
|
setApiInstance({
|
|
listIssueSubscribers: async () => [],
|
|
unsubscribeFromIssueSubtree: async () => {
|
|
throw new ApiError("boom", 500, "Internal Server Error");
|
|
},
|
|
} as unknown as ApiClient);
|
|
|
|
const { result } = renderSubscribers();
|
|
result.current.unsubscribeFromSubtree();
|
|
|
|
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
|
|
expect(toastError).toHaveBeenCalledWith("detail.unsubscribe_subtree_failed");
|
|
});
|
|
|
|
it("stays silent when the unsubscribe succeeds", async () => {
|
|
setApiInstance({
|
|
listIssueSubscribers: async () => [],
|
|
unsubscribeFromIssueSubtree: async () => undefined,
|
|
} as unknown as ApiClient);
|
|
|
|
const { result } = renderSubscribers();
|
|
result.current.unsubscribeFromSubtree();
|
|
|
|
await waitFor(() => expect(toastError).not.toHaveBeenCalled());
|
|
});
|
|
});
|