import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import UploadView from '@/views/UploadView.vue' import { useUploadStore } from '@/stores/upload' import { useDevicesStore } from '@/stores/devices' import { useImagesStore } from '@/stores/images' import type { Device } from '@/types' const routerReplace = vi.fn() vi.mock('@/components/CropEditor.vue', () => ({ default: { name: 'CropEditor', template: '
', props: ['src', 'orientation', 'deviceName', 'initialParams', 'initialOrientation'], emits: ['crop'], }, })) vi.mock('@/components/StickerCanvas.vue', () => ({ default: { name: 'StickerCanvas', template: '
', props: ['croppedUrl', 'orientation', 'stickers'], emits: ['add-sticker', 'update-sticker', 'remove-sticker', 'done'], }, })) vi.mock('@/components/DevicePicker.vue', () => ({ default: { name: 'DevicePicker', template: '
', props: ['modelValue', 'devices', 'selected', 'uploading'], emits: ['update:modelValue', 'update:selected', 'confirm'], }, })) vi.mock('@/components/BaseButton.vue', () => ({ default: { name: 'BaseButton', template: '', props: ['variant', 'disabled'], emits: ['click'], }, })) vi.mock('vue-router', () => ({ useRouter: () => ({ replace: routerReplace, push: vi.fn() }), })) const toastShow = vi.fn() vi.mock('@/stores/toast', () => ({ useToastStore: () => ({ show: toastShow }), })) const makeDevice = (overrides: Partial = {}): Device => ({ id: 1, mac: 'AA', name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, wakeTimes: [], timezone: 'UTC', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', lastSeenAt: null, lockedImageId: null, currentImageId: null, ...overrides, }) function primeUploadStore(file?: File) { const upload = useUploadStore() upload.originalFile = file ?? new File(['x'], 'orig.jpg', { type: 'image/jpeg' }) upload.originalUrl = 'blob:original' return upload } describe('UploadView', () => { beforeEach(() => { setActivePinia(createPinia()) routerReplace.mockClear() toastShow.mockClear() vi.unstubAllGlobals() vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn(), }) }) it('redirects to / when no upload is in progress', async () => { vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() mount(UploadView) await flushPromises() expect(routerReplace).toHaveBeenCalledWith('/') }) it('starts on the crop step with the original URL passed to CropEditor', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() const crop = wrapper.findComponent({ name: 'CropEditor' }) expect(crop.exists()).toBe(true) expect(crop.props('src')).toBe('blob:original') expect(wrapper.text()).toContain('Crop photo') }) it('uses "Edit crop" as the step label when editing', async () => { const upload = primeUploadStore() upload.editingImageId = 7 vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() expect(wrapper.text()).toContain('Edit crop') }) it('falls back to landscape orientation when no devices are loaded', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('landscape') }) it('uses the context device orientation when contextDeviceId matches', async () => { const upload = primeUploadStore() upload.contextDeviceId = 2 const devices = useDevicesStore() devices.devices = [makeDevice({ id: 1, orientation: 'landscape' }), makeDevice({ id: 2, orientation: 'portrait' })] vi.spyOn(devices, 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('portrait') expect(wrapper.findComponent({ name: 'CropEditor' }).props('deviceName')).toBe(devices.devices[1].name) }) it('uses the first device as the context when no contextDeviceId is set', async () => { primeUploadStore() const devices = useDevicesStore() devices.devices = [makeDevice({ id: 1, orientation: 'portrait' })] vi.spyOn(devices, 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('portrait') }) it('crop emit advances to stickers step and stores the crop on the upload store', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() const blob = new Blob(['x']) const params = { natX: 0, natY: 0, natW: 100, natH: 50 } await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params, orientation: 'landscape' }) await flushPromises() expect(wrapper.findComponent({ name: 'StickerCanvas' }).exists()).toBe(true) expect(wrapper.text()).toContain('Add stickers') }) it('skip on stickers step opens the device picker for new uploads', async () => { const upload = primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() const blob = new Blob(['x']) await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape' }) upload.croppedBlob = blob await flushPromises() const skip = wrapper.find('.upload-view__skip') await skip.trigger('click') await flushPromises() expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true) }) it('skip on stickers triggers reprocess directly when editing', async () => { const upload = primeUploadStore() upload.editingImageId = 11 const devices = useDevicesStore() vi.spyOn(devices, 'fetchDevices').mockResolvedValue() const images = useImagesStore() const reprocess = vi.spyOn(images, 'reprocessImage').mockResolvedValue({ id: 11 } as any) const wrapper = mount(UploadView) await flushPromises() const blob = new Blob(['x']) await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape' }) upload.croppedBlob = blob await flushPromises() await wrapper.find('.upload-view__skip').trigger('click') await flushPromises() expect(reprocess).toHaveBeenCalled() expect(wrapper.text()).toContain('Photo updated!') }) it('skip is a no-op when there is no cropped blob yet', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() // Force into stickers without having a croppedBlob await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: null as any, params: null as any, orientation: 'landscape' }) // Even if we click skip, no DevicePicker should open const skip = wrapper.find('.upload-view__skip') if (skip.exists()) await skip.trigger('click') expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false) }) it('stickers done opens the picker for new uploads', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true) }) it('stickers done triggers reprocess directly when editing', async () => { const upload = primeUploadStore() upload.editingImageId = 22 vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const reprocess = vi.spyOn(useImagesStore(), 'reprocessImage').mockResolvedValue({ id: 22 } as any) const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() expect(reprocess).toHaveBeenCalled() expect(wrapper.text()).toContain('Photo updated!') }) it('back from crop cleans up and routes to /library', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.find('.upload-view__back').trigger('click') expect(routerReplace).toHaveBeenCalledWith('/library') }) it('back from stickers returns to the crop step', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() expect(wrapper.text()).toContain('Add stickers') await wrapper.find('.upload-view__back').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Crop photo') }) it('confirm in the device picker uploads, sets approvals, and shows the done step', async () => { const upload = primeUploadStore() upload.selectedDeviceIds = [1, 2] const images = useImagesStore() const uploadSpy = vi.spyOn(images, 'uploadImage').mockResolvedValue({ id: 100 } as any) const approvalSpy = vi.spyOn(images, 'setApproval').mockResolvedValue() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() expect(uploadSpy).toHaveBeenCalled() expect(approvalSpy).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('Photo added!') }) it('upload errors surface as a toast', async () => { primeUploadStore() vi.spyOn(useImagesStore(), 'uploadImage').mockRejectedValue(new Error('disk full')) vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() expect(toastShow).toHaveBeenCalledWith('disk full', 'error') }) it('falls back to a generic message for non-Error rejections', async () => { primeUploadStore() vi.spyOn(useImagesStore(), 'uploadImage').mockRejectedValue('weird') vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() expect(toastShow).toHaveBeenCalledWith('Upload failed', 'error') }) it('Done button on the success step routes to /library', async () => { primeUploadStore() vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any) vi.spyOn(useImagesStore(), 'setApproval').mockResolvedValue() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() routerReplace.mockClear() await wrapper.find('.upload-view__done-btn').trigger('click') expect(routerReplace).toHaveBeenCalledWith('/library') }) it('updates selectedDeviceIds when the picker emits update:selected', async () => { const upload = primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('update:selected', [3, 4]) expect(upload.selectedDeviceIds).toEqual([3, 4]) }) it('forwards sticker emits to the upload store', async () => { const upload = primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() const canvas = wrapper.findComponent({ name: 'StickerCanvas' }) const sticker = { id: 's1', type: 'emoji', x: 0, y: 0, scale: 1, rotation: 0 } as any await canvas.vm.$emit('add-sticker', sticker) expect(upload.stickers).toHaveLength(1) await canvas.vm.$emit('update-sticker', 's1', { x: 9 }) expect(upload.stickers[0].x).toBe(9) await canvas.vm.$emit('remove-sticker', 's1') expect(upload.stickers).toHaveLength(0) }) it('closes the device picker when it emits update:modelValue=false', async () => { primeUploadStore() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() const picker = wrapper.findComponent({ name: 'DevicePicker' }) expect(picker.props('modelValue')).toBe(false) await picker.vm.$emit('update:modelValue', true) await wrapper.vm.$nextTick() expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true) await picker.vm.$emit('update:modelValue', false) await wrapper.vm.$nextTick() expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false) }) it('confirm fired before any stickers/crop is a no-op (defensive guard)', async () => { primeUploadStore() const uploadSpy = vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any) vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() // Picker exists but finalBlob is null — confirm should early-return await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() expect(uploadSpy).not.toHaveBeenCalled() }) it('reprocess on edit forwards undefined when cropParams/cropOrientation are null', async () => { const upload = primeUploadStore() upload.editingImageId = 33 upload.cropParams = null upload.cropOrientation = null vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const reprocess = vi.spyOn(useImagesStore(), 'reprocessImage').mockResolvedValue({ id: 33 } as any) const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() // After crop, upload.cropParams is set by setCrop. Reset to null to exercise null branch. upload.cropParams = null upload.cropOrientation = null await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() expect(reprocess).toHaveBeenCalledWith(33, expect.any(File), expect.objectContaining({ cropParams: undefined, cropOrientation: undefined, })) }) it('uploadImage on a new upload forwards undefined for null optional fields', async () => { const upload = primeUploadStore() upload.originalFile = null upload.cropParams = null upload.cropOrientation = null vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const uploadSpy = vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any) // No originalFile → onMounted will redirect; instead set it after the redirect would have run upload.originalFile = new File(['x'], 'x.jpg') upload.originalUrl = 'blob:x' const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() // Reset to null after onCrop sets these via the store's setCrop upload.cropParams = null upload.cropOrientation = null upload.originalFile = null await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() expect(uploadSpy).toHaveBeenCalledWith(expect.any(File), expect.objectContaining({ original: undefined, cropParams: undefined, cropOrientation: undefined, })) }) it('does not render the device picker when in edit mode', async () => { const upload = primeUploadStore() upload.editingImageId = 12 vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() expect(wrapper.findComponent({ name: 'DevicePicker' }).exists()).toBe(false) }) it('hides the back button on the done step', async () => { primeUploadStore() vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any) vi.spyOn(useImagesStore(), 'setApproval').mockResolvedValue() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() const wrapper = mount(UploadView) await flushPromises() await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: new Blob(['x']), params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape', }) await flushPromises() await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final'])) await flushPromises() await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm') await flushPromises() expect(wrapper.find('.upload-view__back').exists()).toBe(false) expect(wrapper.text()).toContain('Added') }) })