// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, nextTick, type App } from "vue";
import type { PeekedMessage } from "@/types/mq";
const backend = vi.hoisted(() => ({
mqListSubscriptions: vi.fn(),
mqEnrichSubscriptions: vi.fn(),
mqCreateSubscription: vi.fn(),
mqDeleteSubscription: vi.fn(),
mqResetCursor: vi.fn(),
mqSkipMessages: vi.fn(),
mqClearBacklog: vi.fn(),
mqPeekMessages: vi.fn(),
mqExpireMessages: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => backend);
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: { template: "
" },
}));
vi.mock("@/components/mq/rocketmq/RocketMqConsumerGroupDialogs.vue", () => ({
default: { template: "" },
}));
import SubscriptionsPanel from "@/components/mq/SubscriptionsPanel.vue";
const TOPIC = {
name: "persistent://public/default/events",
shortName: "events",
partitioned: false,
persistent: true,
};
let app: App | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
function buttonByText(container: ParentNode, text: string): HTMLButtonElement {
const button = [...container.querySelectorAll("button")].find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
function buttonWithExactText(container: ParentNode, text: string): HTMLButtonElement {
const button = [...container.querySelectorAll("button")].find((item) => item.textContent?.trim() === text);
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
function deferred() {
let resolve!: (value: T) => void;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function mountPanel(overrides: Record = {}) {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(SubscriptionsPanel, {
connectionId: "mq-1",
topic: TOPIC,
tenant: "_kafka",
namespace: "default",
mqSystemKind: "kafka",
supportsResetCursor: true,
supportsPeekMessages: true,
...overrides,
});
app.mount(root);
await flushUi();
return root;
}
function subscription(name: string, consumerGroupType: string) {
return {
name,
subType: consumerGroupType,
consumerGroupType,
messageModel: "CLUSTERING",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
};
}
beforeEach(() => {
Object.values(backend).forEach((mock) => mock.mockReset());
const rows = [
{
name: "orders-consumer",
subType: "shared",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
},
];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
describe("SubscriptionsPanel message peek", () => {
it("warns when a Kafka peek is incomplete and clears the warning after a complete refresh", async () => {
backend.mqPeekMessages
.mockResolvedValueOnce({
messages: [
{
position: 1,
messageId: "17",
payloadBase64: "",
payloadText: "partial message",
properties: { partition: "0" },
headers: {},
},
],
incomplete: true,
})
.mockResolvedValueOnce({ messages: [], incomplete: false });
const panel = await mountPanel();
buttonByText(panel, "mqSubscriptions.peek").click();
await flushUi();
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "orders-consumer", 5);
expect(panel.querySelector('[data-testid="peek-incomplete"]')?.textContent).toContain("mqMessages.peekIncomplete");
expect(panel.textContent).toContain("partial message");
buttonByText(panel, "mqSubscriptions.refresh").click();
await flushUi();
expect(panel.querySelector('[data-testid="peek-incomplete"]')).toBeNull();
});
it("ignores a stale peek when another subscription is opened", async () => {
const first = deferred<{ messages: PeekedMessage[]; incomplete: boolean }>();
const second = deferred<{ messages: PeekedMessage[]; incomplete: boolean }>();
backend.mqListSubscriptions.mockResolvedValue([
{
name: "orders-consumer",
subType: "shared",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
},
{
name: "payments-consumer",
subType: "shared",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
},
]);
backend.mqPeekMessages.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const panel = await mountPanel();
const peekButtons = [...panel.querySelectorAll("button")].filter((button) => button.textContent?.includes("mqSubscriptions.peek"));
expect(peekButtons).toHaveLength(2);
peekButtons[0].click();
await flushUi();
peekButtons[1].click();
await flushUi();
second.resolve({
messages: [{ position: 1, messageId: "new", payloadBase64: "", payloadText: "payments message", properties: {}, headers: {} }],
incomplete: false,
});
await flushUi();
first.resolve({
messages: [{ position: 1, messageId: "old", payloadBase64: "", payloadText: "orders message", properties: {}, headers: {} }],
incomplete: false,
});
await flushUi();
expect(panel.textContent).toContain("payments message");
expect(panel.textContent).not.toContain("orders message");
});
});
describe("SubscriptionsPanel Kafka absolute offset reset", () => {
async function openAbsoluteReset(panel: HTMLDivElement) {
await vi.waitFor(() => {
expect([...panel.querySelectorAll("button")].some((button) => button.textContent?.includes("mqSubscriptions.resetCursor"))).toBe(true);
});
buttonByText(panel, "mqSubscriptions.resetCursor").click();
await flushUi();
const absolute = panel.querySelector('input[value="partitionOffset"]');
expect(absolute).toBeTruthy();
absolute!.checked = true;
absolute!.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => {
expect(panel.querySelector('[data-testid="reset-partition"]')).toBeTruthy();
});
}
it("shows Kafka-only partition fields and dispatches one exact absolute offset", async () => {
backend.mqResetCursor.mockResolvedValue(undefined);
const panel = await mountPanel();
await openAbsoluteReset(panel);
const partition = panel.querySelector('[data-testid="reset-partition"]');
const offset = panel.querySelector('[data-testid="reset-offset"]');
expect(partition).toBeTruthy();
expect(offset).toBeTruthy();
partition!.value = "1";
partition!.dispatchEvent(new Event("input", { bubbles: true }));
offset!.value = "42";
offset!.dispatchEvent(new Event("input", { bubbles: true }));
buttonWithExactText(panel, "mqSubscriptions.reset").click();
await flushUi();
expect(backend.mqResetCursor).toHaveBeenCalledWith("mq-1", expect.objectContaining({ topic: "events" }), "orders-consumer", { kind: "partitionOffset", partition: 1, offset: 42 });
});
it.each([
["partition", "-1"],
["partition", "1.5"],
["offset", "-1"],
["offset", "42.5"],
["offset", "9007199254740992"],
])("rejects invalid %s value %s", async (field, value) => {
const panel = await mountPanel();
await openAbsoluteReset(panel);
const input = panel.querySelector(`[data-testid="reset-${field}"]`)!;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
buttonWithExactText(panel, "mqSubscriptions.reset").click();
await vi.waitFor(() => {
expect(panel.textContent).toContain("mqSubscriptions.nonNegativeIntegerRequired");
});
expect(backend.mqResetCursor).not.toHaveBeenCalled();
});
it("hides the absolute offset option outside Kafka", async () => {
const pulsarPanel = await mountPanel({ mqSystemKind: "pulsar", tenant: "public" });
await vi.waitFor(() => {
expect([...pulsarPanel.querySelectorAll("button")].some((button) => button.textContent?.includes("mqSubscriptions.resetCursor"))).toBe(true);
});
buttonByText(pulsarPanel, "mqSubscriptions.resetCursor").click();
await flushUi();
expect(pulsarPanel.querySelector('input[value="partitionOffset"]')).toBeNull();
});
});
describe("SubscriptionsPanel RocketMQ online members", () => {
it("shows '-' when onlineMembers is unknown before enrich completes", async () => {
const enrich = deferred[]>();
const rows = [subscription("orders-group", "NORMAL")];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockReturnValueOnce(enrich.promise);
const panel = await mountPanel({
topic: undefined,
tenant: "_rocketmq",
namespace: "default",
mqSystemKind: "rocketmq",
supportsPeekMessages: false,
});
// Fast list paints before enrich; unknown member count must not look like healthy zero.
const membersCell = () => panel.querySelector('[data-testid="online-members"]')?.textContent?.trim();
expect(membersCell()).toBe("-");
enrich.resolve([{ ...rows[0], onlineMembers: 2, topics: ["orders"] }]);
await flushUi();
expect(membersCell()).toBe("2");
});
});
describe("SubscriptionsPanel RocketMQ filter count", () => {
it("updates the visible/total count when type filters or search change", async () => {
const rows = [subscription("orders-group", "NORMAL"), subscription("payments-fifo", "FIFO"), subscription("CID_SYS_GROUP", "SYSTEM"), subscription("orders-retry", "NORMAL")];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
const panel = await mountPanel({
topic: undefined,
tenant: "_rocketmq",
namespace: "default",
mqSystemKind: "rocketmq",
supportsPeekMessages: false,
});
const count = () => panel.querySelector('[data-testid="subscription-count"]')?.textContent?.replace(/\s+/g, " ").trim();
// Default: SYSTEM off → 3 type-filtered groups.
expect(count()).toBe("3 / 3");
const systemCheckbox = [...panel.querySelectorAll('input[type="checkbox"]')].find((input) => input.closest("label")?.textContent?.toLowerCase().includes("system"));
expect(systemCheckbox).toBeTruthy();
expect(systemCheckbox!.checked).toBe(false);
systemCheckbox!.click();
await flushUi();
expect(count()).toBe("4 / 4");
const search = panel.querySelector("input.topic-search");
expect(search).toBeTruthy();
search!.focus();
search!.value = "orders";
search!.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
expect(count()).toBe("2 / 4");
});
});