fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill
Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,13 +9,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import FileuploadField from '../../src/components/form/fields/FileuploadField.vue'
|
||||
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
|
||||
import { FORM_SESSION, createUploadGate, type FormSession } from '../../src/components/form/formContext'
|
||||
import { UPLOAD_ID_HEADER } from '../../src/api/files'
|
||||
import { parentFileRoutes } from '../../src/api/files'
|
||||
import type { FileItem, FormField } from '../../src/api/types'
|
||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { deferredFilesFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
import { API, keydown, mockApi, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
|
||||
const KEY = 'k'.repeat(43)
|
||||
@@ -90,7 +91,7 @@ function item(id: number, name: string, extra: Partial<FileItem> = {}): FileItem
|
||||
}
|
||||
}
|
||||
|
||||
function listOf(files: FileItem[]): Route {
|
||||
function listOf(files: FileItem[]): Reply {
|
||||
return { body: { data: files, meta: {} } }
|
||||
}
|
||||
|
||||
@@ -105,6 +106,7 @@ async function setup(options: Setup = {}) {
|
||||
const field = options.field ?? photos()
|
||||
const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) })
|
||||
const markDirty = vi.fn()
|
||||
const { activeUploads, beginUpload } = createUploadGate()
|
||||
const session: FormSession = {
|
||||
key: KEY,
|
||||
recordId,
|
||||
@@ -112,6 +114,8 @@ async function setup(options: Setup = {}) {
|
||||
markDirty,
|
||||
pendingChanges: ref(0),
|
||||
revision: ref(0),
|
||||
beginUpload,
|
||||
activeUploads,
|
||||
}
|
||||
const wrapper = mount(FileuploadField, {
|
||||
props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' },
|
||||
@@ -119,7 +123,7 @@ async function setup(options: Setup = {}) {
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, calls, markDirty }
|
||||
return { wrapper, calls, markDirty, activeUploads }
|
||||
}
|
||||
|
||||
async function choose(wrapper: VueWrapper, files: File[]): Promise<void> {
|
||||
@@ -193,12 +197,14 @@ describe('dropzone and client checks', () => {
|
||||
|
||||
describe('upload states', () => {
|
||||
it('goes queued, uploading with progress, done; a server failure can be retried', async () => {
|
||||
const { wrapper, markDirty } = await setup()
|
||||
const { wrapper, markDirty, activeUploads } = await setup()
|
||||
await choose(wrapper, [png('a.png'), png('b.png')])
|
||||
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
|
||||
expect(activeUploads.value).toBe(1)
|
||||
expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued')
|
||||
const first = FakeXHR.sent[0]!
|
||||
expect(first.headers[SESSION_HEADER]).toBe(KEY)
|
||||
expect(first.headers[UPLOAD_ID_HEADER]).toMatch(/^[A-Za-z0-9_-]{8,}$/)
|
||||
first.progress(30, 60)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true)
|
||||
@@ -220,6 +226,60 @@ describe('upload states', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2)
|
||||
expect(markDirty).toHaveBeenCalledTimes(2)
|
||||
expect(activeUploads.value).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the same upload id on retry and holds the form upload gate until the XHR ends', async () => {
|
||||
const { wrapper, activeUploads } = await setup()
|
||||
await choose(wrapper, [png('a.png')])
|
||||
expect(activeUploads.value).toBe(1)
|
||||
const first = FakeXHR.sent[0]!
|
||||
const id = first.headers[UPLOAD_ID_HEADER]
|
||||
expect(id).toBeTruthy()
|
||||
first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } })
|
||||
await flushPromises()
|
||||
expect(activeUploads.value).toBe(0)
|
||||
await wrapper.find('[data-action="retry"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(FakeXHR.sent[1]!.headers[UPLOAD_ID_HEADER]).toBe(id)
|
||||
FakeXHR.sent[1]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} })
|
||||
await flushPromises()
|
||||
expect(activeUploads.value).toBe(0)
|
||||
})
|
||||
|
||||
it('deletes a server-side file when the upload is aborted after the store', async () => {
|
||||
let listed = 0
|
||||
const { wrapper, calls } = await setup({
|
||||
routes: {
|
||||
[`GET ${BASE}/7/files/photos`]: () => {
|
||||
listed++
|
||||
return listed === 1 ? listOf([]) : listOf([item(99, 'lost.png', { pending: true })])
|
||||
},
|
||||
[`DELETE ${BASE}/7/files/photos/99`]: { body: { data: { removed: true, pending: true }, meta: {} } },
|
||||
},
|
||||
})
|
||||
await choose(wrapper, [png('lost.png')])
|
||||
FakeXHR.sent[0]!.abort()
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/99`)).toHaveLength(1)
|
||||
expect(wrapper.find('[data-file-item]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('adopts a stored file when the response is lost and keeps the same upload id on retry', async () => {
|
||||
let listed = 0
|
||||
const { wrapper } = await setup({
|
||||
routes: {
|
||||
[`GET ${BASE}/7/files/photos`]: () => {
|
||||
listed++
|
||||
return listed === 1 ? listOf([]) : listOf([item(77, 'net.png', { pending: true })])
|
||||
},
|
||||
},
|
||||
})
|
||||
await choose(wrapper, [png('net.png')])
|
||||
FakeXHR.sent[0]!.onerror?.()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-state="done"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-file-item]').text()).toContain('net.png')
|
||||
})
|
||||
|
||||
it('shows the Unsaved chip on pending files of a saved record only', async () => {
|
||||
@@ -346,6 +406,42 @@ describe('keyboard reorder (backstop 4)', () => {
|
||||
expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png'])
|
||||
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' })
|
||||
})
|
||||
|
||||
it('sends only the latest order when a second move happens while the first request is open', async () => {
|
||||
const replies: Array<(ids: number[]) => Reply> = []
|
||||
const seen: number[][] = []
|
||||
const { wrapper, calls } = await setup({
|
||||
routes: {
|
||||
[`GET ${BASE}/7/files/photos`]: listOf(three),
|
||||
[`POST ${BASE}/7/files/photos/reorder`]: async (request) => {
|
||||
const body = (await request.clone().json()) as { ids: number[] }
|
||||
seen.push(body.ids)
|
||||
return await new Promise<Reply>((resolve) => {
|
||||
replies.push((ids) => {
|
||||
const ordered = ids.map((id) => three.find((file) => file.id === id)!)
|
||||
const reply = { body: { data: ordered, meta: {} } }
|
||||
resolve(reply)
|
||||
return reply
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
keydown(wrapper.findAll('[data-handle]')[0]!.element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
await wait(450)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(1)
|
||||
keydown(wrapper.findAll('[data-handle]')[1]!.element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
await wait(450)
|
||||
await flushPromises()
|
||||
expect(replies).toHaveLength(1)
|
||||
replies[0]!([42, 41, 43])
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(2)
|
||||
expect(seen[1]).toEqual([42, 43, 41])
|
||||
})
|
||||
})
|
||||
|
||||
describe('caption modal', () => {
|
||||
|
||||
Reference in New Issue
Block a user