// Network-side tests for useTaskImages. Mocks the taskService module so we // exercise the composable's wiring (call ordering, refetch on success, // partial-failure handling, pending-state cleanup) without an axios dep. import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/api/taskService.js', () => ({ listTaskImages: vi.fn(), createTaskImage: vi.fn(), deleteTaskImage: vi.fn(), getTaskImageContent: vi.fn(), MAX_IMAGES_PER_TASK: 20, })) import { ref } from 'vue' import { listTaskImages, createTaskImage, deleteTaskImage, getTaskImageContent, } from '@/api/taskService.js' import { useTaskImages } from '@/composables/useTaskImages.js' beforeEach(() => { vi.clearAllMocks() }) describe('useTaskImages — addPending', () => { it('reads a File as data URL and pushes a pending entry', async () => { const img = useTaskImages() const fakeFile = { type: 'image/png', name: 'a.png' } // Stub FileReader for jsdom: trigger onload synchronously. class FakeReader { readAsDataURL() { this.result = 'data:image/png;base64,AAA' queueMicrotask(() => this.onload && this.onload()) } } vi.stubGlobal('FileReader', FakeReader) await img.addPending(fakeFile) expect(img.pendingImages.value).toHaveLength(1) expect(img.pendingImages.value[0]).toMatchObject({ mime_type: 'image/png', file_name: 'a.png', base64: 'data:image/png;base64,AAA', }) expect(typeof img.pendingImages.value[0].localId).toBe('string') vi.unstubAllGlobals() }) it('throws image_limit_reached when at MAX_IMAGES', async () => { const img = useTaskImages() img.images.value = Array.from({ length: 20 }, (_, i) => ({ id: i + 1 })) class FakeReader { readAsDataURL() {} } vi.stubGlobal('FileReader', FakeReader) await expect(img.addPending({ type: 'image/png', name: 'x.png' })).rejects.toThrow( 'image_limit_reached' ) vi.unstubAllGlobals() }) }) describe('useTaskImages — commit', () => { it('runs all deletes then all creates, returns aggregated result', async () => { const img = useTaskImages(ref(7)) img.images.value = [{ id: 1 }, { id: 2 }, { id: 3 }] img.pendingDeletes.value = [1, 2] img.pendingImages.value = [ { localId: 'p1', mime_type: 'image/png', file_name: 'a.png', base64: 'B1' }, { localId: 'p2', mime_type: 'image/png', file_name: 'b.png', base64: 'B2' }, ] deleteTaskImage.mockResolvedValue({}) createTaskImage.mockResolvedValueOnce({ id: 99, file_name: 'a.png' }) createTaskImage.mockResolvedValueOnce({ id: 100, file_name: 'b.png' }) listTaskImages.mockResolvedValueOnce([ { id: 3 }, { id: 99, file_name: 'a.png' }, { id: 100, file_name: 'b.png' }, ]) const result = await img.commit() // Order: deletes first (2 calls), then creates (2 calls). expect(deleteTaskImage).toHaveBeenCalledTimes(2) expect(deleteTaskImage.mock.calls[0]).toEqual([7, 1]) expect(deleteTaskImage.mock.calls[1]).toEqual([7, 2]) expect(createTaskImage).toHaveBeenCalledTimes(2) expect(createTaskImage.mock.calls[0][0]).toBe(7) expect(createTaskImage.mock.calls[0][1]).toEqual({ mime_type: 'image/png', file_name: 'a.png', image_base64: 'B1', }) expect(result.deleted).toEqual([1, 2]) expect(result.uploaded.map((u) => u.id)).toEqual([99, 100]) expect(result.failed).toEqual([]) // Pending cleared, refetch happened. expect(img.pendingDeletes.value).toEqual([]) expect(img.pendingImages.value).toEqual([]) expect(listTaskImages).toHaveBeenCalledWith(7) }) it('keeps remaining pendingImages when an upload fails (partial success)', async () => { const img = useTaskImages(ref(7)) img.pendingImages.value = [ { localId: 'p1', mime_type: 'image/png', file_name: 'a.png', base64: 'B1' }, { localId: 'p2', mime_type: 'image/png', file_name: 'b.png', base64: 'B2' }, { localId: 'p3', mime_type: 'image/png', file_name: 'c.png', base64: 'B3' }, ] createTaskImage.mockResolvedValueOnce({ id: 99, file_name: 'a.png' }) createTaskImage.mockRejectedValueOnce(new Error('soft cap is 20')) // p3 should never be attempted because we break on first failure. listTaskImages.mockResolvedValueOnce([{ id: 99 }]) const result = await img.commit() expect(createTaskImage).toHaveBeenCalledTimes(2) expect(result.uploaded.map((u) => u.id)).toEqual([99]) expect(result.failed).toHaveLength(1) expect(result.failed[0]).toMatchObject({ kind: 'upload', payload: { localId: 'p2' } }) // p1 cleared (uploaded), p2 + p3 remain for retry. expect(img.pendingImages.value.map((p) => p.localId)).toEqual(['p2', 'p3']) }) }) describe('useTaskImages — getContentUrl', () => { it('returns cached URL without re-fetching', async () => { const img = useTaskImages() img.images.value = [{ id: 42, task_id: 7 }] img.imageCache[42] = 'blob:abc' const url = await img.getContentUrl(42) expect(url).toBe('blob:abc') expect(getTaskImageContent).not.toHaveBeenCalled() }) it('fetches and caches on first call, returns blob URL', async () => { const img = useTaskImages(ref(7)) img.images.value = [{ id: 42, task_id: 7 }] const blob = new Blob(['x']) getTaskImageContent.mockResolvedValueOnce(blob) // jsdom URL.createObjectURL returns blob:https://.../ const url = await img.getContentUrl(42) expect(url).toMatch(/^blob:/) expect(img.imageCache[42]).toBe(url) expect(getTaskImageContent).toHaveBeenCalledWith(7, 42) }) it('returns null for imageId marked pending-delete', async () => { const img = useTaskImages() img.images.value = [{ id: 42, task_id: 7 }] img.pendingDeletes.value = [42] const url = await img.getContentUrl(42) expect(url).toBeNull() expect(getTaskImageContent).not.toHaveBeenCalled() }) })