1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 16:41:59 +00:00
immich/e2e/src/utils.ts

545 lines
17 KiB
TypeScript
Raw Normal View History

2024-02-19 17:03:51 +00:00
import {
AllJobStatusResponseDto,
AssetMediaCreateDto,
AssetMediaResponseDto,
AssetResponseDto,
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
2024-03-15 13:16:08 +00:00
MetadataSearchDto,
Permission,
PersonCreateDto,
SharedLinkCreateDto,
UserAdminCreateDto,
UserPreferencesUpdateDto,
ValidateLibraryDto,
checkExistingAssets,
createAlbum,
2024-02-19 22:25:57 +00:00
createApiKey,
createLibrary,
createPartner,
createPerson,
createSharedLink,
createStack,
createUserAdmin,
deleteAssets,
getAllJobsStatus,
getAssetInfo,
getConfigDefaults,
2024-02-19 17:03:51 +00:00
login,
searchAssets,
2024-05-17 20:48:29 +00:00
setBaseUrl,
2024-02-19 17:03:51 +00:00
signUpAdmin,
tagAssets,
updateAdminOnboarding,
feat: readonly album sharing (#8720) * rename albums_shared_users_users to album_permissions and add readonly column * disable synchronize on the original join table * remove unnecessary FK names * set readonly=true as default for new album shares * separate and implement album READ and WRITE permission * expose albumPermissions on the API, deprecate sharedUsers * generate openapi * create readonly view on frontend * ??? move slideshow button out from ellipsis menu so that non-owners can have access too * correct sharedUsers joins * add album permission repository * remove a log * fix assetCount getting reset when adding users * fix lint * add set permission endpoint and UI * sort users * remove log * Revert "??? move slideshow button out from ellipsis menu so that non-owners can have access too" This reverts commit 1343bfa31125f7136f81db28f7aa4c5ef0204847. * rename stuff * fix db schema annotations * sql generate * change readonly default to follow migration * fix deprecation notice * change readonly boolean to role enum * fix joincolumn as primary key * rename albumUserRepository in album service * clean up userId and albumId * add write access to shared link * fix existing tests * switch to vitest * format and fix tests on web * add new test * fix one e2e test * rename new API field to albumUsers * capitalize serverside enum * remove unused ReadWrite type * missed rename from previous commit * rename to albumUsers in album entity as well * remove outdated Equals calls * unnecessary relation * rename to updateUser in album service * minor renamery * move sorting to backend * rename and separate ALBUM_WRITE as ADD_ASSET and REMOVE_ASSET * fix tests * fix "should migrate single moving picture" test failing on European system timezone * generated changes after merge * lint fix * fix correct page to open after removing user from album * fix e2e tests and some bugs * rename updateAlbumUser rest endpoint * add new e2e tests for updateAlbumUser endpoint * small optimizations * refactor album e2e test, add new album shared with viewer * add new test to check if viewer can see the album * add new e2e tests for readonly share * failing test: User delete doesn't cascade to UserAlbum entity * fix: handle deleted users * use lodash for sort * add role to addUsersToAlbum endpoint * add UI for adding editors * lint fixes * change role back to editor as DB default * fix server tests * redesign user selection modal editor selector * style tweaks * fix type error * Revert "style tweaks" This reverts commit ab604f4c8f3a6f12ab0b5fe2dd2ede723aa68775. * Revert "redesign user selection modal editor selector" This reverts commit e6f344856c6c05e4eb5c78f0dffb9f52498795f4. * chore: cleanup and improve add user modal * chore: open api * small styling --------- Co-authored-by: mgabor <> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-04-25 04:19:49 +00:00
updateAlbumUser,
updateAssets,
updateConfig,
updateMyPreferences,
upsertTags,
validate,
2024-02-19 17:03:51 +00:00
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
2024-03-05 17:07:46 +00:00
import { createHash } from 'node:crypto';
2024-03-15 13:16:08 +00:00
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
2024-03-15 13:16:08 +00:00
import path, { dirname } from 'node:path';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
2024-02-19 17:03:51 +00:00
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
2024-02-19 17:03:51 +00:00
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';
2024-03-15 13:16:08 +00:00
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`;
2024-02-19 22:25:57 +00:00
// 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(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
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' });
2024-02-19 22:25:57 +00:00
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 };
};
2024-02-19 22:25:57 +00:00
2024-02-19 17:03:51 +00:00
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>(),
};
2024-03-15 13:16:08 +00:00
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 }) => {
2024-03-15 13:16:08 +00:00
// 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[]) => {
2024-02-19 17:03:51 +00:00
try {
if (!client) {
client = new pg.Client(dbUrl);
2024-02-19 17:03:51 +00:00
await client.connect();
}
tables = tables || [
2024-03-09 17:51:58 +00:00
// TODO e2e test for deleting a stack, since it is quite complex
'asset_stack',
'libraries',
'shared_links',
'person',
2024-02-19 22:25:57 +00:00
'albums',
'assets',
'asset_faces',
'activity',
2024-02-19 22:25:57 +00:00
'api_keys',
'sessions',
2024-02-19 22:25:57 +00:00
'users',
'system_metadata',
'tags',
];
2024-03-09 17:51:58 +00:00
const sql: string[] = [];
for (const table of tables) {
if (table === 'system_metadata') {
2024-09-23 15:16:25 +00:00
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM ${table} CASCADE;`);
}
2024-02-19 17:03:51 +00:00
}
2024-03-09 17:51:58 +00:00
await client.query(sql.join('\n'));
2024-02-19 17:03:51 +00:00
} catch (error) {
console.error('Failed to reset database', error);
throw error;
}
},
2024-02-19 22:25:57 +00:00
resetFilesystem: async () => {
const mediaInternal = '/usr/src/app/upload';
const dirs = [
`"${mediaInternal}/thumbs"`,
`"${mediaInternal}/upload"`,
`"${mediaInternal}/library"`,
`"${mediaInternal}/encoded-video"`,
].join(' ');
2024-02-19 22:25:57 +00:00
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();
}
},
2024-03-15 13:16:08 +00:00
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;
}
2024-03-15 13:16:08 +00:00
if (id) {
idCallbacks[id] = onId;
2024-03-15 13:16:08 +00:00
}
if (count) {
countCallbacks[event] = {
count,
callback: onId,
2024-03-15 13:16:08 +00:00
};
}
});
},
2024-05-17 20:48:29 +00:00
initSdk: () => {
setBaseUrl(app);
2024-02-19 22:25:57 +00:00
},
adminSetup: async (options?: AdminSetupOptions) => {
options = options || { onboarding: true };
2024-02-19 17:03:51 +00:00
await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin });
if (options.onboarding) {
await updateAdminOnboarding(
{ adminOnboardingUpdateDto: { isOnboarded: true } },
{ headers: asBearerAuth(response.accessToken) },
);
}
2024-02-19 17:03:51 +00:00
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) });
2024-02-19 22:25:57 +00:00
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
feat: readonly album sharing (#8720) * rename albums_shared_users_users to album_permissions and add readonly column * disable synchronize on the original join table * remove unnecessary FK names * set readonly=true as default for new album shares * separate and implement album READ and WRITE permission * expose albumPermissions on the API, deprecate sharedUsers * generate openapi * create readonly view on frontend * ??? move slideshow button out from ellipsis menu so that non-owners can have access too * correct sharedUsers joins * add album permission repository * remove a log * fix assetCount getting reset when adding users * fix lint * add set permission endpoint and UI * sort users * remove log * Revert "??? move slideshow button out from ellipsis menu so that non-owners can have access too" This reverts commit 1343bfa31125f7136f81db28f7aa4c5ef0204847. * rename stuff * fix db schema annotations * sql generate * change readonly default to follow migration * fix deprecation notice * change readonly boolean to role enum * fix joincolumn as primary key * rename albumUserRepository in album service * clean up userId and albumId * add write access to shared link * fix existing tests * switch to vitest * format and fix tests on web * add new test * fix one e2e test * rename new API field to albumUsers * capitalize serverside enum * remove unused ReadWrite type * missed rename from previous commit * rename to albumUsers in album entity as well * remove outdated Equals calls * unnecessary relation * rename to updateUser in album service * minor renamery * move sorting to backend * rename and separate ALBUM_WRITE as ADD_ASSET and REMOVE_ASSET * fix tests * fix "should migrate single moving picture" test failing on European system timezone * generated changes after merge * lint fix * fix correct page to open after removing user from album * fix e2e tests and some bugs * rename updateAlbumUser rest endpoint * add new e2e tests for updateAlbumUser endpoint * small optimizations * refactor album e2e test, add new album shared with viewer * add new test to check if viewer can see the album * add new e2e tests for readonly share * failing test: User delete doesn't cascade to UserAlbum entity * fix: handle deleted users * use lodash for sort * add role to addUsersToAlbum endpoint * add UI for adding editors * lint fixes * change role back to editor as DB default * fix server tests * redesign user selection modal editor selector * style tweaks * fix type error * Revert "style tweaks" This reverts commit ab604f4c8f3a6f12ab0b5fe2dd2ede723aa68775. * Revert "redesign user selection modal editor selector" This reverts commit e6f344856c6c05e4eb5c78f0dffb9f52498795f4. * chore: cleanup and improve add user modal * chore: open api * small styling --------- Co-authored-by: mgabor <> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-04-25 04:19:49 +00:00
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';
2024-03-08 16:20:54 +00:00
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;
},
2024-03-15 13:16:08 +00:00
createImageFile: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });
}
writeFileSync(path, makeRandomImage());
2024-03-15 13:16:08 +00:00
},
createDirectory: (path: string) => {
2024-10-11 18:40:29 +00:00
if (!existsSync(path)) {
mkdirSync(path, { recursive: true });
}
},
2024-03-15 13:16:08 +00:00
removeImageFile: (path: string) => {
if (!existsSync(path)) {
return;
}
rmSync(path);
},
removeDirectory: (path: string) => {
if (!existsSync(path)) {
return;
}
2024-10-11 18:40:29 +00:00
rmSync(path, { recursive: true });
},
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
checkExistingAssets({ checkExistingAssetsDto }, { headers: asBearerAuth(accessToken) }),
searchAssets: async (accessToken: string, dto: MetadataSearchDto) => {
return searchAssets({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
2024-03-15 13:16:08 +00:00
},
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) }),
2024-02-19 17:03:51 +00:00
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
updateMyPreferences: (accessToken: string, userPreferencesUpdateDto: UserPreferencesUpdateDto) =>
updateMyPreferences({ userPreferencesUpdateDto }, { headers: asBearerAuth(accessToken) }),
createStack: (accessToken: string, assetIds: string[]) =>
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
upsertTags: (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
2024-08-01 11:36:31 +00:00
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_access_token',
value: accessToken,
2024-08-01 11:36:31 +00:00
domain,
path: '/',
expires: 1_742_402_728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
{
name: 'immich_auth_type',
value: 'password',
2024-08-01 11:36:31 +00:00
domain,
path: '/',
expires: 1_742_402_728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
{
name: 'immich_is_authenticated',
value: 'true',
2024-08-01 11:36:31 +00:00
domain,
path: '/',
expires: 1_742_402_728,
httpOnly: false,
secure: false,
sameSite: 'Lax',
},
2024-02-19 17:03:51 +00:00
]),
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;
},
};
2024-05-17 20:48:29 +00:00
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`,
);
}