|
1 | | -import { describe, expect, it } from 'vitest' |
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 2 | + |
| 3 | +const { mockGetSlackBotCredential } = vi.hoisted(() => ({ |
| 4 | + mockGetSlackBotCredential: vi.fn(), |
| 5 | +})) |
| 6 | + |
| 7 | +vi.mock('@/lib/oauth/credential-service', () => ({ |
| 8 | + getSlackBotCredential: mockGetSlackBotCredential, |
| 9 | + refreshAccessTokenIfNeeded: vi.fn(), |
| 10 | + resolveOAuthAccountId: vi.fn(), |
| 11 | +})) |
| 12 | + |
2 | 13 | import { |
3 | 14 | handleSlackChallenge, |
4 | 15 | resolveSlackEventKey, |
@@ -595,3 +606,185 @@ describe('slackHandler.shouldSkipEvent (custom-app path)', () => { |
595 | 606 | expect(slackHandler.shouldSkipEvent!(skipCtx({}, message))).toBe(false) |
596 | 607 | }) |
597 | 608 | }) |
| 609 | + |
| 610 | +describe('slackHandler prepareSyncDispatch', () => { |
| 611 | + const fetchMock = vi.fn() |
| 612 | + |
| 613 | + const syncCtx = (body: unknown, providerConfig: Record<string, unknown>) => ({ |
| 614 | + webhook: {}, |
| 615 | + workflow: { id: 'wf', userId: 'u' }, |
| 616 | + body, |
| 617 | + requestId: 'slack-test', |
| 618 | + providerConfig, |
| 619 | + }) |
| 620 | + |
| 621 | + const blockActions = (overrides: Record<string, unknown> = {}) => ({ |
| 622 | + type: 'block_actions', |
| 623 | + trigger_id: 'trigger-1', |
| 624 | + user: { id: 'U1' }, |
| 625 | + actions: [{ action_id: 'a1', type: 'button' }], |
| 626 | + ...overrides, |
| 627 | + }) |
| 628 | + |
| 629 | + const enabledConfig = { openLoadingModal: true, botToken: 'xoxb-test' } |
| 630 | + |
| 631 | + const slackOk = (viewId = 'V123') => ({ |
| 632 | + json: async () => ({ ok: true, view: { id: viewId } }), |
| 633 | + status: 200, |
| 634 | + }) |
| 635 | + |
| 636 | + beforeEach(() => { |
| 637 | + vi.clearAllMocks() |
| 638 | + vi.stubGlobal('fetch', fetchMock) |
| 639 | + }) |
| 640 | + |
| 641 | + afterEach(() => { |
| 642 | + vi.unstubAllGlobals() |
| 643 | + }) |
| 644 | + |
| 645 | + it('opens the loading modal for a message button click and returns the view id', async () => { |
| 646 | + fetchMock.mockResolvedValue(slackOk('V123')) |
| 647 | + |
| 648 | + const result = await slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig)) |
| 649 | + |
| 650 | + expect(result).toEqual({ syncInteraction: { loadingViewId: 'V123' } }) |
| 651 | + expect(fetchMock).toHaveBeenCalledTimes(1) |
| 652 | + const [url, init] = fetchMock.mock.calls[0] |
| 653 | + expect(url).toBe('https://slack.com/api/views.open') |
| 654 | + expect(init.headers.Authorization).toBe('Bearer xoxb-test') |
| 655 | + const requestBody = JSON.parse(init.body) |
| 656 | + expect(requestBody.trigger_id).toBe('trigger-1') |
| 657 | + expect(requestBody.view.callback_id).toBe('sim_loading_modal') |
| 658 | + expect(requestBody.view.title.text).toBe('Working on it') |
| 659 | + expect(requestBody.view.blocks[0].text.text).toBe('This will just take a moment…') |
| 660 | + }) |
| 661 | + |
| 662 | + it('opens for message_action and shortcut payloads', async () => { |
| 663 | + fetchMock.mockResolvedValue(slackOk()) |
| 664 | + |
| 665 | + await expect( |
| 666 | + slackHandler.prepareSyncDispatch!( |
| 667 | + syncCtx({ type: 'message_action', trigger_id: 't2' }, enabledConfig) |
| 668 | + ) |
| 669 | + ).resolves.toEqual({ syncInteraction: { loadingViewId: 'V123' } }) |
| 670 | + await expect( |
| 671 | + slackHandler.prepareSyncDispatch!( |
| 672 | + syncCtx({ type: 'shortcut', trigger_id: 't3' }, enabledConfig) |
| 673 | + ) |
| 674 | + ).resolves.toEqual({ syncInteraction: { loadingViewId: 'V123' } }) |
| 675 | + }) |
| 676 | + |
| 677 | + it('applies the configured title and text, truncating the title to 24 characters', async () => { |
| 678 | + fetchMock.mockResolvedValue(slackOk()) |
| 679 | + |
| 680 | + await slackHandler.prepareSyncDispatch!( |
| 681 | + syncCtx(blockActions(), { |
| 682 | + ...enabledConfig, |
| 683 | + loadingModalTitle: 'A very long modal title that overflows', |
| 684 | + loadingModalText: 'Custom body', |
| 685 | + }) |
| 686 | + ) |
| 687 | + |
| 688 | + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) |
| 689 | + expect(requestBody.view.title.text.length).toBeLessThanOrEqual(24) |
| 690 | + expect(requestBody.view.blocks[0].text.text).toBe('Custom body') |
| 691 | + }) |
| 692 | + |
| 693 | + it('never fires for ineligible payloads or when the option is off', async () => { |
| 694 | + const cases: Array<[unknown, Record<string, unknown>]> = [ |
| 695 | + [blockActions(), { botToken: 'xoxb-test' }], |
| 696 | + [blockActions({ view: { id: 'V-open' } }), enabledConfig], |
| 697 | + [{ type: 'view_submission', trigger_id: 't' }, enabledConfig], |
| 698 | + [{ type: 'view_closed', trigger_id: 't' }, enabledConfig], |
| 699 | + [{ event: { type: 'message' }, type: 'event_callback' }, enabledConfig], |
| 700 | + [{ command: '/run', trigger_id: 't' }, enabledConfig], |
| 701 | + [blockActions({ trigger_id: undefined }), enabledConfig], |
| 702 | + [blockActions({ trigger_id: '' }), enabledConfig], |
| 703 | + ['not-an-object', enabledConfig], |
| 704 | + ] |
| 705 | + |
| 706 | + for (const [body, config] of cases) { |
| 707 | + await expect(slackHandler.prepareSyncDispatch!(syncCtx(body, config))).resolves.toBeNull() |
| 708 | + } |
| 709 | + expect(fetchMock).not.toHaveBeenCalled() |
| 710 | + }) |
| 711 | + |
| 712 | + it('returns null without calling Slack when no bot token resolves', async () => { |
| 713 | + await expect( |
| 714 | + slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), { openLoadingModal: true })) |
| 715 | + ).resolves.toBeNull() |
| 716 | + expect(fetchMock).not.toHaveBeenCalled() |
| 717 | + }) |
| 718 | + |
| 719 | + it('resolves the token from a custom-bot credential', async () => { |
| 720 | + mockGetSlackBotCredential.mockResolvedValue({ botToken: 'xoxb-cred' }) |
| 721 | + fetchMock.mockResolvedValue(slackOk('V-cred')) |
| 722 | + |
| 723 | + const result = await slackHandler.prepareSyncDispatch!( |
| 724 | + syncCtx(blockActions(), { openLoadingModal: true, credentialId: 'credential-1' }) |
| 725 | + ) |
| 726 | + |
| 727 | + expect(result).toEqual({ syncInteraction: { loadingViewId: 'V-cred' } }) |
| 728 | + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer xoxb-cred') |
| 729 | + }) |
| 730 | + |
| 731 | + it('tolerates Slack rejections, network failures, and timeouts without throwing', async () => { |
| 732 | + fetchMock.mockResolvedValueOnce({ |
| 733 | + json: async () => ({ ok: false, error: 'expired_trigger_id' }), |
| 734 | + status: 200, |
| 735 | + }) |
| 736 | + await expect( |
| 737 | + slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig)) |
| 738 | + ).resolves.toBeNull() |
| 739 | + |
| 740 | + fetchMock.mockResolvedValueOnce({ |
| 741 | + json: async () => ({ ok: false, error: 'exchanged_trigger_id' }), |
| 742 | + status: 200, |
| 743 | + }) |
| 744 | + await expect( |
| 745 | + slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig)) |
| 746 | + ).resolves.toBeNull() |
| 747 | + |
| 748 | + fetchMock.mockRejectedValueOnce(new Error('network down')) |
| 749 | + await expect( |
| 750 | + slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig)) |
| 751 | + ).resolves.toBeNull() |
| 752 | + |
| 753 | + fetchMock.mockRejectedValueOnce( |
| 754 | + Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' }) |
| 755 | + ) |
| 756 | + await expect( |
| 757 | + slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig)) |
| 758 | + ).resolves.toBeNull() |
| 759 | + }) |
| 760 | +}) |
| 761 | + |
| 762 | +describe('slackHandler formatInput - loading_view_id', () => { |
| 763 | + const interactiveBody = { |
| 764 | + type: 'block_actions', |
| 765 | + trigger_id: 'trigger-1', |
| 766 | + user: { id: 'U1', username: 'alice' }, |
| 767 | + actions: [{ action_id: 'a1', type: 'button', value: 'go' }], |
| 768 | + } |
| 769 | + |
| 770 | + it('surfaces the ingest-created loading view id on interactive payloads', async () => { |
| 771 | + const { input } = await slackHandler.formatInput!({ |
| 772 | + ...ctx(interactiveBody), |
| 773 | + syncInteraction: { loadingViewId: 'V123' }, |
| 774 | + }) |
| 775 | + |
| 776 | + expect(eventOf(input).loading_view_id).toBe('V123') |
| 777 | + }) |
| 778 | + |
| 779 | + it('defaults to an empty loading_view_id without a sync interaction', async () => { |
| 780 | + const { input } = await slackHandler.formatInput!(ctx(interactiveBody)) |
| 781 | + expect(eventOf(input).loading_view_id).toBe('') |
| 782 | + }) |
| 783 | + |
| 784 | + it('keeps the empty default on Events API payloads', async () => { |
| 785 | + const { input } = await slackHandler.formatInput!( |
| 786 | + ctx({ team_id: 'T1', event: { type: 'app_mention', channel: 'C1', ts: '1.2' } }) |
| 787 | + ) |
| 788 | + expect(eventOf(input).loading_view_id).toBe('') |
| 789 | + }) |
| 790 | +}) |
0 commit comments