Skip to content

Commit f7ad6ef

Browse files
authored
feat(server): medium tests (immich-app#13289)
1 parent 27c04f9 commit f7ad6ef

File tree

9 files changed

+272
-11
lines changed

9 files changed

+272
-11
lines changed

.github/workflows/test.yml

+21-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ jobs:
8080
run: npm run check
8181
if: ${{ !cancelled() }}
8282

83-
- name: Run unit tests & coverage
83+
- name: Run small tests & coverage
8484
run: npm run test:cov
8585
if: ${{ !cancelled() }}
8686

@@ -243,6 +243,26 @@ jobs:
243243
run: npm run check
244244
if: ${{ !cancelled() }}
245245

246+
medium-tests-server:
247+
name: Medium Tests (Server)
248+
needs: pre-job
249+
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
250+
runs-on: mich
251+
252+
steps:
253+
- name: Checkout code
254+
uses: actions/checkout@v4
255+
with:
256+
submodules: 'recursive'
257+
258+
- name: Production build
259+
if: ${{ !cancelled() }}
260+
run: docker compose -f e2e/docker-compose.yml build
261+
262+
- name: Run medium tests
263+
if: ${{ !cancelled() }}
264+
run: make test-medium
265+
246266
e2e-tests-server-cli:
247267
name: End-to-End Tests (Server & CLI)
248268
needs: pre-job

Makefile

+12
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ test-e2e:
6666
docker compose -f ./e2e/docker-compose.yml build
6767
npm --prefix e2e run test
6868
npm --prefix e2e run test:web
69+
test-medium:
70+
docker run \
71+
--rm \
72+
-v ./server/src:/usr/src/app/src \
73+
-v ./server/test:/usr/src/app/test \
74+
-v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \
75+
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
76+
-e NODE_ENV=development \
77+
immich-server:latest \
78+
-c "npm ci && npm run test:medium -- --run"
79+
test-medium-dev:
80+
docker exec -it immich_server /bin/sh -c "npm run test:medium"
6981

7082
build-all: $(foreach M,$(MODULES),build-$M) ;
7183
install-all: $(foreach M,$(MODULES),install-$M) ;

server/package-lock.json

+37
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
"check:code": "npm run format && npm run lint && npm run check",
2020
"check:all": "npm run check:code && npm run test:cov",
2121
"test": "vitest",
22-
"test:watch": "vitest --watch",
2322
"test:cov": "vitest --coverage",
23+
"test:medium": "vitest --config vitest.config.medium.mjs",
2424
"typeorm": "typeorm",
2525
"lifecycle": "node ./dist/utils/lifecycle.js",
2626
"typeorm:migrations:create": "typeorm migration:create",
@@ -111,6 +111,7 @@
111111
"@types/node": "^20.16.10",
112112
"@types/nodemailer": "^6.4.14",
113113
"@types/picomatch": "^3.0.0",
114+
"@types/pngjs": "^6.0.5",
114115
"@types/react": "^18.3.4",
115116
"@types/semver": "^7.5.8",
116117
"@types/supertest": "^6.0.0",
@@ -124,6 +125,7 @@
124125
"eslint-plugin-unicorn": "^55.0.0",
125126
"globals": "^15.9.0",
126127
"mock-fs": "^5.2.0",
128+
"pngjs": "^7.0.0",
127129
"prettier": "^3.0.2",
128130
"prettier-plugin-organize-imports": "^4.0.0",
129131
"rimraf": "^6.0.0",

server/src/repositories/metadata.repository.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { Inject, Injectable } from '@nestjs/common';
2-
import { InjectRepository } from '@nestjs/typeorm';
32
import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
43
import geotz from 'geo-tz';
5-
import { ExifEntity } from 'src/entities/exif.entity';
64
import { ILoggerRepository } from 'src/interfaces/logger.interface';
75
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
86
import { Instrumentation } from 'src/utils/instrumentation';
9-
import { Repository } from 'typeorm';
107

118
@Instrumentation()
129
@Injectable()
@@ -25,10 +22,7 @@ export class MetadataRepository implements IMetadataRepository {
2522
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
2623
});
2724

28-
constructor(
29-
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
30-
@Inject(ILoggerRepository) private logger: ILoggerRepository,
31-
) {
25+
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
3226
this.logger.setContext(MetadataRepository.name);
3327
}
3428

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Stats } from 'node:fs';
2+
import { writeFile } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { AssetEntity } from 'src/entities/asset.entity';
6+
import { IAssetRepository } from 'src/interfaces/asset.interface';
7+
import { IStorageRepository } from 'src/interfaces/storage.interface';
8+
import { MetadataRepository } from 'src/repositories/metadata.repository';
9+
import { MetadataService } from 'src/services/metadata.service';
10+
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
11+
import { newRandomImage, newTestService } from 'test/utils';
12+
import { Mocked } from 'vitest';
13+
14+
const metadataRepository = new MetadataRepository(newLoggerRepositoryMock());
15+
16+
const createTestFile = async (exifData: Record<string, any>) => {
17+
const data = newRandomImage();
18+
const filePath = join(tmpdir(), 'test.png');
19+
await writeFile(filePath, data);
20+
await metadataRepository.writeTags(filePath, exifData);
21+
return { filePath };
22+
};
23+
24+
type TimeZoneTest = {
25+
description: string;
26+
serverTimeZone?: string;
27+
exifData: Record<string, any>;
28+
expected: {
29+
localDateTime: string;
30+
dateTimeOriginal: string;
31+
timeZone: string | null;
32+
};
33+
};
34+
35+
describe(MetadataService.name, () => {
36+
let sut: MetadataService;
37+
38+
let assetMock: Mocked<IAssetRepository>;
39+
let storageMock: Mocked<IStorageRepository>;
40+
41+
beforeEach(() => {
42+
({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository }));
43+
44+
storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats);
45+
46+
delete process.env.TZ;
47+
});
48+
49+
it('should be defined', () => {
50+
expect(sut).toBeDefined();
51+
});
52+
53+
describe('handleMetadataExtraction', () => {
54+
const timeZoneTests: TimeZoneTest[] = [
55+
{
56+
description: 'should handle no time zone information',
57+
exifData: {
58+
DateTimeOriginal: '2022:01:01 00:00:00',
59+
},
60+
expected: {
61+
localDateTime: '2022-01-01T00:00:00.000Z',
62+
dateTimeOriginal: '2022-01-01T00:00:00.000Z',
63+
timeZone: null,
64+
},
65+
},
66+
{
67+
description: 'should handle no time zone information and server behind UTC',
68+
serverTimeZone: 'America/Los_Angeles',
69+
exifData: {
70+
DateTimeOriginal: '2022:01:01 00:00:00',
71+
},
72+
expected: {
73+
localDateTime: '2022-01-01T00:00:00.000Z',
74+
dateTimeOriginal: '2022-01-01T08:00:00.000Z',
75+
timeZone: null,
76+
},
77+
},
78+
{
79+
description: 'should handle no time zone information and server ahead of UTC',
80+
serverTimeZone: 'Europe/Brussels',
81+
exifData: {
82+
DateTimeOriginal: '2022:01:01 00:00:00',
83+
},
84+
expected: {
85+
localDateTime: '2022-01-01T00:00:00.000Z',
86+
dateTimeOriginal: '2021-12-31T23:00:00.000Z',
87+
timeZone: null,
88+
},
89+
},
90+
{
91+
description: 'should handle no time zone information and server ahead of UTC in the summer',
92+
serverTimeZone: 'Europe/Brussels',
93+
exifData: {
94+
DateTimeOriginal: '2022:06:01 00:00:00',
95+
},
96+
expected: {
97+
localDateTime: '2022-06-01T00:00:00.000Z',
98+
dateTimeOriginal: '2022-05-31T22:00:00.000Z',
99+
timeZone: null,
100+
},
101+
},
102+
{
103+
description: 'should handle a +13:00 time zone',
104+
exifData: {
105+
DateTimeOriginal: '2022:01:01 00:00:00+13:00',
106+
},
107+
expected: {
108+
localDateTime: '2022-01-01T00:00:00.000Z',
109+
dateTimeOriginal: '2021-12-31T11:00:00.000Z',
110+
timeZone: 'UTC+13',
111+
},
112+
},
113+
];
114+
115+
it.each(timeZoneTests)('$description', async ({ exifData, serverTimeZone, expected }) => {
116+
process.env.TZ = serverTimeZone ?? undefined;
117+
118+
const { filePath } = await createTestFile(exifData);
119+
assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]);
120+
121+
await sut.handleMetadataExtraction({ id: 'asset-1' });
122+
123+
expect(assetMock.upsertExif).toHaveBeenCalledWith(
124+
expect.objectContaining({
125+
dateTimeOriginal: new Date(expected.dateTimeOriginal),
126+
timeZone: expected.timeZone,
127+
}),
128+
);
129+
130+
expect(assetMock.update).toHaveBeenCalledWith(
131+
expect.objectContaining({
132+
localDateTime: new Date(expected.localDateTime),
133+
}),
134+
);
135+
});
136+
});
137+
});

server/test/utils.ts

+43-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { PNG } from 'pngjs';
2+
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
13
import { BaseService } from 'src/services/base.service';
24
import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
35
import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock';
@@ -36,13 +38,22 @@ import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock'
3638
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
3739
import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock';
3840
import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
41+
import { Mocked } from 'vitest';
3942

43+
type RepositoryOverrides = {
44+
metadataRepository: IMetadataRepository;
45+
};
4046
type BaseServiceArgs = ConstructorParameters<typeof BaseService>;
4147
type Constructor<Type, Args extends Array<any>> = {
4248
new (...deps: Args): Type;
4349
};
4450

45-
export const newTestService = <T extends BaseService>(Service: Constructor<T, BaseServiceArgs>) => {
51+
export const newTestService = <T extends BaseService>(
52+
Service: Constructor<T, BaseServiceArgs>,
53+
overrides?: RepositoryOverrides,
54+
) => {
55+
const { metadataRepository } = overrides || {};
56+
4657
const accessMock = newAccessRepositoryMock();
4758
const loggerMock = newLoggerRepositoryMock();
4859
const cryptoMock = newCryptoRepositoryMock();
@@ -61,7 +72,7 @@ export const newTestService = <T extends BaseService>(Service: Constructor<T, Ba
6172
const mapMock = newMapRepositoryMock();
6273
const mediaMock = newMediaRepositoryMock();
6374
const memoryMock = newMemoryRepositoryMock();
64-
const metadataMock = newMetadataRepositoryMock();
75+
const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked<IMetadataRepository>;
6576
const metricMock = newMetricRepositoryMock();
6677
const moveMock = newMoveRepositoryMock();
6778
const notificationMock = newNotificationRepositoryMock();
@@ -162,3 +173,33 @@ export const newTestService = <T extends BaseService>(Service: Constructor<T, Ba
162173
viewMock,
163174
};
164175
};
176+
177+
const createPNG = (r: number, g: number, b: number) => {
178+
const image = new PNG({ width: 1, height: 1 });
179+
image.data[0] = r;
180+
image.data[1] = g;
181+
image.data[2] = b;
182+
image.data[3] = 255;
183+
return PNG.sync.write(image);
184+
};
185+
186+
function* newPngFactory() {
187+
for (let r = 0; r < 255; r++) {
188+
for (let g = 0; g < 255; g++) {
189+
for (let b = 0; b < 255; b++) {
190+
yield createPNG(r, g, b);
191+
}
192+
}
193+
}
194+
}
195+
196+
const pngFactory = newPngFactory();
197+
198+
export const newRandomImage = () => {
199+
const { value } = pngFactory.next();
200+
if (!value) {
201+
throw new Error('Ran out of random asset data');
202+
}
203+
204+
return value;
205+
};

0 commit comments

Comments
 (0)