import { renderHook, act, waitFor } from '@testing-library/react' import { useComposerUploads, type AttachmentUploader } from './useComposerUploads' function fakeUploader(overrides: Partial = {}): AttachmentUploader { return { uploadAttachment: jest.fn(async (_id: string, file: File) => ({ id: `srv-${file.name}`, fileUrl: `https://cdn/${file.name}`, })), deleteAttachment: jest.fn(async () => undefined), ...overrides, } } function file(name: string, type = 'image/png') { return new File(['x'], name, { type }) } beforeEach(() => { if (!('createObjectURL' in URL)) { // @ts-expect-error test shim URL.createObjectURL = jest.fn(() => 'blob:preview') } if (!('revokeObjectURL' in URL)) { // @ts-expect-error test shim URL.revokeObjectURL = jest.fn() } }) describe('useComposerUploads', () => { it('uploads immediately when a real documentId exists and reports ready', async () => { const uploader = fakeUploader() const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1')) act(() => result.current.attachFiles([file('logo.png')])) expect(result.current.attachments).toHaveLength(1) await waitFor(() => expect(result.current.attachments[0].status).toBe('ready')) expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1) expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object)) }) it('non-image files pass through downscale untouched (still upload)', async () => { const uploader = fakeUploader() const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1')) const pdf = file('notes.pdf', 'application/pdf') act(() => result.current.attachFiles([pdf])) await waitFor(() => expect(result.current.attachments[0].status).toBe('ready')) // The exact File instance is forwarded (downscale is a no-op on non-images). expect((uploader.uploadAttachment as jest.Mock).mock.calls[0][1]).toBe(pdf) }) it('holds files client-side when there is no documentId yet, then uploads once it appears (new-deck case)', async () => { const uploader = fakeUploader() let docId: string | undefined const { result, rerender } = renderHook(({ id }: { id?: string }) => useComposerUploads(uploader, id), { initialProps: { id: docId }, }) act(() => result.current.attachFiles([file('brand.png')])) expect(result.current.attachments).toHaveLength(1) expect(uploader.uploadAttachment).not.toHaveBeenCalled() docId = 'doc-new' rerender({ id: docId }) await waitFor(() => expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1)) expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-new', expect.any(File), expect.any(Object)) await waitFor(() => expect(result.current.attachments[0].status).toBe('ready')) }) it('marks a chip as error (with the failure reason) when the upload fails', async () => { const uploader = fakeUploader({ uploadAttachment: jest.fn().mockRejectedValue(new Error('boom')), }) const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1')) act(() => result.current.attachFiles([file('bad.png')])) await waitFor(() => expect(result.current.attachments[0].status).toBe('error')) expect(result.current.attachments[0].error).toBe('boom') }) it('retryAttachment re-uploads a failed chip and reaches ready', async () => { const upload = jest .fn() .mockRejectedValueOnce(new Error('flaky network')) .mockResolvedValueOnce({ id: 'srv-retry', fileUrl: 'https://cdn/r.png' }) const uploader = fakeUploader({ uploadAttachment: upload }) const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1')) act(() => result.current.attachFiles([file('r.png')])) await waitFor(() => expect(result.current.attachments[0].status).toBe('error')) const chipId = result.current.attachments[0].id act(() => result.current.retryAttachment(chipId)) await waitFor(() => expect(result.current.attachments[0].status).toBe('ready')) expect(result.current.attachments[0].error).toBeUndefined() expect(upload).toHaveBeenCalledTimes(2) }) it('removes a pending chip and deletes an uploaded attachment server-side', async () => { const uploader = fakeUploader() const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1')) act(() => result.current.attachFiles([file('a.png')])) await waitFor(() => expect(result.current.attachments[0].status).toBe('ready')) const chipId = result.current.attachments[0].id act(() => result.current.removeAttachment(chipId)) expect(result.current.attachments).toHaveLength(0) expect(uploader.deleteAttachment).toHaveBeenCalledWith('doc-1', 'srv-a.png') }) it('surfaces uploaded refs via onAttachmentsChange when a chip reaches ready', async () => { const uploader = fakeUploader() const onAttachmentsChange = jest.fn() const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1', { onAttachmentsChange }) ) act(() => result.current.attachFiles([file('hero.png')])) await waitFor(() => expect(result.current.attachments[0].status).toBe('ready')) await waitFor(() => expect(onAttachmentsChange).toHaveBeenLastCalledWith([ { id: 'srv-hero.png', fileUrl: 'https://cdn/hero.png', name: 'hero.png' }, ]) ) }) // --- Regression: prod "dropped images never leave 0" (startsim-0fp3) --- // When there is no document path (demo engine / brand-new deck whose document // never arrives), a queued file used to surface as 'uploading' at progress 0 // indefinitely — no honest state, no way to know it's parked, no bound. describe('no-document strand (startsim-0fp3)', () => { it('surfaces a queued file with an honest non-uploading status, not 0%-uploading-forever', () => { const uploader = fakeUploader() const { result } = renderHook(() => useComposerUploads(uploader, undefined)) act(() => result.current.attachFiles([file('parked.png')])) expect(result.current.attachments).toHaveLength(1) // The user must be able to tell this is parked, not actively uploading. expect(uploader.uploadAttachment).not.toHaveBeenCalled() expect(result.current.attachments[0].status).toBe('queued') }) it('removes a queued (never-uploaded) chip and revokes its object url', () => { const revoke = jest.spyOn(URL, 'revokeObjectURL') const uploader = fakeUploader() const { result } = renderHook(() => useComposerUploads(uploader, undefined)) act(() => result.current.attachFiles([file('parked.png')])) const chipId = result.current.attachments[0].id act(() => result.current.removeAttachment(chipId)) expect(result.current.attachments).toHaveLength(0) expect(revoke).toHaveBeenCalled() // Never uploaded → no server-side delete attempted. expect(uploader.deleteAttachment).not.toHaveBeenCalled() revoke.mockRestore() }) }) describe('clear() cleanup', () => { it('empties all chips and revokes every object url', async () => { const revoke = jest.spyOn(URL, 'revokeObjectURL') const uploader = fakeUploader() const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1')) act(() => result.current.attachFiles([file('a.png'), file('b.png')])) await waitFor(() => expect(result.current.attachments).toHaveLength(2)) act(() => result.current.clear()) expect(result.current.attachments).toHaveLength(0) expect(revoke).toHaveBeenCalledTimes(2) revoke.mockRestore() }) }) })