zoomConnectDialog.test.tsx8.1 KBView on GitHub import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import React from 'react';
import { ZoomConnectDialog } from '@/modules/calendar/components/ZoomConnectDialog';
/**
* Connecting Zoom without losing the event you were composing.
*
* Zoom's consent screen has to be a popup — `X-Frame-Options` rules out an iframe — so what this
* dialog owns is everything around it: the explanation, the wait, and the failure. The failure is
* the case worth testing: `connectZoom` resolves FALSE for a blocked popup, a declined consent and
* a closed window alike, and a dialog that closed on false would leave the user staring at an
* unchanged event with no idea what happened.
*/
function setup(overrides: Partial<React.ComponentProps<typeof ZoomConnectDialog>> = {}) {
const props = {
open: true,
onOpenChange: jest.fn(),
connectZoom: jest.fn().mockResolvedValue({ ok: true }),
onConnected: jest.fn(),
...overrides,
};
render(<ZoomConnectDialog {...props} />);
return props;
}
const authorizeButton = () => screen.getByRole('button', { name: /authorize zoom/i });
describe('ZoomConnectDialog', () => {
it('says what Zoom will be allowed to do before asking for it', () => {
setup();
expect(screen.getByText(/Connect Zoom/i)).toBeInTheDocument();
// The one permission, named. A consent dialog that does not say what it is consenting to is
// just a button.
expect(screen.getByText(/creating and deleting meetings/i)).toBeInTheDocument();
expect(screen.getByText(/you are the host/i)).toBeInTheDocument();
});
it('arms the caller and closes once Zoom comes back', async () => {
const props = setup();
fireEvent.click(authorizeButton());
await waitFor(() => expect(props.onConnected).toHaveBeenCalled());
// The order matters: arming before closing is what makes one click on "Add Zoom" still end
// with Zoom on the event, rather than a connected account and an event with no video call.
expect(props.onOpenChange).toHaveBeenCalledWith(false);
});
it('names the reason it failed, and stays open to retry', async () => {
const props = setup({
connectZoom: jest
.fn()
.mockResolvedValue({ ok: false, error: 'Your browser blocked the Zoom window.' }),
});
fireEvent.click(authorizeButton());
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
// The REASON, not a guess. Four things end this flow unconnected and each has a different
// fix; a dialog that says "something went wrong" sends all four to support.
expect(screen.getByRole('alert')).toHaveTextContent(/blocked the Zoom window/i);
expect(props.onConnected).not.toHaveBeenCalled();
expect(props.onOpenChange).not.toHaveBeenCalledWith(false);
// And it is retryable in place — the button comes back rather than staying spent.
expect(authorizeButton()).toBeEnabled();
});
it('does not leave the button live while the popup is open', async () => {
// Two consent windows for one gesture is the shape of the "it connected twice" report, and on
// a provider that rotates refresh tokens the second grant can invalidate the first.
let resolveConnect: ((value: { ok: boolean }) => void) | undefined;
setup({
connectZoom: jest.fn(
() =>
new Promise<{ ok: boolean }>((resolve) => {
resolveConnect = resolve;
}),
),
});
fireEvent.click(screen.getByRole('button', { name: /authorize zoom/i }));
await waitFor(() =>
expect(screen.getByRole('button', { name: /waiting for zoom/i })).toBeDisabled(),
);
// Settle it inside `act`, so the state update it triggers is flushed by the test rather than
// landing after it and warning.
await act(async () => {
resolveConnect?.({ ok: true });
});
});
it('says RE-connect when a connection existed and its token died', async () => {
// "Connect Zoom" to someone who connected Zoom last week reads as though Cedar lost their
// setup — the difference between a click and a support thread.
setup({ needsReauth: true });
expect(screen.getByText(/Reconnect Zoom/i)).toBeInTheDocument();
expect(screen.getByText(/connection expired/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reauthorize zoom/i })).toBeInTheDocument();
});
it('renders above the popover it is opened from', () => {
// `--z-popover` is 10000 and the dialog's own default is 9999, so without the override the
// dialog appears BEHIND the composer that asked for it — and the empty-composer backdrop,
// which dismisses the draft on pointerdown, takes its clicks. Not observable in jsdom, which
// computes no stacking, so it is pinned at the source.
const source = readFileSync(
join(__dirname, '..', '..', '..', 'modules', 'calendar', 'components', 'ZoomConnectDialog.tsx'),
'utf8',
);
expect(source).toContain('var(--z-popover)');
// Both halves, or the scrim sits under the popover while the panel sits over it.
expect(source).toMatch(/className=\{ABOVE_POPOVER\}\s+overlayClassName=\{ABOVE_POPOVER\}/);
});
});
/**
* The "Add Zoom" badge in the event-creation popover.
*
* Structural, because what would regress here is markup and CSS that jsdom does not model: it
* computes no hover state and no stacking. Both invariants below have a wrong version that looks
* identical in a render test and is broken in a browser.
*/
describe('the "Change to …" badge', () => {
const source = readFileSync(
join(__dirname, '..', '..', '..', 'modules', 'calendar', 'components', 'EventComposerForm.tsx'),
'utf8',
);
it('offers the conference you did NOT pick', () => {
// Derived rather than hard-coded to Zoom, so picking Zoom offers Meet back. A Meet-only
// special case would strand anyone who switched, with the × as their only way out.
expect(source).toContain('const alternativeConference =');
expect(source).toMatch(/o\.key !== selectedConference\?\.key/);
expect(source).toContain('Change to {alternativeConference.name}');
});
/**
* The `selectedConference ? … : …` branch, sliced from its own `) : (` rather than the file's
* first one — several earlier ternaries share that text, and slicing to the wrong one gives an
* empty string that every `toContain` then fails against for the wrong reason.
*/
const chosenBranch = (() => {
const start = source.indexOf('{selectedConference ? (');
return source.slice(start, source.indexOf(') : (', start));
})();
it('lives on the CHOSEN conference row, not on the empty one', () => {
// The gesture is "add Meet, then change your mind" — the badge has to be on the thing you
// just added, which is where the cursor already is.
expect(chosenBranch).toContain('Change to {alternativeConference.name}');
expect(chosenBranch).toContain('group relative');
});
it('is a sibling of the row controls, not nested in a button', () => {
// A <button> inside a <button> is invalid markup and browsers disagree about which one a
// click reaches — the badge would sometimes hit the row instead.
expect(chosenBranch.indexOf('Change to {alternativeConference.name}')).toBeLessThan(
chosenBranch.indexOf('Remove video conferencing'),
);
});
it('is revealed by hovering the row, and by focus for the keyboard', () => {
// Hover alone makes it a control you can tab to and cannot see.
expect(source).toContain('group-hover:opacity-100');
expect(source).toContain('focus-visible:opacity-100');
});
it('routes an unconnected provider through the dialog instead of arming it', () => {
// Arming a conference the save cannot create is the one outcome worse than asking.
expect(source).toMatch(/if \(option\.requiresConnect\) \{\s*setZoomConnectOpen\(true\);/);
});
it('is never gated on server configuration', () => {
// The whole point of the ungating: every user sees the option, and an unconfigured server
// says so in the dialog rather than silently having no feature.
expect(source).not.toContain('configured');
});
});