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');
  });
});