Tests: Add component tests for navigation, photo toolbar, and file editing

This commit is contained in:
Ömer Duran 2025-12-19 00:43:08 +01:00
parent 4360fff83c
commit b91ac2e67e
No known key found for this signature in database
GPG key ID: 2550B0D579890013
4 changed files with 1267 additions and 0 deletions

View file

@ -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: "<a><slot /></a>" },
},
},
});
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();
});
});
});

View file

@ -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);
});
});
});

View file

@ -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();
});
});
});

View file

@ -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");
});
});
});