diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.spec.ts b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.spec.ts index da5aa6da3..342c531d0 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.spec.ts +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.spec.ts @@ -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' } }); +}); diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts index 515121f6f..553169320 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts @@ -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(); diff --git a/web/src/routes/(user)/user-settings/page.spec.ts b/web/src/routes/(user)/user-settings/page.spec.ts index 0f1161837..32842404d 100644 --- a/web/src/routes/(user)/user-settings/page.spec.ts +++ b/web/src/routes/(user)/user-settings/page.spec.ts @@ -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(); +}); diff --git a/web/src/routes/(user)/workflows/page.spec.ts b/web/src/routes/(user)/workflows/page.spec.ts new file mode 100644 index 000000000..8494a8634 --- /dev/null +++ b/web/src/routes/(user)/workflows/page.spec.ts @@ -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 } }); +}); diff --git a/web/test/common/data.ts b/web/test/common/data.ts index c396a86b3..5498cc8cc 100644 --- a/web/test/common/data.ts +++ b/web/test/common/data.ts @@ -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(); private readonly activities = new Map(); private readonly workflows = new Map(); + private readonly apiKeys = new Map(); + private readonly libraries = new Map(); + private readonly faces = new Map(); + private readonly sessions = new Map(); + private readonly passkeys = new Map(); private readonly pluginMethods = new Map(); private readonly queueStates = new Map(); @@ -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) { + 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) { + 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) { + 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) { + 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) { + 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) { 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); diff --git a/web/test/common/factories.ts b/web/test/common/factories.ts index 7e3315029..fa6f01210 100644 --- a/web/test/common/factories.ts +++ b/web/test/common/factories.ts @@ -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 => { }; }; +const face = (overrides: Partial = {}): AssetFaceResponseDto => ({ + id: crypto.randomUUID(), + imageWidth: 4000, + imageHeight: 3000, + boundingBoxX1: 1000, + boundingBoxY1: 800, + boundingBoxX2: 1600, + boundingBoxY2: 1500, + person: null, + ...overrides, +}); + +const library = (overrides: Partial = {}): 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 => { + const now = new Date().toISOString(); + return { id: crypto.randomUUID(), name: 'API Key', permissions: [], createdAt: now, updatedAt: now, ...overrides }; +}; + +const session = (overrides: Partial = {}): 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 => { + const now = new Date().toISOString(); + return { id: crypto.randomUUID(), name: 'Passkey', createdAt: now, updatedAt: now, usedAt: now, ...overrides }; +}; + const workflow = (overrides: Partial = {}): 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, diff --git a/web/test/common/handlers.ts b/web/test/common/handlers.ts index 2c2534c82..1d34d2bdd 100644 --- a/web/test/common/handlers.ts +++ b/web/test/common/handlers.ts @@ -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] })), }), diff --git a/web/test/medium/app.ts b/web/test/medium/app.ts index 0b42c4578..94940aca0 100644 --- a/web/test/medium/app.ts +++ b/web/test/medium/app.ts @@ -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 }), diff --git a/web/test/medium/specs/admin/libraries.spec.ts b/web/test/medium/specs/admin/libraries.spec.ts new file mode 100644 index 000000000..94394f4fc --- /dev/null +++ b/web/test/medium/specs/admin/libraries.spec.ts @@ -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/**'] }, + }); + }); +}); diff --git a/web/test/medium/specs/admin/users.spec.ts b/web/test/medium/specs/admin/users.spec.ts index 4e7722cc7..99419ec24 100644 --- a/web/test/medium/specs/admin/users.spec.ts +++ b/web/test/medium/specs/admin/users.spec.ts @@ -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 } }); + }); }); diff --git a/web/test/medium/specs/albums/album.spec.ts b/web/test/medium/specs/albums/album.spec.ts index 117336474..b1fbcbf2a 100644 --- a/web/test/medium/specs/albums/album.spec.ts +++ b/web/test/medium/specs/albums/album.spec.ts @@ -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 } }); +}); diff --git a/web/test/medium/specs/asset-viewer/face-editor.spec.ts b/web/test/medium/specs/asset-viewer/face-editor.spec.ts index 6f58c0290..65f1f9377 100644 --- a/web/test/medium/specs/asset-viewer/face-editor.spec.ts +++ b/web/test/medium/specs/asset-viewer/face-editor.spec.ts @@ -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(); diff --git a/web/test/medium/specs/asset-viewer/person-side-panel.spec.ts b/web/test/medium/specs/asset-viewer/person-side-panel.spec.ts new file mode 100644 index 000000000..14cac0a40 --- /dev/null +++ b/web/test/medium/specs/asset-viewer/person-side-panel.spec.ts @@ -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 } }); +}); diff --git a/web/test/medium/specs/shared-link/shared-link.spec.ts b/web/test/medium/specs/shared-link/shared-link.spec.ts index bc0895fcf..ee0e6b887 100644 --- a/web/test/medium/specs/shared-link/shared-link.spec.ts +++ b/web/test/medium/specs/shared-link/shared-link.spec.ts @@ -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 } }); + }); }); diff --git a/web/test/unit/app.ts b/web/test/unit/app.ts index bddb86c79..ff68a0b49 100644 --- a/web/test/unit/app.ts +++ b/web/test/unit/app.ts @@ -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),