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:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -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', () => {