chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Tests for S3 client functionality
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockSend,
|
||||
mockS3Client,
|
||||
mockS3ClientConstructor,
|
||||
mockPutObjectCommand,
|
||||
mockGetObjectCommand,
|
||||
mockDeleteObjectCommand,
|
||||
mockCompleteMultipartUploadCommand,
|
||||
mockGetSignedUrl,
|
||||
mockEnv,
|
||||
mockS3Config,
|
||||
} = vi.hoisted(() => {
|
||||
const mockSend = vi.fn()
|
||||
const mockS3Client = { send: mockSend }
|
||||
const mockEnv: Record<string, string | undefined> = {
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
S3_BUCKET_NAME: 'test-bucket',
|
||||
AWS_REGION: 'test-region',
|
||||
AWS_ACCESS_KEY_ID: 'test-access-key',
|
||||
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
|
||||
}
|
||||
const mockS3Config: {
|
||||
bucket: string
|
||||
region: string
|
||||
endpoint: string | undefined
|
||||
forcePathStyle: boolean
|
||||
} = {
|
||||
bucket: 'test-bucket',
|
||||
region: 'test-region',
|
||||
endpoint: undefined,
|
||||
forcePathStyle: false,
|
||||
}
|
||||
return {
|
||||
mockSend,
|
||||
mockS3Client,
|
||||
mockS3Config,
|
||||
mockS3ClientConstructor: vi.fn().mockImplementation(
|
||||
class {
|
||||
constructor() {
|
||||
// biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning the object overrides the instance so `new S3Client()` yields the shared mock the tests assert on
|
||||
return mockS3Client
|
||||
}
|
||||
}
|
||||
),
|
||||
mockPutObjectCommand: vi.fn().mockImplementation(class {}),
|
||||
mockGetObjectCommand: vi.fn().mockImplementation(class {}),
|
||||
mockDeleteObjectCommand: vi.fn().mockImplementation(class {}),
|
||||
mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}),
|
||||
mockGetSignedUrl: vi.fn(),
|
||||
mockEnv,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => ({
|
||||
S3Client: mockS3ClientConstructor,
|
||||
PutObjectCommand: mockPutObjectCommand,
|
||||
GetObjectCommand: mockGetObjectCommand,
|
||||
DeleteObjectCommand: mockDeleteObjectCommand,
|
||||
CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand,
|
||||
}))
|
||||
|
||||
vi.mock('@aws-sdk/s3-request-presigner', () => ({
|
||||
getSignedUrl: mockGetSignedUrl,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: mockEnv,
|
||||
getEnv: (key: string) => mockEnv[key],
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
|
||||
isFalsy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/setup', () => ({
|
||||
S3_CONFIG: {
|
||||
bucket: 'test-bucket',
|
||||
region: 'test-region',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/config', () => ({
|
||||
S3_CONFIG: mockS3Config,
|
||||
S3_KB_CONFIG: {
|
||||
bucket: 'test-kb-bucket',
|
||||
region: 'test-region',
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
completeS3MultipartUpload,
|
||||
deleteFromS3,
|
||||
downloadFromS3,
|
||||
getPresignedUrl,
|
||||
getS3Client,
|
||||
resetS3ClientForTesting,
|
||||
uploadToS3,
|
||||
} from '@/lib/uploads/providers/s3/client'
|
||||
|
||||
describe('S3 Client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1672603200000)
|
||||
vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-06-16T01:13:10.765Z')
|
||||
mockEnv.AWS_ACCESS_KEY_ID = 'test-access-key'
|
||||
mockEnv.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
|
||||
mockS3Config.endpoint = undefined
|
||||
mockS3Config.forcePathStyle = false
|
||||
resetS3ClientForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('uploadToS3', () => {
|
||||
it('should upload a file to S3 and return file info', async () => {
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const file = Buffer.from('test content')
|
||||
const fileName = 'test-file.txt'
|
||||
const contentType = 'text/plain'
|
||||
|
||||
const result = await uploadToS3(file, fileName, contentType)
|
||||
|
||||
expect(mockPutObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'test-bucket',
|
||||
Key: expect.stringContaining('test-file.txt'),
|
||||
Body: file,
|
||||
ContentType: 'text/plain',
|
||||
Metadata: {
|
||||
originalName: 'test-file.txt',
|
||||
uploadedAt: expect.any(String),
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith(expect.any(Object))
|
||||
|
||||
expect(result).toEqual({
|
||||
path: expect.stringContaining('/api/files/serve/'),
|
||||
key: expect.stringContaining('test-file.txt'),
|
||||
name: 'test-file.txt',
|
||||
size: file.length,
|
||||
type: 'text/plain',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle spaces in filenames', async () => {
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const testFile = Buffer.from('test file content')
|
||||
const fileName = 'test file with spaces.txt'
|
||||
const contentType = 'text/plain'
|
||||
|
||||
const result = await uploadToS3(testFile, fileName, contentType)
|
||||
|
||||
expect(mockPutObjectCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Key: expect.stringContaining('test-file-with-spaces.txt'),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.name).toBe(fileName)
|
||||
})
|
||||
|
||||
it('should use provided size if available', async () => {
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const testFile = Buffer.from('test file content')
|
||||
const fileName = 'test-file.txt'
|
||||
const contentType = 'text/plain'
|
||||
const providedSize = 1000
|
||||
|
||||
const result = await uploadToS3(testFile, fileName, contentType, providedSize)
|
||||
|
||||
expect(result.size).toBe(providedSize)
|
||||
})
|
||||
|
||||
it('should handle upload errors', async () => {
|
||||
const error = new Error('Upload failed')
|
||||
mockSend.mockRejectedValueOnce(error)
|
||||
|
||||
const testFile = Buffer.from('test file content')
|
||||
const fileName = 'test-file.txt'
|
||||
const contentType = 'text/plain'
|
||||
|
||||
await expect(uploadToS3(testFile, fileName, contentType)).rejects.toThrow('Upload failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPresignedUrl', () => {
|
||||
it('should generate a presigned URL for a file', async () => {
|
||||
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/presigned-url')
|
||||
|
||||
const key = 'test-file.txt'
|
||||
const expiresIn = 1800
|
||||
|
||||
const url = await getPresignedUrl(key, expiresIn)
|
||||
|
||||
expect(mockGetObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'test-bucket',
|
||||
Key: key,
|
||||
})
|
||||
|
||||
expect(mockGetSignedUrl).toHaveBeenCalledWith(mockS3Client, expect.any(Object), { expiresIn })
|
||||
|
||||
expect(url).toBe('https://example.com/presigned-url')
|
||||
})
|
||||
|
||||
it('should use default expiration if not provided', async () => {
|
||||
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/presigned-url')
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
await getPresignedUrl(key)
|
||||
|
||||
expect(mockGetSignedUrl).toHaveBeenCalledWith(mockS3Client, expect.any(Object), {
|
||||
expiresIn: 3600,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle errors when generating presigned URL', async () => {
|
||||
const error = new Error('Presigned URL generation failed')
|
||||
mockGetSignedUrl.mockRejectedValueOnce(error)
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
await expect(getPresignedUrl(key)).rejects.toThrow('Presigned URL generation failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFromS3', () => {
|
||||
it('should download a file from S3', async () => {
|
||||
const mockStream = {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(Buffer.from('chunk1'))
|
||||
callback(Buffer.from('chunk2'))
|
||||
}
|
||||
if (event === 'end') {
|
||||
callback()
|
||||
}
|
||||
return mockStream
|
||||
}),
|
||||
off: vi.fn(() => mockStream),
|
||||
}
|
||||
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Body: mockStream,
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
})
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
const result = await downloadFromS3(key)
|
||||
|
||||
expect(mockGetObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'test-bucket',
|
||||
Key: key,
|
||||
})
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBeInstanceOf(Buffer)
|
||||
expect(result.toString()).toBe('chunk1chunk2')
|
||||
})
|
||||
|
||||
it('should handle stream errors', async () => {
|
||||
const mockStream = {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'error') {
|
||||
callback(new Error('Stream error'))
|
||||
}
|
||||
return mockStream
|
||||
}),
|
||||
off: vi.fn(() => mockStream),
|
||||
}
|
||||
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Body: mockStream,
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
})
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
await expect(downloadFromS3(key)).rejects.toThrow('Stream error')
|
||||
})
|
||||
|
||||
it('should destroy the opened stream when content length exceeds the limit', async () => {
|
||||
const mockDestroy = vi.fn()
|
||||
const mockStream = {
|
||||
destroy: mockDestroy,
|
||||
on: vi.fn(() => mockStream),
|
||||
}
|
||||
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Body: mockStream,
|
||||
ContentLength: 1024,
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
})
|
||||
|
||||
await expect(
|
||||
downloadFromS3('large-file.txt', { bucket: 'test-bucket', region: 'test-region' }, 10)
|
||||
).rejects.toThrow('storage download exceeds maximum size')
|
||||
expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error))
|
||||
})
|
||||
|
||||
it('should handle S3 client errors', async () => {
|
||||
const error = new Error('Download failed')
|
||||
mockSend.mockRejectedValueOnce(error)
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
await expect(downloadFromS3(key)).rejects.toThrow('Download failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFromS3', () => {
|
||||
it('should delete a file from S3', async () => {
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
await deleteFromS3(key)
|
||||
|
||||
expect(mockDeleteObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'test-bucket',
|
||||
Key: key,
|
||||
})
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
const error = new Error('Delete failed')
|
||||
mockSend.mockRejectedValueOnce(error)
|
||||
|
||||
const key = 'test-file.txt'
|
||||
|
||||
await expect(deleteFromS3(key)).rejects.toThrow('Delete failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('s3Client initialization', () => {
|
||||
it('should initialize with correct configuration when credentials are available', () => {
|
||||
mockEnv.AWS_ACCESS_KEY_ID = 'test-access-key'
|
||||
mockEnv.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
|
||||
resetS3ClientForTesting()
|
||||
|
||||
const client = getS3Client()
|
||||
|
||||
expect(client).toBeDefined()
|
||||
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
|
||||
region: 'test-region',
|
||||
endpoint: undefined,
|
||||
forcePathStyle: false,
|
||||
credentials: {
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should initialize without credentials when env vars are not available', () => {
|
||||
mockEnv.AWS_ACCESS_KEY_ID = undefined
|
||||
mockEnv.AWS_SECRET_ACCESS_KEY = undefined
|
||||
resetS3ClientForTesting()
|
||||
|
||||
const client = getS3Client()
|
||||
|
||||
expect(client).toBeDefined()
|
||||
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
|
||||
region: 'test-region',
|
||||
endpoint: undefined,
|
||||
forcePathStyle: false,
|
||||
credentials: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass a custom endpoint and path-style flag for S3-compatible providers', () => {
|
||||
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
|
||||
mockS3Config.forcePathStyle = true
|
||||
resetS3ClientForTesting()
|
||||
|
||||
const client = getS3Client()
|
||||
|
||||
expect(client).toBeDefined()
|
||||
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
|
||||
region: 'test-region',
|
||||
endpoint: 'https://account.r2.cloudflarestorage.com',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeS3MultipartUpload fallback location', () => {
|
||||
const parts = [{ ETag: 'etag-1', PartNumber: 1 }]
|
||||
|
||||
it('uses the SDK-provided Location when present', async () => {
|
||||
mockSend.mockResolvedValueOnce({ Location: 'https://provided.example.com/object' })
|
||||
|
||||
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
|
||||
|
||||
expect(result.location).toBe('https://provided.example.com/object')
|
||||
expect(result.key).toBe('kb/uuid-file.txt')
|
||||
expect(result.path).toBe('/api/files/serve/kb%2Fuuid-file.txt')
|
||||
})
|
||||
|
||||
it('falls back to an AWS virtual-hosted URL when Location is absent', async () => {
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
|
||||
|
||||
expect(result.location).toBe(
|
||||
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-file.txt'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a path-style fallback URL for a custom endpoint with forcePathStyle', async () => {
|
||||
mockS3Config.endpoint = 'https://minio.example.com'
|
||||
mockS3Config.forcePathStyle = true
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
|
||||
|
||||
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
|
||||
})
|
||||
|
||||
it('builds a virtual-hosted fallback URL for a custom endpoint without forcePathStyle', async () => {
|
||||
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
|
||||
mockS3Config.forcePathStyle = false
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
|
||||
|
||||
expect(result.location).toBe(
|
||||
'https://test-kb-bucket.account.r2.cloudflarestorage.com/kb/uuid-file.txt'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a trailing slash from the custom endpoint before appending the key', async () => {
|
||||
mockS3Config.endpoint = 'https://minio.example.com/'
|
||||
mockS3Config.forcePathStyle = true
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
|
||||
|
||||
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
|
||||
})
|
||||
|
||||
it('percent-encodes special characters per path segment, preserving slashes', async () => {
|
||||
mockSend.mockResolvedValueOnce({})
|
||||
|
||||
const result = await completeS3MultipartUpload('kb/uuid-my file.txt', 'upload-1', parts)
|
||||
|
||||
expect(result.location).toBe(
|
||||
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-my%20file.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user