import { QueryClientProvider } from '@tanstack/react-query' import { renderHook, act } from '@testing-library/react-native' import React from 'react' import { buildTestQueryClient } from '../../__utils__/query_client' import { useApiClient } from '../../hooks/use_api_client' import { useAttachmentUploader } from '../../hooks/use_attachment_uploader' import { useChatConfiguration } from '../../hooks/use_chat_configuration' import { useUploadClient } from '../../hooks/use_upload_client' import { FileAttachment } from '../../types/resources/denormalized_attachment_resource_for_create' jest.mock('../../hooks/use_api_client') jest.mock('../../hooks/use_upload_client') jest.mock('../../hooks/use_chat_configuration') jest.mock('../../hooks/use_api', () => ({ useApiGet: jest.fn().mockReturnValue({ data: { id: 1 } }), })) const joltEventCallbacks: Record void> = {} jest.mock('../../hooks/use_jolt', () => ({ useJoltChannel: jest.fn().mockReturnValue('channel-stub'), useJoltEvent: jest.fn((_channel, eventName, callback) => { joltEventCallbacks[eventName] = callback }), })) const mockedUseApiClient = useApiClient as jest.MockedFunction const mockedUseUploadClient = useUploadClient as jest.MockedFunction const mockedUseChatConfiguration = useChatConfiguration as jest.MockedFunction< typeof useChatConfiguration > const createWrapper = () => { const queryClient = buildTestQueryClient() return ({ children }: { children: React.ReactNode }) => ( {children} ) } const setChatConfiguration = (overrides: Partial> = {}) => { mockedUseChatConfiguration.mockReturnValue({ allowedFileExtensions: ['.pdf', '.jpg'], allowedMimeTypes: [], maxFileSizeInBytes: 50 * 1024 * 1024, maxAttachmentsPerMessage: 10, ...overrides, }) } const renderUploader = (opts?: { draftAttachments?: FileAttachment[] onFlaggedInMessageForm?: (id: string) => void }) => renderHook(() => useAttachmentUploader({ conversationId: 1, ...opts }), { wrapper: createWrapper(), }) const makeFile = ( overrides: Partial< Parameters['handleFilesAttached']>[0][number] > = {} ) => ({ uri: 'file:///tmp/example.pdf', name: 'example.pdf', type: 'application/pdf', size: 1024, ...overrides, }) const draftAttachment = (name: string): FileAttachment => ({ file: { uri: `file:///tmp/${name}`, name, type: 'application/pdf', size: 1024 }, status: 'success', uploadedAt: 0, id: `att-${name}`, }) beforeEach(() => { setChatConfiguration() mockedUseApiClient.mockReturnValue({ chat: { post: jest.fn().mockResolvedValue({ data: { id: 'msg-attachment-1' } }) }, } as any) mockedUseUploadClient.mockReturnValue({ uploadFile: jest.fn().mockResolvedValue({ id: 'uploaded-1' }), } as any) }) afterEach(() => { jest.clearAllMocks() }) describe('useAttachmentUploader', () => { describe('extension validation', () => { it('rejects a disallowed extension and lists it in the error', () => { const { result } = renderUploader() act(() => { result.current.handleFilesAttached([makeFile({ name: 'evil.exe' })]) }) expect(result.current.errorMessage).toContain( 'The following file types are not supported: exe' ) expect(result.current.attachments).toHaveLength(0) }) it('accepts a matching extension regardless of casing', async () => { const { result } = renderUploader() await act(async () => { result.current.handleFilesAttached([makeFile({ name: 'Shouting.PDF' })]) }) expect(result.current.errorMessage).toBeNull() expect(result.current.attachments).toHaveLength(1) }) }) describe('file size validation', () => { it('rejects files that exceed maxFileSizeInBytes', () => { setChatConfiguration({ maxFileSizeInBytes: 50 * 1024 * 1024 }) const { result } = renderUploader() act(() => { result.current.handleFilesAttached([makeFile({ size: 50 * 1024 * 1024 + 1 })]) }) expect(result.current.errorMessage).toBe('File size exceeds 50 MB') expect(result.current.attachments).toHaveLength(0) }) it('formats an integer MB limit without a trailing decimal', () => { setChatConfiguration({ maxFileSizeInBytes: 25 * 1024 * 1024 }) const { result } = renderUploader() act(() => { result.current.handleFilesAttached([makeFile({ size: 25 * 1024 * 1024 + 1 })]) }) expect(result.current.errorMessage).toBe('File size exceeds 25 MB') }) it('formats a fractional MB limit to one decimal', () => { // 50.5 MB — server could in principle return a non-multiple-of-MB cap. setChatConfiguration({ maxFileSizeInBytes: Math.round(50.5 * 1024 * 1024) }) const { result } = renderUploader() act(() => { result.current.handleFilesAttached([makeFile({ size: 51 * 1024 * 1024 })]) }) expect(result.current.errorMessage).toBe('File size exceeds 50.5 MB') }) }) describe('attachment count validation', () => { it('rejects a batch that would push past maxAttachmentsPerMessage', () => { setChatConfiguration({ maxAttachmentsPerMessage: 2 }) const { result } = renderUploader() act(() => { result.current.handleFilesAttached([ makeFile({ uri: 'file:///a.pdf', name: 'a.pdf' }), makeFile({ uri: 'file:///b.pdf', name: 'b.pdf' }), makeFile({ uri: 'file:///c.pdf', name: 'c.pdf' }), ]) }) expect(result.current.errorMessage).toBe("You can't attach more than 2 files at once.") expect(result.current.attachments).toHaveLength(0) }) }) describe('remainingAttachable', () => { it('reports how many more files can be attached', () => { setChatConfiguration({ maxAttachmentsPerMessage: 5 }) const { result } = renderUploader({ draftAttachments: [draftAttachment('a.pdf'), draftAttachment('b.pdf')], }) expect(result.current.remainingAttachable).toBe(3) }) it('clamps to 0 when the draft already exceeds the server limit', () => { // Simulates a saved draft whose attachments outnumber a since-lowered // server cap — the value must never go negative. setChatConfiguration({ maxAttachmentsPerMessage: 1 }) const { result } = renderUploader({ draftAttachments: [draftAttachment('a.pdf'), draftAttachment('b.pdf')], }) expect(result.current.remainingAttachable).toBe(0) }) }) describe('upload errors', () => { const rejectingResponse = (body: unknown, status = 500) => { const response = { status, clone: () => response, json: async () => body } return response as unknown as Response } it('shows a generic error message when the upload fails', async () => { mockedUseApiClient.mockReturnValue({ chat: { post: jest.fn().mockRejectedValue(rejectingResponse({})) }, } as any) const { result } = renderUploader() await act(async () => { result.current.handleFilesAttached([makeFile()]) }) await act(async () => { await Promise.resolve() }) expect(result.current.errorMessage).toBe('This file could not be uploaded.') }) it('does not overwrite the error message when the upload is flagged', async () => { mockedUseUploadClient.mockReturnValue({ uploadFile: jest.fn().mockRejectedValue({ code: 'image_flagged' }), } as any) const { result } = renderUploader() await act(async () => { result.current.handleFilesAttached([makeFile()]) }) await act(async () => { await Promise.resolve() }) expect(result.current.errorMessage).toBeNull() expect(result.current.flaggedAttachmentCount).toBe(1) }) }) describe('attachment.flagged jolt event', () => { const flagEvent = (attachment_id: string, content_type = 'video/mp4') => ({ event: 'attachment.flagged', data: { data: { attachment_id, conversation_id: 1, content_type } }, }) it('flips the matching attachment to flagged + error when the event fires', () => { const { result } = renderUploader({ draftAttachments: [draftAttachment('clip.mp4')], }) act(() => { joltEventCallbacks['attachment.flagged'](flagEvent('att-clip.mp4')) }) const flagged = result.current.attachments.find(a => a.id === 'att-clip.mp4') expect(flagged?.flagged).toBe(true) expect(flagged?.status).toBe('error') expect(result.current.flaggedAttachmentCount).toBe(1) }) it('calls onFlaggedInMessageForm when a compose attachment is flagged', () => { const onFlaggedInMessageForm = jest.fn() renderUploader({ draftAttachments: [draftAttachment('clip.mp4')], onFlaggedInMessageForm, }) act(() => { joltEventCallbacks['attachment.flagged'](flagEvent('att-clip.mp4')) }) expect(onFlaggedInMessageForm).toHaveBeenCalledWith('att-clip.mp4') }) it('does not call onFlaggedInMessageForm when the attachment is not in compose', () => { const onFlaggedInMessageForm = jest.fn() renderUploader({ onFlaggedInMessageForm }) act(() => { joltEventCallbacks['attachment.flagged'](flagEvent('sent-att-1')) }) expect(onFlaggedInMessageForm).not.toHaveBeenCalled() }) }) })