feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -0,0 +1,844 @@
|
||||
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<typeof import('@/composables/useApi')>()
|
||||
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<typeof import('@nuxt/ui/composables')>()
|
||||
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<unknown>()
|
||||
const second = deferred<unknown>()
|
||||
const answers = [first, second]
|
||||
await mountPage({ hash: () => (answers.shift() ?? deferred<unknown>()).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<HTMLButtonElement>('[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()
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user