Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): medium tests #13289

Merged
merged 1 commit into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading