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')
})
})