fix(upload): sync stored content type after upload

This commit is contained in:
saltbo
2026-07-23 00:43:49 -04:00
parent 7c3738f950
commit eea3dafefa
16 changed files with 204 additions and 61 deletions
+31 -2
View File
@@ -157,6 +157,16 @@ describe('S3Service', () => {
)
})
it('does not sign Content-Type when it is absent', async () => {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
await service.presignUpload(storage, 'unknown.bin')
expect(getSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'unknown.bin' } }),
{ expiresIn: 3600 },
)
})
it('respects custom expiresIn', async () => {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
await service.presignUpload(storage, 'test.jpg', 'image/jpeg', 600)
@@ -270,6 +280,25 @@ describe('S3Service', () => {
expect(mockSend).not.toHaveBeenCalled()
})
it('omits multipart Content-Type when it is absent', async () => {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response('<CreateMultipartUploadResult><UploadId>upload-1</UploadId></CreateMultipartUploadResult>'),
)
vi.stubGlobal('fetch', fetchMock)
await service.createMultipartUpload(storage, 'unknown.bin')
expect(getSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'unknown.bin' } }),
{ expiresIn: 3600 },
)
expect(fetchMock).toHaveBeenCalledWith('https://signed-url.example.com', { method: 'POST' })
})
it('fails create multipart uploads when S3 omits the upload id', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('<CreateMultipartUploadResult />')))
@@ -346,10 +375,10 @@ describe('S3Service', () => {
)
})
it('defaults size to 0, contentType to application/octet-stream, and etag to empty', async () => {
it('does not invent a content type when S3 omits it', async () => {
mockSend.mockResolvedValueOnce({ $metadata: {} })
const result = await service.headObject(storage, 'test.bin')
expect(result).toEqual({ size: 0, contentType: 'application/octet-stream', etag: '' })
expect(result).toEqual({ size: 0, contentType: undefined, etag: '' })
})
})
+14 -9
View File
@@ -35,7 +35,7 @@ export class S3Service implements S3Gateway {
async presignUpload(
storage: S3StorageCredentials,
key: string,
contentType: string,
contentType?: string,
filenameOrExpiresIn?: string | number,
expiresIn = DEFAULT_EXPIRES_IN,
): Promise<string> {
@@ -49,9 +49,7 @@ export class S3Service implements S3Gateway {
const client = this.createClient(storage)
// Only sign ContentType/ContentDisposition when provided. A signed header must
// be sent verbatim by the client or S3 rejects the PUT; the object-upload flow
// passes neither (a bare PUT) so its uniform slice uploader needs no headers,
// while image hosting passes a contentType it echoes back.
// be sent verbatim by the client or S3 rejects the PUT.
const command = new PutObjectCommand({
Bucket: storage.bucket,
Key: key,
@@ -62,14 +60,21 @@ export class S3Service implements S3Gateway {
return url
}
async createMultipartUpload(storage: S3StorageCredentials, key: string, contentType: string): Promise<string> {
async createMultipartUpload(storage: S3StorageCredentials, key: string, contentType?: string): Promise<string> {
const client = this.createClient(storage)
const url = await getSignedUrl(
client,
new CreateMultipartUploadCommand({ Bucket: storage.bucket, Key: key, ContentType: contentType }),
new CreateMultipartUploadCommand({
Bucket: storage.bucket,
Key: key,
...(contentType ? { ContentType: contentType } : {}),
}),
{ expiresIn: DEFAULT_EXPIRES_IN },
)
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': contentType } })
const response = await fetch(url, {
method: 'POST',
...(contentType ? { headers: { 'Content-Type': contentType } } : {}),
})
const body = await response.text()
if (!response.ok) throw new Error(`S3 multipart upload create failed: ${response.status}: ${body.trim()}`)
const uploadId = xmlTag(body, 'UploadId')
@@ -199,12 +204,12 @@ export class S3Service implements S3Gateway {
async headObject(
storage: S3StorageCredentials,
key: string,
): Promise<{ size: number; contentType: string; etag: string }> {
): Promise<{ size: number; contentType?: string; etag: string }> {
const client = this.createClient(storage)
const result = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }))
return {
size: result.ContentLength ?? 0,
contentType: result.ContentType ?? 'application/octet-stream',
contentType: result.ContentType,
etag: (result.ETag ?? '').replace(/"/g, ''),
}
}
@@ -328,12 +328,16 @@ describe('confirmUpload', () => {
await insertOrgQuota(db, orgId, 10000, 0)
const matterId = await insertDraftFile(db, orgId, { id: 'matter-a', size: 500, storageId })
const result = await confirmUpload(db, matterId, orgId)
const result = await confirmUpload(db, matterId, orgId, { contentType: 'audio/flac' })
expect(result.matter).not.toBeNull()
expect(result.matter?.status).toBe('active')
expect(result.matter?.type).toBe('audio/flac')
expect(result.quotaExceeded).toBeUndefined()
const matterRows = await db.all<{ type: string }>(sql`SELECT type FROM matters WHERE id = ${matterId}`)
expect(matterRows[0].type).toBe('audio/flac')
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
expect(storageRows[0].used).toBe(500)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
+2 -2
View File
@@ -700,13 +700,13 @@ export function createMatterRepo(db: Database): MatterRepo {
return applyConflictResolution(orgId, parent, name, strategy, options)
},
async activateDraft(id, orgId, finalName, now): Promise<boolean> {
async activateDraft(id, orgId, finalName, type, now): Promise<boolean> {
const existing = await getMatter(id, orgId)
if (!existing || existing.status !== 'draft') return false
await executeWriteTransaction(db, [storageUsageOpeningBalanceQuery(db, orgId, existing.storageId, now)])
const activateQuery = db
.update(matters)
.set({ name: finalName, status: 'active', updatedAt: now })
.set({ name: finalName, type, status: 'active', updatedAt: now })
.where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft'), isNull(matters.purgedAt)))
.returning({ id: matters.id })
const writes: AtomicQuery[] = [activateQuery, matterActivationLedgerQuery(db, orgId, id, now)]
+52 -4
View File
@@ -291,9 +291,10 @@ describe('object usecase', () => {
it('creates a small file draft and returns single-PUT upload instructions', async () => {
const draft = file('d1', { status: 'draft', object: 'o1/u1/key.jpg', name: 'photo.jpg', type: 'image/jpeg' })
const presignUpload = vi.fn(async () => 'https://up')
const { deps } = makeDeps({
matter: { create: async () => draft },
s3: { presignUpload: async () => 'https://up' },
s3: { presignUpload },
})
const out = await createObject(deps, {
orgId: 'o1',
@@ -307,11 +308,30 @@ describe('object usecase', () => {
expect(out.upload.urls).toEqual(['https://up'])
expect(out.upload.partSize).toBe(2048)
expect(out.matter.status).toBe('draft')
expect(presignUpload).toHaveBeenCalledWith(storage, expect.any(String), 'image/jpeg')
} else {
throw new Error('expected upload outcome')
}
})
it('presigns and stores a file draft without a content type', async () => {
const create = vi.fn(async (input: Parameters<MatterRepo['create']>[0]) =>
file('d1', { ...input, status: 'draft' }),
)
const presignUpload = vi.fn(async () => 'https://up')
const { deps } = makeDeps({ matter: { create }, s3: { presignUpload } })
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'unknown.bin', size: 10, dirtype: DirType.FILE, parent: '' },
})
expect(out.ok).toBe(true)
expect(create).toHaveBeenCalledWith(expect.objectContaining({ type: '' }))
expect(presignUpload).toHaveBeenCalledWith(storage, expect.any(String), undefined)
})
it('uses an eligible requested storage for a file draft', async () => {
const target = { ...storage, id: 'st-target' } as StorageRecord
const select = vi.fn(async () => target)
@@ -554,8 +574,9 @@ describe('object usecase', () => {
const draft = file('d1', { status: 'draft', size: 100 })
const setStatus = vi.fn(async () => {})
const headObject = vi.fn(async () => ({ size: 100, contentType: 'text/plain', etag: 'abc' }))
const activateDraft = vi.fn(async () => true)
const { deps } = makeDeps({
matter: { get: async () => draft, activateDraft: async () => true },
matter: { get: async () => draft, activateDraft },
s3: { headObject } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus },
})
@@ -570,6 +591,29 @@ describe('object usecase', () => {
if (out.ok) expect(out.matter.status).toBe('active')
expect(headObject).toHaveBeenCalledWith(storage, 'key/d1')
expect(setStatus).toHaveBeenCalledWith('sess-1', 'completed')
expect(activateDraft).toHaveBeenCalledWith('d1', 'o1', 'd1.txt', 'text/plain', expect.any(Date))
})
it('stores an empty type when HEAD returns no Content-Type', async () => {
const draft = file('d1', { status: 'draft', size: 100, type: 'client/type' })
const activateDraft = vi.fn(async () => true)
const { deps } = makeDeps({
matter: { get: async () => draft, activateDraft },
s3: { headObject: async () => ({ size: 100, contentType: undefined, etag: 'abc' }) } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus: async () => {} },
})
const out = await completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'abc' }],
actorId: 'u1',
})
expect(out.ok).toBe(true)
if (out.ok) expect(out.matter.type).toBe('')
expect(activateDraft).toHaveBeenCalledWith('d1', 'o1', 'd1.txt', '', expect.any(Date))
})
it('tolerates quoted ETags from the client (strips quotes before comparing)', async () => {
@@ -610,9 +654,11 @@ describe('object usecase', () => {
it('completes a multipart draft via CompleteMultipartUpload', async () => {
const draft = file('d1', { status: 'draft', size: 100 })
const completeMultipartUpload = vi.fn(async () => {})
const headObject = vi.fn(async () => ({ size: 100, contentType: 'audio/flac', etag: 'multipart-etag' }))
const activateDraft = vi.fn(async () => true)
const { deps } = makeDeps({
matter: { get: async () => draft, activateDraft: async () => true },
s3: { completeMultipartUpload } as Partial<S3Gateway>,
matter: { get: async () => draft, activateDraft },
s3: { completeMultipartUpload, headObject } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session({ uploadId: 'mp-1' }), setStatus: async () => {} },
})
const out = await completeUpload(deps, {
@@ -624,6 +670,8 @@ describe('object usecase', () => {
})
expect(out.ok).toBe(true)
expect(completeMultipartUpload).toHaveBeenCalledWith(storage, 'key/d1', 'mp-1', [{ partNumber: 1, etag: 'e1' }])
expect(headObject).toHaveBeenCalledWith(storage, 'key/d1')
expect(activateDraft).toHaveBeenCalledWith('d1', 'o1', 'd1.txt', 'audio/flac', expect.any(Date))
})
it('throws not_found when the upload session is missing', async () => {
+32 -30
View File
@@ -200,7 +200,7 @@ export async function createObject(
const matter = await deps.matter.create({
orgId,
name,
type: isFolder ? 'folder' : type,
type: isFolder ? 'folder' : (type ?? ''),
size: isFolder ? 0 : size,
dirtype,
parent,
@@ -235,7 +235,7 @@ async function prepareUpload(
objectId: string
storage: StorageRecord
storageKey: string
contentType: string
contentType?: string
size: number
onConflict: ConflictStrategy
actorId: string
@@ -247,12 +247,10 @@ async function prepareUpload(
let urls: string[]
if (size <= PART_SIZE_BYTES) {
// Single PutObject — one presigned PUT, no S3 multipart overhead. Presign a
// bare PUT (no signed ContentType) so the uniform slice uploader can PUT raw
// bytes with no headers, exactly like a multipart part. The object's
// content-type is applied at download/view time (presignDownload/Inline).
// Single PutObject — one presigned PUT, no S3 multipart overhead. When the
// client supplied a content type it is signed and must be sent verbatim.
partSize = size
urls = [await deps.s3.presignUpload(storage, storageKey, '')]
urls = [await deps.s3.presignUpload(storage, storageKey, contentType)]
} else {
partSize = PART_SIZE_BYTES
const partCount = Math.ceil(size / partSize)
@@ -333,10 +331,9 @@ export type CompleteUploadOutcome =
| { ok: false; reason: 'not_found' }
| { ok: false; error: AppError }
// Finalizes a draft upload (draft → live). For a single PutObject it HEADs the
// object and checks the reported ETag; for multipart it calls
// CompleteMultipartUpload. Then it runs the quota-guarded activation (reusing the
// session's stored conflict strategy) and returns the live object.
// Finalizes a draft upload (draft → live). Multipart uploads are completed first,
// then every upload is HEADed so the persisted type matches the object's final
// metadata. Single PutObject uploads additionally verify the reported ETag.
export async function completeUpload(
deps: Pick<
Deps,
@@ -355,23 +352,7 @@ export async function completeUpload(
if (!record) throw new ObjectUploadSessionError('not_found')
if (record.status !== 'active') throw new ObjectUploadSessionError('invalid_state')
if (record.uploadId == null) {
// Single PutObject: confirm the object landed and matches the reported ETag.
let head: { size: number; contentType: string; etag: string }
try {
head = await deps.s3.headObject(storage, record.storageKey)
} catch (error) {
throw new ObjectUploadSessionError(
'invalid_state',
`Uploaded object not found: ${(error as Error).message}`,
'object_not_found',
)
}
const reported = params.parts[0]?.etag.replace(/"/g, '')
if (!reported || reported !== head.etag) {
throw new ObjectUploadSessionError('invalid_state', 'Uploaded object ETag does not match', 'etag_mismatch')
}
} else {
if (record.uploadId != null) {
try {
await deps.s3.completeMultipartUpload(storage, record.storageKey, record.uploadId, params.parts)
} catch (error) {
@@ -381,11 +362,29 @@ export async function completeUpload(
)
}
}
let head: { size: number; contentType?: string; etag: string }
try {
head = await deps.s3.headObject(storage, record.storageKey)
} catch (error) {
throw new ObjectUploadSessionError(
'invalid_state',
`Uploaded object not found: ${(error as Error).message}`,
'object_not_found',
)
}
if (record.uploadId == null) {
const reported = params.parts[0]?.etag.replace(/"/g, '')
if (!reported || reported !== head.etag) {
throw new ObjectUploadSessionError('invalid_state', 'Uploaded object ETag does not match', 'etag_mismatch')
}
}
await deps.objectUploadSessions.setStatus(record.id, 'completed')
// Draft → live: reserve quota, apply the stored conflict strategy, activate.
const { matter, quotaExceeded: exceeded } = await confirmUpload(deps, params.objectId, params.orgId, {
onConflict: record.onConflict,
contentType: head.contentType ?? null,
purgeReplaced: (incumbent) => purgeRecursively(deps, params.orgId, [incumbent]).then(() => undefined),
})
if (exceeded) {
@@ -720,6 +719,8 @@ export type ConfirmUploadDeps = {
export interface ConfirmUploadOptions {
onConflict?: ConflictStrategy
teamQuotaEnabled?: boolean
/** Undefined preserves the draft value; null records that storage returned no type. */
contentType?: string | null
/**
* Overwrites the file being replaced: hard-purge it (delete row, S3 object,
* shares). With it, a 'replace' frees the incumbent's quota so the upload is
@@ -770,7 +771,8 @@ export async function confirmUpload(
}
const now = new Date()
const activated = await deps.matter.activateDraft(id, orgId, plan.finalName, now)
const type = opts.contentType === undefined ? existing.type : (opts.contentType ?? '')
const activated = await deps.matter.activateDraft(id, orgId, plan.finalName, type, now)
if (!activated) {
throw new Error('CONFIRM_UPLOAD_RACE')
}
@@ -779,7 +781,7 @@ export async function confirmUpload(
// once more so the new file's bytes are reflected.
if (overwrites) await deps.storageUsage.reconcile(orgId, [existing.storageId])
const confirmed = { ...existing, name: plan.finalName, status: 'active', updatedAt: now }
const confirmed = { ...existing, name: plan.finalName, type, status: 'active', updatedAt: now }
return { matter: confirmed }
},
+1 -1
View File
@@ -143,5 +143,5 @@ export interface MatterRepo {
* Flips a draft row to active under `finalName`, scoped to status='draft' as a
* concurrent-confirm safety net. Returns false when no draft row matched (race).
*/
activateDraft(id: string, orgId: string, finalName: string, now: Date): Promise<boolean>
activateDraft(id: string, orgId: string, finalName: string, type: string, now: Date): Promise<boolean>
}
+3 -3
View File
@@ -14,11 +14,11 @@ export interface S3Gateway {
presignUpload(
storage: S3StorageCredentials,
key: string,
contentType: string,
contentType?: string,
filenameOrExpiresIn?: string | number,
expiresIn?: number,
): Promise<string>
createMultipartUpload(storage: S3StorageCredentials, key: string, contentType: string): Promise<string>
createMultipartUpload(storage: S3StorageCredentials, key: string, contentType?: string): Promise<string>
presignUploadPart(
storage: S3StorageCredentials,
key: string,
@@ -37,7 +37,7 @@ export interface S3Gateway {
presignInline(storage: S3StorageCredentials, key: string, mime: string, expiresIn?: number): Promise<string>
// `etag` is the S3 ETag with surrounding quotes stripped (= content MD5 for a
// single PutObject); used to verify a finalized single-PUT upload.
headObject(storage: S3StorageCredentials, key: string): Promise<{ size: number; contentType: string; etag: string }>
headObject(storage: S3StorageCredentials, key: string): Promise<{ size: number; contentType?: string; etag: string }>
getObjectBytes(storage: S3StorageCredentials, key: string, range?: string): Promise<Uint8Array>
getObjectBody(storage: S3StorageCredentials, key: string, range?: string): Promise<BodyInit>
getObjectStream(storage: S3StorageCredentials, key: string, range?: string): Promise<ReadableStream<Uint8Array>>
+1 -1
View File
@@ -194,7 +194,7 @@ export type ConflictStrategy = z.infer<typeof conflictStrategySchema>
export const createMatterSchema = z.object({
name: z.string().min(1),
type: z.string().min(1),
type: z.string().min(1).optional(),
size: z.number().int().min(0).optional(),
parent: z.string().default(''),
dirtype: z.number().int().default(0),
+6
View File
@@ -75,6 +75,12 @@ describe('createMatterSchema', () => {
}
})
it('accepts a missing optional content type', () => {
const result = createMatterSchema.safeParse({ name: 'unknown.bin' })
expect(result.success).toBe(true)
if (result.success) expect(result.data.type).toBeUndefined()
})
it('accepts an optional target storage id', () => {
const result = createMatterSchema.safeParse({ name: 'file.txt', type: 'text/plain', storageId: 'st-1' })
expect(result.success).toBe(true)
+18 -1
View File
@@ -37,7 +37,7 @@ describe('uploadObjectSlices', () => {
})
it('PUTs the single URL and returns one part (single-PUT case)', async () => {
const file = new File(['0123456789'], 'small.bin') // 10 bytes, 1 url
const file = new File(['0123456789'], 'small.bin', { type: 'application/x-test' }) // 10 bytes, 1 url
const ctx = makeCtx()
const parts = await uploadObjectSlices(makeUpload(['https://s3/part-1'], file.size), file, ctx)
@@ -46,9 +46,26 @@ describe('uploadObjectSlices', () => {
const [url, blob] = api.uploadPartToS3.mock.calls[0]
expect(url).toBe('https://s3/part-1')
expect((blob as Blob).size).toBe(10)
expect(api.uploadPartToS3).toHaveBeenCalledWith(
'https://s3/part-1',
expect.any(Blob),
expect.objectContaining({ contentType: 'application/x-test' }),
)
expect(parts).toEqual([{ partNumber: 1, etag: 'etag-1' }])
})
it('does not send Content-Type when the file has none', async () => {
const file = new File(['0123'], 'unknown.bin')
await uploadObjectSlices(makeUpload(['https://s3/part-1'], file.size), file, makeCtx())
expect(api.uploadPartToS3).toHaveBeenCalledWith(
'https://s3/part-1',
expect.any(Blob),
expect.objectContaining({ contentType: undefined }),
)
})
it('slices the file by partSize across N URLs and returns parts sorted by partNumber', async () => {
// 10 bytes, partSize 4 -> 3 slices (4, 4, 2)
const file = new File(['0123456789'], 'big.bin')
+3 -1
View File
@@ -14,7 +14,7 @@ function isAbortError(err: unknown): boolean {
async function uploadPartWithRetry(
url: string,
blob: Blob,
options: { signal: AbortSignal; onProgress: (loaded: number) => void },
options: { signal: AbortSignal; onProgress: (loaded: number) => void; contentType?: string },
): Promise<string> {
let lastError: unknown
for (let attempt = 0; attempt < PART_ATTEMPTS; attempt++) {
@@ -22,6 +22,7 @@ async function uploadPartWithRetry(
return await uploadPartToS3(url, blob, {
signal: options.signal,
onProgress: (p) => options.onProgress(p.loaded),
contentType: options.contentType,
})
} catch (error) {
if (isAbortError(error)) throw error
@@ -72,6 +73,7 @@ export async function uploadObjectSlices(
const slice = file.slice(start, Math.min(start + partSize, file.size))
const etag = await uploadPartWithRetry(url, slice, {
signal: ctx.signal,
contentType: file.type || undefined,
onProgress: (loaded) => {
loadedByPart.set(partNumber, loaded)
reportProgress()
@@ -108,7 +108,7 @@ async function simulateOnDropWithUploadFn(
async function simulateDefaultUpload(file: File, parent: string, ctx: UploadRunnerContext): Promise<void> {
const created = await createObject({
name: file.name,
type: file.type || 'application/octet-stream',
type: file.type || undefined,
size: file.size,
parent,
dirtype: 0,
@@ -307,7 +307,7 @@ describe('UploadDropzone — default upload path (no uploadFn)', () => {
expect(abortObjectUpload).toHaveBeenCalledWith('obj-1', 'sess-1')
})
it('uses application/octet-stream when file type is empty', async () => {
it('omits type when the browser does not provide one', async () => {
vi.mocked(createObject).mockResolvedValue(draft as never)
vi.mocked(uploadObjectSlices).mockResolvedValue(parts)
vi.mocked(completeObjectUpload).mockResolvedValue({ id: 'obj-1', status: 'active' } as never)
@@ -315,7 +315,7 @@ describe('UploadDropzone — default upload path (no uploadFn)', () => {
const file = new File(['data'], 'blob') // no type
await simulateDefaultUpload(file, 'root', makeCtx())
expect(createObject).toHaveBeenCalledWith(expect.objectContaining({ type: 'application/octet-stream' }))
expect(createObject).toHaveBeenCalledWith(expect.objectContaining({ type: undefined }))
})
it('throws when createObject returns no upload instructions', async () => {
+2 -2
View File
@@ -142,7 +142,7 @@ async function uploadFile(
(strategy) =>
createObject({
name: file.name,
type: file.type || 'application/octet-stream',
type: file.type || undefined,
size: file.size,
parent,
dirtype: DirType.FILE,
@@ -152,7 +152,7 @@ async function uploadFile(
)
: await createObject({
name: file.name,
type: file.type || 'application/octet-stream',
type: file.type || undefined,
size: file.size,
parent,
dirtype: DirType.FILE,
+28
View File
@@ -520,6 +520,15 @@ describe('api', () => {
expect(headers.get('Content-Type')).toContain('application/json')
})
it('omits an optional content type', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'new1', name: 'unknown.bin' }))
await createObject({ name: 'unknown.bin', parent: 'root', dirtype: 0 })
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(JSON.parse(init.body as string)).toEqual({ name: 'unknown.bin', parent: 'root', dirtype: 0 })
})
it('includes storageId when creating a targeted object draft', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'new1', name: 'doc.pdf' }))
@@ -880,6 +889,7 @@ describe('api', () => {
method = ''
url = ''
body: unknown
headers: Record<string, string> = {}
responseHeaders: Record<string, string> = { ETag: '"etag-abc"' }
constructor() {
@@ -892,6 +902,9 @@ describe('api', () => {
getResponseHeader(key: string) {
return this.responseHeaders[key] ?? null
}
setRequestHeader(key: string, value: string) {
this.headers[key] = value
}
send(body: unknown) {
this.body = body
}
@@ -917,6 +930,21 @@ describe('api', () => {
expect(xhr.body).toBe(blob)
})
it('sets Content-Type only when one is provided', async () => {
const withType = uploadPartToS3('https://s3/part-1', new Blob(['chunk']), { contentType: 'audio/flac' })
const typedXhr = MockPartXHR.instances[0]
typedXhr.onload?.()
await withType
const withoutType = uploadPartToS3('https://s3/part-2', new Blob(['chunk']))
const untypedXhr = MockPartXHR.instances[1]
untypedXhr.onload?.()
await withoutType
expect(typedXhr.headers).toEqual({ 'Content-Type': 'audio/flac' })
expect(untypedXhr.headers).toEqual({})
})
it('rejects when the ETag header is not exposed', async () => {
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
const xhr = MockPartXHR.instances[0]
+3 -1
View File
@@ -271,7 +271,7 @@ export interface CreateObjectResult extends StorageObject {
export function createObject(data: {
name: string
type: string
type?: string
size?: number
parent: string
dirtype: number
@@ -1330,6 +1330,7 @@ export function uploadToS3(url: string, file: File, options: UploadToS3Options =
export interface UploadPartOptions {
onProgress?: (progress: UploadProgress) => void
signal?: AbortSignal
contentType?: string
}
/**
@@ -1376,6 +1377,7 @@ export function uploadPartToS3(url: string, blob: Blob, options: UploadPartOptio
options.signal?.removeEventListener('abort', abort)
}
xhr.open('PUT', url)
if (options.contentType) xhr.setRequestHeader('Content-Type', options.contentType)
xhr.send(blob)
})
}