chore: add tests for confirm flows (#33054)

(cherry picked from commit 71e37b154e359328a61d1952c0da046cc5cc93c2)
This commit is contained in:
Jason Rasmussen authored and Codex committed 2026-11-07 21:31:45 +03:00
1 parent 48cfafd1c1
commit 55df2bb2bf
15 files changed
+641 -4

No files matched your search

@@ -142,3 +142,47 @@ test('unliking an album removes only my album like', async ({ data, router, rend
expect(api.deleteActivity).toHaveBeenCalledWith({ params: { id: myLike.id } });
expect(api.deleteActivity.calls).toHaveLength(1);
});
test('deletes an album', async ({ data, router, render, settle, api, app }) => {
const album = factory.album({ albumUsers: factory.albumUsers({ owner: data.user }) });
data.addAlbums(album);
await router.goto(Route.viewAlbum(album), { albumId: album.id });
await render(Page, load);
await settle();
await app.album.optionsMenuButton.click();
await app.album.deleteMenuItem.click();
await app.cancelButton.click();
await settle();
expect(api.deleteAlbum).not.toHaveBeenCalled();
await app.album.optionsMenuButton.click();
await app.album.deleteMenuItem.click();
await app.confirmButton.click();
await settle();
expect(api.deleteAlbum).toHaveBeenCalledWith({ params: { id: album.id } });
});
test('leaves an album', async ({ data, router, render, settle, api, app }) => {
const album = factory.album({ albumUsers: factory.albumUsers({ owner: factory.user(), viewers: [data.user] }) });
data.addAlbums(album);
await router.goto(Route.viewAlbum(album), { albumId: album.id });
await render(Page, load);
await settle();
await app.album.optionsMenuButton.click();
await app.album.leaveMenuItem.click();
await app.cancelButton.click();
await settle();
expect(api.removeUserFromAlbum).not.toHaveBeenCalled();
await app.album.optionsMenuButton.click();
await app.album.leaveMenuItem.click();
await app.dialogButton('leave').click();
await settle();
expect(api.removeUserFromAlbum).toHaveBeenCalledWith({ params: { id: album.id, userId: 'me' } });
});
@@ -45,6 +45,12 @@ test('deleting the current tag opens its parent', async ({ data, router, render,
await render(Page, load);
await settle();
await app.tags.deleteButton.click();
await app.cancelButton.click();
await settle();
expect(api.deleteTag).not.toHaveBeenCalled();
await app.tags.deleteButton.click();
await app.tags.form.deleteButton.click();
await settle();
@@ -1,5 +1,6 @@
import { OpenQueryParam } from '#lib/constants.ts';
import { Route } from '#lib/route.ts';
import { factory } from '#test/unit/factories.ts';
import { test } from '#test/unit/fixtures.ts';
import { expect } from 'vitest';
import Page from './+page.svelte';
@@ -15,3 +16,103 @@ test('links to the OAuth account management page', async ({ data, router, render
await expect.element(app.settings.manageOauthAccountLink).toHaveAttribute('href', 'https://idp.example/account');
});
test('rotates an api key', async ({ data, router, render, settle, api, app }) => {
const apiKey = factory.apiKey({ name: 'CLI' });
data.addApiKeys(apiKey);
await router.goto(Route.userSettings());
await render(Page, load);
await app.settings.section('api_keys').click();
await app.settings.rotateApiKeyButton.click();
await app.cancelButton.click();
await settle();
expect(api.rotateApiKey).not.toHaveBeenCalled();
await app.settings.rotateApiKeyButton.click();
await app.confirmButton.click();
await settle();
expect(api.rotateApiKey).toHaveBeenCalledWith({ params: { id: apiKey.id } });
await app.settings.apiKeySecretDoneButton.click();
});
test('deletes an api key', async ({ data, router, render, settle, api, app }) => {
const apiKey = factory.apiKey({ name: 'CLI' });
data.addApiKeys(apiKey);
await router.goto(Route.userSettings());
await render(Page, load);
await app.settings.section('api_keys').click();
await app.settings.deleteApiKeyButton.click();
await app.cancelButton.click();
await settle();
expect(api.deleteApiKey).not.toHaveBeenCalled();
await app.settings.deleteApiKeyButton.click();
await app.confirmButton.click();
await settle();
expect(api.deleteApiKey).toHaveBeenCalledWith({ params: { id: apiKey.id } });
});
test('deletes a passkey', async ({ data, router, render, settle, api, app }) => {
const passkey = factory.passkey({ name: 'Laptop' });
data.addPasskeys(passkey);
await router.goto(Route.userSettings());
await render(Page, load);
await app.settings.section('passkeys').click();
await app.settings.deletePasskeyButton.click();
await app.cancelButton.click();
await settle();
expect(api.deletePasskey).not.toHaveBeenCalled();
await app.settings.deletePasskeyButton.click();
await app.confirmButton.click();
await settle();
expect(api.deletePasskey).toHaveBeenCalledWith({ params: { id: passkey.id } });
});
test('logs out a device', async ({ data, router, render, settle, api, app }) => {
const other = factory.session({ deviceOS: 'iOS' });
data.addSessions([factory.session({ current: true }), other]);
await router.goto(Route.userSettings());
await render(Page, load);
await app.settings.section('authorized_devices').click();
await app.settings.logOutDeviceButton.click();
await app.cancelButton.click();
await settle();
expect(api.deleteSession).not.toHaveBeenCalled();
await app.settings.logOutDeviceButton.click();
await app.confirmButton.click();
await settle();
expect(api.deleteSession).toHaveBeenCalledWith({ params: { id: other.id } });
});
test('logs out all devices', async ({ data, router, render, settle, api, app }) => {
data.addSessions([factory.session({ current: true }), factory.session()]);
await router.goto(Route.userSettings());
await render(Page, load);
await app.settings.section('authorized_devices').click();
await app.settings.logOutAllDevicesButton.click();
await app.cancelButton.click();
await settle();
expect(api.deleteAllSessions).not.toHaveBeenCalled();
await app.settings.logOutAllDevicesButton.click();
await app.confirmButton.click();
await settle();
expect(api.deleteAllSessions).toHaveBeenCalled();
});
@@ -0,0 +1,28 @@
import { Route } from '#lib/route.ts';
import { factory } from '#test/unit/factories.ts';
import { test } from '#test/unit/fixtures.ts';
import { expect } from 'vitest';
import Page from './+page.svelte';
import { load } from './+page.ts';
test('deletes a workflow', async ({ data, router, render, settle, api, app }) => {
const workflow = factory.workflow({ name: 'Favorites' });
data.addWorkflows(workflow);
await router.goto(Route.workflows());
await render(Page, load);
await settle();
await app.workflows.menuButton(0).click();
await app.workflows.deleteMenuItem.click();
await app.cancelButton.click();
await settle();
expect(api.deleteWorkflow).not.toHaveBeenCalled();
await app.workflows.menuButton(0).click();
await app.workflows.deleteMenuItem.click();
await app.confirmButton.click();
await settle();
expect(api.deleteWorkflow).toHaveBeenCalledWith({ params: { id: workflow.id } });
});
+146
View File
@@ -14,7 +14,9 @@ import {
type ActivityResponseDto,
type AdminConfigDto,
type AlbumResponseDto,
type ApiKeyResponseDto,
type AssetBulkUpdateDto,
type AssetFaceResponseDto,
type AssetOcrResponseDto,
type AssetResponseDto,
type CreateAlbumDto,
@@ -22,10 +24,12 @@ import {
type DuplicateResolveDto,
type DuplicateResponseDto,
type ExifResponseDto,
type LibraryResponseDto,
type MapMarkerResponseDto,
type MemoryResponseDto,
type MemoryUpdateDto,
type MetadataSearchDto,
type PasskeyResponseDto,
type PersonResponseDto,
type PersonUpdateDto,
type PlacesResponseDto,
@@ -37,6 +41,7 @@ import {
type QueueUpdateDto,
type SearchExploreResponseDto,
type SearchResponseDto,
type SessionResponseDto,
type SharedLinkResponseDto,
type StackCreateDto,
type StackResponseDto,
@@ -47,6 +52,7 @@ import {
type TimeBucketsResponseDto,
type UpdateAlbumDto,
type UpdateAssetDto,
type UpdateLibraryDto,
type UserAdminCreateDto,
type UserAdminResponseDto,
type UserAdminUpdateDto,
@@ -144,6 +150,11 @@ export class MockData {
private readonly tags = new Map<string, TagResponseDto>();
private readonly activities = new Map<string, ActivityResponseDto>();
private readonly workflows = new Map<string, WorkflowResponseDto>();
private readonly apiKeys = new Map<string, ApiKeyResponseDto>();
private readonly libraries = new Map<string, LibraryResponseDto>();
private readonly faces = new Map<string, AssetFaceResponseDto[]>();
private readonly sessions = new Map<string, SessionResponseDto>();
private readonly passkeys = new Map<string, PasskeyResponseDto>();
private readonly pluginMethods = new Map<string, PluginMethodResponseDto>();
private readonly queueStates = new Map<QueueName, QueueResponseDto>();
@@ -325,6 +336,10 @@ export class MockData {
}
}
removeSharedLink(id: string) {
return this.sharedLinks.delete(id);
}
removeSharedLinkAssets(id: string, assetIds: string[]) {
const link = this.sharedLinks.get(id);
if (!link) {
@@ -430,6 +445,17 @@ export class MockData {
return this;
}
removeUserFromAlbum(albumId: string, userId: string) {
const album = this.albums.get(albumId);
if (!album) {
return false;
}
const id = userId === 'me' ? this.user.id : userId;
this.albums.set(albumId, { ...album, albumUsers: album.albumUsers.filter(({ user }) => user.id !== id) });
return true;
}
addDuplicates(assets: AssetResponseDto[]) {
const duplicateId = crypto.randomUUID();
for (const asset of assets) {
@@ -586,6 +612,122 @@ export class MockData {
return tag;
}
addFaces(assetId: string, faces: MaybeArray<AssetFaceResponseDto>) {
this.faces.set(assetId, [...(this.faces.get(assetId) ?? []), ...toList(faces)]);
return this;
}
getFaces(assetId: string) {
return this.faces.get(assetId) ?? [];
}
deleteFace(id: string) {
for (const [assetId, faces] of this.faces) {
if (faces.some((face) => face.id === id)) {
this.faces.set(
assetId,
faces.filter((face) => face.id !== id),
);
return true;
}
}
return false;
}
addLibraries(libraries: MaybeArray<LibraryResponseDto>) {
for (const library of toList(libraries)) {
this.libraries.set(library.id, library);
}
return this;
}
libraryList() {
return [...this.libraries.values()];
}
getLibrary(id: string) {
return this.libraries.get(id);
}
updateLibrary(id: string, dto: UpdateLibraryDto) {
const library = this.libraries.get(id);
if (!library) {
return;
}
const updated = { ...library, ...dto, updatedAt: new Date().toISOString() };
this.libraries.set(id, updated);
return updated;
}
deleteLibrary(id: string) {
return this.libraries.delete(id);
}
addApiKeys(apiKeys: MaybeArray<ApiKeyResponseDto>) {
for (const apiKey of toList(apiKeys)) {
this.apiKeys.set(apiKey.id, apiKey);
}
return this;
}
apiKeyList() {
return [...this.apiKeys.values()];
}
rotateApiKey(id: string) {
const apiKey = this.apiKeys.get(id);
if (!apiKey) {
return;
}
const rotated = { ...apiKey, updatedAt: new Date().toISOString() };
this.apiKeys.set(id, rotated);
return { ...rotated, apiKey: rotated, secret: 'rotated-secret' };
}
deleteApiKey(id: string) {
return this.apiKeys.delete(id);
}
addSessions(sessions: MaybeArray<SessionResponseDto>) {
for (const session of toList(sessions)) {
this.sessions.set(session.id, session);
}
return this;
}
sessionList() {
return [...this.sessions.values()];
}
deleteSession(id: string) {
return this.sessions.delete(id);
}
deleteOtherSessions() {
for (const session of this.sessionList()) {
if (!session.current) {
this.sessions.delete(session.id);
}
}
}
addPasskeys(passkeys: MaybeArray<PasskeyResponseDto>) {
for (const passkey of toList(passkeys)) {
this.passkeys.set(passkey.id, passkey);
}
return this;
}
passkeyList() {
return [...this.passkeys.values()];
}
deletePasskey(id: string) {
return this.passkeys.delete(id);
}
addActivities(activities: MaybeArray<ActivityResponseDto>) {
for (const activity of toList(activities)) {
this.activities.set(activity.id, activity);
@@ -634,6 +776,10 @@ export class MockData {
return [...this.workflows.values()];
}
deleteWorkflow(id: string) {
return this.workflows.delete(id);
}
createWorkflow(dto: WorkflowCreateDto) {
const workflow = factory.workflow({ ...dto, steps: toWorkflowSteps(dto.steps ?? []) });
this.workflows.set(workflow.id, workflow);
+63
View File
@@ -15,13 +15,18 @@ import {
type ActivityResponseDto,
type AlbumResponseDto,
type AlbumUserResponseDto,
type ApiKeyResponseDto,
type AssetFaceResponseDto,
type AssetOcrResponseDto,
type AssetResponseDto,
type LibraryResponseDto,
type MemoryResponseDto,
type PasskeyResponseDto,
type PersonResponseDto,
type PluginMethodResponseDto,
type QueueResponseDto,
type QueueStatisticsDto,
type SessionResponseDto,
type SharedLinkResponseDto,
type StackResponseDto,
type TagResponseDto,
@@ -290,6 +295,59 @@ const tag = (overrides: Partial<TagResponseDto> = {}): TagResponseDto => {
};
};
const face = (overrides: Partial<AssetFaceResponseDto> = {}): AssetFaceResponseDto => ({
id: crypto.randomUUID(),
imageWidth: 4000,
imageHeight: 3000,
boundingBoxX1: 1000,
boundingBoxY1: 800,
boundingBoxX2: 1600,
boundingBoxY2: 1500,
person: null,
...overrides,
});
const library = (overrides: Partial<LibraryResponseDto> = {}): LibraryResponseDto => {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
name: 'Library',
ownerId: adminUserId,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
createdAt: now,
updatedAt: now,
refreshedAt: null,
...overrides,
};
};
const apiKey = (overrides: Partial<ApiKeyResponseDto> = {}): ApiKeyResponseDto => {
const now = new Date().toISOString();
return { id: crypto.randomUUID(), name: 'API Key', permissions: [], createdAt: now, updatedAt: now, ...overrides };
};
const session = (overrides: Partial<SessionResponseDto> = {}): SessionResponseDto => {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
appVersion: '3.0.0',
deviceOS: 'Android',
deviceType: 'Mobile',
current: false,
isPendingSyncReset: false,
createdAt: now,
updatedAt: now,
...overrides,
};
};
const passkey = (overrides: Partial<PasskeyResponseDto> = {}): PasskeyResponseDto => {
const now = new Date().toISOString();
return { id: crypto.randomUUID(), name: 'Passkey', createdAt: now, updatedAt: now, usedAt: now, ...overrides };
};
const workflow = (overrides: Partial<WorkflowResponseDto> = {}): WorkflowResponseDto => {
const now = new Date().toISOString();
return {
@@ -580,6 +638,11 @@ export const factory = {
tag,
sharedLink,
workflow,
face,
library,
apiKey,
session,
passkey,
pluginMethod,
queue,
memories,
+36 -4
View File
@@ -40,9 +40,36 @@ export const createHandlers = (data: MockData): Handlers => {
},
getUserStatisticsAdmin: () => ({ json: { images: 0, videos: 0, total: 0 } }),
getUserSessionsAdmin: () => ({ json: [] }),
getApiKeys: () => ({ json: [] }),
getSessions: () => ({ json: [] }),
searchPasskeys: () => ({ json: [] }),
getAllLibraries: () => ({ json: data.libraryList() }),
getLibrary: ({ params }) => {
const library = data.getLibrary(params.id);
return library ? { json: library } : notFound;
},
getLibraryStatistics: ({ params }) => {
const library = data.getLibrary(params.id);
return library
? { json: { photos: library.assetCount, videos: 0, total: library.assetCount, usage: 0 } }
: notFound;
},
updateLibrary: ({ params, body }) => {
const library = data.updateLibrary(params.id, body);
return library ? { json: library } : notFound;
},
deleteLibrary: ({ params }) => (data.deleteLibrary(params.id) ? { status: 204 } : notFound),
getApiKeys: () => ({ json: data.apiKeyList() }),
rotateApiKey: ({ params }) => {
const rotated = data.rotateApiKey(params.id);
return rotated ? { status: 201, json: rotated } : notFound;
},
deleteApiKey: ({ params }) => (data.deleteApiKey(params.id) ? { status: 204 } : notFound),
getSessions: () => ({ json: data.sessionList() }),
deleteSession: ({ params }) => (data.deleteSession(params.id) ? { status: 204 } : notFound),
deleteAllSessions: () => {
data.deleteOtherSessions();
return { status: 204 };
},
searchPasskeys: () => ({ json: data.passkeyList() }),
deletePasskey: ({ params }) => (data.deletePasskey(params.id) ? { status: 204 } : notFound),
getUserCalendarHeatmapAdmin: ({ params }) => ({
json: { from: params.$from ?? '', to: params.to ?? '', series: [], totalCount: 0 },
}),
@@ -139,7 +166,8 @@ export const createHandlers = (data: MockData): Handlers => {
const person = data.updatePerson(params.id, body);
return person ? { json: person } : notFound;
},
getFaces: () => ({ json: [] }),
getFaces: ({ params }) => ({ json: data.getFaces(params.id) }),
deleteFace: ({ params }) => (data.deleteFace(params.id) ? { status: 204 } : notFound),
createFace: () => ({ status: 201 }),
getAllPeople: ({ params = {} }) => ({ json: data.peoplePage(params) }),
getPersonThumbnail: ({ params }) => ({
@@ -169,6 +197,8 @@ export const createHandlers = (data: MockData): Handlers => {
data.deleteAlbum(params.id);
return { status: 204 };
},
removeUserFromAlbum: ({ params }) =>
data.removeUserFromAlbum(params.id, params.userId) ? { status: 204 } : notFound,
getAlbumMapMarkers: ({ params }) => ({ json: data.mapMarkers(params.id) }),
getMapMarkers: () => ({ json: data.mapMarkers() }),
getAlbumInfo: ({ params }) => {
@@ -209,6 +239,7 @@ export const createHandlers = (data: MockData): Handlers => {
getAssetsByCity: () => ({ json: data.assetsByCity() }),
getAllSharedLinks: () => ({ json: data.sharedLinkList() }),
removeSharedLink: ({ params }) => (data.removeSharedLink(params.id) ? { status: 204 } : notFound),
getMySharedLink: ({ params = {} }) => {
const link = data.getSharedLink(params);
if (!link) {
@@ -235,6 +266,7 @@ export const createHandlers = (data: MockData): Handlers => {
}),
searchWorkflows: () => ({ json: data.workflowList() }),
deleteWorkflow: ({ params }) => (data.deleteWorkflow(params.id) ? { status: 204 } : notFound),
getWorkflowTriggers: () => ({
json: Object.values(WorkflowTrigger).map((trigger) => ({ trigger, types: [WorkflowType.AssetV1] })),
}),
+19
View File
@@ -20,6 +20,7 @@ export const createApp = (page: Page, data: MockData) => {
return {
dialog: page.getByRole('dialog'),
dialogButton: (name: string) => page.getByRole('dialog').getByRole('button', { name, exact: true }),
toast: (key: string) => page.getByText(key),
admin: {
@@ -37,6 +38,7 @@ export const createApp = (page: Page, data: MockData) => {
editButton: button('edit'),
deleteButton: button('delete'),
resetPasswordButton: button('reset_password'),
resetPinCodeButton: button('reset_pin_code'),
form: {
emailInput: labelled('email'),
passwordInput: labelled('password'),
@@ -53,6 +55,12 @@ export const createApp = (page: Page, data: MockData) => {
createAlbumButton: button('create_album'),
},
library: {
deleteButton: button('delete').filter({ hasText: 'delete' }),
rowDeleteButton: (text: string) =>
page.getByRole('row').filter({ hasText: text }).getByRole('button', { name: 'delete', exact: true }),
},
tags: {
child: (name: string) => page.getByRole('main').getByRole('button', { name, exact: true }),
},
@@ -63,6 +71,10 @@ export const createApp = (page: Page, data: MockData) => {
mapButton: labelled('map'),
mapDialog: page.getByRole('dialog'),
mapMarkers: page.getByRole('dialog').locator('.maplibregl-marker'),
optionsMenuButton: labelled('album_options'),
optionsMenuItem: page.getByRole('menuitem', { name: 'options', exact: true }),
userRoleSelect: (role: string) => page.getByRole('dialog').getByText(role, { exact: true }),
removeUserOption: page.getByRole('option', { name: 'remove_user', exact: true }),
activity: {
commentsButton: page.getByRole('button', { name: 'activity', exact: true }),
},
@@ -114,6 +126,7 @@ export const createApp = (page: Page, data: MockData) => {
heading: (name: string) => page.getByRole('heading', { name }),
confirmRemove: () => button('remove').click(),
card,
deleteButton: labelled('delete_link'),
cover: (link: { key: string }) => card(link).getByTestId('album-image'),
brokenCover: (link: { key: string }) => card(link).getByTestId('broken-asset'),
form: {
@@ -202,6 +215,10 @@ const createTimeline = (page: Page, data: MockData) => {
await page.getByRole('menuitem', { name: 'unstack', exact: true }).click();
},
openRemoveFromAlbum: async () => {
await page.getByLabel('menu', { exact: true }).click();
await page.getByRole('menuitem', { name: 'remove_from_album', exact: true }).click();
},
removeFromAlbum: async () => {
await page.getByLabel('menu', { exact: true }).click();
await page.getByRole('menuitem', { name: 'remove_from_album', exact: true }).click();
@@ -341,6 +358,8 @@ const createViewer = (page: Page) => {
},
navbarActions: page.getByTestId('asset-viewer-navbar-actions'),
tags: page.getByTestId('detail-panel-tags').getByRole('link'),
editPeopleButton: page.getByLabel('edit_people', { exact: true }),
deleteFaceButton: page.getByLabel('delete_face', { exact: true }),
tag: (value: string) => page.getByTestId('detail-panel-tags').getByRole('link', { name: value, exact: true }),
addTagButton: page.getByTestId('detail-panel-tags').getByRole('button', { name: 'add_tag', exact: true }),
removeTagButton: page.getByTestId('detail-panel-tags').getByRole('button', { name: 'remove_tag', exact: true }),
@@ -0,0 +1,65 @@
import { Route } from '#lib/route.ts';
import { factory } from '#test/common/factories.ts';
import { expect, test } from '#test/medium/fixtures.ts';
test.describe('library administration', () => {
test('deletes a library with assets', async ({ page, data, api, app }) => {
const library = factory.library({ name: 'Photos', assetCount: 3 });
data.addLibraries(library);
await page.goto(Route.viewLibrary(library));
await app.library.deleteButton.click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
await app.library.deleteButton.click();
await app.dialogButton('confirm').click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.deleteLibrary).not.toHaveBeenCalled();
await app.library.deleteButton.click();
await app.dialogButton('confirm').click();
await app.dialogButton('confirm').click();
await expect(page).toHaveURL(Route.libraries());
expect(api.deleteLibrary).toHaveBeenCalledWith({ params: { id: library.id } });
});
test('removes an import folder', async ({ page, data, api, app }) => {
const library = factory.library({ importPaths: ['/photos', '/videos'] });
data.addLibraries(library);
await page.goto(Route.viewLibrary(library));
await app.library.rowDeleteButton('/videos').click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.updateLibrary).not.toHaveBeenCalled();
await app.library.rowDeleteButton('/videos').click();
await app.dialogButton('remove').click();
await expect(app.library.rowDeleteButton('/videos')).toHaveCount(0);
expect(api.updateLibrary).toHaveBeenCalledWith({ params: { id: library.id }, body: { importPaths: ['/photos'] } });
});
test('removes an exclusion pattern', async ({ page, data, api, app }) => {
const library = factory.library({ exclusionPatterns: ['**/@eaDir/**', '**/.trash/**'] });
data.addLibraries(library);
await page.goto(Route.viewLibrary(library));
await app.library.rowDeleteButton('**/.trash/**').click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.updateLibrary).not.toHaveBeenCalled();
await app.library.rowDeleteButton('**/.trash/**').click();
await app.dialogButton('confirm').click();
await expect(app.library.rowDeleteButton('**/.trash/**')).toHaveCount(0);
expect(api.updateLibrary).toHaveBeenCalledWith({
params: { id: library.id },
body: { exclusionPatterns: ['**/@eaDir/**'] },
});
});
});
+37
View File
@@ -71,4 +71,41 @@ test.describe('user administration', () => {
await expect(app.admin.users.deleteButton).toHaveCount(0);
await expect(app.admin.users.resetPasswordButton).toHaveCount(0);
});
test('resets a user password', async ({ page, data, api, app }) => {
const user = factory.user({ name: 'Reset User', email: 'reset@immich.cloud', isAdmin: false });
data.addUsers(user);
await page.goto(Route.viewUser(user));
await app.admin.users.resetPasswordButton.click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.updateUserAdmin).not.toHaveBeenCalled();
await app.admin.users.resetPasswordButton.click();
await app.dialogButton('confirm').click();
await expect.poll(() => api.updateUserAdmin.calls.length).toBe(1);
expect(api.updateUserAdmin).toHaveBeenCalledWith({
params: { id: user.id },
body: { password: expect.any(String), shouldChangePassword: true },
});
});
test('resets a user pin code', async ({ page, data, api, app }) => {
const user = factory.user({ name: 'Reset User', email: 'reset@immich.cloud', isAdmin: false });
data.addUsers(user);
await page.goto(Route.viewUser(user));
await app.admin.users.resetPinCodeButton.click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.updateUserAdmin).not.toHaveBeenCalled();
await app.admin.users.resetPinCodeButton.click();
await app.dialogButton('confirm').click();
await expect.poll(() => api.updateUserAdmin.calls.length).toBe(1);
expect(api.updateUserAdmin).toHaveBeenCalledWith({ params: { id: user.id }, body: { pinCode: null } });
});
});
@@ -31,6 +31,11 @@ test('removing an asset updates the summary and closes the gap in the timeline',
const firstPosition = await app.timeline.position(first);
await app.timeline.select(first);
await app.timeline.selectionBar.openRemoveFromAlbum();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.removeAssetFromAlbum).not.toHaveBeenCalled();
await app.timeline.selectionBar.removeFromAlbum();
await expect(app.timeline.thumbnail(first)).toHaveCount(0);
@@ -58,3 +63,27 @@ test('map stays open after viewing an asset from a marker and going back', async
await expect(app.viewer.root).not.toBeVisible();
await expect(app.album.mapDialog).toBeVisible();
});
test('removes a user from an album', async ({ page, data, api, app }) => {
const viewer = factory.user();
const asset = factory.asset();
const album = factory.album({ albumUsers: factory.albumUsers({ owner: data.user, viewers: [viewer] }) });
data.addAssets(asset);
data.addAlbums(album).addToAlbum(album.id, [asset]);
await page.goto(Route.viewAlbum({ id: album.id }));
await app.album.optionsMenuButton.click();
await app.album.optionsMenuItem.click();
await app.album.userRoleSelect('role_viewer').click();
await app.album.removeUserOption.click();
await app.dialogButton('cancel').click();
await expect(app.dialogButton('cancel')).toHaveCount(0);
expect(api.removeUserFromAlbum).not.toHaveBeenCalled();
await app.album.userRoleSelect('role_viewer').click();
await app.album.removeUserOption.click();
await app.dialogButton('remove_user').click();
await expect.poll(() => api.removeUserFromAlbum.calls.length).toBe(1);
expect(api.removeUserFromAlbum).toHaveBeenCalledWith({ params: { id: album.id, userId: viewer.id } });
});
@@ -59,6 +59,11 @@ test.describe('face editor', () => {
test('tags the selected person after confirming', async ({ api, app }) => {
const [alice] = people;
await app.viewer.faceEditor.person(alice.name).click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.createFace).not.toHaveBeenCalled();
await app.viewer.faceEditor.person(alice.name).click();
await expect(app.dialog).toBeVisible();
await app.viewer.faceEditor.confirmButton.click();
@@ -0,0 +1,25 @@
import { Route } from '#lib/route.ts';
import { factory } from '#test/common/factories.ts';
import { expect, test } from '#test/medium/fixtures.ts';
test('deletes a face', async ({ page, data, api, app }) => {
const asset = factory.asset({ width: 4000, height: 3000 });
const alice = factory.person({ name: 'Alice Johnson' });
const face = factory.face({ person: alice });
data.addAssets(asset).addPeople(alice).addFaces(asset.id, face);
await page.goto(Route.viewAsset({ id: asset.id }));
await app.viewer.waitForAsset(asset);
await app.viewer.openDetailPanel();
await app.viewer.editPeopleButton.click();
await app.viewer.deleteFaceButton.click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.deleteFace).not.toHaveBeenCalled();
await app.viewer.deleteFaceButton.click();
await app.dialogButton('confirm').click();
await expect.poll(() => api.deleteFace.calls.length).toBe(1);
expect(api.deleteFace).toHaveBeenCalledWith({ params: { id: face.id }, body: { force: false } });
});
@@ -115,6 +115,11 @@ test.describe('shared link', () => {
await expect(app.timeline.thumbnails).toHaveCount(3);
await app.timeline.select(first);
await app.sharedLink.removeFromSharedLinkButton.click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.removeSharedLinkAssets).not.toHaveBeenCalled();
await app.sharedLink.removeFromSharedLinkButton.click();
await app.sharedLink.confirmRemove();
@@ -125,4 +130,21 @@ test.describe('shared link', () => {
body: { assetIds: [first.id] },
});
});
test('deletes a shared link', async ({ page, data, api, app }) => {
const link = factory.sharedLink({ type: SharedLinkType.Individual, assets: [factory.asset()] });
data.addSharedLinks(link);
await page.goto(Route.sharedLinks());
await app.sharedLink.deleteButton.click();
await app.dialogButton('cancel').click();
await expect(app.dialog).toBeHidden();
expect(api.removeSharedLink).not.toHaveBeenCalled();
await app.sharedLink.deleteButton.click();
await app.dialogButton('delete').click();
await expect(app.sharedLink.card(link)).toHaveCount(0);
expect(api.removeSharedLink).toHaveBeenCalledWith({ params: { id: link.id } });
});
});
+15
View File
@@ -90,6 +90,9 @@ export const createApp = () => {
card: (name: string) => main.getByTestId('album-card').filter({ has: page.getByTitle(name, { exact: true }) }),
},
album: {
optionsMenuButton: button('album_options'),
deleteMenuItem: page.getByRole('menuitem', { name: 'delete_album', exact: true }),
leaveMenuItem: page.getByRole('menuitem', { name: 'leave_album', exact: true }),
descriptionInput: page.getByTestId('autogrow-textarea'),
commentsButton: button('activity'),
likeButton: page.getByRole('button', { name: 'like', exact: true, pressed: false }),
@@ -102,6 +105,7 @@ export const createApp = () => {
},
},
confirmButton: page.getByRole('dialog').getByRole('button', { name: 'confirm', exact: true }),
dialogButton: (name: string) => page.getByRole('dialog').getByRole('button', { name, exact: true }),
cancelButton: page.getByRole('dialog').getByRole('button', { name: 'cancel', exact: true }),
duplicates: {
keepToggle: (index: number) => button('keep').nth(index),
@@ -126,12 +130,23 @@ export const createApp = () => {
deleteButton: page.getByRole('dialog').getByRole('button', { name: 'delete', exact: true }),
},
},
workflows: {
menuButton: (index: number) => page.getByRole('button', { name: 'open', exact: true }).nth(index),
deleteMenuItem: page.getByRole('menuitem', { name: 'delete', exact: true }),
},
trash: {
emptyTrashButton: button('empty_trash'),
restoreAllButton: button('restore_all'),
},
settings: {
manageOauthAccountLink: page.getByRole('link', { name: 'manage_oauth_account', exact: true }),
section: (title: string) => page.getByRole('button', { name: new RegExp(`^${title}`) }),
rotateApiKeyButton: button('rotate_key'),
apiKeySecretDoneButton: page.getByRole('dialog').getByRole('button', { name: 'done', exact: true }),
deleteApiKeyButton: button('delete_key'),
deletePasskeyButton: button('delete_passkey'),
logOutDeviceButton: button('log_out'),
logOutAllDevicesButton: button('log_out_all_devices'),
},
people: {
nameInput: (index: number) => main.getByPlaceholder('add_a_name').nth(index),