mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 17:23:21 +03:00
* refactor: library scanning fix tests remove offline files step cleanup library service improve tests cleanup tests add db migration fix e2e cleanup openapi fix tests fix tests update docs update docs update mobile code fix formatting don't remove assets from library with invalid import path use trash for offline files add migration simplify scan endpoint cleanup library panel fix library tests e2e lint fix e2e trash e2e fix lint add asset trash tests add more tests ensure thumbs are generated cleanup svelte cleanup queue names fix tests fix lint add warning due to trash fix trash tests fix lint fix tests Admin message for offline asset fix comments Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> add permission to library scan endpoint revert asset interface sort add trash reason to shared link stub improve path view in offline update docs improve trash performance fix comments remove stray comment * refactor: add back isOffline and remove trashReason from asset, change sync job flow * chore(server): drop coverage to 80% for functions * chore: rebase and generated files --------- Co-authored-by: Zack Pollard <zackpollard@ymail.com>
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
import {
|
|
AllJobStatusResponseDto,
|
|
AssetMediaCreateDto,
|
|
AssetMediaResponseDto,
|
|
AssetResponseDto,
|
|
CheckExistingAssetsDto,
|
|
CreateAlbumDto,
|
|
CreateLibraryDto,
|
|
MetadataSearchDto,
|
|
Permission,
|
|
PersonCreateDto,
|
|
SharedLinkCreateDto,
|
|
UserAdminCreateDto,
|
|
ValidateLibraryDto,
|
|
checkExistingAssets,
|
|
createAlbum,
|
|
createApiKey,
|
|
createLibrary,
|
|
createPartner,
|
|
createPerson,
|
|
createSharedLink,
|
|
createUserAdmin,
|
|
deleteAssets,
|
|
getAllJobsStatus,
|
|
getAssetInfo,
|
|
getConfigDefaults,
|
|
login,
|
|
searchMetadata,
|
|
setBaseUrl,
|
|
signUpAdmin,
|
|
updateAdminOnboarding,
|
|
updateAlbumUser,
|
|
updateAssets,
|
|
updateConfig,
|
|
validate,
|
|
} from '@immich/sdk';
|
|
import { BrowserContext } from '@playwright/test';
|
|
import { exec, spawn } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path, { dirname } from 'node:path';
|
|
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
|
import { promisify } from 'node:util';
|
|
import pg from 'pg';
|
|
import { io, type Socket } from 'socket.io-client';
|
|
import { loginDto, signupDto } from 'src/fixtures';
|
|
import { makeRandomImage } from 'src/generators';
|
|
import request from 'supertest';
|
|
|
|
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
|
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden';
|
|
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
|
|
type AdminSetupOptions = { onboarding?: boolean };
|
|
type FileData = { bytes?: Buffer; filename: string };
|
|
|
|
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
|
|
export const baseUrl = 'http://127.0.0.1:2285';
|
|
export const shareUrl = `${baseUrl}/share`;
|
|
export const app = `${baseUrl}/api`;
|
|
// TODO move test assets into e2e/assets
|
|
export const testAssetDir = path.resolve('./test-assets');
|
|
export const testAssetDirInternal = '/test-assets';
|
|
export const tempDir = tmpdir();
|
|
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
|
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
|
export const immichCli = (args: string[]) =>
|
|
executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise;
|
|
export const immichAdmin = (args: string[]) =>
|
|
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
|
|
|
const executeCommand = (command: string, args: string[]) => {
|
|
let _resolve: (value: CommandResponse) => void;
|
|
const promise = new Promise<CommandResponse>((resolve) => (_resolve = resolve));
|
|
const child = spawn(command, args, { stdio: 'pipe' });
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (data) => (stdout += data.toString()));
|
|
child.stderr.on('data', (data) => (stderr += data.toString()));
|
|
child.on('exit', (exitCode) => {
|
|
_resolve({
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
exitCode,
|
|
});
|
|
});
|
|
|
|
return { promise, child };
|
|
};
|
|
|
|
let client: pg.Client | null = null;
|
|
|
|
const events: Record<EventType, Set<string>> = {
|
|
assetHidden: new Set<string>(),
|
|
assetUpload: new Set<string>(),
|
|
assetUpdate: new Set<string>(),
|
|
assetDelete: new Set<string>(),
|
|
userDelete: new Set<string>(),
|
|
};
|
|
|
|
const idCallbacks: Record<string, () => void> = {};
|
|
const countCallbacks: Record<string, { count: number; callback: () => void }> = {};
|
|
|
|
const execPromise = promisify(exec);
|
|
|
|
const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
|
// console.log(`Received event: ${event} [id=${id}]`);
|
|
const set = events[event];
|
|
set.add(id);
|
|
|
|
const idCallback = idCallbacks[id];
|
|
if (idCallback) {
|
|
idCallback();
|
|
delete idCallbacks[id];
|
|
}
|
|
|
|
const item = countCallbacks[event];
|
|
if (item) {
|
|
const { count, callback: countCallback } = item;
|
|
|
|
if (set.size >= count) {
|
|
countCallback();
|
|
delete countCallbacks[event];
|
|
}
|
|
}
|
|
};
|
|
|
|
export const utils = {
|
|
resetDatabase: async (tables?: string[]) => {
|
|
try {
|
|
if (!client) {
|
|
client = new pg.Client(dbUrl);
|
|
await client.connect();
|
|
}
|
|
|
|
tables = tables || [
|
|
// TODO e2e test for deleting a stack, since it is quite complex
|
|
'asset_stack',
|
|
'libraries',
|
|
'shared_links',
|
|
'person',
|
|
'albums',
|
|
'assets',
|
|
'asset_faces',
|
|
'activity',
|
|
'api_keys',
|
|
'sessions',
|
|
'users',
|
|
'system_metadata',
|
|
'tags',
|
|
];
|
|
|
|
const sql: string[] = [];
|
|
|
|
for (const table of tables) {
|
|
if (table === 'system_metadata') {
|
|
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
|
} else {
|
|
sql.push(`DELETE FROM ${table} CASCADE;`);
|
|
}
|
|
}
|
|
|
|
await client.query(sql.join('\n'));
|
|
} catch (error) {
|
|
console.error('Failed to reset database', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
resetFilesystem: async () => {
|
|
const mediaInternal = '/usr/src/app/upload';
|
|
const dirs = [
|
|
`"${mediaInternal}/thumbs"`,
|
|
`"${mediaInternal}/upload"`,
|
|
`"${mediaInternal}/library"`,
|
|
`"${mediaInternal}/encoded-video"`,
|
|
].join(' ');
|
|
|
|
await execPromise(`docker exec -i "immich-e2e-server" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
|
|
},
|
|
|
|
unzip: async (input: string, output: string) => {
|
|
await execPromise(`unzip -o -d "${output}" "${input}"`);
|
|
},
|
|
|
|
sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'),
|
|
|
|
connectWebsocket: async (accessToken: string) => {
|
|
const websocket = io(baseUrl, {
|
|
path: '/api/socket.io',
|
|
transports: ['websocket'],
|
|
extraHeaders: { Authorization: `Bearer ${accessToken}` },
|
|
autoConnect: true,
|
|
forceNew: true,
|
|
});
|
|
|
|
return new Promise<Socket>((resolve) => {
|
|
websocket
|
|
.on('connect', () => resolve(websocket))
|
|
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
|
|
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id }))
|
|
.on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId }))
|
|
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
|
|
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
|
|
.connect();
|
|
});
|
|
},
|
|
|
|
disconnectWebsocket: (ws: Socket) => {
|
|
if (ws?.connected) {
|
|
ws.disconnect();
|
|
}
|
|
|
|
for (const set of Object.values(events)) {
|
|
set.clear();
|
|
}
|
|
},
|
|
|
|
resetEvents: () => {
|
|
for (const set of Object.values(events)) {
|
|
set.clear();
|
|
}
|
|
},
|
|
|
|
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (!id && !count) {
|
|
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
|
|
}
|
|
|
|
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
|
|
const type = id ? `id=${id}` : `count=${count}`;
|
|
console.log(`Waiting for ${event} [${type}]`);
|
|
const set = events[event];
|
|
const onId = () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
};
|
|
if ((id && set.has(id)) || (count && set.size >= count)) {
|
|
onId();
|
|
return;
|
|
}
|
|
|
|
if (id) {
|
|
idCallbacks[id] = onId;
|
|
}
|
|
|
|
if (count) {
|
|
countCallbacks[event] = {
|
|
count,
|
|
callback: onId,
|
|
};
|
|
}
|
|
});
|
|
},
|
|
|
|
initSdk: () => {
|
|
setBaseUrl(app);
|
|
},
|
|
|
|
adminSetup: async (options?: AdminSetupOptions) => {
|
|
options = options || { onboarding: true };
|
|
|
|
await signUpAdmin({ signUpDto: signupDto.admin });
|
|
const response = await login({ loginCredentialDto: loginDto.admin });
|
|
if (options.onboarding) {
|
|
await updateAdminOnboarding(
|
|
{ adminOnboardingUpdateDto: { isOnboarded: true } },
|
|
{ headers: asBearerAuth(response.accessToken) },
|
|
);
|
|
}
|
|
return response;
|
|
},
|
|
|
|
userSetup: async (accessToken: string, dto: UserAdminCreateDto) => {
|
|
await createUserAdmin({ userAdminCreateDto: dto }, { headers: asBearerAuth(accessToken) });
|
|
return login({
|
|
loginCredentialDto: { email: dto.email, password: dto.password },
|
|
});
|
|
},
|
|
|
|
createApiKey: (accessToken: string, permissions: Permission[]) => {
|
|
return createApiKey({ apiKeyCreateDto: { name: 'e2e', permissions } }, { headers: asBearerAuth(accessToken) });
|
|
},
|
|
|
|
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
|
|
createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
updateAlbumUser: (accessToken: string, args: Parameters<typeof updateAlbumUser>[0]) =>
|
|
updateAlbumUser(args, { headers: asBearerAuth(accessToken) }),
|
|
|
|
createAsset: async (
|
|
accessToken: string,
|
|
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData' | 'sidecarData'>> & {
|
|
assetData?: FileData;
|
|
sidecarData?: FileData;
|
|
},
|
|
) => {
|
|
const _dto = {
|
|
deviceAssetId: 'test-1',
|
|
deviceId: 'test',
|
|
fileCreatedAt: new Date().toISOString(),
|
|
fileModifiedAt: new Date().toISOString(),
|
|
...dto,
|
|
};
|
|
|
|
const assetData = dto?.assetData?.bytes || makeRandomImage();
|
|
const filename = dto?.assetData?.filename || 'example.png';
|
|
|
|
if (dto?.assetData?.bytes) {
|
|
console.log(`Uploading ${filename}`);
|
|
}
|
|
|
|
const builder = request(app)
|
|
.post(`/assets`)
|
|
.attach('assetData', assetData, filename)
|
|
.set('Authorization', `Bearer ${accessToken}`);
|
|
|
|
if (dto?.sidecarData?.bytes) {
|
|
void builder.attach('sidecarData', dto.sidecarData.bytes, dto.sidecarData.filename);
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(_dto)) {
|
|
void builder.field(key, String(value));
|
|
}
|
|
|
|
const { body } = await builder;
|
|
|
|
return body as AssetMediaResponseDto;
|
|
},
|
|
|
|
replaceAsset: async (
|
|
accessToken: string,
|
|
assetId: string,
|
|
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: FileData },
|
|
) => {
|
|
const _dto = {
|
|
deviceAssetId: 'test-1',
|
|
deviceId: 'test',
|
|
fileCreatedAt: new Date().toISOString(),
|
|
fileModifiedAt: new Date().toISOString(),
|
|
...dto,
|
|
};
|
|
|
|
const assetData = dto?.assetData?.bytes || makeRandomImage();
|
|
const filename = dto?.assetData?.filename || 'example.png';
|
|
|
|
if (dto?.assetData?.bytes) {
|
|
console.log(`Uploading ${filename}`);
|
|
}
|
|
|
|
const builder = request(app)
|
|
.put(`/assets/${assetId}/original`)
|
|
.attach('assetData', assetData, filename)
|
|
.set('Authorization', `Bearer ${accessToken}`);
|
|
|
|
for (const [key, value] of Object.entries(_dto)) {
|
|
void builder.field(key, String(value));
|
|
}
|
|
|
|
const { body } = await builder;
|
|
|
|
return body as AssetMediaResponseDto;
|
|
},
|
|
|
|
createImageFile: (path: string) => {
|
|
if (!existsSync(dirname(path))) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
}
|
|
writeFileSync(path, makeRandomImage());
|
|
},
|
|
|
|
createDirectory: (path: string) => {
|
|
if (!existsSync(dirname(path))) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
}
|
|
},
|
|
|
|
removeImageFile: (path: string) => {
|
|
if (!existsSync(path)) {
|
|
return;
|
|
}
|
|
|
|
rmSync(path);
|
|
},
|
|
|
|
removeDirectory: (path: string) => {
|
|
if (!existsSync(path)) {
|
|
return;
|
|
}
|
|
|
|
rmSync(path);
|
|
},
|
|
|
|
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
|
|
checkExistingAssets({ checkExistingAssetsDto }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
metadataSearch: async (accessToken: string, dto: MetadataSearchDto) => {
|
|
return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
|
|
},
|
|
|
|
archiveAssets: (accessToken: string, ids: string[]) =>
|
|
updateAssets({ assetBulkUpdateDto: { ids, isArchived: true } }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
deleteAssets: (accessToken: string, ids: string[]) =>
|
|
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
createPerson: async (accessToken: string, dto?: PersonCreateDto) => {
|
|
const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(accessToken) });
|
|
await utils.setPersonThumbnail(person.id);
|
|
|
|
return person;
|
|
},
|
|
|
|
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
|
|
if (!client) {
|
|
return;
|
|
}
|
|
|
|
await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
|
|
},
|
|
|
|
setPersonThumbnail: async (personId: string) => {
|
|
if (!client) {
|
|
return;
|
|
}
|
|
|
|
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
|
|
},
|
|
|
|
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
|
|
createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
createLibrary: (accessToken: string, dto: CreateLibraryDto) =>
|
|
createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) =>
|
|
validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
|
|
|
|
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
|
await context.addCookies([
|
|
{
|
|
name: 'immich_access_token',
|
|
value: accessToken,
|
|
domain,
|
|
path: '/',
|
|
expires: 1_742_402_728,
|
|
httpOnly: true,
|
|
secure: false,
|
|
sameSite: 'Lax',
|
|
},
|
|
{
|
|
name: 'immich_auth_type',
|
|
value: 'password',
|
|
domain,
|
|
path: '/',
|
|
expires: 1_742_402_728,
|
|
httpOnly: true,
|
|
secure: false,
|
|
sameSite: 'Lax',
|
|
},
|
|
{
|
|
name: 'immich_is_authenticated',
|
|
value: 'true',
|
|
domain,
|
|
path: '/',
|
|
expires: 1_742_402_728,
|
|
httpOnly: false,
|
|
secure: false,
|
|
sameSite: 'Lax',
|
|
},
|
|
]),
|
|
|
|
resetTempFolder: () => {
|
|
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
|
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
|
},
|
|
|
|
resetAdminConfig: async (accessToken: string) => {
|
|
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
|
},
|
|
|
|
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
|
|
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
|
|
const jobCounts = queues[queue].jobCounts;
|
|
return !jobCounts.active && !jobCounts.waiting;
|
|
},
|
|
|
|
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
|
|
return new Promise<void>(async (resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
|
|
|
while (true) {
|
|
const done = await utils.isQueueEmpty(accessToken, queue);
|
|
if (done) {
|
|
break;
|
|
}
|
|
await setAsyncTimeout(200);
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
},
|
|
|
|
cliLogin: async (accessToken: string) => {
|
|
const key = await utils.createApiKey(accessToken, [Permission.All]);
|
|
await immichCli(['login', app, `${key.secret}`]);
|
|
return key.secret;
|
|
},
|
|
};
|
|
|
|
utils.initSdk();
|
|
|
|
if (!existsSync(`${testAssetDir}/albums`)) {
|
|
throw new Error(
|
|
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`,
|
|
);
|
|
}
|