Skip to content

Commit 8366a69

Browse files
committed
test(pusher-web): add unit tests for Pusher, getChannelName, fetchPusherConfig
Cover the previously untested upper layers: widget subscription building logic in Pusher.tsx, channel name derivation, and config fetch error paths.
1 parent 40a5e9b commit 8366a69

5 files changed

Lines changed: 307 additions & 7 deletions

File tree

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,70 @@
1+
import { render } from "@testing-library/react";
2+
import { ActionValue, DynamicValue, ObjectItem } from "mendix";
3+
import { createElement } from "react";
4+
import * as usePusherSubscribeModule from "../hooks/usePusherSubscribe";
5+
import Pusher from "../Pusher";
6+
import * as getChannelNameModule from "../utils/getChannelName";
7+
8+
jest.mock("../utils/getChannelName");
9+
jest.mock("../hooks/usePusherSubscribe", () => ({
10+
usePusherSubscribe: jest.fn()
11+
}));
12+
13+
const mockGetChannelName = getChannelNameModule.getChannelName as jest.MockedFunction<
14+
typeof getChannelNameModule.getChannelName
15+
>;
16+
const mockUsePusherSubscribe = usePusherSubscribeModule.usePusherSubscribe as jest.Mock;
17+
18+
function makeAction(canExecute = true): ActionValue {
19+
return { canExecute, execute: jest.fn(), isExecuting: false } as unknown as ActionValue;
20+
}
21+
22+
function makeProps(channelName: string | undefined, handlers: Array<{ actionName: string; action: ActionValue }>) {
23+
mockGetChannelName.mockReturnValue(channelName);
24+
return {
25+
name: "pusher",
26+
class: "",
27+
objectSource: {} as DynamicValue<ObjectItem>,
28+
eventHandlers: handlers
29+
} as any;
30+
}
31+
132
describe("Pusher", () => {
2-
it("placeholder – tests to be implemented", () => {
3-
// TODO: Add comprehensive unit tests for:
4-
// - PusherListener class (connection, subscription, cleanup)
5-
// - usePusherConfig hook (fetching config)
6-
// - usePusherListener hook (React lifecycle integration)
7-
// - Event handling and action execution
8-
expect(true).toBe(true);
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it("passes undefined subscription when channelName is undefined", () => {
38+
render(createElement(Pusher, makeProps(undefined, [{ actionName: "update", action: makeAction() }])));
39+
40+
expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined);
41+
});
42+
43+
it("passes undefined subscription when eventHandlers is empty", () => {
44+
render(createElement(Pusher, makeProps("private-Entity.123", [])));
45+
46+
expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined);
47+
});
48+
49+
it("passes subscription with correct channelName and eventBindings", () => {
50+
const action = makeAction();
51+
render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }])));
52+
53+
expect(mockUsePusherSubscribe).toHaveBeenCalledWith(
54+
expect.objectContaining({
55+
channelName: "private-Entity.123",
56+
eventBindings: [expect.objectContaining({ eventName: "update" })]
57+
})
58+
);
59+
});
60+
61+
it("calls executeAction when onEvent fires", () => {
62+
const action = makeAction();
63+
render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }])));
64+
65+
const { eventBindings } = mockUsePusherSubscribe.mock.calls[0][0];
66+
eventBindings[0].onEvent();
67+
68+
expect(action.execute).toHaveBeenCalledTimes(1);
969
});
1070
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { PusherListener } from "../utils/PusherListener";
2+
3+
const mockChannel = {
4+
bind_global: jest.fn(),
5+
unbind_all: jest.fn(),
6+
bind: jest.fn()
7+
};
8+
9+
const mockPusherInstance = {
10+
subscribe: jest.fn().mockReturnValue(mockChannel),
11+
unsubscribe: jest.fn(),
12+
disconnect: jest.fn(),
13+
connection: { bind: jest.fn(), unbind: jest.fn() }
14+
};
15+
16+
jest.mock("pusher-js", () => jest.fn().mockImplementation(() => mockPusherInstance));
17+
18+
const stubConfig = { key: "key", cluster: "eu", authEndpoint: "/auth", csrfToken: "tok" };
19+
const stubSubscription = {
20+
channelName: "private-Entity.123",
21+
eventBindings: [{ eventName: "update", onEvent: jest.fn() }]
22+
};
23+
24+
describe("PusherListener", () => {
25+
let listener: PusherListener;
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
mockPusherInstance.subscribe.mockReturnValue(mockChannel);
30+
listener = new PusherListener(stubConfig);
31+
});
32+
33+
it("subscribes to channel and binds global handler", () => {
34+
listener.subscribe(stubSubscription);
35+
36+
expect(mockPusherInstance.subscribe).toHaveBeenCalledWith(stubSubscription.channelName);
37+
expect(mockChannel.bind_global).toHaveBeenCalledTimes(1);
38+
});
39+
40+
it("skips Pusher resubscription on same channel name", () => {
41+
listener.subscribe(stubSubscription);
42+
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "other", onEvent: jest.fn() }] });
43+
44+
expect(mockPusherInstance.subscribe).toHaveBeenCalledTimes(1);
45+
});
46+
47+
it("resubscribes when channel name changes", () => {
48+
listener.subscribe(stubSubscription);
49+
listener.subscribe({ ...stubSubscription, channelName: "private-Entity.456" });
50+
51+
expect(mockPusherInstance.unsubscribe).toHaveBeenCalledWith(stubSubscription.channelName);
52+
expect(mockPusherInstance.subscribe).toHaveBeenCalledWith("private-Entity.456");
53+
});
54+
55+
it("updates handler map so new handler fires without resubscribing", () => {
56+
const firstHandler = jest.fn();
57+
const secondHandler = jest.fn();
58+
59+
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "update", onEvent: firstHandler }] });
60+
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "update", onEvent: secondHandler }] });
61+
62+
const globalCb = mockChannel.bind_global.mock.calls[0][0] as (name: string, data: unknown) => void;
63+
globalCb("update", {});
64+
65+
expect(mockPusherInstance.subscribe).toHaveBeenCalledTimes(1);
66+
expect(firstHandler).not.toHaveBeenCalled();
67+
expect(secondHandler).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("destroy disconnects and is idempotent", () => {
71+
listener.subscribe(stubSubscription);
72+
listener.destroy();
73+
listener.destroy();
74+
75+
expect(mockPusherInstance.disconnect).toHaveBeenCalledTimes(1);
76+
});
77+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { fetchPusherConfig } from "../utils/fetchPusherConfig";
2+
3+
function mockFetch(status: number, body: unknown): void {
4+
global.fetch = jest.fn().mockResolvedValue({
5+
status,
6+
json: () => Promise.resolve(body)
7+
});
8+
}
9+
10+
beforeEach(() => {
11+
(window as any).mx = {
12+
remoteUrl: "https://app.example.com/",
13+
sessionData: { csrftoken: "test-csrf" }
14+
};
15+
});
16+
17+
afterEach(() => {
18+
delete (window as any).mx;
19+
jest.resetAllMocks();
20+
});
21+
22+
describe("fetchPusherConfig", () => {
23+
it("returns config on successful response", async () => {
24+
mockFetch(200, { key: "app-key", cluster: "eu" });
25+
26+
const result = await fetchPusherConfig(new AbortController().signal);
27+
28+
expect(result).toEqual({
29+
key: "app-key",
30+
cluster: "eu",
31+
authEndpoint: "https://app.example.com/rest/pusher/auth",
32+
csrfToken: "test-csrf"
33+
});
34+
});
35+
36+
it("returns null on non-200 response", async () => {
37+
mockFetch(403, {});
38+
39+
const result = await fetchPusherConfig(new AbortController().signal);
40+
41+
expect(result).toBeNull();
42+
});
43+
44+
it("returns null on network error", async () => {
45+
global.fetch = jest.fn().mockRejectedValue(new Error("Network failure"));
46+
47+
const result = await fetchPusherConfig(new AbortController().signal);
48+
49+
expect(result).toBeNull();
50+
});
51+
52+
it("returns null when signal is already aborted", async () => {
53+
const controller = new AbortController();
54+
const abortError = new DOMException("Aborted", "AbortError");
55+
global.fetch = jest.fn().mockRejectedValue(abortError);
56+
controller.abort();
57+
58+
const result = await fetchPusherConfig(controller.signal);
59+
60+
expect(result).toBeNull();
61+
});
62+
63+
it("returns null when response is missing required fields", async () => {
64+
mockFetch(200, { key: "app-key" }); // missing cluster
65+
66+
const result = await fetchPusherConfig(new AbortController().signal);
67+
68+
expect(result).toBeNull();
69+
});
70+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { DynamicValue, ObjectItem } from "mendix";
2+
import { getChannelName } from "../utils/getChannelName";
3+
4+
function makeObjectSource(value?: ObjectItem): DynamicValue<ObjectItem> {
5+
return { value } as DynamicValue<ObjectItem>;
6+
}
7+
8+
function makeObjectItem(entityName: string, guid: string): ObjectItem {
9+
const mxObject = { getEntity: () => entityName };
10+
const sym = Symbol("mxObject");
11+
const item = { id: guid, [sym]: mxObject } as unknown as ObjectItem;
12+
Object.defineProperty(item, sym, { value: mxObject, enumerable: false });
13+
// Place symbol as first own symbol so extractEntityName finds it
14+
return item;
15+
}
16+
17+
describe("getChannelName", () => {
18+
it("returns undefined when objectSource has no value", () => {
19+
expect(getChannelName(makeObjectSource(undefined))).toBeUndefined();
20+
});
21+
22+
it("returns private channel name for valid object", () => {
23+
const item = makeObjectItem("MyModule.MyEntity", "guid-123");
24+
expect(getChannelName(makeObjectSource(item))).toBe("private-MyModule.MyEntity.guid-123");
25+
});
26+
27+
it("throws when mxObject symbol is absent", () => {
28+
const item = { id: "guid-123" } as unknown as ObjectItem;
29+
expect(() => getChannelName(makeObjectSource(item))).toThrow("Unable to extract entity name");
30+
});
31+
32+
it("returns undefined when guid is empty", () => {
33+
const item = makeObjectItem("MyModule.MyEntity", "");
34+
expect(getChannelName(makeObjectSource(item))).toBeUndefined();
35+
});
36+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { usePusherSubscribe } from "../hooks/usePusherSubscribe";
3+
import { createPusherListener } from "../utils/createPusherListener";
4+
5+
jest.mock("../utils/createPusherListener");
6+
7+
const mockListener = {
8+
subscribe: jest.fn(),
9+
unsubscribe: jest.fn(),
10+
destroy: jest.fn()
11+
};
12+
13+
const mockCreatePusherListener = createPusherListener as jest.MockedFunction<typeof createPusherListener>;
14+
15+
const stubSubscription = {
16+
channelName: "private-Entity.123",
17+
eventBindings: [{ eventName: "update", onEvent: jest.fn() }]
18+
};
19+
20+
describe("usePusherSubscribe", () => {
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
mockCreatePusherListener.mockResolvedValue(mockListener as never);
24+
});
25+
26+
it("calls subscribe when listener and subscription are ready", async () => {
27+
renderHook(() => usePusherSubscribe(stubSubscription));
28+
29+
await act(async () => {});
30+
31+
expect(mockListener.subscribe).toHaveBeenCalledWith(stubSubscription);
32+
});
33+
34+
it("calls unsubscribe when subscription is undefined", async () => {
35+
renderHook(() => usePusherSubscribe(undefined));
36+
37+
await act(async () => {});
38+
39+
expect(mockListener.unsubscribe).toHaveBeenCalledTimes(1);
40+
expect(mockListener.subscribe).not.toHaveBeenCalled();
41+
});
42+
43+
it("calls destroy on unmount, not unsubscribe directly", async () => {
44+
const { unmount } = renderHook(() => usePusherSubscribe(stubSubscription));
45+
46+
await act(async () => {});
47+
48+
const unsubscribeCallsBefore = mockListener.unsubscribe.mock.calls.length;
49+
50+
act(() => {
51+
unmount();
52+
});
53+
54+
expect(mockListener.destroy).toHaveBeenCalledTimes(1);
55+
expect(mockListener.unsubscribe.mock.calls.length).toBe(unsubscribeCallsBefore);
56+
});
57+
});

0 commit comments

Comments
 (0)