diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 4dd02ec69f..f39a26a05b 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -2,7 +2,6 @@ import { AssetMediaResponseDto, AssetMediaStatus, AssetResponseDto, - AssetTypeEnum, LoginResponseDto, SharedLinkType, getAssetInfo, @@ -942,250 +941,17 @@ describe('/asset', () => { expect(body).toEqual(errorDto.badRequest()); }); - const tests = [ - { - input: 'formats/avif/8bit-sRGB.avif', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '8bit-sRGB.avif', - exifInfo: { - description: '', - exifImageHeight: 1080, - exifImageWidth: 1617, - fileSizeInByte: 862_424, - latitude: null, - longitude: null, - }, - }, - }, - { - input: 'formats/jpg/el_torcal_rocks.jpg', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'el_torcal_rocks.jpg', - exifInfo: { - dateTimeOriginal: '2012-08-05T11:39:59.000Z', - exifImageWidth: 512, - exifImageHeight: 341, - latitude: null, - longitude: null, - focalLength: 75, - iso: 200, - fNumber: 11, - exposureTime: '1/160', - fileSizeInByte: 53_493, - make: 'SONY', - model: 'DSLR-A550', - orientation: null, - description: 'SONY DSC', - }, - }, - }, - { - input: 'formats/jxl/8bit-sRGB.jxl', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '8bit-sRGB.jxl', - exifInfo: { - description: '', - exifImageHeight: 1080, - exifImageWidth: 1440, - fileSizeInByte: 1_780_777, - latitude: null, - longitude: null, - }, - }, - }, - { - input: 'formats/heic/IMG_2682.heic', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'IMG_2682.heic', - fileCreatedAt: '2019-03-21T16:04:22.348Z', - exifInfo: { - dateTimeOriginal: '2019-03-21T16:04:22.348Z', - exifImageWidth: 4032, - exifImageHeight: 3024, - latitude: 41.2203, - longitude: -96.071_625, - make: 'Apple', - model: 'iPhone 7', - lensModel: 'iPhone 7 back camera 3.99mm f/1.8', - fileSizeInByte: 880_703, - exposureTime: '1/887', - iso: 20, - focalLength: 3.99, - fNumber: 1.8, - timeZone: 'America/Chicago', - }, - }, - }, - { - input: 'formats/png/density_plot.png', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'density_plot.png', - exifInfo: { - exifImageWidth: 800, - exifImageHeight: 800, - latitude: null, - longitude: null, - fileSizeInByte: 25_408, - }, - }, - }, - { - input: 'formats/raw/Nikon/D80/glarus.nef', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D80', - exposureTime: '1/200', - fNumber: 10, - focalLength: 18, - iso: 100, - fileSizeInByte: 9_057_784, - dateTimeOriginal: '2010-07-20T17:27:12.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Nikon/D700/philadelphia.nef', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'philadelphia.nef', - fileCreatedAt: '2016-09-22T22:10:29.060Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D700', - exposureTime: '1/400', - fNumber: 11, - focalLength: 85, - iso: 200, - fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T22:10:29.060Z', - latitude: null, - longitude: null, - orientation: '1', - timeZone: 'UTC-5', - }, - }, - }, - { - input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '4_3.rw2', - fileCreatedAt: '2018-05-10T08:42:37.842Z', - exifInfo: { - make: 'Panasonic', - model: 'DMC-GH4', - exifImageHeight: 3456, - exifImageWidth: 4608, - exposureTime: '1/100', - fNumber: 3.2, - focalLength: 35, - iso: 400, - fileSizeInByte: 19_587_072, - dateTimeOriginal: '2018-05-10T08:42:37.842Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '12bit-compressed-(3_2).arw', - fileCreatedAt: '2016-09-27T10:51:44.000Z', - exifInfo: { - make: 'SONY', - model: 'ILCE-6300', - exifImageHeight: 4024, - exifImageWidth: 6048, - exposureTime: '1/320', - fNumber: 8, - focalLength: 97, - iso: 100, - lensModel: 'E PZ 18-105mm F4 G OSS', - fileSizeInByte: 25_001_984, - dateTimeOriginal: '2016-09-27T10:51:44.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '14bit-uncompressed-(3_2).arw', - fileCreatedAt: '2016-01-08T14:08:01.000Z', - exifInfo: { - make: 'SONY', - model: 'ILCE-7M2', - exifImageHeight: 4024, - exifImageWidth: 6048, - exposureTime: '1.3', - fNumber: 22, - focalLength: 25, - iso: 100, - lensModel: 'E 25mm F2', - fileSizeInByte: 49_512_448, - dateTimeOriginal: '2016-01-08T14:08:01.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - ]; - - it(`should upload and generate a thumbnail for different file types`, async () => { - // upload in parallel - const assets = await Promise.all( - tests.map(async ({ input }) => { - const filepath = join(testAssetDir, input); - return utils.createAsset(admin.accessToken, { - assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, - }); - }), - ); - - for (const { id, status } of assets) { - expect(status).toBe(AssetMediaStatus.Created); - await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); - } - - for (const [i, { id }] of assets.entries()) { - const { expected } = tests[i]; - const asset = await utils.getAssetInfo(admin.accessToken, id); - - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); - } - }); - it('should handle a duplicate', async () => { const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; - const { status } = await utils.createAsset(admin.accessToken, { - assetData: { - bytes: await readFile(join(testAssetDir, filepath)), - filename: basename(filepath), - }, - }); + const assetData = { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }; + const upload1 = await utils.createAsset(admin.accessToken, { assetData }); + expect(upload1.status).toBe(AssetMediaStatus.Created); - expect(status).toBe(AssetMediaStatus.Duplicate); + const upload2 = await utils.createAsset(admin.accessToken, { assetData }); + expect(upload2.status).toBe(AssetMediaStatus.Duplicate); }); it('should update the used quota', async () => { diff --git a/server/test/medium/media.service.spec.ts b/server/test/medium/media.service.spec.ts new file mode 100644 index 0000000000..5421916908 --- /dev/null +++ b/server/test/medium/media.service.spec.ts @@ -0,0 +1,74 @@ +import { Stats } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { MediaService } from 'src/services/media.service'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newTestService, testAssetDir } from 'test/utils'; +import { Mocked } from 'vitest'; + +type ThumbnailTest = { + input: { + filePath: string; + type: AssetType; + }; +}; + +describe(MediaService.name, () => { + let sut: MediaService; + + let assetMock: Mocked; + let storageMock: Mocked; + + beforeEach(async () => { + const mediaRepository = new MediaRepository(newLoggerRepositoryMock()); + ({ sut, assetMock, storageMock } = newTestService(MediaService, { mediaRepository })); + + storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + + const mediaLocation = join(tmpdir(), 'immich-medium-tests'); + process.env.IMMICH_MEDIA_LOCATION = mediaLocation; + await mkdir(mediaLocation, { recursive: true }); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('handleGenerateThumbnails', () => { + const thumbnailTests: ThumbnailTest[] = [ + { input: { filePath: 'formats/avif/8bit-sRGB.avif', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/jpg/el_torcal_rocks.jpg', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/jxl/8bit-sRGB.jxl', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/heic/IMG_2682.heic', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/png/density_plot.png', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/raw/Nikon/D80/glarus.nef', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/raw/Nikon/D700/philadelphia.nef', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', type: AssetType.IMAGE } }, + // { input: { filePath: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', type: AssetType.IMAGE } }, + ]; + + it.each(thumbnailTests)(`should generate a thumbnail for $input.filePath`, async ({ input }) => { + const filePath = join(testAssetDir, input.filePath); + + assetMock.getById.mockResolvedValue({ + id: 'asset-id', + isVisible: true, + originalPath: filePath, + ownerId: 'owner-id', + type: input.type, + } as AssetEntity); + + await expect(sut.handleGenerateThumbnails({ id: 'asset-id' })).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.upsertFile).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/server/test/utils.ts b/server/test/utils.ts index 3b7e80994d..f17f3e1d82 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,4 +1,5 @@ import { PNG } from 'pngjs'; +import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { BaseService } from 'src/services/base.service'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; @@ -41,18 +42,21 @@ import { newViewRepositoryMock } from 'test/repositories/view.repository.mock'; import { Mocked } from 'vitest'; type RepositoryOverrides = { - metadataRepository: IMetadataRepository; + mediaRepository?: IMediaRepository; + metadataRepository?: IMetadataRepository; }; type BaseServiceArgs = ConstructorParameters; type Constructor> = { new (...deps: Args): Type; }; +export const testAssetDir = '/test-assets'; + export const newTestService = ( Service: Constructor, overrides?: RepositoryOverrides, ) => { - const { metadataRepository } = overrides || {}; + const { mediaRepository, metadataRepository } = overrides || {}; const accessMock = newAccessRepositoryMock(); const loggerMock = newLoggerRepositoryMock(); @@ -70,7 +74,7 @@ export const newTestService = ( const libraryMock = newLibraryRepositoryMock(); const machineLearningMock = newMachineLearningRepositoryMock(); const mapMock = newMapRepositoryMock(); - const mediaMock = newMediaRepositoryMock(); + const mediaMock = (mediaRepository || newMediaRepositoryMock()) as Mocked; const memoryMock = newMemoryRepositoryMock(); const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked; const metricMock = newMetricRepositoryMock();