conferencing.test.ts8.4 KBView on GitHub
import {
  canCreateZoom,
  conferenceCreateRequest,
  conferenceErrorMessage,
  conferenceOnEvent,
  conferenceOptionsFor,
  minutesBetween,
  type ZoomStatus,
} from '@/modules/calendar/utils/conferencing';

/**
 * What Cedar offers when you ask to put a video call on an event.
 *
 * This used to be a question about the CALENDAR: Meet always, plus an "add-on" option gated on
 * `conferenceProperties.allowedConferenceSolutionTypes` containing `addOn`. Probed live, every
 * calendar on every account reports exactly `["hangoutsMeet"]` — that field says what an API
 * client may create, and Google only lets one create its own solutions. The add-on option was
 * unreachable code, and had it rendered, the request would have failed.
 *
 * It is now a question about the USER: has this person connected Zoom. Cedar mints the meeting on
 * their Zoom account and writes the join details onto the event.
 * See apps/server/docs/zoom-conferencing.md.
 */

const status = (overrides: Partial<ZoomStatus> = {}): ZoomStatus => ({
  configured: true,
  connected: true,
  needsReauth: false,
  ...overrides,
});

describe('conferenceOptionsFor', () => {
  it('offers Zoom to a user who has connected it', () => {
    const options = conferenceOptionsFor(status());
    expect(options.map((o) => o.key)).toEqual(['hangoutsMeet', 'zoom']);
    expect(options[1]!.label).toBe('Zoom');
    expect(options[1]!.requiresConnect).toBeFalsy();
  });

  it('offers to CONNECT Zoom rather than hiding it, when the user has not', () => {
    // A disabled row does not tell anyone what to do about it; this one does, and clicking it
    // fixes the thing it is complaining about.
    const options = conferenceOptionsFor(status({ connected: false }));
    expect(options[1]).toMatchObject({ key=[redacted], label: 'Connect Zoom', requiresConnect: true });
  });

  it('says RE-connect when a connection existed and its token died', () => {
    // "Connect Zoom" to someone who connected Zoom last week reads as though Cedar lost their
    // setup. The word is the whole difference between a bug report and a click.
    const options = conferenceOptionsFor(status({ connected: false, needsReauth: true }));
    expect(options[1]).toMatchObject({ key=[redacted], label: 'Reconnect Zoom', requiresConnect: true });
  });

  it('offers Zoom even where the server has no Zoom app', () => {
    // Zoom is a product capability, not a per-deployment one. Hiding it behind `ZOOM_CLIENT_ID`
    // made the feature invisible to everyone until an env var landed somewhere, and left nobody
    // able to tell whether it was missing or broken. The honest failure for an unconfigured
    // server is a message inside the connect dialog, not an absence in the picker.
    const options = conferenceOptionsFor(status({ configured: false, connected: false }));
    expect(options.map((o) => o.key)).toEqual(['hangoutsMeet', 'zoom']);
    expect(options[1]!.requiresConnect).toBe(true);
  });

  it('offers both while the status is still loading', () => {
    // The picker renders before the query resolves. An unknown status reads as not-connected, so
    // the option is there from the first frame and only its WORDING settles — rather than the
    // control popping into existence a beat later.
    for (const unknown of [null, undefined]) {
      const options = conferenceOptionsFor(unknown);
      expect(options.map((o) => o.key)).toEqual(['hangoutsMeet', 'zoom']);
      expect(options[1]!.label).toBe('Connect Zoom');
    }
  });
});

describe('canCreateZoom', () => {
  it('turns on `connected`, and nothing else', () => {
    // `configured` is deliberately not consulted: a user cannot be connected to an app that does
    // not exist, so checking it could only ever hide the option from someone able to use it.
    expect(canCreateZoom(status())).toBe(true);
    expect(canCreateZoom(status({ connected: false }))).toBe(false);
    expect(canCreateZoom(null)).toBe(false);
  });
});

describe('conferenceOnEvent', () => {
  it("uses Google's own name, so a Zoom meeting does not read Google Meet", () => {
    const event = {
      conferenceData: {
        conferenceSolution: { name: 'Zoom Meeting' },
        entryPoints: [{ entryPointType: 'video', uri: 'https://us02web.zoom.us/j/123' }],
      },
    };
    expect(conferenceOnEvent(event)).toEqual({
      name: 'Zoom Meeting',
      uri: 'https://us02web.zoom.us/j/123',
    });
  });

  it('names an unlabelled conference generically rather than wrongly', () => {
    const event = {
      conferenceData: { entryPoints: [{ entryPointType: 'video', uri: 'https://example.com/x' }] },
    };
    expect(conferenceOnEvent(event)?.name).toBe('Video call');
  });

  it('falls back to the location, which is where Cedar writes a Zoom link', () => {
    // The load-bearing case: `conferenceData` is best-effort — Google may decline to store entry
    // points from an outside client — while `location` always sticks. Without this fallback the
    // panel would claim there is no call on an event whose invite plainly shows one.
    expect(conferenceOnEvent({ location: 'https://us02web.zoom.us/j/12345678901' })).toEqual({
      name: 'Zoom Meeting',
      uri: 'https://us02web.zoom.us/j/12345678901',
    });
    expect(conferenceOnEvent({ location: 'https://meet.google.com/abc-defg-hij' })?.name).toBe(
      'Google Meet',
    );
  });

  it('does not mistake a street address for a meeting link', () => {
    expect(conferenceOnEvent({ location: '600 Congress Ave, Austin' })).toBeNull();
    expect(conferenceOnEvent({ location: 'https://notion.so/some-doc' })).toBeNull();
  });

  it('reports nothing when the event has neither', () => {
    expect(conferenceOnEvent({})).toBeNull();
    expect(conferenceOnEvent(null)).toBeNull();
  });
});

describe('conferenceCreateRequest', () => {
  it('asks only for Meet — the one solution Google will create for a client', () => {
    const { conferenceData } = conferenceCreateRequest('req-1');
    expect(conferenceData.createRequest.conferenceSolutionKey.type).toBe('hangoutsMeet');
    expect(conferenceData.createRequest.requestId).toBe('req-1');
  });

  it('always carries the version — without it Google ignores the block', () => {
    expect(conferenceCreateRequest('req-2').conferenceDataVersion).toBe(1);
  });
});

describe('conferenceErrorMessage', () => {
  it("passes a Zoom failure through, because the server already wrote it for a human", () => {
    // `zoom-meetings.ts` is the only layer that knows what Zoom's code 124 means; flattening its
    // translation here would throw away the only actionable sentence in the stack.
    const message = conferenceErrorMessage(
      'zoom',
      new Error('Your Zoom connection expired. Reconnect Zoom and try again.'),
    );
    expect(message).toBe('Your Zoom connection expired. Reconnect Zoom and try again.');
  });

  it('does not show a raw exception name to the user', () => {
    expect(conferenceErrorMessage('zoom', new Error('TypeError: fetch failed'))).toBe(
      'Could not create the Zoom meeting',
    );
    expect(conferenceErrorMessage('zoom', undefined)).toBe('Could not create the Zoom meeting');
  });

  it('leaves an ordinary Meet failure ordinary', () => {
    expect(conferenceErrorMessage('hangoutsMeet', new Error('boom'))).toBe(
      'Failed to add Google Meet',
    );
  });
});

describe('minutesBetween', () => {
  it('is the real length, not Zoom’s 60-minute default', () => {
    // Zoom fills in 60 when no duration is sent, so a 25-minute call would be booked as an hour
    // on the host's account — visible to them, and wrong on any Zoom-side report.
    expect(minutesBetween('2026-09-02T15:00:00Z', '2026-09-02T15:25:00Z')).toBe(25);
    expect(minutesBetween('2026-09-02T15:00:00Z', '2026-09-02T16:30:00Z')).toBe(90);
  });

  it('rounds up, so a meeting is never cut short', () => {
    expect(minutesBetween('2026-09-02T15:00:00Z', '2026-09-02T15:25:30Z')).toBe(26);
  });

  it('declines to guess rather than sending a wrong number', () => {
    expect(minutesBetween(undefined, '2026-09-02T15:25:00Z')).toBeUndefined();
    expect(minutesBetween('2026-09-02T15:00:00Z', null)).toBeUndefined();
    // An inverted range is a bug upstream; passing a negative duration on would make Zoom the
    // place it surfaces, which is the hardest place to debug it from.
    expect(minutesBetween('2026-09-02T16:00:00Z', '2026-09-02T15:00:00Z')).toBeUndefined();
    expect(minutesBetween('not a date', '2026-09-02T15:00:00Z')).toBeUndefined();
  });
});