From b91ac2e67eee06db3f394f5d0f61c926afaa03e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=CC=88mer=20Duran?= Date: Fri, 19 Dec 2025 00:43:08 +0100 Subject: [PATCH] Tests: Add component tests for navigation, photo toolbar, and file editing --- .../tests/vitest/component/navigation.test.js | 370 ++++++++++++++++++ .../vitest/component/photo/edit/files.test.js | 325 +++++++++++++++ .../component/photo/edit/labels.test.js | 226 +++++++++++ .../vitest/component/photo/toolbar.test.js | 346 ++++++++++++++++ 4 files changed, 1267 insertions(+) create mode 100644 frontend/tests/vitest/component/navigation.test.js create mode 100644 frontend/tests/vitest/component/photo/edit/files.test.js create mode 100644 frontend/tests/vitest/component/photo/edit/labels.test.js create mode 100644 frontend/tests/vitest/component/photo/toolbar.test.js diff --git a/frontend/tests/vitest/component/navigation.test.js b/frontend/tests/vitest/component/navigation.test.js new file mode 100644 index 000000000..37dd6a035 --- /dev/null +++ b/frontend/tests/vitest/component/navigation.test.js @@ -0,0 +1,370 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { shallowMount, config as VTUConfig } from "@vue/test-utils"; +import PNavigation from "component/navigation.vue"; + +function mountNavigation({ + routeName = "photos", + routeMeta = { hideNav: false }, + isPublic = false, + isRestricted = false, + sessionAuth = true, + featureOverrides = {}, + configValues = {}, + allowMock, + routerPush, + vuetifyDisplay = { smAndDown: false }, + eventPublish, + utilOverrides = {}, + sessionOverrides = {}, +} = {}) { + const baseConfig = VTUConfig.global.mocks.$config || {}; + const baseEvent = VTUConfig.global.mocks.$event || {}; + const baseUtil = VTUConfig.global.mocks.$util || {}; + const baseNotify = VTUConfig.global.mocks.$notify || {}; + + const featureFlags = { + files: true, + settings: true, + upload: true, + account: true, + logs: true, + library: true, + places: true, + ...featureOverrides, + }; + + const values = { + siteUrl: "http://localhost:2342/", + usage: { filesTotal: 1024, filesUsed: 512 }, + legalUrl: configValues.legalUrl ?? null, + legalInfo: configValues.legalInfo ?? "", + disable: { settings: false }, + count: {}, + ...configValues, + }; + + const configMock = { + ...baseConfig, + getName: baseConfig.getName || vi.fn(() => "PhotoPrism"), + getAbout: baseConfig.getAbout || vi.fn(() => "About"), + getIcon: baseConfig.getIcon || vi.fn(() => "/icon.png"), + getTier: baseConfig.getTier || vi.fn(() => 1), + isPro: baseConfig.isPro || vi.fn(() => false), + isSponsor: baseConfig.isSponsor || vi.fn(() => false), + get: vi.fn((key) => { + if (key === "demo") return false; + if (key === "public") return isPublic; + if (key === "readonly") return false; + return false; + }), + feature: vi.fn((name) => { + if (name in featureFlags) { + return !!featureFlags[name]; + } + return true; + }), + allow: allowMock || baseConfig.allow || vi.fn(() => true), + deny: vi.fn((resource, action) => (resource === "photos" && action === "access_library" ? isRestricted : false)), + values, + disconnected: false, + page: { title: "Photos" }, + test: false, + }; + + const session = { + auth: sessionAuth, + isAdmin: vi.fn(() => true), + isSuperAdmin: vi.fn(() => true), + hasScope: vi.fn(() => false), + getUser: vi.fn(() => ({ + getDisplayName: vi.fn(() => "Test User"), + getAccountInfo: vi.fn(() => "test@example.com"), + getAvatarURL: vi.fn(() => "/avatar.jpg"), + })), + logout: vi.fn(), + ...sessionOverrides, + }; + + const publish = eventPublish || baseEvent.publish || vi.fn(); + + const eventBus = { + ...baseEvent, + publish, + subscribe: baseEvent.subscribe || vi.fn(() => "sub-id"), + unsubscribe: baseEvent.unsubscribe || vi.fn(), + }; + + const notify = { + ...baseNotify, + info: baseNotify.info || vi.fn(), + blockUI: baseNotify.blockUI || vi.fn(), + }; + + const util = { + ...baseUtil, + openExternalUrl: vi.fn(), + gigaBytes: vi.fn((bytes) => bytes), + ...utilOverrides, + }; + + const push = routerPush || vi.fn(); + + const wrapper = shallowMount(PNavigation, { + global: { + mocks: { + $config: configMock, + $session: session, + $router: { push }, + $route: { name: routeName, meta: routeMeta }, + $vuetify: { display: { smAndDown: !!vuetifyDisplay.smAndDown } }, + $event: eventBus, + $util: util, + $notify: notify, + $isRtl: false, + }, + stubs: { + "router-link": { template: "" }, + }, + }, + }); + + return { + wrapper, + configMock, + session, + eventBus, + notify, + util, + push, + }; +} + +describe("component/navigation", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("routeName", () => { + it("returns true when current route starts with given name", () => { + const { wrapper } = mountNavigation({ routeName: "photos_browse" }); + expect(wrapper.vm.routeName("photos")).toBe(true); + expect(wrapper.vm.routeName("albums")).toBe(false); + }); + + it("returns false when name or route name is missing", () => { + const { wrapper } = mountNavigation({ routeName: "" }); + expect(wrapper.vm.routeName("photos")).toBe(false); + expect(wrapper.vm.routeName("")).toBe(false); + }); + }); + + describe("auth and visibility", () => { + it("auth is true when session is authenticated", () => { + const { wrapper } = mountNavigation({ sessionAuth: true, isPublic: false }); + expect(wrapper.vm.auth).toBe(true); + }); + + it("auth is true when instance is public even without session", () => { + const { wrapper } = mountNavigation({ sessionAuth: false, isPublic: true }); + expect(wrapper.vm.auth).toBe(true); + }); + + it("auth is false when neither session nor public access is available", () => { + const { wrapper } = mountNavigation({ sessionAuth: false, isPublic: false }); + expect(wrapper.vm.auth).toBe(false); + }); + + it("visible is false when route meta.hideNav is true", () => { + const { wrapper } = mountNavigation({ routeMeta: { hideNav: true } }); + expect(wrapper.vm.visible).toBe(false); + }); + }); + + describe("drawer behavior", () => { + it("toggleDrawer toggles drawer on small screens", () => { + const { wrapper } = mountNavigation({ + vuetifyDisplay: { smAndDown: true }, + sessionAuth: true, + }); + + // Force small-screen mode and authenticated session + wrapper.vm.$vuetify.display.smAndDown = true; + wrapper.vm.session.auth = true; + wrapper.vm.isPublic = false; + + wrapper.vm.drawer = false; + wrapper.vm.toggleDrawer({ target: {} }); + expect(wrapper.vm.drawer).toBe(true); + + wrapper.vm.toggleDrawer({ target: {} }); + expect(wrapper.vm.drawer).toBe(false); + }); + + it("toggleDrawer toggles mini mode on desktop", () => { + const { wrapper } = mountNavigation({ + vuetifyDisplay: { smAndDown: false }, + isRestricted: false, + }); + + const initial = wrapper.vm.isMini; + wrapper.vm.toggleDrawer({ target: {} }); + expect(wrapper.vm.isMini).toBe(!initial); + }); + + it("toggleIsMini respects restricted mode and updates localStorage", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + + const { wrapper } = mountNavigation({ isRestricted: false }); + const initial = wrapper.vm.isMini; + + wrapper.vm.toggleIsMini(); + expect(wrapper.vm.isMini).toBe(!initial); + expect(setItemSpy).toHaveBeenCalledWith("navigation.mode", `${!initial}`); + + wrapper.vm.isRestricted = true; + const before = wrapper.vm.isMini; + wrapper.vm.toggleIsMini(); + expect(wrapper.vm.isMini).toBe(before); + }); + }); + + describe("account and legal navigation", () => { + it("showAccountSettings routes to account settings when account feature is enabled", () => { + const { wrapper, push } = mountNavigation({ + featureOverrides: { account: true }, + }); + + wrapper.vm.showAccountSettings(); + expect(push).toHaveBeenCalledWith({ name: "settings_account" }); + }); + + it("showAccountSettings falls back to general settings when account feature is disabled", () => { + const { wrapper, push } = mountNavigation({ + featureOverrides: { account: false }, + }); + + wrapper.vm.showAccountSettings(); + expect(push).toHaveBeenCalledWith({ name: "settings" }); + }); + + it("showLegalInfo opens external URL when legalUrl is configured", () => { + const { wrapper, util } = mountNavigation({ + configValues: { legalUrl: "https://example.com/legal" }, + }); + + wrapper.vm.showLegalInfo(); + expect(util.openExternalUrl).toHaveBeenCalledWith("https://example.com/legal"); + }); + + it("showLegalInfo routes to about page when legalUrl is missing", () => { + const { wrapper, push } = mountNavigation({ + configValues: { legalUrl: null }, + }); + + wrapper.vm.showLegalInfo(); + expect(push).toHaveBeenCalledWith({ name: "about" }); + }); + }); + + describe("home and upload actions", () => { + it("onHome toggles drawer on small screens and does not navigate", () => { + const { wrapper, push } = mountNavigation({ + vuetifyDisplay: { smAndDown: true }, + routeName: "browse", + }); + + // Ensure mobile mode and authenticated session so drawer logic runs + wrapper.vm.$vuetify.display.smAndDown = true; + wrapper.vm.session.auth = true; + wrapper.vm.isPublic = false; + wrapper.vm.drawer = false; + + wrapper.vm.onHome({ target: {} }); + expect(wrapper.vm.drawer).toBe(true); + expect(push).not.toHaveBeenCalled(); + }); + + it("onHome navigates to home on desktop when not already there", () => { + const { wrapper, push } = mountNavigation({ + vuetifyDisplay: { smAndDown: false }, + routeName: "albums", + }); + + // Force desktop mode explicitly to avoid relying on Vuetify defaults + wrapper.vm.$vuetify.display.smAndDown = false; + + wrapper.vm.onHome({ target: {} }); + expect(push).toHaveBeenCalledWith({ name: "home" }); + }); + + it("openUpload publishes dialog.upload event", () => { + const publish = vi.fn(); + const { wrapper, eventBus } = mountNavigation({ eventPublish: publish }); + + wrapper.vm.openUpload(); + expect(eventBus.publish).toHaveBeenCalledWith("dialog.upload"); + }); + }); + + describe("info and usage actions", () => { + it("reloadApp shows info notification and blocks UI", () => { + vi.useFakeTimers(); + const { wrapper, notify } = mountNavigation(); + const setTimeoutSpy = vi.spyOn(global, "setTimeout"); + + wrapper.vm.reloadApp(); + + expect(notify.info).toHaveBeenCalledWith("Reloading…"); + expect(notify.blockUI).toHaveBeenCalled(); + expect(setTimeoutSpy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("showUsageInfo routes to index files", () => { + const { wrapper, push } = mountNavigation(); + wrapper.vm.showUsageInfo(); + expect(push).toHaveBeenCalledWith({ path: "/index/files" }); + }); + + it("showServerConnectionHelp routes to websockets help", () => { + const { wrapper, push } = mountNavigation(); + wrapper.vm.showServerConnectionHelp(); + expect(push).toHaveBeenCalledWith({ path: "/help/websockets" }); + }); + }); + + describe("indexing state", () => { + it("onIndex sets indexing true for file, folder and indexing events", () => { + const { wrapper } = mountNavigation(); + + wrapper.vm.onIndex("index.file"); + expect(wrapper.vm.indexing).toBe(true); + + wrapper.vm.onIndex("index.folder"); + expect(wrapper.vm.indexing).toBe(true); + + wrapper.vm.onIndex("index.indexing"); + expect(wrapper.vm.indexing).toBe(true); + }); + + it("onIndex sets indexing false when completed", () => { + const { wrapper } = mountNavigation(); + + wrapper.vm.indexing = true; + wrapper.vm.onIndex("index.completed"); + expect(wrapper.vm.indexing).toBe(false); + }); + }); + + describe("logout", () => { + it("onLogout calls session.logout", () => { + const logout = vi.fn(); + const { wrapper, session } = mountNavigation({ + sessionOverrides: { logout }, + }); + + wrapper.vm.onLogout(); + expect(session.logout).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/tests/vitest/component/photo/edit/files.test.js b/frontend/tests/vitest/component/photo/edit/files.test.js new file mode 100644 index 000000000..f3154488a --- /dev/null +++ b/frontend/tests/vitest/component/photo/edit/files.test.js @@ -0,0 +1,325 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { shallowMount, config as VTUConfig } from "@vue/test-utils"; +import PTabPhotoFiles from "component/photo/edit/files.vue"; +import Thumb from "model/thumb"; + +function createFile(overrides = {}) { + return { + UID: "file-uid", + Name: "2018/01/dir:with#hash/file.jpg", + FileType: "jpg", + Error: "", + Primary: false, + Sidecar: false, + Root: "/", + Missing: false, + Pages: 0, + Frames: 0, + Duration: 0, + FPS: 0, + Hash: "hash123", + OriginalName: "file.jpg", + ColorProfile: "", + MainColor: "", + Chroma: 0, + CreatedAt: "2023-01-01T12:00:00Z", + CreatedIn: 1000, + UpdatedAt: "2023-01-02T12:00:00Z", + UpdatedIn: 2000, + thumbnailUrl: vi.fn(() => "/thumb/file.jpg"), + storageInfo: vi.fn(() => "local"), + typeInfo: vi.fn(() => "JPEG"), + sizeInfo: vi.fn(() => "1 MB"), + isAnimated: vi.fn(() => false), + baseName: vi.fn(() => "file.jpg"), + download: vi.fn(), + ...overrides, + }; +} + +function mountPhotoFiles({ + fileOverrides = {}, + featuresOverrides = {}, + experimental = false, + isMobile = false, + modelOverrides = {}, + routerOverrides = {}, +} = {}) { + const baseConfig = VTUConfig.global.mocks.$config || {}; + const baseSettings = baseConfig.getSettings ? baseConfig.getSettings() : { features: {} }; + + const features = { + ...(baseSettings.features || {}), + download: true, + edit: true, + delete: true, + ...featuresOverrides, + }; + + const configMock = { + ...baseConfig, + getSettings: vi.fn(() => ({ + ...baseSettings, + features, + })), + get: vi.fn((key) => { + if (key === "experimental") return experimental; + if (baseConfig.get) { + return baseConfig.get(key); + } + return false; + }), + getTimeZone: baseConfig.getTimeZone || vi.fn(() => "UTC"), + allow: baseConfig.allow || vi.fn(() => true), + values: baseConfig.values || {}, + }; + + const file = createFile(fileOverrides); + + const model = { + fileModels: vi.fn(() => [file]), + deleteFile: vi.fn(() => Promise.resolve()), + unstackFile: vi.fn(), + setPrimaryFile: vi.fn(), + changeFileOrientation: vi.fn(() => Promise.resolve()), + ...modelOverrides, + }; + + const router = { + push: vi.fn(), + resolve: vi.fn((route) => ({ href: route.path || "" })), + ...routerOverrides, + }; + + const lightbox = { + openModels: vi.fn(), + }; + + const baseUtil = VTUConfig.global.mocks.$util || {}; + const util = { + ...baseUtil, + openUrl: vi.fn(), + formatDuration: baseUtil.formatDuration || vi.fn((d) => String(d)), + fileType: baseUtil.fileType || vi.fn((t) => t), + codecName: baseUtil.codecName || vi.fn((c) => c), + formatNs: baseUtil.formatNs || vi.fn((n) => String(n)), + }; + + const wrapper = shallowMount(PTabPhotoFiles, { + props: { + uid: "photo-uid", + }, + global: { + mocks: { + $config: configMock, + $view: { + getData: () => ({ + model, + }), + }, + $router: router, + $lightbox: lightbox, + $util: util, + $isMobile: isMobile, + $gettext: VTUConfig.global.mocks.$gettext || ((s) => s), + $notify: VTUConfig.global.mocks.$notify, + $isRtl: false, + }, + stubs: { + "p-file-delete-dialog": true, + }, + }, + }); + + return { + wrapper, + file, + model, + router, + lightbox, + util, + configMock, + }; +} + +describe("component/photo/edit/files", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("action buttons visibility", () => { + it("shows download, primary, unstack and delete buttons for editable JPG file", () => { + const { wrapper } = mountPhotoFiles({ + fileOverrides: { FileType: "jpg", Primary: false, Sidecar: false, Root: "/", Error: "" }, + featuresOverrides: { download: true, edit: true, delete: true }, + }); + + const file = wrapper.vm.view.model.fileModels()[0]; + const { features, experimental, canAccessPrivate } = wrapper.vm; + + // Download button conditions + expect(features.download).toBe(true); + // Primary button conditions + expect(features.edit && (file.FileType === "jpg" || file.FileType === "png") && !file.Error && !file.Primary).toBe(true); + // Unstack button conditions + expect(features.edit && !file.Sidecar && !file.Error && !file.Primary && file.Root === "/").toBe(true); + // Delete button conditions + expect(features.delete && !file.Primary).toBe(true); + // Browse button should not be visible in this scenario + expect(experimental && canAccessPrivate && file.Primary).toBe(false); + }); + + it("shows browse button only for primary file when experimental and private access are enabled", () => { + const { wrapper } = mountPhotoFiles({ + fileOverrides: { Primary: true, Root: "/", FileType: "jpg" }, + experimental: true, + }); + + const file = wrapper.vm.view.model.fileModels()[0]; + const { features, experimental, canAccessPrivate } = wrapper.vm; + + // Browse button conditions + expect(experimental && canAccessPrivate && file.Primary).toBe(true); + // Other actions should not be available for primary file in this scenario + expect(features.edit && (file.FileType === "jpg" || file.FileType === "png") && !file.Error && !file.Primary).toBe(false); + expect(features.edit && !file.Sidecar && !file.Error && !file.Primary && file.Root === "/").toBe(false); + expect(features.delete && !file.Primary).toBe(false); + }); + }); + + describe("openFile", () => { + it("opens file in lightbox using Thumb.fromFile", () => { + const thumbModel = {}; + const { wrapper, file, model, lightbox } = mountPhotoFiles(); + const thumbSpy = vi.spyOn(Thumb, "fromFile").mockReturnValue(thumbModel); + + wrapper.vm.openFile(file); + + expect(thumbSpy).toHaveBeenCalledWith(model, file); + expect(lightbox.openModels).toHaveBeenCalledWith([thumbModel], 0); + }); + }); + + describe("openFolder", () => { + it("emits close and navigates via router.push on mobile", () => { + const { wrapper, router, util, file } = mountPhotoFiles({ + isMobile: true, + fileOverrides: { Name: "2018/01/file.jpg" }, + }); + + wrapper.vm.openFolder(file); + + expect(wrapper.emitted("close")).toBeTruthy(); + expect(router.push).toHaveBeenCalledWith({ path: "/index/files/2018/01" }); + expect(util.openUrl).not.toHaveBeenCalled(); + }); + + it("opens folder in new tab on desktop with encoded path", () => { + const encodedPath = "/index/files/2018/01/dir%3Awith%23hash"; + const resolve = vi.fn((route) => ({ href: route.path })); + const { wrapper, util, file } = mountPhotoFiles({ + isMobile: false, + routerOverrides: { resolve }, + fileOverrides: { Name: "2018/01/dir:with#hash/file.jpg" }, + }); + + wrapper.vm.openFolder(file); + + expect(resolve).toHaveBeenCalledWith({ path: encodedPath }); + expect(util.openUrl).toHaveBeenCalledWith(encodedPath); + }); + }); + + describe("file actions", () => { + it("downloadFile shows notification and calls file.download", async () => { + const { wrapper, file } = mountPhotoFiles(); + const { default: notifyModule } = await import("common/notify"); + const notifySpy = vi.spyOn(notifyModule, "success"); + + wrapper.vm.downloadFile(file); + + expect(notifySpy).toHaveBeenCalledWith("Downloading…"); + expect(file.download).toHaveBeenCalledTimes(1); + }); + + it("unstackFile and setPrimaryFile delegate to model when file is present", () => { + const unstackSpy = vi.fn(); + const setPrimarySpy = vi.fn(); + const { wrapper, file } = mountPhotoFiles({ + modelOverrides: { + unstackFile: unstackSpy, + setPrimaryFile: setPrimarySpy, + }, + }); + + wrapper.vm.unstackFile(file); + wrapper.vm.setPrimaryFile(file); + + expect(unstackSpy).toHaveBeenCalledWith(file.UID); + expect(setPrimarySpy).toHaveBeenCalledWith(file.UID); + + unstackSpy.mockClear(); + setPrimarySpy.mockClear(); + + wrapper.vm.unstackFile(null); + wrapper.vm.setPrimaryFile(null); + + expect(unstackSpy).not.toHaveBeenCalled(); + expect(setPrimarySpy).not.toHaveBeenCalled(); + }); + + it("confirmDeleteFile calls model.deleteFile and closes dialog", async () => { + const deleteFileSpy = vi.fn(() => Promise.resolve()); + const { wrapper, file } = mountPhotoFiles({ + modelOverrides: { + deleteFile: deleteFileSpy, + }, + }); + + wrapper.vm.deleteFile.dialog = true; + wrapper.vm.deleteFile.file = file; + + await wrapper.vm.confirmDeleteFile(); + + expect(deleteFileSpy).toHaveBeenCalledWith(file.UID); + expect(wrapper.vm.deleteFile.dialog).toBe(false); + expect(wrapper.vm.deleteFile.file).toBeNull(); + }); + }); + + describe("changeOrientation", () => { + it("calls model.changeFileOrientation and shows success message", async () => { + const changeOrientationSpy = vi.fn(() => Promise.resolve()); + const { wrapper, file } = mountPhotoFiles({ + modelOverrides: { + changeFileOrientation: changeOrientationSpy, + }, + }); + + const notifySuccessSpy = vi.spyOn(wrapper.vm.$notify, "success"); + + wrapper.vm.changeOrientation(file); + expect(wrapper.vm.busy).toBe(true); + + await Promise.resolve(); + + expect(changeOrientationSpy).toHaveBeenCalledWith(file); + expect(notifySuccessSpy).toHaveBeenCalledWith("Changes successfully saved"); + expect(wrapper.vm.busy).toBe(false); + }); + + it("does nothing when file is missing", () => { + const changeOrientationSpy = vi.fn(() => Promise.resolve()); + const { wrapper } = mountPhotoFiles({ + modelOverrides: { + changeFileOrientation: changeOrientationSpy, + }, + }); + + wrapper.vm.changeOrientation(null); + + expect(changeOrientationSpy).not.toHaveBeenCalled(); + expect(wrapper.vm.busy).toBe(false); + }); + }); +}); diff --git a/frontend/tests/vitest/component/photo/edit/labels.test.js b/frontend/tests/vitest/component/photo/edit/labels.test.js new file mode 100644 index 000000000..ada18747a --- /dev/null +++ b/frontend/tests/vitest/component/photo/edit/labels.test.js @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { shallowMount, config as VTUConfig } from "@vue/test-utils"; +import PTabPhotoLabels from "component/photo/edit/labels.vue"; +import Thumb from "model/thumb"; + +function mountPhotoLabels({ modelOverrides = {}, routerOverrides = {}, utilOverrides = {}, notifyOverrides = {}, viewHasModel = true } = {}) { + const baseConfig = VTUConfig.global.mocks.$config || {}; + const baseNotify = VTUConfig.global.mocks.$notify || {}; + const baseUtil = VTUConfig.global.mocks.$util || {}; + + const model = viewHasModel + ? { + removeLabel: vi.fn(() => Promise.resolve()), + addLabel: vi.fn(() => Promise.resolve()), + activateLabel: vi.fn(), + ...modelOverrides, + } + : null; + + const router = { + push: vi.fn(() => Promise.resolve()), + ...routerOverrides, + }; + + const util = { + ...baseUtil, + sourceName: vi.fn((s) => `source-${s}`), + ...utilOverrides, + }; + + const notify = { + ...baseNotify, + success: baseNotify.success || vi.fn(), + error: baseNotify.error || vi.fn(), + warn: baseNotify.warn || vi.fn(), + ...notifyOverrides, + }; + + const lightbox = { + openModels: vi.fn(), + }; + + const wrapper = shallowMount(PTabPhotoLabels, { + props: { + uid: "photo-uid", + }, + global: { + mocks: { + $config: baseConfig, + $view: { + getData: () => ({ + model, + }), + }, + $router: router, + $util: util, + $notify: notify, + $lightbox: lightbox, + $gettext: VTUConfig.global.mocks.$gettext || ((s) => s), + $isRtl: false, + }, + }, + }); + + return { + wrapper, + model, + router, + util, + notify, + lightbox, + }; +} + +describe("component/photo/edit/labels", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("sourceName", () => { + it("delegates to $util.sourceName", () => { + const sourceNameSpy = vi.fn(() => "Human"); + const { wrapper, util } = mountPhotoLabels({ + utilOverrides: { sourceName: sourceNameSpy }, + }); + + const result = wrapper.vm.sourceName("auto"); + + expect(sourceNameSpy).toHaveBeenCalledWith("auto"); + expect(result).toBe("Human"); + // Ensure util on instance is the same object so we actually spied on the right method + expect(wrapper.vm.$util).toBe(util); + }); + }); + + describe("removeLabel", () => { + it("does nothing when label is missing", () => { + const removeSpy = vi.fn(() => Promise.resolve()); + const { wrapper } = mountPhotoLabels({ + modelOverrides: { removeLabel: removeSpy }, + }); + + wrapper.vm.removeLabel(null); + + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it("calls model.removeLabel and shows success message", async () => { + const removeSpy = vi.fn(() => Promise.resolve()); + const notifySuccessSpy = vi.fn(); + const { wrapper } = mountPhotoLabels({ + modelOverrides: { removeLabel: removeSpy }, + notifyOverrides: { success: notifySuccessSpy }, + }); + + const label = { ID: 5, Name: "Cat" }; + + wrapper.vm.removeLabel(label); + await Promise.resolve(); + + expect(removeSpy).toHaveBeenCalledWith(5); + expect(notifySuccessSpy).toHaveBeenCalledWith("removed Cat"); + }); + }); + + describe("addLabel", () => { + it("does nothing when newLabel is empty", () => { + const addSpy = vi.fn(() => Promise.resolve()); + const { wrapper } = mountPhotoLabels({ + modelOverrides: { addLabel: addSpy }, + }); + + wrapper.vm.newLabel = ""; + wrapper.vm.addLabel(); + + expect(addSpy).not.toHaveBeenCalled(); + }); + + it("calls model.addLabel, shows success message and clears newLabel", async () => { + const addSpy = vi.fn(() => Promise.resolve()); + const notifySuccessSpy = vi.fn(); + const { wrapper } = mountPhotoLabels({ + modelOverrides: { addLabel: addSpy }, + notifyOverrides: { success: notifySuccessSpy }, + }); + + wrapper.vm.newLabel = "Dog"; + wrapper.vm.addLabel(); + + await Promise.resolve(); + + expect(addSpy).toHaveBeenCalledWith("Dog"); + expect(notifySuccessSpy).toHaveBeenCalledWith("added Dog"); + expect(wrapper.vm.newLabel).toBe(""); + }); + }); + + describe("activateLabel", () => { + it("does nothing when label is missing", () => { + const activateSpy = vi.fn(); + const { wrapper } = mountPhotoLabels({ + modelOverrides: { activateLabel: activateSpy }, + }); + + wrapper.vm.activateLabel(null); + + expect(activateSpy).not.toHaveBeenCalled(); + }); + + it("delegates to model.activateLabel for valid label", () => { + const activateSpy = vi.fn(); + const { wrapper } = mountPhotoLabels({ + modelOverrides: { activateLabel: activateSpy }, + }); + + const label = { ID: 7, Name: "Summer" }; + + wrapper.vm.activateLabel(label); + + expect(activateSpy).toHaveBeenCalledWith(7); + }); + }); + + describe("searchLabel", () => { + it("navigates to all route with label query and emits close", () => { + const push = vi.fn(() => Promise.resolve()); + const { wrapper, router } = mountPhotoLabels({ + routerOverrides: { push }, + }); + + const label = { Slug: "animals" }; + + wrapper.vm.searchLabel(label); + + expect(router.push).toHaveBeenCalledWith({ + name: "all", + query: { q: "label:animals" }, + }); + expect(wrapper.emitted("close")).toBeTruthy(); + }); + }); + + describe("openPhoto", () => { + it("opens photo in lightbox using Thumb.fromPhotos when model is present", () => { + const thumbModel = {}; + const fromPhotosSpy = vi.spyOn(Thumb, "fromPhotos").mockReturnValue([thumbModel]); + + const { wrapper, model, lightbox } = mountPhotoLabels(); + + wrapper.vm.openPhoto(); + + expect(fromPhotosSpy).toHaveBeenCalledWith([model]); + expect(lightbox.openModels).toHaveBeenCalledWith([thumbModel], 0); + }); + + it("does nothing when model is missing", () => { + const fromPhotosSpy = vi.spyOn(Thumb, "fromPhotos").mockReturnValue([]); + const { wrapper, lightbox } = mountPhotoLabels({ viewHasModel: false }); + + wrapper.vm.openPhoto(); + + expect(fromPhotosSpy).not.toHaveBeenCalled(); + expect(lightbox.openModels).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/tests/vitest/component/photo/toolbar.test.js b/frontend/tests/vitest/component/photo/toolbar.test.js new file mode 100644 index 000000000..d4f136d78 --- /dev/null +++ b/frontend/tests/vitest/component/photo/toolbar.test.js @@ -0,0 +1,346 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { shallowMount, config as VTUConfig } from "@vue/test-utils"; +import PPhotoToolbar from "component/photo/toolbar.vue"; +import * as contexts from "options/contexts"; +import "../../fixtures"; + +function mountToolbar({ + context = contexts.Photos, + embedded = false, + filter = { + q: "", + country: "", + camera: 0, + year: 0, + month: 0, + color: "", + label: "", + order: "newest", + latlng: null, + }, + staticFilter = {}, + settings = { view: "mosaic" }, + featuresOverrides = {}, + searchOverrides = {}, + allowMock, + refresh = vi.fn(), + updateFilter = vi.fn(), + updateQuery = vi.fn(), + eventPublish, + routerOverrides = {}, + clipboard, + openUrlSpy, +} = {}) { + const baseConfig = VTUConfig.global.mocks.$config; + const baseSettings = baseConfig.getSettings ? baseConfig.getSettings() : { features: {} }; + + const features = { + ...(baseSettings.features || {}), + upload: true, + delete: true, + settings: true, + ...featuresOverrides, + }; + + const search = { + listView: true, + ...searchOverrides, + }; + + const configMock = { + ...baseConfig, + getSettings: vi.fn(() => ({ + ...baseSettings, + features, + search, + })), + allow: allowMock || vi.fn(() => true), + values: { + countries: [], + cameras: [], + categories: [], + ...(baseConfig.values || {}), + }, + }; + + const publish = eventPublish || vi.fn(); + + const router = { + push: vi.fn(), + resolve: vi.fn((route) => ({ + href: `/library/${route.name || "browse"}`, + })), + ...routerOverrides, + }; + + const clipboardMock = + clipboard || + { + clear: vi.fn(), + }; + + const baseUtil = VTUConfig.global.mocks.$util || {}; + const util = { + ...baseUtil, + openUrl: openUrlSpy || vi.fn(), + }; + + const wrapper = shallowMount(PPhotoToolbar, { + props: { + context, + filter, + staticFilter, + settings, + embedded, + refresh, + updateFilter, + updateQuery, + }, + global: { + mocks: { + $config: configMock, + $session: { isSuperAdmin: vi.fn(() => false) }, + $event: { + ...(VTUConfig.global.mocks.$event || {}), + publish, + }, + $router: router, + $clipboard: clipboardMock, + $util: util, + }, + stubs: { + PActionMenu: true, + PConfirmDialog: true, + }, + }, + }); + + return { + wrapper, + configMock, + publish, + router, + clipboard: clipboardMock, + refresh, + updateFilter, + updateQuery, + openUrl: util.openUrl, + }; +} + +describe("component/photo/toolbar", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("menuActions", () => { + it("shows upload and docs actions for photos context when upload is allowed", () => { + const { wrapper } = mountToolbar(); + + const actions = wrapper.vm.menuActions(); + const byName = (name) => actions.find((a) => a.name === name); + + const refreshAction = byName("refresh"); + const uploadAction = byName("upload"); + const docsAction = byName("docs"); + const troubleshootingAction = byName("troubleshooting"); + + expect(refreshAction).toBeDefined(); + expect(uploadAction).toBeDefined(); + expect(docsAction).toBeDefined(); + expect(troubleshootingAction).toBeDefined(); + + expect(refreshAction.visible).toBe(true); + expect(uploadAction.visible).toBe(true); + expect(docsAction.visible).toBe(true); + expect(troubleshootingAction.visible).toBe(false); + }); + + it("hides upload action in archive and hidden contexts", () => { + const { wrapper: archiveWrapper } = mountToolbar({ context: contexts.Archive }); + const archiveActions = archiveWrapper.vm.menuActions(); + const archiveUpload = archiveActions.find((a) => a.name === "upload"); + const archiveDocs = archiveActions.find((a) => a.name === "docs"); + const archiveTroubleshooting = archiveActions.find((a) => a.name === "troubleshooting"); + + expect(archiveUpload).toBeDefined(); + expect(archiveDocs).toBeDefined(); + expect(archiveTroubleshooting).toBeDefined(); + + expect(archiveUpload.visible).toBe(false); + expect(archiveDocs.visible).toBe(true); + expect(archiveTroubleshooting.visible).toBe(false); + + const { wrapper: hiddenWrapper } = mountToolbar({ context: contexts.Hidden }); + const hiddenActions = hiddenWrapper.vm.menuActions(); + const hiddenUpload = hiddenActions.find((a) => a.name === "upload"); + const hiddenDocs = hiddenActions.find((a) => a.name === "docs"); + const hiddenTroubleshooting = hiddenActions.find((a) => a.name === "troubleshooting"); + + expect(hiddenUpload).toBeDefined(); + expect(hiddenDocs).toBeDefined(); + expect(hiddenTroubleshooting).toBeDefined(); + + expect(hiddenUpload.visible).toBe(false); + expect(hiddenDocs.visible).toBe(false); + expect(hiddenTroubleshooting.visible).toBe(true); + }); + + it("invokes refresh prop and publishes upload dialog events on click", () => { + const refresh = vi.fn(); + const publish = vi.fn(); + const { wrapper } = mountToolbar({ refresh, eventPublish: publish }); + + const actions = wrapper.vm.menuActions(); + const refreshAction = actions.find((a) => a.name === "refresh"); + const uploadAction = actions.find((a) => a.name === "upload"); + + expect(refreshAction).toBeDefined(); + expect(uploadAction).toBeDefined(); + + refreshAction.click(); + expect(refresh).toHaveBeenCalledTimes(1); + + uploadAction.click(); + expect(publish).toHaveBeenCalledWith("dialog.upload"); + }); + }); + + describe("view handling", () => { + it("setView keeps list when listView search setting is enabled", () => { + const refresh = vi.fn(); + const { wrapper } = mountToolbar({ + refresh, + searchOverrides: { listView: true }, + }); + + wrapper.vm.expanded = true; + wrapper.vm.setView("list"); + + expect(refresh).toHaveBeenCalledWith({ view: "list" }); + expect(wrapper.vm.expanded).toBe(false); + }); + + it("setView falls back to mosaic when list view is disabled", () => { + const refresh = vi.fn(); + const { wrapper } = mountToolbar({ + refresh, + searchOverrides: { listView: false }, + }); + + wrapper.vm.expanded = true; + wrapper.vm.setView("list"); + + expect(refresh).toHaveBeenCalledWith({ view: "mosaic" }); + expect(wrapper.vm.expanded).toBe(false); + }); + }); + + describe("sortOptions", () => { + it("provides archive-specific sort options for archive context", () => { + const { wrapper } = mountToolbar({ context: contexts.Archive }); + + const values = wrapper.vm.sortOptions.map((o) => o.value); + expect(values).toContain("archived"); + expect(values).not.toContain("similar"); + expect(values).not.toContain("relevance"); + }); + + it("includes similarity and relevance options in default photos context", () => { + const { wrapper } = mountToolbar({ context: contexts.Photos }); + + const values = wrapper.vm.sortOptions.map((o) => o.value); + expect(values).toContain("similar"); + expect(values).toContain("relevance"); + }); + }); + + describe("delete actions", () => { + it("deleteAll opens confirmation dialog only when delete is allowed", () => { + const allowAll = vi.fn(() => true); + const { wrapper } = mountToolbar({ + allowMock: allowAll, + featuresOverrides: { delete: true }, + }); + + wrapper.vm.deleteAll(); + expect(wrapper.vm.dialog.delete).toBe(true); + + const denyDelete = vi.fn((resource, action) => { + if (resource === "photos" && action === "delete") { + return false; + } + return true; + }); + const { wrapper: noDeleteWrapper } = mountToolbar({ + allowMock: denyDelete, + featuresOverrides: { delete: true }, + }); + + noDeleteWrapper.vm.deleteAll(); + expect(noDeleteWrapper.vm.dialog.delete).toBe(false); + }); + + it("batchDelete posts delete request and clears clipboard on success", async () => { + const clipboard = { clear: vi.fn() }; + const { default: $notify } = await import("common/notify"); + const { default: $api } = await import("common/api"); + + const postSpy = vi.spyOn($api, "post").mockResolvedValue({ data: {} }); + const notifySpy = vi.spyOn($notify, "success"); + + const { wrapper } = mountToolbar({ + clipboard, + featuresOverrides: { delete: true }, + }); + + wrapper.vm.dialog.delete = true; + + await wrapper.vm.batchDelete(); + + expect(postSpy).toHaveBeenCalledWith("batch/photos/delete", { all: true }); + expect(wrapper.vm.dialog.delete).toBe(false); + expect(notifySpy).toHaveBeenCalledWith("Permanently deleted"); + expect(clipboard.clear).toHaveBeenCalledTimes(1); + }); + }); + + describe("browse actions", () => { + it("clearLocation navigates back to browse list", () => { + const push = vi.fn(); + const { wrapper } = mountToolbar({ + routerOverrides: { + push, + }, + }); + + wrapper.vm.clearLocation(); + expect(push).toHaveBeenCalledWith({ name: "browse" }); + }); + + it("onBrowse opens places browse in new tab on desktop", () => { + const push = vi.fn(); + const openUrlSpy = vi.fn(); + + const staticFilter = { q: "country:US" }; + const { wrapper, router, openUrl } = mountToolbar({ + staticFilter, + routerOverrides: { + push, + resolve: vi.fn((route) => ({ + href: `/library/${route.name}?q=${route.query?.q || ""}`, + })), + }, + openUrlSpy, + }); + + wrapper.vm.onBrowse(); + + expect(push).not.toHaveBeenCalled(); + expect(router.resolve).toHaveBeenCalledWith({ name: "places_browse", query: staticFilter }); + expect(openUrl).toHaveBeenCalledWith("/library/places_browse?q=country:US"); + }); + }); +}); + +