Tests: Improve test isolation and cleanup in vitest suites (#5387)

* Tests: Improve test isolation and cleanup in vitest suites

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

* Tests: Remove unused notification spies from batch edit component tests

* Tests: update Vitest `$notify` mock to use `vi.fn()` and include `info` method.
This commit is contained in:
Ömer Duran 2025-12-30 13:03:10 +01:00 committed by GitHub
parent 1b09c2b8e3
commit cdf00beda4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1377 additions and 79 deletions

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import Config from "common/config";
import StorageShim from "node-storage-shim";
@ -11,7 +11,31 @@ const createTestConfig = () => {
return new Config(new StorageShim(), values);
};
const resetThemesToDefault = () => {
themes.SetOptions([
{
text: "Default",
value: "default",
disabled: false,
},
]);
themes.Set("default", {
name: "default",
title: "Default",
colors: {},
variables: {},
});
};
describe("common/config", () => {
beforeEach(() => {
resetThemesToDefault();
});
afterEach(() => {
resetThemesToDefault();
});
it("should get all config values", () => {
const storage = new StorageShim();
const values = { siteTitle: "Foo", name: "testConfig", year: "2300" };
@ -116,42 +140,12 @@ describe("common/config", () => {
variables: {},
};
themes.SetOptions([
{
text: "Default",
value: "default",
disabled: false,
},
]);
themes.Set("default", {
name: "default",
title: "Default",
colors: {},
variables: {},
});
themes.Assign([forcedTheme]);
cfg.setTheme("default");
expect(cfg.themeName).toBe("portal-forced");
expect(cfg.theme.colors.background).toBe("#111111");
themes.Remove("portal-forced");
themes.SetOptions([
{
text: "Default",
value: "default",
disabled: false,
},
]);
themes.Set("default", {
name: "default",
title: "Default",
colors: {},
variables: {},
});
});
it("should return app edition", () => {

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

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { shallowMount } from "@vue/test-utils";
import { shallowMount, config as VTUConfig } from "@vue/test-utils";
import { nextTick } from "vue";
import PPhotoBatchEdit from "component/photo/batch-edit.vue";
import * as contexts from "options/contexts";
@ -146,20 +146,9 @@ describe("component/photo/batch-edit", () => {
},
global: {
mocks: {
$notify: {
success: vi.fn(),
error: vi.fn(),
},
$lightbox: {
openView: vi.fn(),
},
$event: {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
$config: {
feature: vi.fn().mockReturnValue(true),
},
$vuetify: { display: { mdAndDown: false } },
},
stubs: {
@ -202,6 +191,7 @@ describe("component/photo/batch-edit", () => {
});
afterEach(() => {
vi.restoreAllMocks();
if (wrapper) {
wrapper.unmount();
}
@ -395,8 +385,6 @@ describe("component/photo/batch-edit", () => {
expect(ctx.allowEdit).toBe(false);
expect(ctx.allowSelect).toBe(false);
expect(ctx.context).toBe(contexts.BatchEdit);
spy.mockRestore();
});
it("should clamp invalid index to first photo", () => {
@ -407,8 +395,6 @@ describe("component/photo/batch-edit", () => {
expect(ctx.index).toBe(0);
expect(ctx.allowSelect).toBe(false);
spy.mockRestore();
});
});

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

View file

@ -1,8 +1,18 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Album, BatchSize } from "model/album";
describe("model/album", () => {
let originalBatchSize;
beforeEach(() => {
originalBatchSize = Album.batchSize();
});
afterEach(() => {
Album.setBatchSize(originalBatchSize);
});
it("should get route view", () => {
const values = { ID: 5, Title: "Christmas 2019", Slug: "christmas-2019" };
const album = new Album(values);
@ -312,7 +322,6 @@ describe("model/album", () => {
expect(Album.batchSize()).toBe(BatchSize);
Album.setBatchSize(30);
expect(Album.batchSize()).toBe(30);
Album.setBatchSize(BatchSize);
});
it("should like album", () => {

View file

@ -1,8 +1,18 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Face, BatchSize } from "model/face";
describe("model/face", () => {
let originalBatchSize;
beforeEach(() => {
originalBatchSize = Face.batchSize();
});
afterEach(() => {
Face.setBatchSize(originalBatchSize);
});
it("should get face defaults", () => {
const values = {};
const face = new Face(values);
@ -146,7 +156,6 @@ describe("model/face", () => {
expect(Face.batchSize()).toBe(BatchSize);
Face.setBatchSize(30);
expect(Face.batchSize()).toBe(30);
Face.setBatchSize(BatchSize);
});
it("should get collection resource", () => {

View file

@ -1,8 +1,18 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Label, BatchSize } from "model/label";
describe("model/label", () => {
let originalBatchSize;
beforeEach(() => {
originalBatchSize = Label.batchSize();
});
afterEach(() => {
Label.setBatchSize(originalBatchSize);
});
it("should get route view", () => {
const values = { ID: 5, UID: "ABC123", Name: "Black Cat", Slug: "black-cat" };
const label = new Label(values);
@ -15,7 +25,6 @@ describe("model/label", () => {
expect(Label.batchSize()).toBe(BatchSize);
Label.setBatchSize(30);
expect(Label.batchSize()).toBe(30);
Label.setBatchSize(BatchSize);
});
it("should return classes", () => {

View file

@ -1,8 +1,18 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Marker, BatchSize } from "model/marker";
describe("model/marker", () => {
let originalBatchSize;
beforeEach(() => {
originalBatchSize = Marker.batchSize();
});
afterEach(() => {
Marker.setBatchSize(originalBatchSize);
});
it("should get marker defaults", () => {
const values = { FileUID: "fghjojp" };
const marker = new Marker(values);
@ -193,7 +203,6 @@ describe("model/marker", () => {
expect(Marker.batchSize()).toBe(BatchSize);
Marker.setBatchSize(30);
expect(Marker.batchSize()).toBe(30);
Marker.setBatchSize(BatchSize);
});
it("should get collection resource", () => {

View file

@ -344,21 +344,21 @@ describe("model/photo", () => {
expect(result5).toBe("July 2012");
});
it("should test whether photo has location", () => {
it("should report hasLocation true for non-zero coordinates", () => {
const values = { ID: 5, Title: "Crazy Cat", Lat: 36.442881666666665, Lng: 28.229493333333334 };
const photo = new Photo(values);
const result = photo.hasLocation();
expect(result).toBe(true);
});
it("should test whether photo has location", () => {
it("should report hasLocation false for zero coordinates", () => {
const values = { ID: 5, Title: "Crazy Cat", Lat: 0, Lng: 0 };
const photo = new Photo(values);
const result = photo.hasLocation();
expect(result).toBe(false);
});
it("should get location", () => {
it("should get primary location label with country", () => {
const values = {
ID: 5,
Title: "Crazy Cat",
@ -372,7 +372,7 @@ describe("model/photo", () => {
expect(result).toBe("Cape Point, South Africa");
});
it("should get location", () => {
it("should get full location with state and country", () => {
const values = {
ID: 5,
Title: "Crazy Cat",
@ -389,7 +389,7 @@ describe("model/photo", () => {
expect(result).toBe("Cape Point, State, South Africa");
});
it("should get location", () => {
it("should return Unknown when country name does not match", () => {
const values = {
ID: 5,
Title: "Crazy Cat",
@ -405,14 +405,14 @@ describe("model/photo", () => {
expect(result).toBe("Unknown");
});
it("should get location", () => {
it("should return Unknown when only country name is set", () => {
const values = { ID: 5, Title: "Crazy Cat", CountryName: "Africa", PlaceCity: "Cape Town" };
const photo = new Photo(values);
const result = photo.locationInfo();
expect(result).toBe("Unknown");
});
it("should get camera", () => {
it("should get camera from model and file camera data", () => {
const values = { ID: 5, Title: "Crazy Cat", CameraModel: "EOSD10", CameraMake: "Canon" };
const photo = new Photo(values);
const result = photo.getCamera();
@ -438,7 +438,7 @@ describe("model/photo", () => {
expect(photo2.getCamera()).toBe("Canon abc");
});
it("should get camera", () => {
it("should return Unknown when camera info is missing", () => {
const values = { ID: 5, Title: "Crazy Cat" };
const photo = new Photo(values);
const result = photo.getCamera();

View file

@ -1,8 +1,18 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import { Subject, BatchSize } from "model/subject";
describe("model/subject", () => {
let originalBatchSize;
beforeEach(() => {
originalBatchSize = Subject.batchSize();
});
afterEach(() => {
Subject.setBatchSize(originalBatchSize);
});
it("should get face defaults", () => {
const values = {};
const subject = new Subject(values);
@ -238,7 +248,6 @@ describe("model/subject", () => {
expect(Subject.batchSize()).toBe(BatchSize);
Subject.setBatchSize(30);
expect(Subject.batchSize()).toBe(30);
Subject.setBatchSize(BatchSize);
});
it("should get collection resource", () => {

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import "../fixtures";
import * as options from "options/options";
import {
@ -25,6 +25,15 @@ import {
} from "options/options";
describe("options/options", () => {
let originalDefaultLocale;
beforeEach(() => {
originalDefaultLocale = options.DefaultLocale;
});
afterEach(() => {
SetDefaultLocale(originalDefaultLocale);
});
it("should get timezones", () => {
const timezones = options.TimeZones();
expect(timezones[0].ID).toBe("Local");
@ -93,13 +102,10 @@ describe("options/options", () => {
});
it("should set default locale", () => {
// Assuming DefaultLocale is exported and mutable for testing purposes
// Initial state check might depend on test execution order, so we control it here.
SetDefaultLocale("en"); // Ensure starting state
SetDefaultLocale("en");
expect(options.DefaultLocale).toBe("en");
SetDefaultLocale("de");
expect(options.DefaultLocale).toBe("de");
SetDefaultLocale("en"); // Reset for other tests
});
it("should return default when no locale is provided", () => {

View file

@ -30,9 +30,9 @@ if (typeof global.ResizeObserver === "undefined") {
constructor(callback) {
this.callback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
observe() { }
unobserve() { }
disconnect() { }
};
}
@ -54,22 +54,22 @@ config.global.mocks = {
$event: {
subscribe: () => "sub-id",
subscribeOnce: () => "sub-id-once",
unsubscribe: () => {},
publish: () => {},
unsubscribe: () => { },
publish: () => { },
},
$view: {
enter: () => {},
leave: () => {},
enter: () => { },
leave: () => { },
isActive: () => true,
},
$notify: { success: () => {}, error: () => {}, warn: () => {} },
$notify: { success: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
$fullscreen: {
isSupported: () => true,
isEnabled: () => false,
request: () => Promise.resolve(),
exit: () => Promise.resolve(),
},
$clipboard: { selection: [], has: () => false, toggle: () => {} },
$clipboard: { selection: [], has: () => false, toggle: () => { } },
$util: {
hasTouch: () => false,
encodeHTML: (s) => s,