mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
refactor: asset e2e (#7769)
This commit is contained in:
parent
8eb9dad989
commit
30b0b2474e
18 changed files with 852 additions and 1617 deletions
17
.github/workflows/test.yml
vendored
17
.github/workflows/test.yml
vendored
|
@ -10,23 +10,6 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-e2e-api:
|
|
||||||
name: Server (e2e-api)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./server
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Run npm install
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run e2e tests
|
|
||||||
run: npm run e2e:api
|
|
||||||
|
|
||||||
server-e2e-jobs:
|
server-e2e-jobs:
|
||||||
name: Server (e2e-jobs)
|
name: Server (e2e-jobs)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
3
Makefile
3
Makefile
|
@ -19,9 +19,6 @@ pull-stage:
|
||||||
server-e2e-jobs:
|
server-e2e-jobs:
|
||||||
docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||||
|
|
||||||
server-e2e-api:
|
|
||||||
npm run e2e:api --prefix server
|
|
||||||
|
|
||||||
.PHONY: e2e
|
.PHONY: e2e
|
||||||
e2e:
|
e2e:
|
||||||
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
|
@ -2,20 +2,45 @@ import {
|
||||||
AssetFileUploadResponseDto,
|
AssetFileUploadResponseDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
|
LibraryResponseDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SharedLinkType,
|
SharedLinkType,
|
||||||
|
TimeBucketSize,
|
||||||
|
getAllLibraries,
|
||||||
|
getAssetInfo,
|
||||||
|
updateAssets,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { exiftool } from 'exiftool-vendored';
|
import { exiftool } from 'exiftool-vendored';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||||
|
import { makeRandomImage } from 'src/generators';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { app, tempDir, testAssetDir, utils } from 'src/utils';
|
import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
|
||||||
|
const dto: Record<string, any> = {
|
||||||
|
deviceAssetId: 'example-image',
|
||||||
|
deviceId: 'TEST',
|
||||||
|
fileCreatedAt: new Date().toISOString(),
|
||||||
|
fileModifiedAt: new Date().toISOString(),
|
||||||
|
isFavorite: 'testing',
|
||||||
|
duration: '0:00:00.000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
const omit = options?.omit;
|
||||||
|
if (omit) {
|
||||||
|
delete dto[omit];
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
};
|
||||||
|
|
||||||
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||||
|
@ -35,34 +60,43 @@ const yesterday = today.minus({ days: 1 });
|
||||||
|
|
||||||
describe('/asset', () => {
|
describe('/asset', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
|
let websocket: Socket;
|
||||||
|
|
||||||
let user1: LoginResponseDto;
|
let user1: LoginResponseDto;
|
||||||
let user2: LoginResponseDto;
|
let user2: LoginResponseDto;
|
||||||
let userStats: LoginResponseDto;
|
let timeBucketUser: LoginResponseDto;
|
||||||
|
let quotaUser: LoginResponseDto;
|
||||||
|
let statsUser: LoginResponseDto;
|
||||||
|
let stackUser: LoginResponseDto;
|
||||||
|
|
||||||
let user1Assets: AssetFileUploadResponseDto[];
|
let user1Assets: AssetFileUploadResponseDto[];
|
||||||
let user2Assets: AssetFileUploadResponseDto[];
|
let user2Assets: AssetFileUploadResponseDto[];
|
||||||
let assetLocation: AssetFileUploadResponseDto;
|
let stackAssets: AssetFileUploadResponseDto[];
|
||||||
let ws: Socket;
|
let locationAsset: AssetFileUploadResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup({ onboarding: false });
|
admin = await utils.adminSetup({ onboarding: false });
|
||||||
|
|
||||||
[ws, user1, user2, userStats] = await Promise.all([
|
[websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([
|
||||||
utils.connectWebsocket(admin.accessToken),
|
utils.connectWebsocket(admin.accessToken),
|
||||||
utils.userSetup(admin.accessToken, createUserDto.user1),
|
utils.userSetup(admin.accessToken, createUserDto.create('1')),
|
||||||
utils.userSetup(admin.accessToken, createUserDto.user2),
|
utils.userSetup(admin.accessToken, createUserDto.create('2')),
|
||||||
utils.userSetup(admin.accessToken, createUserDto.user3),
|
utils.userSetup(admin.accessToken, createUserDto.create('stats')),
|
||||||
|
utils.userSetup(admin.accessToken, createUserDto.userQuota),
|
||||||
|
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
|
||||||
|
utils.userSetup(admin.accessToken, createUserDto.create('stack')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// asset location
|
// asset location
|
||||||
assetLocation = await utils.createAsset(admin.accessToken, {
|
locationAsset = await utils.createAsset(admin.accessToken, {
|
||||||
assetData: {
|
assetData: {
|
||||||
filename: 'thompson-springs.jpg',
|
filename: 'thompson-springs.jpg',
|
||||||
bytes: await readFile(locationAssetFilepath),
|
bytes: await readFile(locationAssetFilepath),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id });
|
await utils.waitForWebsocketEvent({ event: 'upload', assetId: locationAsset.id });
|
||||||
|
|
||||||
user1Assets = await Promise.all([
|
user1Assets = await Promise.all([
|
||||||
utils.createAsset(user1.accessToken),
|
utils.createAsset(user1.accessToken),
|
||||||
|
@ -80,22 +114,43 @@ describe('/asset', () => {
|
||||||
|
|
||||||
user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]);
|
user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
|
||||||
|
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }),
|
||||||
|
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
|
||||||
|
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
|
||||||
|
]);
|
||||||
|
|
||||||
for (const asset of [...user1Assets, ...user2Assets]) {
|
for (const asset of [...user1Assets, ...user2Assets]) {
|
||||||
expect(asset.duplicate).toBe(false);
|
expect(asset.duplicate).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// stats
|
// stats
|
||||||
utils.createAsset(userStats.accessToken),
|
utils.createAsset(statsUser.accessToken),
|
||||||
utils.createAsset(userStats.accessToken, { isFavorite: true }),
|
utils.createAsset(statsUser.accessToken, { isFavorite: true }),
|
||||||
utils.createAsset(userStats.accessToken, { isArchived: true }),
|
utils.createAsset(statsUser.accessToken, { isArchived: true }),
|
||||||
utils.createAsset(userStats.accessToken, {
|
utils.createAsset(statsUser.accessToken, {
|
||||||
isArchived: true,
|
isArchived: true,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
assetData: { filename: 'example.mp4' },
|
assetData: { filename: 'example.mp4' },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// stacks
|
||||||
|
stackAssets = await Promise.all([
|
||||||
|
utils.createAsset(stackUser.accessToken),
|
||||||
|
utils.createAsset(stackUser.accessToken),
|
||||||
|
utils.createAsset(stackUser.accessToken),
|
||||||
|
utils.createAsset(stackUser.accessToken),
|
||||||
|
utils.createAsset(stackUser.accessToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await updateAssets(
|
||||||
|
{ assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
|
||||||
|
{ headers: asBearerAuth(stackUser.accessToken) },
|
||||||
|
);
|
||||||
|
|
||||||
const person1 = await utils.createPerson(user1.accessToken, {
|
const person1 = await utils.createPerson(user1.accessToken, {
|
||||||
name: 'Test Person',
|
name: 'Test Person',
|
||||||
});
|
});
|
||||||
|
@ -106,7 +161,7 @@ describe('/asset', () => {
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
utils.disconnectWebsocket(ws);
|
utils.disconnectWebsocket(websocket);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /asset/:id', () => {
|
describe('GET /asset/:id', () => {
|
||||||
|
@ -193,7 +248,7 @@ describe('/asset', () => {
|
||||||
it('should return stats of all assets', async () => {
|
it('should return stats of all assets', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/asset/statistics')
|
.get('/asset/statistics')
|
||||||
.set('Authorization', `Bearer ${userStats.accessToken}`);
|
.set('Authorization', `Bearer ${statsUser.accessToken}`);
|
||||||
|
|
||||||
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
|
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -202,7 +257,7 @@ describe('/asset', () => {
|
||||||
it('should return stats of all favored assets', async () => {
|
it('should return stats of all favored assets', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/asset/statistics')
|
.get('/asset/statistics')
|
||||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||||
.query({ isFavorite: true });
|
.query({ isFavorite: true });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -212,7 +267,7 @@ describe('/asset', () => {
|
||||||
it('should return stats of all archived assets', async () => {
|
it('should return stats of all archived assets', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/asset/statistics')
|
.get('/asset/statistics')
|
||||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||||
.query({ isArchived: true });
|
.query({ isArchived: true });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -222,7 +277,7 @@ describe('/asset', () => {
|
||||||
it('should return stats of all favored and archived assets', async () => {
|
it('should return stats of all favored and archived assets', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/asset/statistics')
|
.get('/asset/statistics')
|
||||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||||
.query({ isFavorite: true, isArchived: true });
|
.query({ isFavorite: true, isArchived: true });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -232,7 +287,7 @@ describe('/asset', () => {
|
||||||
it('should return stats of all assets neither favored nor archived', async () => {
|
it('should return stats of all assets neither favored nor archived', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/asset/statistics')
|
.get('/asset/statistics')
|
||||||
.set('Authorization', `Bearer ${userStats.accessToken}`)
|
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||||
.query({ isFavorite: false, isArchived: false });
|
.query({ isFavorite: false, isArchived: false });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -488,6 +543,35 @@ describe('/asset', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /asset/upload', () => {
|
describe('POST /asset/upload', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).post(`/asset/upload`);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
expect(status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
{ should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } },
|
||||||
|
{ should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } },
|
||||||
|
{ should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } },
|
||||||
|
{ should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } },
|
||||||
|
{ should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } },
|
||||||
|
{ should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } },
|
||||||
|
{ should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } },
|
||||||
|
{ should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { should, dto } of invalid) {
|
||||||
|
it(`should ${should}`, async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/asset/upload')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.attach('assetData', makeRandomImage(), 'example.png')
|
||||||
|
.field(dto);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tests = [
|
const tests = [
|
||||||
{
|
{
|
||||||
input: 'formats/jpg/el_torcal_rocks.jpg',
|
input: 'formats/jpg/el_torcal_rocks.jpg',
|
||||||
|
@ -601,7 +685,7 @@ describe('/asset', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { input, expected } of tests) {
|
for (const { input, expected } of tests) {
|
||||||
it(`should generate a thumbnail for ${input}`, async () => {
|
it(`should upload and generate a thumbnail for ${input}`, async () => {
|
||||||
const filepath = join(testAssetDir, input);
|
const filepath = join(testAssetDir, input);
|
||||||
const { id, duplicate } = await utils.createAsset(admin.accessToken, {
|
const { id, duplicate } = await utils.createAsset(admin.accessToken, {
|
||||||
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
|
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
|
||||||
|
@ -631,6 +715,57 @@ describe('/asset', () => {
|
||||||
expect(duplicate).toBe(true);
|
expect(duplicate).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not upload to another user's library", async () => {
|
||||||
|
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto;
|
||||||
|
|
||||||
|
const { body, status } = await request(app)
|
||||||
|
.post('/asset/upload')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.field('libraryId', library.id)
|
||||||
|
.field('deviceAssetId', 'example-image')
|
||||||
|
.field('deviceId', 'e2e')
|
||||||
|
.field('fileCreatedAt', new Date().toISOString())
|
||||||
|
.field('fileModifiedAt', new Date().toISOString())
|
||||||
|
.field('duration', '0:00:00.000000')
|
||||||
|
.attach('assetData', makeRandomImage(), 'example.png');
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the used quota', async () => {
|
||||||
|
const { body, status } = await request(app)
|
||||||
|
.post('/asset/upload')
|
||||||
|
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||||
|
.field('deviceAssetId', 'example-image')
|
||||||
|
.field('deviceId', 'e2e')
|
||||||
|
.field('fileCreatedAt', new Date().toISOString())
|
||||||
|
.field('fileModifiedAt', new Date().toISOString())
|
||||||
|
.attach('assetData', makeRandomImage(), 'example.jpg');
|
||||||
|
|
||||||
|
expect(body).toEqual({ id: expect.any(String), duplicate: false });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
const { body: user } = await request(app).get('/user/me').set('Authorization', `Bearer ${quotaUser.accessToken}`);
|
||||||
|
|
||||||
|
expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not upload an asset if it would exceed the quota', async () => {
|
||||||
|
const { body, status } = await request(app)
|
||||||
|
.post('/asset/upload')
|
||||||
|
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||||
|
.field('deviceAssetId', 'example-image')
|
||||||
|
.field('deviceId', 'e2e')
|
||||||
|
.field('fileCreatedAt', new Date().toISOString())
|
||||||
|
.field('fileModifiedAt', new Date().toISOString())
|
||||||
|
.attach('assetData', randomBytes(2014), 'example.jpg');
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!'));
|
||||||
|
});
|
||||||
|
|
||||||
// These hashes were created by copying the image files to a Samsung phone,
|
// These hashes were created by copying the image files to a Samsung phone,
|
||||||
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
|
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
|
||||||
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
|
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
|
||||||
|
@ -675,7 +810,7 @@ describe('/asset', () => {
|
||||||
|
|
||||||
describe('GET /asset/thumbnail/:id', () => {
|
describe('GET /asset/thumbnail/:id', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
|
const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`);
|
||||||
|
|
||||||
expect(status).toBe(401);
|
expect(status).toBe(401);
|
||||||
expect(body).toEqual(errorDto.unauthorized);
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
@ -683,12 +818,12 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should not include gps data for webp thumbnails', async () => {
|
it('should not include gps data for webp thumbnails', async () => {
|
||||||
const { status, body, type } = await request(app)
|
const { status, body, type } = await request(app)
|
||||||
.get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
|
.get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({
|
await utils.waitForWebsocketEvent({
|
||||||
event: 'upload',
|
event: 'upload',
|
||||||
assetId: assetLocation.id,
|
assetId: locationAsset.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -702,7 +837,7 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should not include gps data for jpeg thumbnails', async () => {
|
it('should not include gps data for jpeg thumbnails', async () => {
|
||||||
const { status, body, type } = await request(app)
|
const { status, body, type } = await request(app)
|
||||||
.get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
|
.get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
@ -717,7 +852,7 @@ describe('/asset', () => {
|
||||||
|
|
||||||
describe('GET /asset/file/:id', () => {
|
describe('GET /asset/file/:id', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
|
const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`);
|
||||||
|
|
||||||
expect(status).toBe(401);
|
expect(status).toBe(401);
|
||||||
expect(body).toEqual(errorDto.unauthorized);
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
@ -725,14 +860,14 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should download the original', async () => {
|
it('should download the original', async () => {
|
||||||
const { status, body, type } = await request(app)
|
const { status, body, type } = await request(app)
|
||||||
.get(`/asset/file/${assetLocation.id}`)
|
.get(`/asset/file/${locationAsset.id}`)
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toBeDefined();
|
expect(body).toBeDefined();
|
||||||
expect(type).toBe('image/jpeg');
|
expect(type).toBe('image/jpeg');
|
||||||
|
|
||||||
const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id);
|
const asset = await utils.getAssetInfo(admin.accessToken, locationAsset.id);
|
||||||
|
|
||||||
const original = await readFile(locationAssetFilepath);
|
const original = await readFile(locationAssetFilepath);
|
||||||
const originalChecksum = utils.sha1(original);
|
const originalChecksum = utils.sha1(original);
|
||||||
|
@ -742,4 +877,376 @@ describe('/asset', () => {
|
||||||
expect(downloadChecksum).toBe(asset.checksum);
|
expect(downloadChecksum).toBe(asset.checksum);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /asset/map-marker', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).get('/asset/map-marker');
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO archive one of these assets
|
||||||
|
it('should get map markers for all non-archived assets', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/map-marker')
|
||||||
|
.query({ isArchived: false })
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
expect(body).toEqual([
|
||||||
|
{
|
||||||
|
city: 'Palisade',
|
||||||
|
country: 'United States of America',
|
||||||
|
id: expect.any(String),
|
||||||
|
lat: expect.closeTo(39.115),
|
||||||
|
lon: expect.closeTo(-108.400_968),
|
||||||
|
state: 'Mesa County, Colorado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
city: 'Ralston',
|
||||||
|
country: 'United States of America',
|
||||||
|
id: expect.any(String),
|
||||||
|
lat: expect.closeTo(41.2203),
|
||||||
|
lon: expect.closeTo(-96.071_625),
|
||||||
|
state: 'Douglas County, Nebraska',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO archive one of these assets
|
||||||
|
it('should get all map markers', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/map-marker')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual([
|
||||||
|
{
|
||||||
|
city: 'Palisade',
|
||||||
|
country: 'United States of America',
|
||||||
|
id: expect.any(String),
|
||||||
|
lat: expect.closeTo(39.115),
|
||||||
|
lon: expect.closeTo(-108.400_968),
|
||||||
|
state: 'Mesa County, Colorado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
city: 'Ralston',
|
||||||
|
country: 'United States of America',
|
||||||
|
id: expect.any(String),
|
||||||
|
lat: expect.closeTo(41.2203),
|
||||||
|
lon: expect.closeTo(-96.071_625),
|
||||||
|
state: 'Douglas County, Nebraska',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /asset/time-buckets', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get time buckets by month', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
|
||||||
|
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow access for unrelated shared links', async () => {
|
||||||
|
const sharedLink = await utils.createSharedLink(user1.accessToken, {
|
||||||
|
type: SharedLinkType.Individual,
|
||||||
|
assetIds: user1Assets.map(({ id }) => id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get time buckets by day', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Day });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual([
|
||||||
|
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
|
||||||
|
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
|
||||||
|
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /asset/time-bucket', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).get('/asset/time-bucket').query({
|
||||||
|
size: TimeBucketSize.Month,
|
||||||
|
timeBucket: '1900-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 5 digit years', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/time-bucket')
|
||||||
|
.query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' })
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO enable date string validation while still accepting 5 digit years
|
||||||
|
// it('should fail if time bucket is invalid', async () => {
|
||||||
|
// const { status, body } = await request(app)
|
||||||
|
// .get('/asset/time-bucket')
|
||||||
|
// .set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
|
||||||
|
|
||||||
|
// expect(status).toBe(400);
|
||||||
|
// expect(body).toEqual(errorDto.badRequest);
|
||||||
|
// });
|
||||||
|
|
||||||
|
it('should return time bucket', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/asset/time-bucket')
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if time bucket is requested with partners asset and archived', async () => {
|
||||||
|
const req1 = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
|
||||||
|
|
||||||
|
expect(req1.status).toBe(400);
|
||||||
|
expect(req1.body).toEqual(errorDto.badRequest());
|
||||||
|
|
||||||
|
const req2 = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
|
||||||
|
|
||||||
|
expect(req2.status).toBe(400);
|
||||||
|
expect(req2.body).toEqual(errorDto.badRequest());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if time bucket is requested with partners asset and favorite', async () => {
|
||||||
|
const req1 = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
|
||||||
|
|
||||||
|
expect(req1.status).toBe(400);
|
||||||
|
expect(req1.body).toEqual(errorDto.badRequest());
|
||||||
|
|
||||||
|
const req2 = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
|
||||||
|
|
||||||
|
expect(req2.status).toBe(400);
|
||||||
|
expect(req2.body).toEqual(errorDto.badRequest());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error if time bucket is requested with partners asset and trash', async () => {
|
||||||
|
const req = await request(app)
|
||||||
|
.get('/asset/time-buckets')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
|
||||||
|
|
||||||
|
expect(req.status).toBe(400);
|
||||||
|
expect(req.body).toEqual(errorDto.badRequest());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /asset', () => {
|
||||||
|
it('should return stack data', async () => {
|
||||||
|
const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`);
|
||||||
|
|
||||||
|
const stack = body.find((asset: AssetResponseDto) => asset.id === stackAssets[0].id);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(stack).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
stackCount: 3,
|
||||||
|
stack:
|
||||||
|
// Response includes children at the root level
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: stackAssets[1].id }),
|
||||||
|
expect.objectContaining({ id: stackAssets[2].id }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /asset', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).put('/asset');
|
||||||
|
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid parent id', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require access to the parent', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add stack children', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||||
|
.send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove stack children', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||||
|
.send({ removeParent: true, ids: [stackAssets[1].id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: stackAssets[2].id }),
|
||||||
|
expect.objectContaining({ id: stackAssets[3].id }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove all stack children', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||||
|
.send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
|
||||||
|
expect(asset.stack).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge stack children', async () => {
|
||||||
|
// create stack after previous test removed stack children
|
||||||
|
await updateAssets(
|
||||||
|
{ assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
|
||||||
|
{ headers: asBearerAuth(stackUser.accessToken) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||||
|
.send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) });
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: stackAssets[0].id }),
|
||||||
|
expect.objectContaining({ id: stackAssets[1].id }),
|
||||||
|
expect.objectContaining({ id: stackAssets[2].id }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /asset/stack/parent', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).put('/asset/stack/parent');
|
||||||
|
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require access', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make old parent child of new parent', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||||
|
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
|
||||||
|
|
||||||
|
// new parent
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: stackAssets[1].id }),
|
||||||
|
expect.objectContaining({ id: stackAssets[2].id }),
|
||||||
|
expect.objectContaining({ id: stackAssets[3].id }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,52 +1,76 @@
|
||||||
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
|
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { app, testAssetDir, utils } from 'src/utils';
|
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const albums = { total: 0, count: 0, items: [], facets: [] };
|
const today = DateTime.now();
|
||||||
|
|
||||||
describe('/search', () => {
|
describe('/search', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
|
let websocket: Socket;
|
||||||
|
|
||||||
let assetFalcon: AssetFileUploadResponseDto;
|
let assetFalcon: AssetFileUploadResponseDto;
|
||||||
let assetDenali: AssetFileUploadResponseDto;
|
let assetDenali: AssetFileUploadResponseDto;
|
||||||
let websocket: Socket;
|
let assetCyclamen: AssetFileUploadResponseDto;
|
||||||
|
let assetNotocactus: AssetFileUploadResponseDto;
|
||||||
|
let assetSilver: AssetFileUploadResponseDto;
|
||||||
|
// let assetDensity: AssetFileUploadResponseDto;
|
||||||
|
// let assetPhiladelphia: AssetFileUploadResponseDto;
|
||||||
|
// let assetOrychophragmus: AssetFileUploadResponseDto;
|
||||||
|
// let assetRidge: AssetFileUploadResponseDto;
|
||||||
|
// let assetPolemonium: AssetFileUploadResponseDto;
|
||||||
|
// let assetWood: AssetFileUploadResponseDto;
|
||||||
|
let assetHeic: AssetFileUploadResponseDto;
|
||||||
|
let assetRocks: AssetFileUploadResponseDto;
|
||||||
|
let assetOneJpg6: AssetFileUploadResponseDto;
|
||||||
|
let assetOneHeic6: AssetFileUploadResponseDto;
|
||||||
|
let assetOneJpg5: AssetFileUploadResponseDto;
|
||||||
|
let assetGlarus: AssetFileUploadResponseDto;
|
||||||
|
let assetSprings: AssetFileUploadResponseDto;
|
||||||
|
let assetLast: AssetFileUploadResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||||
|
|
||||||
const files: string[] = [
|
const files = [
|
||||||
'/albums/nature/prairie_falcon.jpg',
|
{ filename: '/albums/nature/prairie_falcon.jpg' },
|
||||||
'/formats/webp/denali.webp',
|
{ filename: '/formats/webp/denali.webp' },
|
||||||
'/formats/raw/Nikon/D700/philadelphia.nef',
|
{ filename: '/albums/nature/cyclamen_persicum.jpg', dto: { isFavorite: true } },
|
||||||
'/albums/nature/orychophragmus_violaceus.jpg',
|
{ filename: '/albums/nature/notocactus_minimus.jpg' },
|
||||||
'/albums/nature/notocactus_minimus.jpg',
|
{ filename: '/albums/nature/silver_fir.jpg' },
|
||||||
'/albums/nature/silver_fir.jpg',
|
{ filename: '/formats/heic/IMG_2682.heic' },
|
||||||
'/albums/nature/tanners_ridge.jpg',
|
{ filename: '/formats/jpg/el_torcal_rocks.jpg' },
|
||||||
'/albums/nature/cyclamen_persicum.jpg',
|
{ filename: '/formats/motionphoto/Samsung One UI 6.jpg' },
|
||||||
'/albums/nature/polemonium_reptans.jpg',
|
{ filename: '/formats/motionphoto/Samsung One UI 6.heic' },
|
||||||
'/albums/nature/wood_anemones.jpg',
|
{ filename: '/formats/motionphoto/Samsung One UI 5.jpg' },
|
||||||
'/formats/heic/IMG_2682.heic',
|
{ filename: '/formats/raw/Nikon/D80/glarus.nef', dto: { isReadOnly: true } },
|
||||||
'/formats/jpg/el_torcal_rocks.jpg',
|
{ filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } },
|
||||||
'/formats/png/density_plot.png',
|
|
||||||
'/formats/motionphoto/Samsung One UI 6.jpg',
|
// used for search suggestions
|
||||||
'/formats/motionphoto/Samsung One UI 6.heic',
|
{ filename: '/formats/png/density_plot.png' },
|
||||||
'/formats/motionphoto/Samsung One UI 5.jpg',
|
{ filename: '/formats/raw/Nikon/D700/philadelphia.nef' },
|
||||||
'/formats/raw/Nikon/D80/glarus.nef',
|
{ filename: '/albums/nature/orychophragmus_violaceus.jpg' },
|
||||||
'/metadata/gps-position/thompson-springs.jpg',
|
{ filename: '/albums/nature/tanners_ridge.jpg' },
|
||||||
|
{ filename: '/albums/nature/polemonium_reptans.jpg' },
|
||||||
|
|
||||||
|
// last asset
|
||||||
|
{ filename: '/albums/nature/wood_anemones.jpg' },
|
||||||
];
|
];
|
||||||
const assets: AssetFileUploadResponseDto[] = [];
|
const assets: AssetFileUploadResponseDto[] = [];
|
||||||
for (const filename of files) {
|
for (const { filename, dto } of files) {
|
||||||
const bytes = await readFile(join(testAssetDir, filename));
|
const bytes = await readFile(join(testAssetDir, filename));
|
||||||
assets.push(
|
assets.push(
|
||||||
await utils.createAsset(admin.accessToken, {
|
await utils.createAsset(admin.accessToken, {
|
||||||
deviceAssetId: `test-${filename}`,
|
deviceAssetId: `test-${filename}`,
|
||||||
assetData: { bytes, filename },
|
assetData: { bytes, filename },
|
||||||
|
...dto,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -55,7 +79,30 @@ describe('/search', () => {
|
||||||
await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id });
|
await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
[assetFalcon, assetDenali] = assets;
|
[
|
||||||
|
assetFalcon,
|
||||||
|
assetDenali,
|
||||||
|
assetCyclamen,
|
||||||
|
assetNotocactus,
|
||||||
|
assetSilver,
|
||||||
|
assetHeic,
|
||||||
|
assetRocks,
|
||||||
|
assetOneJpg6,
|
||||||
|
assetOneHeic6,
|
||||||
|
assetOneJpg5,
|
||||||
|
assetGlarus,
|
||||||
|
assetSprings,
|
||||||
|
// assetDensity,
|
||||||
|
// assetPhiladelphia,
|
||||||
|
// assetOrychophragmus,
|
||||||
|
// assetRidge,
|
||||||
|
// assetPolemonium,
|
||||||
|
// assetWood,
|
||||||
|
] = assets;
|
||||||
|
|
||||||
|
assetLast = assets.at(-1) as AssetFileUploadResponseDto;
|
||||||
|
|
||||||
|
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -69,44 +116,226 @@ describe('/search', () => {
|
||||||
expect(body).toEqual(errorDto.unauthorized);
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should search by camera make', async () => {
|
const badTests = [
|
||||||
const { status, body } = await request(app)
|
{
|
||||||
.post('/search/metadata')
|
should: 'should reject page as a string',
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
dto: { page: 'abc' },
|
||||||
.send({ make: 'Canon' });
|
expected: ['page must not be less than 1', 'page must be an integer number'],
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toEqual({
|
|
||||||
albums,
|
|
||||||
assets: {
|
|
||||||
count: 2,
|
|
||||||
items: expect.arrayContaining([
|
|
||||||
expect.objectContaining({ id: assetDenali.id }),
|
|
||||||
expect.objectContaining({ id: assetFalcon.id }),
|
|
||||||
]),
|
|
||||||
facets: [],
|
|
||||||
nextPage: null,
|
|
||||||
total: 2,
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
});
|
should: 'should reject page as a decimal',
|
||||||
|
dto: { page: 1.5 },
|
||||||
|
expected: ['page must be an integer number'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should reject page as a negative number',
|
||||||
|
dto: { page: -10 },
|
||||||
|
expected: ['page must not be less than 1'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should reject page as 0',
|
||||||
|
dto: { page: 0 },
|
||||||
|
expected: ['page must not be less than 1'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should reject size as a string',
|
||||||
|
dto: { size: 'abc' },
|
||||||
|
expected: [
|
||||||
|
'size must not be greater than 1000',
|
||||||
|
'size must not be less than 1',
|
||||||
|
'size must be an integer number',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should reject an invalid size',
|
||||||
|
dto: { size: -1.5 },
|
||||||
|
expected: ['size must not be less than 1', 'size must be an integer number'],
|
||||||
|
},
|
||||||
|
...[
|
||||||
|
'isArchived',
|
||||||
|
'isFavorite',
|
||||||
|
'isReadOnly',
|
||||||
|
'isExternal',
|
||||||
|
'isEncoded',
|
||||||
|
'isMotion',
|
||||||
|
'isOffline',
|
||||||
|
'isVisible',
|
||||||
|
].map((value) => ({
|
||||||
|
should: `should reject ${value} not a boolean`,
|
||||||
|
dto: { [value]: 'immich' },
|
||||||
|
expected: [`${value} must be a boolean value`],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
it('should search by camera model', async () => {
|
for (const { should, dto, expected } of badTests) {
|
||||||
|
it(should, async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post('/search/metadata')
|
.post('/search/metadata')
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
.send({ model: 'Canon EOS 7D' });
|
.send(dto);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual(errorDto.badRequest(expected));
|
||||||
albums,
|
});
|
||||||
assets: {
|
}
|
||||||
count: 1,
|
|
||||||
items: [expect.objectContaining({ id: assetDenali.id })],
|
const searchTests = [
|
||||||
facets: [],
|
{
|
||||||
nextPage: null,
|
should: 'should get my assets',
|
||||||
total: 1,
|
deferred: () => ({ dto: { size: 1 }, assets: [assetLast] }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
should: 'should sort my assets in reverse',
|
||||||
|
deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should support pagination',
|
||||||
|
deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by checksum (base64)',
|
||||||
|
deferred: () => ({ dto: { checksum: '9IXBDMjj9OrQb+1YMHprZJgZ/UQ=' }, assets: [assetCyclamen] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by checksum (hex)',
|
||||||
|
deferred: () => ({ dto: { checksum: 'f485c10cc8e3f4ead06fed58307a6b649819fd44' }, assets: [assetCyclamen] }),
|
||||||
|
},
|
||||||
|
{ should: 'should search by id', deferred: () => ({ dto: { id: assetCyclamen.id }, assets: [assetCyclamen] }) },
|
||||||
|
{
|
||||||
|
should: 'should search by isFavorite (true)',
|
||||||
|
deferred: () => ({ dto: { isFavorite: true }, assets: [assetCyclamen] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by isFavorite (false)',
|
||||||
|
deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by isArchived (true)',
|
||||||
|
deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by isArchived (false)',
|
||||||
|
deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by isReadOnly (true)',
|
||||||
|
deferred: () => ({ dto: { isReadOnly: true }, assets: [assetGlarus] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by isReadOnly (false)',
|
||||||
|
deferred: () => ({ dto: { size: 1, isReadOnly: false }, assets: [assetLast] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by type (image)',
|
||||||
|
deferred: () => ({ dto: { size: 1, type: 'IMAGE' }, assets: [assetLast] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by type (video)',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: { type: 'VIDEO' },
|
||||||
|
assets: [
|
||||||
|
// the three live motion photos
|
||||||
|
{ id: expect.any(String) },
|
||||||
|
{ id: expect.any(String) },
|
||||||
|
{ id: expect.any(String) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by trashedBefore',
|
||||||
|
deferred: () => ({ dto: { trashedBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by trashedBefore (no results)',
|
||||||
|
deferred: () => ({ dto: { trashedBefore: today.minus({ days: 1 }).toJSDate() }, assets: [] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by trashedAfter',
|
||||||
|
deferred: () => ({ dto: { trashedAfter: today.minus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by trashedAfter (no results)',
|
||||||
|
deferred: () => ({ dto: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by takenBefore',
|
||||||
|
deferred: () => ({ dto: { size: 1, takenBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetLast] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by takenBefore (no results)',
|
||||||
|
deferred: () => ({ dto: { takenBefore: DateTime.fromObject({ year: 1234 }).toJSDate() }, assets: [] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by takenAfter',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: { size: 1, takenAfter: DateTime.fromObject({ year: 1234 }).toJSDate() },
|
||||||
|
assets: [assetLast],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by takenAfter (no results)',
|
||||||
|
deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// should: 'should search by originalPath',
|
||||||
|
// deferred: () => ({
|
||||||
|
// dto: { originalPath: asset1.originalPath },
|
||||||
|
// assets: [asset1],
|
||||||
|
// }),
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
should: 'should search by originalFilename',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: { originalFileName: 'rocks' },
|
||||||
|
assets: [assetRocks],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by originalFilename with spaces',
|
||||||
|
deferred: () => ({
|
||||||
|
dto: { originalFileName: 'Samsung One', type: 'IMAGE' },
|
||||||
|
assets: [assetOneJpg5, assetOneJpg6, assetOneHeic6],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by city',
|
||||||
|
deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by state',
|
||||||
|
deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by country',
|
||||||
|
deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by make',
|
||||||
|
deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
should: 'should search by model',
|
||||||
|
deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { should, deferred } of searchTests) {
|
||||||
|
it(should, async () => {
|
||||||
|
const { assets, dto } = deferred();
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/search/metadata')
|
||||||
|
.send(dto)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
console.dir({ status, body }, { depth: 10 });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.assets).toBeDefined();
|
||||||
|
expect(Array.isArray(body.assets.items)).toBe(true);
|
||||||
|
console.log({ assets: body.assets.items });
|
||||||
|
for (const [i, asset] of assets.entries()) {
|
||||||
|
expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id }));
|
||||||
|
}
|
||||||
|
expect(body.assets.items).toHaveLength(assets.length);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /search/smart', () => {
|
describe('POST /search/smart', () => {
|
||||||
|
|
|
@ -21,6 +21,13 @@ export const signupDto = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createUserDto = {
|
export const createUserDto = {
|
||||||
|
create(key: string) {
|
||||||
|
return {
|
||||||
|
email: `${key}@immich.cloud`,
|
||||||
|
name: `User ${key}`,
|
||||||
|
password: `password-${key}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
user1: {
|
user1: {
|
||||||
email: 'user1@immich.cloud',
|
email: 'user1@immich.cloud',
|
||||||
name: 'User 1',
|
name: 'User 1',
|
||||||
|
@ -36,6 +43,12 @@ export const createUserDto = {
|
||||||
name: 'User 3',
|
name: 'User 3',
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
},
|
},
|
||||||
|
userQuota: {
|
||||||
|
email: 'user-quota@immich.cloud',
|
||||||
|
name: 'User Quota',
|
||||||
|
password: 'password-quota',
|
||||||
|
quotaSizeInBytes: 512,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userDto = {
|
export const userDto = {
|
||||||
|
|
|
@ -104,6 +104,8 @@ export const utils = {
|
||||||
}
|
}
|
||||||
|
|
||||||
tables = tables || [
|
tables = tables || [
|
||||||
|
// TODO e2e test for deleting a stack, since it is quite complex
|
||||||
|
'asset_stack',
|
||||||
'libraries',
|
'libraries',
|
||||||
'shared_links',
|
'shared_links',
|
||||||
'person',
|
'person',
|
||||||
|
@ -117,9 +119,17 @@ export const utils = {
|
||||||
'system_metadata',
|
'system_metadata',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tables) {
|
const sql: string[] = [];
|
||||||
await client.query(`DELETE FROM ${table} CASCADE;`);
|
|
||||||
|
if (tables.includes('asset_stack')) {
|
||||||
|
sql.push('UPDATE "assets" SET "stackId" = NULL;');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
sql.push(`DELETE FROM ${table} CASCADE;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(sql.join('\n'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset database', error);
|
console.error('Failed to reset database', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"modulePaths": ["<rootDir>"],
|
|
||||||
"rootDir": "../..",
|
|
||||||
"globalSetup": "<rootDir>/e2e/api/setup.ts",
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"],
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"<rootDir>/src/**/*.(t|j)s",
|
|
||||||
"!<rootDir>/src/**/*.spec.(t|s)s",
|
|
||||||
"!<rootDir>/src/infra/migrations/**"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "./coverage",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"^@test(|/.*)$": "<rootDir>/test/$1",
|
|
||||||
"^@app/immich(|/.*)$": "<rootDir>/src/immich/$1",
|
|
||||||
"^@app/infra(|/.*)$": "<rootDir>/src/infra/$1",
|
|
||||||
"^@app/domain(|/.*)$": "<rootDir>/src/domain/$1"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
let IMMICH_TEST_ASSET_PATH: string = '';
|
|
||||||
|
|
||||||
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
|
|
||||||
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`);
|
|
||||||
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
|
|
||||||
} else {
|
|
||||||
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
|
|
||||||
.withDatabase('immich')
|
|
||||||
.withUsername('postgres')
|
|
||||||
.withPassword('postgres')
|
|
||||||
.withReuse()
|
|
||||||
.withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so'])
|
|
||||||
.start();
|
|
||||||
|
|
||||||
process.env.DB_URL = pg.getConnectionUri();
|
|
||||||
process.env.NODE_ENV = 'development';
|
|
||||||
process.env.TZ = 'Z';
|
|
||||||
|
|
||||||
if (process.env.LOG_LEVEL === undefined) {
|
|
||||||
process.env.LOG_LEVEL = 'fatal';
|
|
||||||
}
|
|
||||||
};
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,118 +0,0 @@
|
||||||
import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain';
|
|
||||||
import { AppModule } from '@app/immich';
|
|
||||||
import { InfraModule, InfraTestModule, dataSource } from '@app/infra';
|
|
||||||
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { EntityTarget, ObjectLiteral } from 'typeorm';
|
|
||||||
import { AppService } from '../../src/microservices/app.service';
|
|
||||||
import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test';
|
|
||||||
|
|
||||||
export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
|
|
||||||
export const yesterday = today.minus({ days: 1 });
|
|
||||||
|
|
||||||
export interface ResetOptions {
|
|
||||||
entities?: EntityTarget<ObjectLiteral>[];
|
|
||||||
}
|
|
||||||
export const db = {
|
|
||||||
reset: async (options?: ResetOptions) => {
|
|
||||||
if (!dataSource.isInitialized) {
|
|
||||||
await dataSource.initialize();
|
|
||||||
}
|
|
||||||
await dataSource.transaction(async (em) => {
|
|
||||||
const entities = options?.entities || [];
|
|
||||||
const tableNames =
|
|
||||||
entities.length > 0
|
|
||||||
? entities.map((entity) => em.getRepository(entity).metadata.tableName)
|
|
||||||
: dataSource.entityMetadatas
|
|
||||||
.map((entity) => entity.tableName)
|
|
||||||
.filter((tableName) => !tableName.startsWith('geodata'));
|
|
||||||
|
|
||||||
if (tableNames.includes('asset_stack')) {
|
|
||||||
await em.query(`DELETE FROM "asset_stack" CASCADE;`);
|
|
||||||
}
|
|
||||||
let deleteUsers = false;
|
|
||||||
for (const tableName of tableNames) {
|
|
||||||
if (tableName === 'users') {
|
|
||||||
deleteUsers = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await em.query(`DELETE FROM ${tableName} CASCADE;`);
|
|
||||||
}
|
|
||||||
if (deleteUsers) {
|
|
||||||
await em.query(`DELETE FROM "users" CASCADE;`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
disconnect: async () => {
|
|
||||||
if (dataSource.isInitialized) {
|
|
||||||
await dataSource.destroy();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
export const testApp = {
|
|
||||||
create: async (): Promise<INestApplication> => {
|
|
||||||
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
|
|
||||||
.overrideModule(InfraModule)
|
|
||||||
.useModule(InfraTestModule)
|
|
||||||
.overrideProvider(IJobRepository)
|
|
||||||
.useValue(newJobRepositoryMock())
|
|
||||||
.overrideProvider(IMetadataRepository)
|
|
||||||
.useValue(newMetadataRepositoryMock())
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = await moduleFixture.createNestApplication().init();
|
|
||||||
await app.get(AppService).init();
|
|
||||||
|
|
||||||
return app;
|
|
||||||
},
|
|
||||||
reset: async (options?: ResetOptions) => {
|
|
||||||
await db.reset(options);
|
|
||||||
},
|
|
||||||
teardown: async () => {
|
|
||||||
if (app) {
|
|
||||||
await app.get(AppService).teardown();
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
await db.disconnect();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function randomDate(start: Date, end: Date): Date {
|
|
||||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let assetCount = 0;
|
|
||||||
export function generateAsset(
|
|
||||||
userId: string,
|
|
||||||
libraries: LibraryResponseDto[],
|
|
||||||
other: Partial<AssetEntity> = {},
|
|
||||||
): AssetCreate {
|
|
||||||
const id = assetCount++;
|
|
||||||
const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other;
|
|
||||||
|
|
||||||
return {
|
|
||||||
createdAt: today.toJSDate(),
|
|
||||||
updatedAt: today.toJSDate(),
|
|
||||||
ownerId: userId,
|
|
||||||
checksum: randomBytes(20),
|
|
||||||
originalPath: `/tests/test_${id}`,
|
|
||||||
deviceAssetId: `test_${id}`,
|
|
||||||
deviceId: 'e2e-test',
|
|
||||||
libraryId: (
|
|
||||||
libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto
|
|
||||||
).id,
|
|
||||||
isVisible: true,
|
|
||||||
fileCreatedAt,
|
|
||||||
fileModifiedAt: new Date(),
|
|
||||||
localDateTime: fileCreatedAt,
|
|
||||||
type: AssetType.IMAGE,
|
|
||||||
originalFileName: `test_${id}`,
|
|
||||||
...other,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,77 +1,10 @@
|
||||||
import { AssetResponseDto } from '@app/domain';
|
import { AssetResponseDto } from '@app/domain';
|
||||||
import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto';
|
|
||||||
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
type UploadDto = Partial<CreateAssetDto> & { content?: Buffer; filename?: string };
|
|
||||||
|
|
||||||
const asset = {
|
|
||||||
deviceAssetId: 'test-1',
|
|
||||||
deviceId: 'test',
|
|
||||||
fileCreatedAt: new Date(),
|
|
||||||
fileModifiedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assetApi = {
|
export const assetApi = {
|
||||||
create: async (
|
|
||||||
server: any,
|
|
||||||
accessToken: string,
|
|
||||||
dto?: Omit<CreateAssetDto, 'assetData'>,
|
|
||||||
): Promise<AssetResponseDto> => {
|
|
||||||
dto = dto || asset;
|
|
||||||
const { status, body } = await request(server)
|
|
||||||
.post(`/asset/upload`)
|
|
||||||
.field('deviceAssetId', dto.deviceAssetId)
|
|
||||||
.field('deviceId', dto.deviceId)
|
|
||||||
.field('fileCreatedAt', dto.fileCreatedAt.toISOString())
|
|
||||||
.field('fileModifiedAt', dto.fileModifiedAt.toISOString())
|
|
||||||
.attach('assetData', randomBytes(32), 'example.jpg')
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
|
|
||||||
expect([200, 201].includes(status)).toBe(true);
|
|
||||||
|
|
||||||
return body as AssetResponseDto;
|
|
||||||
},
|
|
||||||
get: async (server: any, accessToken: string, id: string): Promise<AssetResponseDto> => {
|
|
||||||
const { body, status } = await request(server).get(`/asset/${id}`).set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
expect(status).toBe(200);
|
|
||||||
return body as AssetResponseDto;
|
|
||||||
},
|
|
||||||
getAllAssets: async (server: any, accessToken: string) => {
|
getAllAssets: async (server: any, accessToken: string) => {
|
||||||
const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`);
|
const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
return body as AssetResponseDto[];
|
return body as AssetResponseDto[];
|
||||||
},
|
},
|
||||||
upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => {
|
|
||||||
const { content, filename, isFavorite = false, isArchived = false } = dto;
|
|
||||||
const { body, status } = await request(server)
|
|
||||||
.post('/asset/upload')
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
.field('deviceAssetId', deviceAssetId)
|
|
||||||
.field('deviceId', 'TEST')
|
|
||||||
.field('fileCreatedAt', new Date().toISOString())
|
|
||||||
.field('fileModifiedAt', new Date().toISOString())
|
|
||||||
.field('isFavorite', isFavorite)
|
|
||||||
.field('isArchived', isArchived)
|
|
||||||
.field('duration', '0:00:00.000000')
|
|
||||||
.attach('assetData', content || randomBytes(32), filename || 'example.jpg');
|
|
||||||
|
|
||||||
expect(status).toBe(201);
|
|
||||||
return body as AssetFileUploadResponseDto;
|
|
||||||
},
|
|
||||||
getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => {
|
|
||||||
const { body, status } = await request(server)
|
|
||||||
.get(`/asset/thumbnail/${assetId}`)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
expect(status).toBe(200);
|
|
||||||
return body;
|
|
||||||
},
|
|
||||||
getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => {
|
|
||||||
const { body, status } = await request(server)
|
|
||||||
.get(`/asset/thumbnail/${assetId}?format=JPEG`)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
expect(status).toBe(200);
|
|
||||||
return body;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
|
import { LoginResponseDto, UserResponseDto } from '@app/domain';
|
||||||
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
|
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
|
@ -17,14 +17,6 @@ export const authApi = {
|
||||||
expect(body).toMatchObject({ accessToken: expect.any(String) });
|
expect(body).toMatchObject({ accessToken: expect.any(String) });
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
|
|
||||||
return body as LoginResponseDto;
|
|
||||||
},
|
|
||||||
login: async (server: any, dto: LoginCredentialDto) => {
|
|
||||||
const { status, body } = await request(server).post('/auth/login').send(dto);
|
|
||||||
|
|
||||||
expect(status).toEqual(201);
|
|
||||||
expect(body).toMatchObject({ accessToken: expect.any(String) });
|
|
||||||
|
|
||||||
return body as LoginResponseDto;
|
return body as LoginResponseDto;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,15 +1,9 @@
|
||||||
import { assetApi } from './asset-api';
|
import { assetApi } from './asset-api';
|
||||||
import { authApi } from './auth-api';
|
import { authApi } from './auth-api';
|
||||||
import { libraryApi } from './library-api';
|
import { libraryApi } from './library-api';
|
||||||
import { sharedLinkApi } from './shared-link-api';
|
|
||||||
import { trashApi } from './trash-api';
|
|
||||||
import { userApi } from './user-api';
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
authApi,
|
authApi,
|
||||||
assetApi,
|
assetApi,
|
||||||
libraryApi,
|
libraryApi,
|
||||||
sharedLinkApi,
|
|
||||||
trashApi,
|
|
||||||
userApi,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,4 @@
|
||||||
import {
|
import { CreateLibraryDto, LibraryResponseDto, ScanLibraryDto } from '@app/domain';
|
||||||
CreateLibraryDto,
|
|
||||||
LibraryResponseDto,
|
|
||||||
LibraryStatsResponseDto,
|
|
||||||
ScanLibraryDto,
|
|
||||||
UpdateLibraryDto,
|
|
||||||
ValidateLibraryDto,
|
|
||||||
ValidateLibraryResponseDto,
|
|
||||||
} from '@app/domain';
|
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
export const libraryApi = {
|
export const libraryApi = {
|
||||||
|
@ -38,34 +30,4 @@ export const libraryApi = {
|
||||||
.send(dto);
|
.send(dto);
|
||||||
expect(status).toBe(204);
|
expect(status).toBe(204);
|
||||||
},
|
},
|
||||||
removeOfflineFiles: async (server: any, accessToken: string, id: string) => {
|
|
||||||
const { status } = await request(server)
|
|
||||||
.post(`/library/${id}/removeOffline`)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
.send();
|
|
||||||
expect(status).toBe(204);
|
|
||||||
},
|
|
||||||
getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => {
|
|
||||||
const { body, status } = await request(server)
|
|
||||||
.get(`/library/${id}/statistics`)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
expect(status).toBe(200);
|
|
||||||
return body;
|
|
||||||
},
|
|
||||||
update: async (server: any, accessToken: string, id: string, data: UpdateLibraryDto) => {
|
|
||||||
const { body, status } = await request(server)
|
|
||||||
.put(`/library/${id}`)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
.send(data);
|
|
||||||
expect(status).toBe(200);
|
|
||||||
return body as LibraryResponseDto;
|
|
||||||
},
|
|
||||||
validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => {
|
|
||||||
const { body, status } = await request(server)
|
|
||||||
.post(`/library/${id}/validate`)
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
.send(data);
|
|
||||||
expect(status).toBe(200);
|
|
||||||
return body as ValidateLibraryResponseDto;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain';
|
|
||||||
import request from 'supertest';
|
|
||||||
|
|
||||||
export const sharedLinkApi = {
|
|
||||||
create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => {
|
|
||||||
const { status, body } = await request(server)
|
|
||||||
.post('/shared-link')
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
.send(dto);
|
|
||||||
expect(status).toBe(201);
|
|
||||||
return body as SharedLinkResponseDto;
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,13 +0,0 @@
|
||||||
import request from 'supertest';
|
|
||||||
import type { App } from 'supertest/types';
|
|
||||||
|
|
||||||
export const trashApi = {
|
|
||||||
async empty(server: App, accessToken: string) {
|
|
||||||
const { status } = await request(server).post('/trash/empty').set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
expect(status).toBe(204);
|
|
||||||
},
|
|
||||||
async restore(server: App, accessToken: string) {
|
|
||||||
const { status } = await request(server).post('/trash/restore').set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
expect(status).toBe(204);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,37 +0,0 @@
|
||||||
import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain';
|
|
||||||
import request from 'supertest';
|
|
||||||
|
|
||||||
export const userApi = {
|
|
||||||
create: async (server: any, accessToken: string, dto: CreateUserDto) => {
|
|
||||||
const { status, body } = await request(server)
|
|
||||||
.post('/user')
|
|
||||||
.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
.send(dto);
|
|
||||||
|
|
||||||
expect(status).toBe(201);
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
id: expect.any(String),
|
|
||||||
createdAt: expect.any(String),
|
|
||||||
updatedAt: expect.any(String),
|
|
||||||
email: dto.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
return body as UserResponseDto;
|
|
||||||
},
|
|
||||||
update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
|
|
||||||
const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toMatchObject({ id: dto.id });
|
|
||||||
|
|
||||||
return body as UserResponseDto;
|
|
||||||
},
|
|
||||||
delete: async (server: any, accessToken: string, id: string) => {
|
|
||||||
const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
|
|
||||||
|
|
||||||
return body as UserResponseDto;
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -23,7 +23,6 @@
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"e2e:jobs": "jest --config e2e/jobs/jest-e2e.json --runInBand",
|
"e2e:jobs": "jest --config e2e/jobs/jest-e2e.json --runInBand",
|
||||||
"e2e:api": "jest --config e2e/api/jest-e2e.json --runInBand",
|
|
||||||
"typeorm": "typeorm",
|
"typeorm": "typeorm",
|
||||||
"typeorm:migrations:create": "typeorm migration:create",
|
"typeorm:migrations:create": "typeorm migration:create",
|
||||||
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
|
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
|
||||||
|
|
Loading…
Reference in a new issue