import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { ApiError, NetworkError } from '@/composables/useApi' import { useSessionStore } from '@/stores/session' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { FakeXhr, MEGABYTE, announcer, bulkBar, callsTo, cards, chooseFiles, dragEvent, deferred, fileInput, fileWith, findButton, fixture, hexOf, inFlight, listBody, listCalls, mountPage, rowTexts, settle, stubDigest, stubXhr, tab, textOf, unmountPage, uploadPanel, uploadRows, xhrFor, type Handlers, } from './mediaTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() })) vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => toasts } }) const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' const UNREADABLE_TEXT = "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library." function photo(name: string, size = 1000) { return fileWith(name, 'image/jpeg', size) } function hashLookups() { return callsTo('GET', /\?hash=/) } function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] { return (path) => path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([]) } function headerButton(label: string) { return findButton(label) } beforeEach(() => { toasts.add.mockClear() toasts.remove.mockClear() vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) stubDigest() stubXhr() }) afterEach(unmountPage) describe('Upload entry points', () => { it.each([ ['at md width or more', 'Upload', 1440], ['below md width', 'Upload', MOBILE_WIDTH], ])( 'the Default state loaded %s: "%s" opens the file chooser', async (_context, label, width) => { await mountPage({}, '/media', width) const click = vi.spyOn(HTMLInputElement.prototype, 'click') const buttons = Array.from(document.body.querySelectorAll('button')).filter( (button) => textOf(button) === label, ) expect(buttons).toHaveLength(1) buttons[0]!.click() expect(click).toHaveBeenCalledTimes(1) expect(click.mock.contexts[0]).toBe(fileInput()) }, ) it('and the library has no image: "Upload an image" opens the file chooser', async () => { await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) }) const click = vi.spyOn(HTMLInputElement.prototype, 'click') findButton('Upload an image')!.click() expect(click.mock.contexts[0]).toBe(fileInput()) }) it('the chooser accepts several files and offers the four image types', async () => { await mountPage() expect(fileInput().multiple).toBe(true) expect(fileInput().getAttribute('accept')).toBe( 'image/png,image/jpeg,image/webp,image/svg+xml', ) }) }) describe('Dropping files anywhere on the page', () => { it('shows the drop area while files are dragged over, then uploads the drop with no crop step', async () => { await mountPage() dragEvent('dragenter', ['Files']) await flushPromises() const area = document.body.querySelector('[data-testid="media-drop-area"]')! expect(textOf(area)).toBe('Drop images to upload PNG, JPEG, WEBP, SVG · max 10 MB each') expect(area.className).toContain('border-dashed') expect(area.className).toContain('border-primary') dragEvent('dragleave', ['Files']) await flushPromises() expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull() expect(FakeXhr.instances).toHaveLength(0) expect(uploadPanel()).toBeNull() dragEvent('dragenter', ['Files']) dragEvent('drop', ['Files'], [photo('a.jpg'), photo('b.jpg')]) await settle() expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull() expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg', 'b.jpg']) expect(document.body.querySelector('[role="dialog"]')).toBeNull() }) it('hides the mobile pinned Upload button while the area shows', async () => { await mountPage({}, '/media', MOBILE_WIDTH) expect(headerButton('Upload')).toBeTruthy() dragEvent('dragenter', ['Files']) await flushPromises() expect(headerButton('Upload')).toBeUndefined() dragEvent('dragleave', ['Files']) await flushPromises() expect(headerButton('Upload')).toBeTruthy() }) it('does nothing when something that is not a file is dragged over', async () => { await mountPage() const event = dragEvent('dragenter', ['text/plain']) await flushPromises() expect(event.defaultPrevented).toBe(false) expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull() }) }) describe('Browser checks, nothing is sent', () => { it.each([ ['scan.tif', 14.2 * MEGABYTE, 'image/tiff', 'scan.tif is 14.2 MB'], ['notes.pdf', 210 * 1024, 'application/pdf', "notes.pdf isn't an image"], ['photo.png', 10.1 * MEGABYTE, 'image/png', 'photo.png is 10.1 MB'], ['fake.jpg', 40 * 1024, 'text/plain', "fake.jpg isn't an image"], ['logo.gif', 40 * 1024, 'image/gif', "logo.gif isn't an image"], ['pic.jpg', 40 * 1024, 'image/png', "pic.jpg isn't an image"], ])('%s becomes a rejected row', async (name, size, mime, title) => { await mountPage() const digest = globalThis.crypto.subtle.digest await chooseFiles([fileWith(name, mime, size)]) const [row] = uploadRows() expect(row!.getAttribute('data-status')).toBe('rejected') expect(textOf(row)).toContain(title) expect(textOf(row)).toContain(REJECTION_TEXT) await vi.waitFor(() => expect(row!.querySelector('svg.iconify--lucide')).toBeTruthy()) expect(digest).not.toHaveBeenCalled() expect(hashLookups()).toHaveLength(0) expect(FakeXhr.instances).toHaveLength(0) }) it('accepts exactly 10485760 bytes and rejects one byte more', async () => { await mountPage() await chooseFiles([ fileWith('edge.png', 'image/png', 10485760), fileWith('over.png', 'image/png', 10485761), ]) expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([ 'uploading', 'rejected', ]) expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['edge.png']) }) it('checks the size before the type', async () => { await mountPage() await chooseFiles([fileWith('huge.tif', 'image/tiff', 11 * MEGABYTE)]) expect(textOf(uploadRows()[0])).toContain('huge.tif is 11.0 MB') }) it('leaves the panel with "Nothing was uploaded" when every file is rejected', async () => { await mountPage() await chooseFiles([ fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE), fileWith('notes.pdf', 'application/pdf', 210 * 1024), fileWith('broken.png', 'image/png', 3 * MEGABYTE), ]) xhrFor('broken.png').respond(400) await settle() const panel = uploadPanel()! expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe( 'Nothing was uploaded', ) expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe( '3 files were rejected', ) expect(findButton('Dismiss', panel)).toBeTruthy() expect(panel.querySelector('[data-testid="upload-summary"]')).toBeNull() const [scan, notes, broken] = rowTexts() expect(scan).toContain(`scan.tif is 14.2 MB ${REJECTION_TEXT}`) expect(notes).toContain(`notes.pdf isn't an image ${REJECTION_TEXT}`) expect(broken).toContain(`broken.png couldn't be read ${UNREADABLE_TEXT}`) }) it('reads "2 files need attention" for a rejected and a failed file, with no summary', async () => { await mountPage() await chooseFiles([fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE), photo('roof.jpg')]) xhrFor('roof.jpg').respond(500) await settle() expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe( 'Nothing was uploaded', ) expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe( '2 files need attention', ) expect(uploadPanel()!.querySelector('[data-testid="upload-summary"]')).toBeNull() }) }) describe('The upload panel and its rows', () => { function sixImages() { return Array.from({ length: 6 }, (_, index) => photo(`img-${index + 1}.jpg`, 2.6 * MEGABYTE), ) } it('uploads two at a time, in order, with progress', async () => { await mountPage() await chooseFiles(sixImages()) const panel = uploadPanel()! expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe( 'Uploading 6 images', ) expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('0 of 6 done') expect(findButton('Cancel all', panel)).toBeTruthy() expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([ 'uploading', 'uploading', 'waiting', 'waiting', 'waiting', 'waiting', ]) expect(textOf(uploadRows()[2])).toContain('Waiting') expect(textOf(uploadRows()[2])).toContain('2.6 MB') expect(inFlight()).toHaveLength(2) xhrFor('img-1.jpg').progress(1.1 * MEGABYTE, 2.6 * MEGABYTE) await flushPromises() expect(textOf(uploadRows()[0])).toContain('Uploading 1.1 of 2.6 MB') expect( uploadRows()[0]!.querySelector('[role="progressbar"]')!.getAttribute('aria-valuenow'), ).toBe('42') expect( uploadRows()[0]!.querySelector(`[aria-label="Cancel upload of img-1.jpg"]`), ).toBeTruthy() xhrFor('img-1.jpg').respond(201) await settle() expect(textOf(uploadRows()[0])).toContain('Added to your library') await vi.waitFor(() => expect(uploadRows()[0]!.querySelector('svg.iconify--lucide')).toBeTruthy(), ) expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('1 of 6 done') expect(uploadRows()[2]!.getAttribute('data-status')).toBe('uploading') expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([ 'img-1.jpg', 'img-2.jpg', 'img-3.jpg', ]) expect(inFlight()).toHaveLength(2) expect(FakeXhr.instances[0]!.url).toBe('/v1/media/') expect(FakeXhr.instances[0]!.method).toBe('POST') expect(FakeXhr.instances[0]!.withCredentials).toBe(true) }) it('never has more than two requests in flight while draining the queue', async () => { await mountPage() await chooseFiles(sixImages()) let peak = inFlight().length for (let guard = 0; guard < 6 && inFlight().length > 0; guard++) { inFlight()[0]!.respond(201) await settle() peak = Math.max(peak, inFlight().length) } expect(peak).toBe(2) expect(FakeXhr.instances).toHaveLength(6) expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe( 'Upload finished', ) }) it('counts settled rows in the header', async () => { await mountPage({ hash: hashHandlerFor('dup.jpg') }) await chooseFiles([ photo('added.jpg'), photo('dup.jpg'), fileWith('rej.tif', 'image/tiff', 1000), photo('fail.jpg'), photo('up-1.jpg'), photo('up-2.jpg'), ]) xhrFor('added.jpg').respond(201) await settle() xhrFor('fail.jpg').respond(500) await settle() expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([ 'added', 'duplicate', 'rejected', 'failed', 'uploading', 'uploading', ]) expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe( '4 of 6 done', ) }) it('uses the singular for one image', async () => { await mountPage() await chooseFiles([photo('one.jpg')]) expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe( 'Uploading 1 image', ) }) it('appends files added while the panel is open', async () => { await mountPage() await chooseFiles([photo('a.jpg'), photo('b.jpg')]) await chooseFiles([photo('c.jpg')]) expect(uploadRows()).toHaveLength(3) expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe( 'Uploading 3 images', ) expect(textOf(uploadRows()[2])).toContain('c.jpg') }) it('refreshes the list in the background after a 201', async () => { let calls = 0 await mountPage({ list: () => ++calls === 1 ? listBody(fixture()) : listBody(fixture(), { all: 13, unused: 2, duplicates: 2 }), }) expect(listCalls()).toHaveLength(1) await chooseFiles([photo('a.jpg')]) xhrFor('a.jpg').respond(201) await settle() expect(listCalls()).toHaveLength(2) expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength( 0, ) expect(tab('All').textContent).toContain('13') }) }) describe('A row whose content already exists', () => { const existingText = 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.' async function mountWithDuplicate(handlers: Handlers = {}) { await mountPage({ hash: hashHandlerFor('floorboards-2.jpg'), ...handlers }) await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)]) } it('looks up the hash before any POST and shows the duplicate row, naming the oldest match', async () => { await mountWithDuplicate() expect(hashLookups().map(([path]) => path)).toEqual([ `/v1/media/?hash=${hexOf('floorboards-2.jpg')}`, ]) expect(FakeXhr.instances).toHaveLength(0) const row = uploadRows()[0]! expect(row.getAttribute('data-status')).toBe('duplicate') expect(textOf(row)).toContain('Already in your library') expect(textOf(row)).toContain(existingText) expect(findButton('Use existing', row)).toBeTruthy() expect(findButton('Upload anyway', row)).toBeTruthy() }) it('hashes inside the concurrency slot while the row reads Waiting', async () => { const first = deferred() const second = deferred() const answers = [first, second] await mountPage({ hash: () => (answers.shift() ?? deferred()).promise }) await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')]) expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([ 'waiting', 'waiting', 'waiting', ]) expect(hashLookups()).toHaveLength(2) expect(FakeXhr.instances).toHaveLength(0) first.resolve(listBody([])) await settle() expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg']) expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading') expect(hashLookups()).toHaveLength(2) xhrFor('a.jpg').respond(201) await settle() expect(hashLookups()).toHaveLength(3) }) it('releases the concurrency slot so the next file starts', async () => { await mountPage({ hash: hashHandlerFor('dup.jpg') }) await chooseFiles([photo('dup.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')]) expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([ 'duplicate', 'uploading', 'uploading', 'waiting', ]) }) it('Use existing drops the row and selects the image', async () => { await mountWithDuplicate() findButton('Use existing')!.click() await settle() expect(uploadPanel()).toBeNull() expect(textOf(bulkBar())).toContain('1 selected') expect(announcer()).toBe('floorboards.jpg selected, 1 selected') }) it('Use existing resets the search and the tab when the image is not loaded', async () => { await mountPage( { hash: hashHandlerFor('floorboards-2.jpg'), list: (path) => path.includes('filter=unused') ? listBody(fixture([8, 10])) : listBody(fixture()), }, '/media?filter=unused', ) await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)]) findButton('Use existing')!.click() await settle() expect(router.currentRoute.value.query).toEqual({}) expect(tab('All').getAttribute('aria-selected')).toBe('true') expect(textOf(bulkBar())).toContain('1 selected') expect(announcer()).toBe('floorboards.jpg selected, 1 selected') }) it('Use existing drops the row without selecting when the image is still not loaded', async () => { await mountWithDuplicate({ list: () => listBody(fixture().filter((item) => item.id !== 12)), }) findButton('Use existing')!.click() await settle() expect(uploadPanel()).toBeNull() expect(bulkBar()).toBeNull() }) it('Upload anyway stores a second copy and the Duplicates count follows', async () => { let calls = 0 await mountWithDuplicate({ list: () => ++calls === 1 ? listBody(fixture()) : listBody(fixture(), { all: 13, unused: 2, duplicates: 3 }), }) findButton('Upload anyway')!.click() await settle() expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['floorboards-2.jpg']) expect(hashLookups()).toHaveLength(1) expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading') FakeXhr.instances[0]!.respond(201) await settle() expect(textOf(uploadRows()[0])).toContain('Added to your library') expect(tab('Duplicates').textContent).toContain('3') }) it.each([ [ 'crypto.subtle is undefined', () => vi.stubGlobal('crypto', { subtle: undefined }), undefined, ], ['the lookup fails with 500', () => undefined, () => Promise.reject(new ApiError(500))], [ 'the lookup never reaches the server', () => undefined, () => Promise.reject(new NetworkError()), ], ])('skips the duplicate check when %s', async (_name, prepare, hash) => { prepare() await mountPage({ hash }) await chooseFiles([photo('a.jpg')]) expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg']) expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading') }) }) describe('Server answers for an upload that passed the checks', () => { it.each([ ['roof.png', 1.4 * MEGABYTE, 201, 'Added to your library'], ['scan.png', 9.9 * MEGABYTE, 413, `scan.png is 9.9 MB ${REJECTION_TEXT}`], ['notes.png', 210 * 1024, 415, `notes.png isn't an image ${REJECTION_TEXT}`], ['broken.png', 3 * MEGABYTE, 400, `broken.png couldn't be read ${UNREADABLE_TEXT}`], ['roof.png', 1.4 * MEGABYTE, 500, `Upload failed ${FAILED_TEXT} Try again`], ['roof.png', 1.4 * MEGABYTE, 0, `Upload failed ${FAILED_TEXT} Try again`], ])('%s of %i bytes resolving %i reads the right row', async (name, size, status, text) => { await mountPage() await chooseFiles([fileWith(name, 'image/png', size)]) if (status === 0) { FakeXhr.instances[0]!.fail() } else { FakeXhr.instances[0]!.respond(status) } await settle() const row = uploadRows()[0]! expect(textOf(row)).toContain(text) await vi.waitFor(() => expect(row.querySelector('svg.iconify--lucide')).toBeTruthy()) }) it('Try again retries one file with no new duplicate lookup', async () => { await mountPage() await chooseFiles([photo('roof.jpg'), photo('other.jpg')]) xhrFor('roof.jpg').respond(500) await settle() expect(hashLookups()).toHaveLength(2) findButton('Try again')!.click() await settle() expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([ 'roof.jpg', 'other.jpg', 'roof.jpg', ]) expect(hashLookups()).toHaveLength(2) expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading') }) }) describe('Cancelling one upload or all of them', () => { it('the row x aborts that request, removes the row and starts the next waiting file', async () => { await mountPage() await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')]) document.body .querySelector('[aria-label="Cancel upload of a.jpg"]')! .click() await settle() expect(xhrFor('a.jpg').aborted).toBe(true) expect(uploadRows().map((row) => textOf(row).split(' ')[0])).toEqual(['b.jpg', 'c.jpg']) expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg', 'c.jpg']) }) it('Cancel all aborts what is in flight, removes waiting rows and keeps settled ones', async () => { await mountPage() await chooseFiles([ photo('a.jpg'), photo('b.jpg'), photo('c.jpg'), fileWith('bad.tif', 'image/tiff', 10), ]) xhrFor('a.jpg').respond(201) await settle() findButton('Cancel all')!.click() await settle() expect(xhrFor('b.jpg').aborted).toBe(true) expect(xhrFor('c.jpg').aborted).toBe(true) expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([ 'added', 'rejected', ]) }) it('closes the panel when no row is left', async () => { await mountPage() await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')]) findButton('Cancel all')!.click() await settle() expect(uploadPanel()).toBeNull() expect(FakeXhr.instances.every((xhr) => xhr.aborted)).toBe(true) }) }) describe('The panel settles with a summary', () => { it('settles with the counts, announces the summary and Dismiss removes every row', async () => { await mountPage({ hash: hashHandlerFor('dup.jpg') }) await chooseFiles([ photo('one.jpg'), photo('two.jpg'), photo('dup.jpg'), fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE), photo('roof.jpg'), ]) xhrFor('one.jpg').respond(201) xhrFor('two.jpg').respond(201) await settle() xhrFor('roof.jpg').respond(500) await settle() const panel = uploadPanel()! expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe('Upload finished') expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('5 images') expect(findButton('Dismiss', panel)).toBeTruthy() expect(findButton('Cancel all', panel)).toBeUndefined() expect(textOf(panel.querySelector('[data-testid="upload-summary"]'))).toBe( '2 uploaded · 1 already in your library · 2 need attention', ) expect(announcer()).toBe('2 uploaded · 1 already in your library · 2 need attention') findButton('Dismiss', panel)!.click() await flushPromises() expect(uploadPanel()).toBeNull() expect(uploadRows()).toHaveLength(0) }) }) describe('Too many uploads (429)', () => { async function rateLimit(retryAfter?: string, width = 1440) { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) await mountPage({}, '/media', width) await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')]) xhrFor('a.jpg').respond(429, retryAfter === undefined ? {} : { 'Retry-After': retryAfter }) await settle() } it('shows the alert, counts down in a disabled button and pauses the queue', async () => { await rateLimit('42') const alert = Array.from(document.body.querySelectorAll('[role="status"]')).find( (element) => textOf(element).includes('Too many uploads'), ) expect(textOf(alert)).toBe( 'Too many uploads For your security, wait a moment before uploading again.', ) const button = findButton('Try again in 0:42')! expect(button.disabled).toBe(true) await vi.waitFor(() => expect(button.querySelector('svg.iconify--lucide')).toBeTruthy()) expect(findButton('Upload')).toBeUndefined() expect(uploadRows()[0]!.getAttribute('data-status')).toBe('waiting') expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg']) expect(FakeXhr.instances).toHaveLength(2) }) it('resumes by itself when the countdown reaches 0:00, keeping Added rows', async () => { await rateLimit('42') xhrFor('b.jpg').respond(201) await settle() expect(FakeXhr.instances).toHaveLength(2) vi.advanceTimersByTime(1000) await flushPromises() expect(findButton('Try again in 0:41')).toBeTruthy() vi.advanceTimersByTime(41000) await settle() expect(document.body.textContent).not.toContain('Too many uploads') expect(findButton('Upload')!.disabled).toBe(false) expect(uploadRows()[1]!.getAttribute('data-status')).toBe('added') expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([ 'a.jpg', 'b.jpg', 'a.jpg', 'c.jpg', ]) }) it('counts 60 seconds when Retry-After is missing or unreadable', async () => { await rateLimit(undefined) expect(findButton('Try again in 1:00')).toBeTruthy() }) it('disables the mobile pinned button too', async () => { await rateLimit('42', MOBILE_WIDTH) const pinned = findButton('Try again in 0:42')! expect(pinned.disabled).toBe(true) expect(pinned.className).toContain('h-12') }) it('ignores drops and the drop area while counting', async () => { await rateLimit('42') const before = FakeXhr.instances.length dragEvent('dragenter', ['Files']) await flushPromises() expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull() dragEvent('drop', ['Files'], [photo('z.jpg')]) await settle() expect(FakeXhr.instances).toHaveLength(before) expect(uploadRows()).toHaveLength(4) }) }) describe('Offline', () => { async function goOffline() { vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) window.dispatchEvent(new Event('offline')) await flushPromises() } it('disables the Upload controls and ignores drops, then enables them again', async () => { await mountPage() await goOffline() expect(document.body.textContent).toContain("You're offline.") expect(findButton('Upload')!.disabled).toBe(true) dragEvent('dragenter', ['Files']) await flushPromises() expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull() dragEvent('drop', ['Files'], [photo('z.jpg')]) await settle() expect(FakeXhr.instances).toHaveLength(0) expect(cards()).toHaveLength(12) vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) window.dispatchEvent(new Event('online')) await flushPromises() expect(findButton('Upload')!.disabled).toBe(false) }) it('disables the mobile pinned button and "Upload an image"', async () => { await mountPage({}, '/media', MOBILE_WIDTH) await goOffline() expect(findButton('Upload')!.disabled).toBe(true) await unmountPage() stubDigest() stubXhr() await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) }) await goOffline() expect(findButton('Upload an image')!.disabled).toBe(true) }) }) describe('Session errors on the upload calls are global', () => { it.each([ [401, '/login', { redirect: '/media', reason: 'expired' }], [403, '/register/pending', {}], ])( 'an XHR upload resolving %i sends the user to %s with no local copy', async (status, path, query) => { await mountPage() useSessionStore().session = { verified: false, username: 'marta', id: 1, displayName: '', role: 'owner', } await chooseFiles([photo('a.jpg')]) FakeXhr.instances[0]!.respond(status) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(path)) await settle() expect(router.currentRoute.value.path).toBe(path) expect(router.currentRoute.value.query).toEqual(query) expect(document.body.textContent).not.toContain('Upload failed') expect(toasts.add).not.toHaveBeenCalled() }, ) })