Skip to content

Commit

Permalink
feat(server): medium tests (immich-app#13289)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrasm91 authored Oct 9, 2024
1 parent 27c04f9 commit f7ad6ef
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 11 deletions.
22 changes: 21 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() }}

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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) ;
Expand Down
37 changes: 37 additions & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 1 addition & 7 deletions server/src/repositories/metadata.repository.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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);
}

Expand Down
137 changes: 137 additions & 0 deletions server/test/medium/metadata.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
);
});
});
});
45 changes: 43 additions & 2 deletions server/test/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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;
};
Loading

0 comments on commit f7ad6ef

Please sign in to comment.