mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Add web test setup (#597)
* Extract logic from Albums page - move "albums" page logic to `albums-bloc` - add types to AlbumCard custom events * Implement some album-bloc unit-tests - add libraries for testing - add album factory - changes in albums-bloc API * Add rest of albums-bloc test Cleanup and remove console logs * Refactor `isShowContextMenu` writable to derived
This commit is contained in:
		
							
								
								
									
										3
									
								
								web/babel.config.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								web/babel.config.cjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| module.exports = { | ||||
| 	presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'] | ||||
| }; | ||||
							
								
								
									
										201
									
								
								web/jest.config.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								web/jest.config.mjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| /* | ||||
|  * For a detailed explanation regarding each configuration property, visit: | ||||
|  * https://jestjs.io/docs/configuration | ||||
|  */ | ||||
|  | ||||
| export default { | ||||
| 	// All imported modules in your tests should be mocked automatically | ||||
| 	// automock: false, | ||||
|  | ||||
| 	// Stop running tests after `n` failures | ||||
| 	// bail: 0, | ||||
|  | ||||
| 	// The directory where Jest should store its cached dependency information | ||||
| 	// cacheDirectory: "/private/var/folders/6n/31wm28711gzbt3gzsxhzxx500000gn/T/jest_dx", | ||||
|  | ||||
| 	// Automatically clear mock calls, instances, contexts and results before every test | ||||
| 	clearMocks: true, | ||||
|  | ||||
| 	// Indicates whether the coverage information should be collected while executing the test | ||||
| 	// collectCoverage: false, | ||||
|  | ||||
| 	// An array of glob patterns indicating a set of files for which coverage information should be collected | ||||
| 	// collectCoverageFrom: undefined, | ||||
|  | ||||
| 	// The directory where Jest should output its coverage files | ||||
| 	// coverageDirectory: undefined, | ||||
|  | ||||
| 	// An array of regexp pattern strings used to skip coverage collection | ||||
| 	// coveragePathIgnorePatterns: [ | ||||
| 	//   "/node_modules/" | ||||
| 	// ], | ||||
|  | ||||
| 	// Indicates which provider should be used to instrument code for coverage | ||||
| 	coverageProvider: 'v8', | ||||
|  | ||||
| 	// A list of reporter names that Jest uses when writing coverage reports | ||||
| 	// coverageReporters: [ | ||||
| 	//   "json", | ||||
| 	//   "text", | ||||
| 	//   "lcov", | ||||
| 	//   "clover" | ||||
| 	// ], | ||||
|  | ||||
| 	// An object that configures minimum threshold enforcement for coverage results | ||||
| 	// coverageThreshold: undefined, | ||||
|  | ||||
| 	// A path to a custom dependency extractor | ||||
| 	// dependencyExtractor: undefined, | ||||
|  | ||||
| 	// Make calling deprecated APIs throw helpful error messages | ||||
| 	// errorOnDeprecated: false, | ||||
|  | ||||
| 	// The default configuration for fake timers | ||||
| 	// fakeTimers: { | ||||
| 	//   "enableGlobally": false | ||||
| 	// }, | ||||
|  | ||||
| 	// Force coverage collection from ignored files using an array of glob patterns | ||||
| 	// forceCoverageMatch: [], | ||||
|  | ||||
| 	// A path to a module which exports an async function that is triggered once before all test suites | ||||
| 	// globalSetup: undefined, | ||||
|  | ||||
| 	// A path to a module which exports an async function that is triggered once after all test suites | ||||
| 	// globalTeardown: undefined, | ||||
|  | ||||
| 	// A set of global variables that need to be available in all test environments | ||||
| 	// globals: {}, | ||||
|  | ||||
| 	// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. | ||||
| 	// maxWorkers: "50%", | ||||
|  | ||||
| 	// An array of directory names to be searched recursively up from the requiring module's location | ||||
| 	// moduleDirectories: [ | ||||
| 	//   "node_modules" | ||||
| 	// ], | ||||
|  | ||||
| 	// An array of file extensions your modules use | ||||
| 	// moduleFileExtensions: [ | ||||
| 	//   "js", | ||||
| 	//   "mjs", | ||||
| 	//   "cjs", | ||||
| 	//   "jsx", | ||||
| 	//   "ts", | ||||
| 	//   "tsx", | ||||
| 	//   "json", | ||||
| 	//   "node" | ||||
| 	// ], | ||||
|  | ||||
| 	// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module | ||||
| 	moduleNameMapper: { | ||||
| 		'^\\$lib(.*)$': '<rootDir>/src/lib$1', | ||||
| 		'^\\@api(.*)$': '<rootDir>/src/api$1', | ||||
| 		'^\\@test-data(.*)$': '<rootDir>/src/test-data$1' | ||||
| 	}, | ||||
|  | ||||
| 	// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader | ||||
| 	// modulePathIgnorePatterns: [], | ||||
|  | ||||
| 	// Activates notifications for test results | ||||
| 	// notify: false, | ||||
|  | ||||
| 	// An enum that specifies notification mode. Requires { notify: true } | ||||
| 	// notifyMode: "failure-change", | ||||
|  | ||||
| 	// A preset that is used as a base for Jest's configuration | ||||
| 	// preset: undefined, | ||||
|  | ||||
| 	// Run tests from one or more projects | ||||
| 	// projects: undefined, | ||||
|  | ||||
| 	// Use this configuration option to add custom reporters to Jest | ||||
| 	// reporters: undefined, | ||||
|  | ||||
| 	// Automatically reset mock state before every test | ||||
| 	// resetMocks: false, | ||||
|  | ||||
| 	// Reset the module registry before running each individual test | ||||
| 	// resetModules: false, | ||||
|  | ||||
| 	// A path to a custom resolver | ||||
| 	// resolver: undefined, | ||||
|  | ||||
| 	// Automatically restore mock state and implementation before every test | ||||
| 	// restoreMocks: false, | ||||
|  | ||||
| 	// The root directory that Jest should scan for tests and modules within | ||||
| 	// rootDir: undefined, | ||||
|  | ||||
| 	// A list of paths to directories that Jest should use to search for files in | ||||
| 	// roots: [ | ||||
| 	//   "<rootDir>" | ||||
| 	// ], | ||||
|  | ||||
| 	// Allows you to use a custom runner instead of Jest's default test runner | ||||
| 	// runner: "jest-runner", | ||||
|  | ||||
| 	// The paths to modules that run some code to configure or set up the testing environment before each test | ||||
| 	// setupFiles: [], | ||||
|  | ||||
| 	// A list of paths to modules that run some code to configure or set up the testing framework before each test | ||||
| 	// setupFilesAfterEnv: [], | ||||
|  | ||||
| 	// The number of seconds after which a test is considered as slow and reported as such in the results. | ||||
| 	// slowTestThreshold: 5, | ||||
|  | ||||
| 	// A list of paths to snapshot serializer modules Jest should use for snapshot testing | ||||
| 	// snapshotSerializers: [], | ||||
|  | ||||
| 	// The test environment that will be used for testing | ||||
| 	testEnvironment: 'jsdom', | ||||
|  | ||||
| 	// Options that will be passed to the testEnvironment | ||||
| 	// testEnvironmentOptions: {}, | ||||
|  | ||||
| 	// Adds a location field to test results | ||||
| 	// testLocationInResults: false, | ||||
|  | ||||
| 	// The glob patterns Jest uses to detect test files | ||||
| 	// testMatch: [ | ||||
| 	//   "**/__tests__/**/*.[jt]s?(x)", | ||||
| 	//   "**/?(*.)+(spec|test).[tj]s?(x)" | ||||
| 	// ], | ||||
|  | ||||
| 	// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped | ||||
| 	// testPathIgnorePatterns: [ | ||||
| 	//   "/node_modules/" | ||||
| 	// ], | ||||
|  | ||||
| 	// The regexp pattern or array of patterns that Jest uses to detect test files | ||||
| 	// testRegex: [], | ||||
|  | ||||
| 	// This option allows the use of a custom results processor | ||||
| 	// testResultsProcessor: undefined, | ||||
|  | ||||
| 	// This option allows use of a custom test runner | ||||
| 	// testRunner: "jest-circus/runner", | ||||
|  | ||||
| 	// A map from regular expressions to paths to transformers | ||||
| 	transform: { | ||||
| 		'\\.[jt]sx?$': 'babel-jest' | ||||
| 	} | ||||
|  | ||||
| 	// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation | ||||
| 	// transformIgnorePatterns: [ | ||||
| 	//   "/node_modules/", | ||||
| 	//   "\\.pnp\\.[^\\/]+$" | ||||
| 	// ], | ||||
|  | ||||
| 	// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them | ||||
| 	// unmockedModulePathPatterns: undefined, | ||||
|  | ||||
| 	// Indicates whether each individual test should be reported during the run | ||||
| 	// verbose: undefined, | ||||
|  | ||||
| 	// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode | ||||
| 	// watchPathIgnorePatterns: [], | ||||
|  | ||||
| 	// Whether to use watchman for file crawling | ||||
| 	// watchman: true, | ||||
| }; | ||||
							
								
								
									
										8577
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8577
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -9,25 +9,17 @@ | ||||
| 		"check": "svelte-check --tsconfig ./tsconfig.json", | ||||
| 		"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", | ||||
| 		"lint": "prettier --check --plugin-search-dir=. . && eslint .", | ||||
| 		"format": "prettier --write --plugin-search-dir=. ." | ||||
| 		"format": "prettier --write --plugin-search-dir=. .", | ||||
| 		"test": "jest", | ||||
| 		"test:watch": "npm test -- --watch" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@babel/preset-env": "^7.19.0", | ||||
| 		"@babel/preset-typescript": "^7.18.6", | ||||
| 		"@faker-js/faker": "^7.5.0", | ||||
| 		"@sveltejs/adapter-auto": "next", | ||||
| 		"@sveltejs/kit": "next", | ||||
| 		"@typescript-eslint/eslint-plugin": "^5.27.0", | ||||
| 		"@typescript-eslint/parser": "^5.27.0", | ||||
| 		"eslint": "^8.16.0", | ||||
| 		"eslint-config-prettier": "^8.3.0", | ||||
| 		"eslint-plugin-svelte3": "^4.0.0", | ||||
| 		"prettier": "^2.6.2", | ||||
| 		"prettier-plugin-svelte": "^2.7.0", | ||||
| 		"svelte": "^3.44.0", | ||||
| 		"svelte-check": "^2.7.1", | ||||
| 		"svelte-preprocess": "^4.10.6", | ||||
| 		"tslib": "^2.3.1", | ||||
| 		"typescript": "^4.7.4", | ||||
| 		"vite": "^3.0.0", | ||||
| 		"@sveltejs/adapter-node": "next", | ||||
| 		"@sveltejs/kit": "next", | ||||
| 		"@types/bcrypt": "^5.0.0", | ||||
| 		"@types/cookie": "^0.4.1", | ||||
| 		"@types/fluent-ffmpeg": "^2.1.20", | ||||
| @@ -35,9 +27,27 @@ | ||||
| 		"@types/lodash": "^4.14.182", | ||||
| 		"@types/lodash-es": "^4.17.6", | ||||
| 		"@types/socket.io-client": "^3.0.0", | ||||
| 		"@typescript-eslint/eslint-plugin": "^5.27.0", | ||||
| 		"@typescript-eslint/parser": "^5.27.0", | ||||
| 		"autoprefixer": "^10.4.7", | ||||
| 		"babel-jest": "^29.0.2", | ||||
| 		"eslint": "^8.16.0", | ||||
| 		"eslint-config-prettier": "^8.3.0", | ||||
| 		"eslint-plugin-svelte3": "^4.0.0", | ||||
| 		"factory.ts": "^1.2.0", | ||||
| 		"jest": "^29.0.2", | ||||
| 		"jest-environment-jsdom": "^29.0.2", | ||||
| 		"postcss": "^8.4.13", | ||||
| 		"tailwindcss": "^3.0.24" | ||||
| 		"prettier": "^2.6.2", | ||||
| 		"prettier-plugin-svelte": "^2.7.0", | ||||
| 		"svelte": "^3.44.0", | ||||
| 		"svelte-check": "^2.7.1", | ||||
| 		"svelte-jester": "^2.3.2", | ||||
| 		"svelte-preprocess": "^4.10.6", | ||||
| 		"tailwindcss": "^3.0.24", | ||||
| 		"tslib": "^2.3.1", | ||||
| 		"typescript": "^4.7.4", | ||||
| 		"vite": "^3.0.0" | ||||
| 	}, | ||||
| 	"type": "module", | ||||
| 	"dependencies": { | ||||
|   | ||||
| @@ -1,3 +1,16 @@ | ||||
| <script lang="ts" context="module"> | ||||
| 	type OnShowContextMenu = { | ||||
| 		showalbumcontextmenu: OnShowContextMenuDetail; | ||||
| 	}; | ||||
|  | ||||
| 	type OnClick = { | ||||
| 		click: OnClickDetail; | ||||
| 	}; | ||||
|  | ||||
| 	export type OnShowContextMenuDetail = { x: number; y: number }; | ||||
| 	export type OnClickDetail = AlbumResponseDto; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import { AlbumResponseDto, api, ThumbnailFormat } from '@api'; | ||||
| 	import { createEventDispatcher, onMount } from 'svelte'; | ||||
| @@ -8,7 +21,8 @@ | ||||
| 	export let album: AlbumResponseDto; | ||||
|  | ||||
| 	let imageData: string = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`; | ||||
| 	const dispatch = createEventDispatcher(); | ||||
| 	const dispatchClick = createEventDispatcher<OnClick>(); | ||||
| 	const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>(); | ||||
|  | ||||
| 	const loadHighQualityThumbnail = async (thubmnailId: string | null) => { | ||||
| 		if (thubmnailId == null) { | ||||
| @@ -25,7 +39,7 @@ | ||||
| 	}; | ||||
|  | ||||
| 	const showAlbumContextMenu = (e: MouseEvent) => { | ||||
| 		dispatch('showalbumcontextmenu', { | ||||
| 		dispatchShowContextMenu('showalbumcontextmenu', { | ||||
| 			x: e.clientX, | ||||
| 			y: e.clientY | ||||
| 		}); | ||||
| @@ -38,7 +52,7 @@ | ||||
|  | ||||
| <div | ||||
| 	class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative" | ||||
| 	on:click={() => dispatch('click', album)} | ||||
| 	on:click={() => dispatchClick('click', album)} | ||||
| > | ||||
| 	<div | ||||
| 		id={`icon-${album.id}`} | ||||
|   | ||||
| @@ -6,93 +6,32 @@ | ||||
| 	import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
| 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 	import { AlbumResponseDto, api } from '@api'; | ||||
| 	import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; | ||||
| 	import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; | ||||
| 	import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; | ||||
| 	import { | ||||
| 		notificationController, | ||||
| 		NotificationType | ||||
| 	} from '$lib/components/shared-components/notification/notification'; | ||||
| 	import { useAlbums } from './albums-bloc'; | ||||
|  | ||||
| 	export let data: PageData; | ||||
|  | ||||
| 	let isShowContextMenu = false; | ||||
| 	let contextMenuPosition = { x: 0, y: 0 }; | ||||
| 	let targetAlbum: AlbumResponseDto; | ||||
| 	const { | ||||
| 		albums, | ||||
| 		isShowContextMenu, | ||||
| 		contextMenuPosition, | ||||
| 		createAlbum, | ||||
| 		deleteSelectedContextAlbum, | ||||
| 		loadAlbums, | ||||
| 		showAlbumContextMenu, | ||||
| 		closeAlbumContextMenu | ||||
| 	} = useAlbums({ albums: data.albums }); | ||||
|  | ||||
| 	onMount(async () => { | ||||
| 		const getAllAlbumsRes = await api.albumApi.getAllAlbums(); | ||||
| 		data.albums = getAllAlbumsRes.data; | ||||
|  | ||||
| 		// Delete album that has no photos and is named 'Untitled' | ||||
| 		for (const album of data.albums) { | ||||
| 			if (album.albumName === 'Untitled' && album.assetCount === 0) { | ||||
| 				setTimeout(async () => { | ||||
| 					await autoDeleteAlbum(album); | ||||
| 					data.albums = data.albums.filter((a) => a.id !== album.id); | ||||
| 				}, 500); | ||||
| 			} | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| 	const createAlbum = async () => { | ||||
| 		try { | ||||
| 			const { data: newAlbum } = await api.albumApi.createAlbum({ | ||||
| 				albumName: 'Untitled' | ||||
| 			}); | ||||
| 	onMount(loadAlbums); | ||||
|  | ||||
| 	const handleCreateAlbum = async () => { | ||||
| 		const newAlbum = await createAlbum(); | ||||
| 		if (newAlbum) { | ||||
| 			goto('/albums/' + newAlbum.id); | ||||
| 		} catch (e) { | ||||
| 			console.error('Error [createAlbum] ', e); | ||||
| 			notificationController.show({ | ||||
| 				message: 'Error creating album, check console for more details', | ||||
| 				type: NotificationType.Error | ||||
| 			}); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const autoDeleteAlbum = async (album: AlbumResponseDto) => { | ||||
| 		try { | ||||
| 			await api.albumApi.deleteAlbum(album.id); | ||||
| 			return true; | ||||
| 		} catch (e) { | ||||
| 			console.error('Error [autoDeleteAlbum] ', e); | ||||
| 			return false; | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const userDeleteMenu = async () => { | ||||
| 		if ( | ||||
| 			window.confirm( | ||||
| 				`Are you sure you want to delete album ${targetAlbum.albumName}? If the album is shared, other users will not be able to access it.` | ||||
| 			) | ||||
| 		) { | ||||
| 			try { | ||||
| 				await api.albumApi.deleteAlbum(targetAlbum.id); | ||||
| 				data.albums = data.albums.filter((a) => a.id !== targetAlbum.id); | ||||
| 			} catch (e) { | ||||
| 				console.error('Error [userDeleteMenu] ', e); | ||||
| 				notificationController.show({ | ||||
| 					message: 'Error deleting user, check console for more details', | ||||
| 					type: NotificationType.Error | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		isShowContextMenu = false; | ||||
| 	}; | ||||
|  | ||||
| 	const showAlbumContextMenu = (event: CustomEvent, album: AlbumResponseDto) => { | ||||
| 		targetAlbum = album; | ||||
|  | ||||
| 		contextMenuPosition = { | ||||
| 			x: event.detail.x, | ||||
| 			y: event.detail.y | ||||
| 		}; | ||||
|  | ||||
| 		isShowContextMenu = !isShowContextMenu; | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <svelte:head> | ||||
| @@ -116,7 +55,7 @@ | ||||
| 				</div> | ||||
|  | ||||
| 				<div> | ||||
| 					<button on:click={createAlbum} class="immich-text-button text-sm"> | ||||
| 					<button on:click={handleCreateAlbum} class="immich-text-button text-sm"> | ||||
| 						<span> | ||||
| 							<PlusBoxOutline size="18" /> | ||||
| 						</span> | ||||
| @@ -131,17 +70,20 @@ | ||||
|  | ||||
| 			<!-- Album Card --> | ||||
| 			<div class="flex flex-wrap gap-8"> | ||||
| 				{#each data.albums as album} | ||||
| 				{#each $albums as album} | ||||
| 					{#key album.id} | ||||
| 						<a sveltekit:prefetch href={`albums/${album.id}`}> | ||||
| 							<AlbumCard {album} on:showalbumcontextmenu={(e) => showAlbumContextMenu(e, album)} /> | ||||
| 							<AlbumCard | ||||
| 								{album} | ||||
| 								on:showalbumcontextmenu={(e) => showAlbumContextMenu(e.detail, album)} | ||||
| 							/> | ||||
| 						</a> | ||||
| 					{/key} | ||||
| 				{/each} | ||||
| 			</div> | ||||
|  | ||||
| 			<!-- Empty Message --> | ||||
| 			{#if data.albums.length === 0} | ||||
| 			{#if $albums.length === 0} | ||||
| 				<div | ||||
| 					class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center" | ||||
| 				> | ||||
| @@ -156,9 +98,9 @@ | ||||
| 	</section> | ||||
|  | ||||
| 	<!-- Context Menu --> | ||||
| 	{#if isShowContextMenu} | ||||
| 		<ContextMenu {...contextMenuPosition} on:clickoutside={() => (isShowContextMenu = false)}> | ||||
| 			<MenuOption on:click={userDeleteMenu}> | ||||
| 	{#if $isShowContextMenu} | ||||
| 		<ContextMenu {...$contextMenuPosition} on:clickoutside={closeAlbumContextMenu}> | ||||
| 			<MenuOption on:click={deleteSelectedContextAlbum}> | ||||
| 				<span class="flex place-items-center place-content-center gap-2"> | ||||
| 					<DeleteOutline size="18" /> | ||||
| 					<p>Delete album</p> | ||||
|   | ||||
							
								
								
									
										185
									
								
								web/src/routes/albums/__tests__/albums-bloc.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								web/src/routes/albums/__tests__/albums-bloc.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | ||||
| import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; | ||||
| import { useAlbums } from '../albums-bloc'; | ||||
| import { api, CreateAlbumDto } from '@api'; | ||||
| import { | ||||
| 	notificationController, | ||||
| 	NotificationType | ||||
| } from '$lib/components/shared-components/notification/notification'; | ||||
| import { albumFactory } from '@test-data'; | ||||
| import { get } from 'svelte/store'; | ||||
|  | ||||
| jest.mock('@api'); | ||||
|  | ||||
| const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>; | ||||
|  | ||||
| function mockWindowConfirm(result: boolean) { | ||||
| 	jest.spyOn(global, 'confirm').mockReturnValueOnce(result); | ||||
| } | ||||
|  | ||||
| describe('Albums BLoC', () => { | ||||
| 	let sut: ReturnType<typeof useAlbums>; | ||||
| 	const _albums = albumFactory.buildList(5); | ||||
|  | ||||
| 	beforeEach(() => { | ||||
| 		sut = useAlbums({ albums: [..._albums] }); | ||||
| 	}); | ||||
|  | ||||
| 	afterEach(() => { | ||||
| 		const notifications = get(notificationController.notificationList); | ||||
|  | ||||
| 		notifications.forEach((notification) => | ||||
| 			notificationController.removeNotificationById(notification.id) | ||||
| 		); | ||||
| 	}); | ||||
|  | ||||
| 	it('inits with provided albums', () => { | ||||
| 		const albums = get(sut.albums); | ||||
| 		expect(albums.length).toEqual(5); | ||||
| 		expect(albums).toEqual(_albums); | ||||
| 	}); | ||||
|  | ||||
| 	it('loads albums from the server', async () => { | ||||
| 		// TODO: this method currently deletes albums with no assets and albumName === 'Untitled' which might not be the best approach | ||||
| 		const loadedAlbums = [..._albums, albumFactory.build({ id: 'new_loaded_uuid' })]; | ||||
|  | ||||
| 		apiMock.albumApi.getAllAlbums.mockResolvedValueOnce({ | ||||
| 			data: loadedAlbums, | ||||
| 			config: {}, | ||||
| 			headers: {}, | ||||
| 			status: 200, | ||||
| 			statusText: '' | ||||
| 		}); | ||||
|  | ||||
| 		await sut.loadAlbums(); | ||||
| 		const albums = get(sut.albums); | ||||
|  | ||||
| 		expect(apiMock.albumApi.getAllAlbums).toHaveBeenCalledTimes(1); | ||||
| 		expect(albums).toEqual(loadedAlbums); | ||||
| 	}); | ||||
|  | ||||
| 	it('shows error message when it fails loading albums', async () => { | ||||
| 		apiMock.albumApi.getAllAlbums.mockRejectedValueOnce({}); // TODO: implement APIProblem interface in the server | ||||
|  | ||||
| 		expect(get(notificationController.notificationList)).toHaveLength(0); | ||||
| 		await sut.loadAlbums(); | ||||
| 		const albums = get(sut.albums); | ||||
| 		const notifications = get(notificationController.notificationList); | ||||
|  | ||||
| 		expect(apiMock.albumApi.getAllAlbums).toHaveBeenCalledTimes(1); | ||||
| 		expect(albums).toEqual(_albums); | ||||
| 		expect(notifications).toHaveLength(1); | ||||
| 		expect(notifications[0].type).toEqual(NotificationType.Error); | ||||
| 	}); | ||||
|  | ||||
| 	it('creates a new album', async () => { | ||||
| 		// TODO: we probably shouldn't hardcode the album name "untitled" here and let the user input the album name before creating it | ||||
| 		const payload: CreateAlbumDto = { | ||||
| 			albumName: 'Untitled' | ||||
| 		}; | ||||
|  | ||||
| 		const returnedAlbum = albumFactory.build(); | ||||
|  | ||||
| 		apiMock.albumApi.createAlbum.mockResolvedValueOnce({ | ||||
| 			data: returnedAlbum, | ||||
| 			config: {}, | ||||
| 			headers: {}, | ||||
| 			status: 200, | ||||
| 			statusText: '' | ||||
| 		}); | ||||
|  | ||||
| 		const newAlbum = await sut.createAlbum(); | ||||
|  | ||||
| 		expect(apiMock.albumApi.createAlbum).toHaveBeenCalledTimes(1); | ||||
| 		expect(apiMock.albumApi.createAlbum).toHaveBeenCalledWith(payload); | ||||
| 		expect(newAlbum).toEqual(returnedAlbum); | ||||
| 	}); | ||||
|  | ||||
| 	it('shows error message when it fails creating an album', async () => { | ||||
| 		apiMock.albumApi.createAlbum.mockRejectedValueOnce({}); | ||||
|  | ||||
| 		const newAlbum = await sut.createAlbum(); | ||||
| 		const notifications = get(notificationController.notificationList); | ||||
|  | ||||
| 		expect(apiMock.albumApi.createAlbum).toHaveBeenCalledTimes(1); | ||||
| 		expect(newAlbum).not.toBeDefined(); | ||||
| 		expect(notifications).toHaveLength(1); | ||||
| 		expect(notifications[0].type).toEqual(NotificationType.Error); | ||||
| 	}); | ||||
|  | ||||
| 	it('selects an album and deletes it', async () => { | ||||
| 		apiMock.albumApi.deleteAlbum.mockResolvedValueOnce({ | ||||
| 			data: undefined, | ||||
| 			config: {}, | ||||
| 			headers: {}, | ||||
| 			status: 200, | ||||
| 			statusText: '' | ||||
| 		}); | ||||
|  | ||||
| 		mockWindowConfirm(true); | ||||
|  | ||||
| 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||
| 		const albumToDeleteId = albumToDelete.id; | ||||
| 		const contextMenuCoords = { x: 100, y: 150 }; | ||||
|  | ||||
| 		expect(get(sut.isShowContextMenu)).toBe(false); | ||||
| 		sut.showAlbumContextMenu(contextMenuCoords, albumToDelete); | ||||
| 		expect(get(sut.contextMenuPosition)).toEqual(contextMenuCoords); | ||||
| 		expect(get(sut.isShowContextMenu)).toBe(true); | ||||
|  | ||||
| 		await sut.deleteSelectedContextAlbum(); | ||||
| 		const updatedAlbums = get(sut.albums); | ||||
|  | ||||
| 		expect(apiMock.albumApi.deleteAlbum).toHaveBeenCalledTimes(1); | ||||
| 		expect(apiMock.albumApi.deleteAlbum).toHaveBeenCalledWith(albumToDeleteId); | ||||
| 		expect(updatedAlbums).toHaveLength(4); | ||||
| 		expect(updatedAlbums).not.toContain(albumToDelete); | ||||
| 		expect(get(sut.isShowContextMenu)).toBe(false); | ||||
| 	}); | ||||
|  | ||||
| 	it('shows error message when it fails deleting an album', async () => { | ||||
| 		mockWindowConfirm(true); | ||||
|  | ||||
| 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||
| 		const contextMenuCoords = { x: 100, y: 150 }; | ||||
|  | ||||
| 		apiMock.albumApi.deleteAlbum.mockRejectedValueOnce({}); | ||||
|  | ||||
| 		sut.showAlbumContextMenu(contextMenuCoords, albumToDelete); | ||||
| 		const newAlbum = await sut.deleteSelectedContextAlbum(); | ||||
| 		const notifications = get(notificationController.notificationList); | ||||
|  | ||||
| 		expect(apiMock.albumApi.deleteAlbum).toHaveBeenCalledTimes(1); | ||||
| 		expect(newAlbum).not.toBeDefined(); | ||||
| 		expect(notifications).toHaveLength(1); | ||||
| 		expect(notifications[0].type).toEqual(NotificationType.Error); | ||||
| 	}); | ||||
|  | ||||
| 	it('prevents deleting an album when rejecting confirm dialog', async () => { | ||||
| 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||
|  | ||||
| 		mockWindowConfirm(false); | ||||
|  | ||||
| 		sut.showAlbumContextMenu({ x: 100, y: 150 }, albumToDelete); | ||||
| 		await sut.deleteSelectedContextAlbum(); | ||||
|  | ||||
| 		expect(apiMock.albumApi.deleteAlbum).not.toHaveBeenCalled(); | ||||
| 	}); | ||||
|  | ||||
| 	it('prevents deleting an album when not previously selected', async () => { | ||||
| 		mockWindowConfirm(true); | ||||
|  | ||||
| 		await sut.deleteSelectedContextAlbum(); | ||||
|  | ||||
| 		expect(apiMock.albumApi.deleteAlbum).not.toHaveBeenCalled(); | ||||
| 	}); | ||||
|  | ||||
| 	it('closes album context menu, deselecting album', () => { | ||||
| 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||
| 		sut.showAlbumContextMenu({ x: 100, y: 150 }, albumToDelete); | ||||
|  | ||||
| 		expect(get(sut.isShowContextMenu)).toBe(true); | ||||
|  | ||||
| 		sut.closeAlbumContextMenu(); | ||||
| 		expect(get(sut.isShowContextMenu)).toBe(false); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										114
									
								
								web/src/routes/albums/albums-bloc.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								web/src/routes/albums/albums-bloc.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| import { | ||||
| 	notificationController, | ||||
| 	NotificationType | ||||
| } from '$lib/components/shared-components/notification/notification'; | ||||
| import { AlbumResponseDto, api } from '@api'; | ||||
| import { OnShowContextMenuDetail } from '$lib/components/album-page/album-card.svelte'; | ||||
| import { writable, derived, get } from 'svelte/store'; | ||||
|  | ||||
| type AlbumsProps = { albums: AlbumResponseDto[] }; | ||||
|  | ||||
| export const useAlbums = (props: AlbumsProps) => { | ||||
| 	const albums = writable([...props.albums]); | ||||
| 	const contextMenuPosition = writable<OnShowContextMenuDetail>({ x: 0, y: 0 }); | ||||
| 	const contextMenuTargetAlbum = writable<AlbumResponseDto | undefined>(); | ||||
| 	const isShowContextMenu = derived(contextMenuTargetAlbum, ($selectedAlbum) => !!$selectedAlbum); | ||||
|  | ||||
| 	async function loadAlbums(): Promise<void> { | ||||
| 		try { | ||||
| 			const { data } = await api.albumApi.getAllAlbums(); | ||||
| 			albums.set(data); | ||||
|  | ||||
| 			// Delete album that has no photos and is named 'Untitled' | ||||
| 			for (const album of data) { | ||||
| 				if (album.albumName === 'Untitled' && album.assetCount === 0) { | ||||
| 					setTimeout(async () => { | ||||
| 						await deleteAlbum(album); | ||||
| 						const _albums = get(albums); | ||||
| 						albums.set(_albums.filter((a) => a.id !== album.id)); | ||||
| 					}, 500); | ||||
| 				} | ||||
| 			} | ||||
| 		} catch { | ||||
| 			notificationController.show({ | ||||
| 				message: 'Error loading albums', | ||||
| 				type: NotificationType.Error | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async function createAlbum(): Promise<AlbumResponseDto | undefined> { | ||||
| 		try { | ||||
| 			const { data: newAlbum } = await api.albumApi.createAlbum({ | ||||
| 				albumName: 'Untitled' | ||||
| 			}); | ||||
|  | ||||
| 			return newAlbum; | ||||
| 		} catch { | ||||
| 			notificationController.show({ | ||||
| 				message: 'Error creating album', | ||||
| 				type: NotificationType.Error | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async function deleteAlbum(album: AlbumResponseDto): Promise<void> { | ||||
| 		try { | ||||
| 			await api.albumApi.deleteAlbum(album.id); | ||||
| 		} catch { | ||||
| 			// Do nothing? | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async function showAlbumContextMenu( | ||||
| 		contextMenuDetail: OnShowContextMenuDetail, | ||||
| 		album: AlbumResponseDto | ||||
| 	): Promise<void> { | ||||
| 		contextMenuTargetAlbum.set(album); | ||||
|  | ||||
| 		contextMenuPosition.set({ | ||||
| 			x: contextMenuDetail.x, | ||||
| 			y: contextMenuDetail.y | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	function closeAlbumContextMenu() { | ||||
| 		contextMenuTargetAlbum.set(undefined); | ||||
| 	} | ||||
|  | ||||
| 	async function deleteSelectedContextAlbum(): Promise<void> { | ||||
| 		const albumToDelete = get(contextMenuTargetAlbum); | ||||
| 		if (!albumToDelete) { | ||||
| 			return; | ||||
| 		} | ||||
| 		if ( | ||||
| 			window.confirm( | ||||
| 				`Are you sure you want to delete album ${albumToDelete.albumName}? If the album is shared, other users will not be able to access it.` | ||||
| 			) | ||||
| 		) { | ||||
| 			try { | ||||
| 				await api.albumApi.deleteAlbum(albumToDelete.id); | ||||
| 				const _albums = get(albums); | ||||
| 				albums.set(_albums.filter((a) => a.id !== albumToDelete.id)); | ||||
| 			} catch { | ||||
| 				notificationController.show({ | ||||
| 					message: 'Error deleting album', | ||||
| 					type: NotificationType.Error | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		closeAlbumContextMenu(); | ||||
| 	} | ||||
|  | ||||
| 	return { | ||||
| 		albums, | ||||
| 		isShowContextMenu, | ||||
| 		contextMenuPosition, | ||||
| 		loadAlbums, | ||||
| 		createAlbum, | ||||
| 		showAlbumContextMenu, | ||||
| 		closeAlbumContextMenu, | ||||
| 		deleteSelectedContextAlbum | ||||
| 	}; | ||||
| }; | ||||
							
								
								
									
										15
									
								
								web/src/test-data/factories/album-factory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								web/src/test-data/factories/album-factory.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { AlbumResponseDto } from '@api'; | ||||
| import { Sync } from 'factory.ts'; | ||||
| import { faker } from '@faker-js/faker'; | ||||
|  | ||||
| export const albumFactory = Sync.makeFactory<AlbumResponseDto>({ | ||||
| 	albumName: Sync.each(() => faker.commerce.product()), | ||||
| 	albumThumbnailAssetId: null, | ||||
| 	assetCount: Sync.each((i) => i % 5), | ||||
| 	assets: [], | ||||
| 	createdAt: Sync.each(() => faker.date.past().toISOString()), | ||||
| 	id: Sync.each(() => faker.datatype.uuid()), | ||||
| 	ownerId: Sync.each(() => faker.datatype.uuid()), | ||||
| 	shared: false, | ||||
| 	sharedUsers: [] | ||||
| }); | ||||
							
								
								
									
										1
									
								
								web/src/test-data/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/test-data/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * from './factories/album-factory'; | ||||
| @@ -27,6 +27,9 @@ | ||||
|       ], | ||||
|       "@api": [ | ||||
|         "./src/api" | ||||
|       ], | ||||
|       "@test-data": [ | ||||
|         "./src/test-data" | ||||
|       ] | ||||
|     } | ||||
|   } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user