mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
fix(upload): sync stored content type after upload
This commit is contained in:
@@ -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: '' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user