diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24e3e086235f0d..064e3c27616f72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,7 +80,7 @@ jobs: run: npm run check if: ${{ !cancelled() }} - - name: Run unit tests & coverage + - name: Run small tests & coverage run: npm run test:cov if: ${{ !cancelled() }} @@ -243,6 +243,26 @@ jobs: run: npm run check if: ${{ !cancelled() }} + medium-tests-server: + name: Medium Tests (Server) + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} + runs-on: mich + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Production build + if: ${{ !cancelled() }} + run: docker compose -f e2e/docker-compose.yml build + + - name: Run medium tests + if: ${{ !cancelled() }} + run: make test-medium + e2e-tests-server-cli: name: End-to-End Tests (Server & CLI) needs: pre-job diff --git a/Makefile b/Makefile index 349a5c5e920ef9..85f34dfe9a9615 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,16 @@ test-e2e: docker compose -f ./e2e/docker-compose.yml build npm --prefix e2e run test npm --prefix e2e run test:web +test-medium: + docker run \ + --rm \ + -v ./server/src:/usr/src/app/src \ + -v ./server/test:/usr/src/app/test \ + -v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \ + -v ./server/tsconfig.json:/usr/src/app/tsconfig.json \ + -e NODE_ENV=development \ + immich-server:latest \ + -c "npm ci && npm run test:medium -- --run" build-all: $(foreach M,$(MODULES),build-$M) ; install-all: $(foreach M,$(MODULES),install-$M) ; diff --git a/server/package-lock.json b/server/package-lock.json index 173cbe51e127cc..04f0ddfcd60c0e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -86,6 +86,7 @@ "@types/node": "^20.16.10", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", @@ -99,6 +100,7 @@ "eslint-plugin-unicorn": "^55.0.0", "globals": "^15.9.0", "mock-fs": "^5.2.0", + "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", "rimraf": "^6.0.0", @@ -5496,6 +5498,16 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -11446,6 +11458,16 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/point-in-polygon-hao": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz", @@ -18794,6 +18816,15 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, + "@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -23193,6 +23224,12 @@ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true + }, "point-in-polygon-hao": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz", diff --git a/server/package.json b/server/package.json index ee548d1d770a31..78922609d99d88 100644 --- a/server/package.json +++ b/server/package.json @@ -19,8 +19,8 @@ "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm run test:cov", "test": "vitest", - "test:watch": "vitest --watch", "test:cov": "vitest --coverage", + "test:medium": "vitest --config vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", "typeorm:migrations:create": "typeorm migration:create", @@ -111,6 +111,7 @@ "@types/node": "^20.16.10", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", @@ -124,6 +125,7 @@ "eslint-plugin-unicorn": "^55.0.0", "globals": "^15.9.0", "mock-fs": "^5.2.0", + "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", "rimraf": "^6.0.0", diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index ec798145203941..dc2a4cdf9bd1f3 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,12 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; -import { ExifEntity } from 'src/entities/exif.entity'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; @Instrumentation() @Injectable() @@ -25,10 +22,7 @@ export class MetadataRepository implements IMetadataRepository { writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], }); - constructor( - @InjectRepository(ExifEntity) private exifRepository: Repository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(MetadataRepository.name); } diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts new file mode 100644 index 00000000000000..3ccce0f16e725a --- /dev/null +++ b/server/test/medium/metadata.service.spec.ts @@ -0,0 +1,137 @@ +import { Stats } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MetadataService } from 'src/services/metadata.service'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newRandomImage, newTestService } from 'test/utils'; +import { Mocked } from 'vitest'; + +const metadataRepository = new MetadataRepository(newLoggerRepositoryMock()); + +const createTestFile = async (exifData: Record) => { + const data = newRandomImage(); + const filePath = join(tmpdir(), 'test.png'); + await writeFile(filePath, data); + await metadataRepository.writeTags(filePath, exifData); + return { filePath }; +}; + +type TimeZoneTest = { + description: string; + serverTimeZone?: string; + exifData: Record; + expected: { + localDateTime: string; + dateTimeOriginal: string; + timeZone: string | null; + }; +}; + +describe(MetadataService.name, () => { + let sut: MetadataService; + + let assetMock: Mocked; + let storageMock: Mocked; + + beforeEach(() => { + ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository })); + + storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + + delete process.env.TZ; + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('handleMetadataExtraction', () => { + const timeZoneTests: TimeZoneTest[] = [ + { + description: 'should handle no time zone information', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2022-01-01T00:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server behind UTC', + serverTimeZone: 'America/Los_Angeles', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2022-01-01T08:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server ahead of UTC', + serverTimeZone: 'Europe/Brussels', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2021-12-31T23:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server ahead of UTC in the summer', + serverTimeZone: 'Europe/Brussels', + exifData: { + DateTimeOriginal: '2022:06:01 00:00:00', + }, + expected: { + localDateTime: '2022-06-01T00:00:00.000Z', + dateTimeOriginal: '2022-05-31T22:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle a +13:00 time zone', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00+13:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2021-12-31T11:00:00.000Z', + timeZone: 'UTC+13', + }, + }, + ]; + + it.each(timeZoneTests)('$description', async ({ exifData, serverTimeZone, expected }) => { + process.env.TZ = serverTimeZone ?? undefined; + + const { filePath } = await createTestFile(exifData); + assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + + await sut.handleMetadataExtraction({ id: 'asset-1' }); + + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + dateTimeOriginal: new Date(expected.dateTimeOriginal), + timeZone: expected.timeZone, + }), + ); + + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: new Date(expected.localDateTime), + }), + ); + }); + }); +}); diff --git a/server/test/utils.ts b/server/test/utils.ts index 05257c19eeab49..3b7e80994d62ca 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,3 +1,5 @@ +import { PNG } from 'pngjs'; +import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { BaseService } from 'src/services/base.service'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; @@ -36,13 +38,22 @@ import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock' import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; import { newViewRepositoryMock } from 'test/repositories/view.repository.mock'; +import { Mocked } from 'vitest'; +type RepositoryOverrides = { + metadataRepository: IMetadataRepository; +}; type BaseServiceArgs = ConstructorParameters; type Constructor> = { new (...deps: Args): Type; }; -export const newTestService = (Service: Constructor) => { +export const newTestService = ( + Service: Constructor, + overrides?: RepositoryOverrides, +) => { + const { metadataRepository } = overrides || {}; + const accessMock = newAccessRepositoryMock(); const loggerMock = newLoggerRepositoryMock(); const cryptoMock = newCryptoRepositoryMock(); @@ -61,7 +72,7 @@ export const newTestService = (Service: Constructor; const metricMock = newMetricRepositoryMock(); const moveMock = newMoveRepositoryMock(); const notificationMock = newNotificationRepositoryMock(); @@ -162,3 +173,33 @@ export const newTestService = (Service: Constructor { + const image = new PNG({ width: 1, height: 1 }); + image.data[0] = r; + image.data[1] = g; + image.data[2] = b; + image.data[3] = 255; + return PNG.sync.write(image); +}; + +function* newPngFactory() { + for (let r = 0; r < 255; r++) { + for (let g = 0; g < 255; g++) { + for (let b = 0; b < 255; b++) { + yield createPNG(r, g, b); + } + } + } +} + +const pngFactory = newPngFactory(); + +export const newRandomImage = () => { + const { value } = pngFactory.next(); + if (!value) { + throw new Error('Ran out of random asset data'); + } + + return value; +}; diff --git a/server/vitest.config.medium.mjs b/server/vitest.config.medium.mjs new file mode 100644 index 00000000000000..40dad8d6a5024a --- /dev/null +++ b/server/vitest.config.medium.mjs @@ -0,0 +1,17 @@ +import swc from 'unplugin-swc'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: './', + globals: true, + include: ['test/medium/**/*.spec.ts'], + server: { + deps: { + fallbackCJS: true, + }, + }, + }, + plugins: [swc.vite(), tsconfigPaths()], +}); diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 1013b4606df3f5..038d65123f1752 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -6,6 +6,7 @@ export default defineConfig({ test: { root: './', globals: true, + include: ['src/**/*.spec.ts'], coverage: { provider: 'v8', include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'],