mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 01:48:49 -07:00
feat(server): medium tests (#13289)
This commit is contained in:
parent
27c04f9d26
commit
f7ad6efc4a
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
@ -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
|
||||
|
12
Makefile
12
Makefile
@ -66,6 +66,18 @@ 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"
|
||||
test-medium-dev:
|
||||
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
||||
|
||||
build-all: $(foreach M,$(MODULES),build-$M) ;
|
||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
||||
|
37
server/package-lock.json
generated
37
server/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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<ExifEntity>,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||
this.logger.setContext(MetadataRepository.name);
|
||||
}
|
||||
|
||||
|
137
server/test/medium/metadata.service.spec.ts
Normal file
137
server/test/medium/metadata.service.spec.ts
Normal file
@ -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<string, any>) => {
|
||||
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<string, any>;
|
||||
expected: {
|
||||
localDateTime: string;
|
||||
dateTimeOriginal: string;
|
||||
timeZone: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
describe(MetadataService.name, () => {
|
||||
let sut: MetadataService;
|
||||
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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<typeof BaseService>;
|
||||
type Constructor<Type, Args extends Array<any>> = {
|
||||
new (...deps: Args): Type;
|
||||
};
|
||||
|
||||
export const newTestService = <T extends BaseService>(Service: Constructor<T, BaseServiceArgs>) => {
|
||||
export const newTestService = <T extends BaseService>(
|
||||
Service: Constructor<T, BaseServiceArgs>,
|
||||
overrides?: RepositoryOverrides,
|
||||
) => {
|
||||
const { metadataRepository } = overrides || {};
|
||||
|
||||
const accessMock = newAccessRepositoryMock();
|
||||
const loggerMock = newLoggerRepositoryMock();
|
||||
const cryptoMock = newCryptoRepositoryMock();
|
||||
@ -61,7 +72,7 @@ export const newTestService = <T extends BaseService>(Service: Constructor<T, Ba
|
||||
const mapMock = newMapRepositoryMock();
|
||||
const mediaMock = newMediaRepositoryMock();
|
||||
const memoryMock = newMemoryRepositoryMock();
|
||||
const metadataMock = newMetadataRepositoryMock();
|
||||
const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked<IMetadataRepository>;
|
||||
const metricMock = newMetricRepositoryMock();
|
||||
const moveMock = newMoveRepositoryMock();
|
||||
const notificationMock = newNotificationRepositoryMock();
|
||||
@ -162,3 +173,33 @@ export const newTestService = <T extends BaseService>(Service: Constructor<T, Ba
|
||||
viewMock,
|
||||
};
|
||||
};
|
||||
|
||||
const createPNG = (r: number, g: number, b: number) => {
|
||||
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;
|
||||
};
|
||||
|
17
server/vitest.config.medium.mjs
Normal file
17
server/vitest.config.medium.mjs
Normal file
@ -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()],
|
||||
});
|
@ -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/**'],
|
||||
|
Loading…
Reference in New Issue
Block a user