Files
speckle-server/packages/server/modules/blobstorage/tests/integration/blobstorage.integration.spec.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

394 lines
14 KiB
TypeScript

import { beforeEachContext } from '@/test/hooks'
import { NotFoundError, BadRequestError } from '@/modules/shared/errors'
import { range } from 'lodash-es'
import { fakeIdGenerator, createBlobs } from '@/modules/blobstorage/tests/helpers'
import {
uploadFileStreamFactory,
getFileStreamFactory,
markUploadSuccessFactory,
markUploadOverFileSizeLimitFactory,
fullyDeleteBlobFactory
} from '@/modules/blobstorage/services/management'
import {
upsertBlobFactory,
updateBlobFactory,
getBlobMetadataFactory,
getBlobMetadataCollectionFactory,
blobCollectionSummaryFactory,
deleteBlobFactory
} from '@/modules/blobstorage/repositories'
import { db } from '@/db/knex'
import { cursorFromRows, decodeCursor } from '@/modules/blobstorage/helpers/db'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import cryptoRandomString from 'crypto-random-string'
import { BasicTestUser, createTestUser } from '@/test/authHelper'
import { storeFileStreamFactory } from '@/modules/blobstorage/repositories/blobs'
import {
getMainObjectStorage,
getPublicMainObjectStorage
} from '@/modules/blobstorage/clients/objectStorage'
import { expect } from 'chai'
import { UploadFileStream } from '@/modules/blobstorage/domain/operations'
import { BlobStorageItem } from '@/modules/blobstorage/domain/types'
import {
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { waitForRegionUser } from '@/test/speckle-helpers/regions'
import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector'
import { BlobUploadStatus } from '@speckle/shared/blobs'
type UploadFileStreamStreamData = Parameters<UploadFileStream>[0]
type UploadFileStreamBlobData = Parameters<UploadFileStream>[1]
const buildUploadFileStream = async (params: { streamId: string | null }) => {
const { streamId } = params
const storage = streamId
? await getProjectObjectStorage({ projectId: streamId })
: { private: getMainObjectStorage(), public: getPublicMainObjectStorage() }
const storeFileStream = storeFileStreamFactory({ storage: storage.public })
const uploadFileStream = uploadFileStreamFactory({
upsertBlob,
updateBlob,
storeFileStream
})
return uploadFileStream
}
const fakeFileStreamStore = (fakeHash: string) => async () => ({ fileHash: fakeHash })
const upsertBlob = upsertBlobFactory({ db })
const updateBlob = updateBlobFactory({ db })
const getBlobMetadata = getBlobMetadataFactory({ db })
const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ db })
const blobCollectionSummary = blobCollectionSummaryFactory({ db })
const getFileStream = getFileStreamFactory({ getBlobMetadata })
const markUploadSuccess = markUploadSuccessFactory({ getBlobMetadata, updateBlob })
const markUploadOverFileSizeLimit = markUploadOverFileSizeLimitFactory({
getBlobMetadata,
updateBlob
})
const deleteBlob = fullyDeleteBlobFactory({
getBlobMetadata,
deleteBlob: deleteBlobFactory({ db }),
deleteObject: async () => {}
})
describe('Blob storage @blobstorage', () => {
before(async () => {
await beforeEachContext()
})
describe('Upload file stream', () => {
const invalidData: Array<
[
caseName: string,
streamData: UploadFileStreamStreamData,
blobData: UploadFileStreamBlobData
]
> = [
[
'stream',
{ streamId: 'a'.padStart(1, 'a'), userId: 'a'.padStart(10, 'b') },
{ blobId: 'a'.padStart(10, 'c') } as UploadFileStreamBlobData
],
[
'user',
{ streamId: 'a'.padStart(10, 'a'), userId: 'a'.padStart(1, 'b') },
{ blobId: 'a'.padStart(10, 'c') } as UploadFileStreamBlobData
]
]
invalidData.map(([caseName, streamData, blobData]) =>
it(`Should throw if ${caseName} id length is incorrect`, async () => {
const uploadFileStream = await buildUploadFileStream({ streamId: null })
try {
await uploadFileStream(streamData, blobData)
} catch (err) {
if (!(err instanceof BadRequestError)) throw err
expect(err.message).to.equal(`The ${caseName} id has to be of length 10`)
}
})
)
it('Should store file stream', async () => {
const fileName = `testFile_${fakeIdGenerator()}`
const streamId = fakeIdGenerator()
const blobId = fakeIdGenerator()
const userId = fakeIdGenerator()
const fileHash = fakeIdGenerator()
const uploadFileStream = uploadFileStreamFactory({
upsertBlob,
updateBlob,
storeFileStream: fakeFileStreamStore(fileHash)
})
const blobData = await uploadFileStream(
{ streamId, userId },
{ blobId, fileName, fileType: '.something', fileStream: Buffer.from('') }
)
expect(blobData).to.deep.equal({ blobId, fileName, fileHash })
})
})
describe('Get blob metadata', () => {
const testUser1: BasicTestUser = {
name: 'Blob Test User #1',
email: 'testUser1@gmailll.com',
id: ''
}
const testStream1: BasicTestStream = {
name: 'Blob Test Stream #1',
isPublic: false,
ownerId: '',
id: ''
}
const testWorkspace1: BasicTestWorkspace = {
name: 'Blob Test Workspace #1',
ownerId: '',
id: '',
slug: ''
}
let testStreamBlob1: BlobStorageItem
before(async () => {
// Insert blob
await createTestUser(testUser1)
await waitForRegionUser(testUser1)
await createTestWorkspace(testWorkspace1, testUser1)
testStream1.workspaceId = testWorkspace1.id
await createTestStream(testStream1, testUser1)
testStreamBlob1 = await upsertBlob({
id: cryptoRandomString({ length: 10 }),
streamId: testStream1.id,
userId: testUser1.id,
objectKey: 'testObjectKey',
fileName: 'testFileName',
fileType: 'png'
})
})
it('when no blob found throws NotFoundError', async () => {
try {
await getBlobMetadata({ streamId: 'foo', blobId: 'bar' })
throw new Error('This should have failed')
} catch (err) {
if (!(err instanceof NotFoundError)) throw err
}
})
it('when no streamId throws ResourceMismatch', async () => {
try {
await getBlobMetadata({ streamId: null as unknown as string, blobId: 'bar' })
throw new Error('This should have failed')
} catch (err) {
if (!(err instanceof BadRequestError)) throw err
}
})
it('for valid input return the data', async () => {
const blobMetadata = await getBlobMetadata({
streamId: testStream1.id,
blobId: testStreamBlob1.id
})
expect(blobMetadata).to.be.ok
expect(blobMetadata.streamId).to.eq(testStream1.id)
expect(blobMetadata.id).to.eq(testStreamBlob1.id)
})
})
describe('Query cursor handling', () => {
describe('cursorFromRows', () => {
it('returns base64 encoded date ISO string', () => {
const cursorTarget = 'foo'
const rowItem: Record<string, Date> = {}
const cursorValue = new Date()
rowItem[cursorTarget] = cursorValue
const createdCursor = cursorFromRows([rowItem], cursorTarget)!
expect(Buffer.from(createdCursor, 'base64').toString()).to.equal(
cursorValue.toISOString()
)
})
it('return null if rows is null or empty array', () => {
expect(cursorFromRows([], 'cursorTarget')).to.be.null
expect(cursorFromRows(null as unknown as [], 'cursorTarget')).to.be.null
})
it("throws if the cursor target doesn't find a date object", () => {
try {
cursorFromRows([{}], 'cursorTarget' as never)
throw new Error('This should have thrown')
} catch (err) {
if (!(err instanceof BadRequestError)) throw err
expect(err.message).to.equal('The cursor target is not a date object')
}
})
})
describe('decodeCursor', () => {
it('throws if cursor cannot be parsed into date', () => {
try {
decodeCursor('asdf')
throw new Error('This should have thrown')
} catch (err) {
if (!(err instanceof BadRequestError)) throw err
expect(err.message).to.equal('The cursor is not a base64 encoded date string')
}
})
it('should decode cursor', () => {
const cursor = new Date().toISOString()
const encodedCursor = Buffer.from(cursor).toString('base64')
const decoded = decodeCursor(encodedCursor)
expect(decoded).to.equal(cursor)
})
})
})
describe('Get blob metadata collection', () => {
it('When no blobs are found, no cursor returned', async () => {
const noBlobs = await getBlobMetadataCollection({ streamId: 'foo' })
expect(noBlobs).to.deep.equal({ blobs: [], cursor: null })
})
it('Returns the correct data for good input', async () => {
const streamId = fakeIdGenerator()
const number = 4
const createdBlobs = await createBlobs({ streamId, number })
const blobData = await getBlobMetadataCollection({ streamId })
expect(blobData.blobs).to.have.lengthOf(number)
expect(blobData.blobs.map((r) => r.id)).deep.equalInAnyOrder(
createdBlobs.map((b) => b.id)
)
})
it('Clamps limit to predefined maximum', async () => {
const streamId = fakeIdGenerator()
const number = 30
const limitMax = 25
await createBlobs({ streamId, number })
let blobData = await getBlobMetadataCollection({ streamId })
expect(blobData.blobs).to.have.lengthOf(limitMax)
const localLimit = 3
blobData = await getBlobMetadataCollection({ streamId, limit: localLimit })
expect(blobData.blobs).to.have.lengthOf(localLimit)
})
it('Cursor paginates query', async () => {
const streamId = fakeIdGenerator()
const number = 30
await createBlobs({ streamId, number })
let blobData = await getBlobMetadataCollection({ streamId })
expect(blobData.blobs).to.have.lengthOf(25)
expect(blobData.blobs.map((blob) => blob.id)).to.deep.equalInAnyOrder(
range(5, 30).map((index) => `${index}`.padStart(10, '0'))
)
blobData = await getBlobMetadataCollection({
streamId,
cursor: blobData.cursor
})
expect(blobData.blobs).to.have.lengthOf(5)
expect(blobData.blobs.map((blob) => blob.id)).to.deep.equalInAnyOrder(
range(5).map((index) => `${index}`.padStart(10, '0'))
)
})
it('Query matches for partial file name', async () => {
const streamId = fakeIdGenerator()
const number = 3
await createBlobs({ streamId, number })
let blobData = await getBlobMetadataCollection({ streamId, query: '00000' })
expect(blobData.blobs).to.have.lengthOf(number)
blobData = await getBlobMetadataCollection({ streamId, query: '000002' })
expect(blobData.blobs).to.have.lengthOf(1)
})
})
describe('Get blobCollectionSummary', () => {
it('should set values to 0 if no blobs found', async () => {
const summary = await blobCollectionSummary({ streamId: 'foo' })
expect(summary).to.deep.equal({ totalSize: 0, totalCount: 0 })
})
it('should report fileSize and count correctly', async () => {
const streamId = fakeIdGenerator()
const number = 30
const fileSize = 10
await createBlobs({ streamId, number, fileSize })
const summary = await blobCollectionSummary({ streamId })
expect(summary).to.deep.equal({
totalSize: number * fileSize,
totalCount: number
})
})
})
it('getFileStream should return content of getObjectStream', async () => {
const fakeData = 'this is not a stream'
const getObjectStream = async () => fakeData
const streamId = fakeIdGenerator()
const [blob] = await createBlobs({ streamId, number: 1 })
const fs = await getFileStream({ getObjectStream, streamId, blobId: blob.id })
expect(fs).to.equal(fakeData)
})
it('deleteBlob should delete blob data', async () => {
const streamId = fakeIdGenerator()
const [blob] = await createBlobs({ streamId, number: 1 })
const blobId = blob.id
const { objectKey } = await getBlobMetadata({ streamId, blobId })
expect(objectKey).to.equal(blob.objectKey)
await deleteBlob({ streamId, blobId })
try {
await getBlobMetadata({ streamId, blobId })
throw new Error('This should have thrown')
} catch (err) {
if (!(err instanceof NotFoundError)) throw err
}
})
it('markUploadOverFileSizeLimit calls delete object', async () => {
let callCount = 0
async function deleteObjectSpy() {
callCount++
}
const streamId = fakeIdGenerator()
const [blob] = await createBlobs({ streamId, number: 1 })
const blobId = blob.id
const markResult = await markUploadOverFileSizeLimit(
deleteObjectSpy,
streamId,
blobId
)
expect(callCount).to.equal(1)
expect(markResult).to.deep.equal({
blobId,
fileName: blob.fileName,
fileSize: null,
uploadStatus: BlobUploadStatus.Error,
uploadError: 'File size limit reached'
})
})
it('markUploadSuccess returns with fileSize', async () => {
const streamId = fakeIdGenerator()
const [blob] = await createBlobs({ streamId, number: 1 })
const blobId = blob.id
const fileSize = 12345
const getObjectAttributes = async () => ({ fileSize })
const markResult = await markUploadSuccess(getObjectAttributes, streamId, blobId)
expect(markResult).to.deep.equal({
blobId,
fileName: blob.fileName,
uploadStatus: BlobUploadStatus.Completed,
fileSize
})
})
})