From 9098aefc268b33398b44761817c7efd3af5863ca Mon Sep 17 00:00:00 2001 From: Jeremy <161660+jamarzka@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:47:53 -0500 Subject: [PATCH 1/6] Added orderBy to search metadata --- server/src/dtos/search.dto.ts | 5 ++++- server/src/enum.ts | 5 +++++ server/src/repositories/search.repository.ts | 9 ++++++--- server/src/services/search.service.ts | 3 ++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 5f8b018afe9ba..a9673a18074fe 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -5,7 +5,7 @@ import { Place } from 'src/database'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; +import { AssetOrder, AssetOrderBy, AssetType, AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; class BaseSearchDto { @@ -178,6 +178,9 @@ export class MetadataSearchDto extends RandomSearchDto { @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true, default: AssetOrder.Desc }) order?: AssetOrder; + @ValidateEnum({ enum: AssetOrderBy, name: 'AssetOrderBy', optional: true, default: AssetOrderBy.DateTaken }) + orderBy?: AssetOrderBy; + @IsInt() @Min(1) @Type(() => Number) diff --git a/server/src/enum.ts b/server/src/enum.ts index 646138b0601e0..f94154b1e927a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -55,6 +55,11 @@ export enum AssetOrder { Desc = 'desc', } +export enum AssetOrderBy { + DateAdded = 'DATE_ADDED', + DateTaken = 'DATE_TAKEN', +} + export enum DatabaseAction { Create = 'CREATE', Update = 'UPDATE', diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 88de2fb06fbfc..eea0d29a6ec4a 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -4,7 +4,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { randomUUID } from 'node:crypto'; import { DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; +import { AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -97,7 +97,8 @@ export interface SearchAlbumOptions { } export interface SearchOrderOptions { - orderDirection?: 'asc' | 'desc'; + orderDirection?: AssetOrder; + orderBy?: AssetOrderBy; } export interface SearchPaginationOptions { @@ -184,9 +185,11 @@ export class SearchRepository { }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; + const orderBy = options.orderBy ?? AssetOrderBy.DateTaken; const items = await searchAssetBuilder(this.db, options) .selectAll('asset') - .orderBy('asset.fileCreatedAt', orderDirection) + .$if(orderBy === AssetOrderBy.DateAdded, (qb) => qb.orderBy('asset.createdAt', orderDirection)) + .$if(orderBy === AssetOrderBy.DateTaken, (qb) => qb.orderBy('asset.fileCreatedAt', orderDirection)) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index fea1670e27c60..88fc46deca168 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -18,7 +18,7 @@ import { SmartSearchDto, StatisticsSearchDto, } from 'src/dtos/search.dto'; -import { AssetOrder, AssetVisibility, Permission } from 'src/enum'; +import { AssetOrder, AssetOrderBy, AssetVisibility, Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { requireElevatedPermission } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; @@ -67,6 +67,7 @@ export class SearchService extends BaseService { checksum, userIds, orderDirection: dto.order ?? AssetOrder.Desc, + orderBy: dto.orderBy ?? AssetOrderBy.DateTaken, }, ); From 055e8b731d899681d248c8fb63011acbbdb670f5 Mon Sep 17 00:00:00 2001 From: Jeremy <161660+jamarzka@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:49:42 -0500 Subject: [PATCH 2/6] Generated OpenAPI --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + mobile/openapi/lib/model/asset_order_by.dart | 85 +++++++++++++++++++ .../lib/model/metadata_search_dto.dart | 9 +- open-api/immich-openapi-specs.json | 15 ++++ open-api/typescript-sdk/src/fetch-client.ts | 5 ++ 8 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 mobile/openapi/lib/model/asset_order_by.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4a7d516a9d49d..1f2e62356c736 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -340,6 +340,7 @@ Class | Method | HTTP request | Description - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) - [AssetOrder](doc//AssetOrder.md) + - [AssetOrderBy](doc//AssetOrderBy.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index df2c2226b189c..27a33932717c1 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -111,6 +111,7 @@ part 'model/asset_metadata_response_dto.dart'; part 'model/asset_metadata_upsert_dto.dart'; part 'model/asset_metadata_upsert_item_dto.dart'; part 'model/asset_order.dart'; +part 'model/asset_order_by.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 06d27593c922c..5db430a43e067 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -276,6 +276,8 @@ class ApiClient { return AssetMetadataUpsertItemDto.fromJson(value); case 'AssetOrder': return AssetOrderTypeTransformer().decode(value); + case 'AssetOrderBy': + return AssetOrderByTypeTransformer().decode(value); case 'AssetResponseDto': return AssetResponseDto.fromJson(value); case 'AssetStackResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b34e9210c8649..064bb0df41e90 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -73,6 +73,9 @@ String parameterToString(dynamic value) { if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } + if (value is AssetOrderBy) { + return AssetOrderByTypeTransformer().encode(value).toString(); + } if (value is AssetTypeEnum) { return AssetTypeEnumTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_order_by.dart b/mobile/openapi/lib/model/asset_order_by.dart new file mode 100644 index 0000000000000..f20b4a538c0c3 --- /dev/null +++ b/mobile/openapi/lib/model/asset_order_by.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetOrderBy { + /// Instantiate a new enum with the provided [value]. + const AssetOrderBy._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const ADDED = AssetOrderBy._(r'DATE_ADDED'); + static const TAKEN = AssetOrderBy._(r'DATE_TAKEN'); + + /// List of all possible values in this [enum][AssetOrderBy]. + static const values = [ + ADDED, + TAKEN, + ]; + + static AssetOrderBy? fromJson(dynamic value) => AssetOrderByTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetOrderBy.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetOrderBy] to String, +/// and [decode] dynamic data back to [AssetOrderBy]. +class AssetOrderByTypeTransformer { + factory AssetOrderByTypeTransformer() => _instance ??= const AssetOrderByTypeTransformer._(); + + const AssetOrderByTypeTransformer._(); + + String encode(AssetOrderBy data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetOrderBy. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetOrderBy? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'DATE_ADDED': return AssetOrderBy.ADDED; + case r'DATE_TAKEN': return AssetOrderBy.TAKEN; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetOrderByTypeTransformer] instance. + static AssetOrderByTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index b7e637d4b4d46..11663c450d7da 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -34,6 +34,7 @@ class MetadataSearchDto { this.make, this.model, this.order = AssetOrder.desc, + this.orderBy = AssetOrderBy.TAKEN, this.originalFileName, this.originalPath, this.page, @@ -184,6 +185,8 @@ class MetadataSearchDto { AssetOrder order; + AssetOrderBy orderBy; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -370,6 +373,7 @@ class MetadataSearchDto { other.make == make && other.model == model && other.order == order && + other.orderBy == orderBy && other.originalFileName == originalFileName && other.originalPath == originalPath && other.page == page && @@ -417,6 +421,7 @@ class MetadataSearchDto { (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + (order.hashCode) + + (orderBy.hashCode) + (originalFileName == null ? 0 : originalFileName!.hashCode) + (originalPath == null ? 0 : originalPath!.hashCode) + (page == null ? 0 : page!.hashCode) + @@ -441,7 +446,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[albumIds=$albumIds, checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[albumIds=$albumIds, checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, orderBy=$orderBy, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -542,6 +547,7 @@ class MetadataSearchDto { // json[r'model'] = null; } json[r'order'] = this.order; + json[r'orderBy'] = this.orderBy; if (this.originalFileName != null) { json[r'originalFileName'] = this.originalFileName; } else { @@ -683,6 +689,7 @@ class MetadataSearchDto { make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), order: AssetOrder.fromJson(json[r'order']) ?? AssetOrder.desc, + orderBy: AssetOrderBy.fromJson(json[r'orderBy']) ?? AssetOrderBy.TAKEN, originalFileName: mapValueOfType(json, r'originalFileName'), originalPath: mapValueOfType(json, r'originalPath'), page: num.parse('${json[r'page']}'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b574bc66249ef..d6bd56d0ac1b1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11050,6 +11050,13 @@ ], "type": "string" }, + "AssetOrderBy": { + "enum": [ + "DATE_ADDED", + "DATE_TAKEN" + ], + "type": "string" + }, "AssetResponseDto": { "properties": { "checksum": { @@ -12605,6 +12612,14 @@ ], "default": "desc" }, + "orderBy": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrderBy" + } + ], + "default": "DATE_TAKEN" + }, "originalFileName": { "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c8a69dfe8cd03..0f0043dffdff1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -909,6 +909,7 @@ export type MetadataSearchDto = { make?: string; model?: string | null; order?: AssetOrder; + orderBy?: AssetOrderBy; originalFileName?: string; originalPath?: string; page?: number; @@ -4890,6 +4891,10 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum AssetOrderBy { + DateAdded = "DATE_ADDED", + DateTaken = "DATE_TAKEN" +} export enum SearchSuggestionType { Country = "country", State = "state", From e09eb74ae1ffa508fa519d608afc185dd6f54e2a Mon Sep 17 00:00:00 2001 From: Jeremy <161660+jamarzka@users.noreply.github.com> Date: Sat, 4 Oct 2025 23:41:46 -0500 Subject: [PATCH 3/6] New recently-added utility --- .../utilities-page/utilities-menu.svelte | 3 +- web/src/lib/constants.ts | 1 + .../[[assetId=id]]/+page.svelte | 168 ++++++++++++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 18 ++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index fc747dc6af5fe..aa0590d49a9cd 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,13 +1,14 @@ diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index da638bb41d2db..df6afc7bc3fcf 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -54,6 +54,7 @@ export enum AppRoute { DUPLICATES = '/utilities/duplicates', LARGE_FILES = '/utilities/large-files', GEOLOCATION = '/utilities/geolocation', + RECENTLY_ADDED = '/utilities/recently-added', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000000..2d2a089ba0861 --- /dev/null +++ b/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,168 @@ + + + +
+
+ {#if recentlyAddedAssets.length > 0} + + {:else if !isLoading} +
+
+ +

{$t('no_results')}

+

{$t('no_results_description')}

+
+
+ {/if} + + {#if isLoading} +
+ +
+ {/if} +
+
+
+ +{#if assetInteraction.selectionActive} +
+ cancelMultiselect(assetInteraction)} + > + + + + cancelMultiselect(assetInteraction)} /> + cancelMultiselect(assetInteraction)} shared /> + + { + for (const assetId of assetIds) { + const asset = recentlyAddedAssets.find((recentAsset) => recentAsset.id === assetId); + if (asset) { + asset.isFavorite = isFavorite; + } + } + }} + /> + + + + + + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} + + {/if} + +
+ +
+
+
+{/if} diff --git a/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000000..074bdfce95617 --- /dev/null +++ b/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,18 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(url); + + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + return { + asset, + meta: { + title: $t('recently_added'), + }, + }; +}) satisfies PageLoad; From c1b898296b8d4785c68dc257105b4195b4d1e9f0 Mon Sep 17 00:00:00 2001 From: Jeremy <161660+jamarzka@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:43:39 -0500 Subject: [PATCH 4/6] Fix checks --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2d2a089ba0861..783c2e79836ec 100644 --- a/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -20,12 +20,12 @@ import { cancelMultiselect } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { Icon, IconButton, LoadingSpinner } from '@immich/ui'; import { AssetOrderBy, searchAssets } from '@immich/sdk'; - import { mdiImageOffOutline, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js'; + import { Icon, IconButton, LoadingSpinner } from '@immich/ui'; + import { mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; + import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import { onMount } from 'svelte'; interface Props { data: PageData; @@ -73,7 +73,7 @@ nextPage = Number(assets.nextPage) || 0; } catch (error) { - handleError(error, $t('errors.failed_to_load_recently_added_assets')); + handleError(error, $t('errors.failed_to_load_assets')); } finally { isLoading = false; } From 8f1e3344731ab530e0f063079e6b1a33a26da207 Mon Sep 17 00:00:00 2001 From: Jeremy <161660+jamarzka@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:46:37 -0500 Subject: [PATCH 5/6] Added unit tests --- .../src/controllers/search.controller.spec.ts | 184 ++++++++++-------- server/src/services/search.service.spec.ts | 32 +++ 2 files changed, 130 insertions(+), 86 deletions(-) diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index adbc8be0f3fcd..b3c4c0136e317 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -100,92 +100,104 @@ describe(SearchController.name, () => { expect(body).toEqual(errorDto.badRequest(['isMotion must be a boolean value'])); }); - describe('POST /search/random', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/random'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - - it('should reject if withStacked is not a boolean', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/search/random') - .send({ withStacked: 'immich' }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value'])); - }); - - it('should reject if withPeople is not a boolean', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/search/random') - .send({ withPeople: 'immich' }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value'])); - }); - }); - - describe('POST /search/smart', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/smart'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /search/explore', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/explore'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('POST /search/person', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/person'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - - it('should require a name', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); - }); - }); - - describe('GET /search/places', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/places'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - - it('should require a name', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); - }); - }); - - describe('GET /search/cities', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/cities'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /search/suggestions', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/suggestions'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - - it('should require a type', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.badRequest([ - 'type should not be empty', - expect.stringContaining('type must be one of the following values:'), - ]), - ); - }); + it('should reject an order as not an enum', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ order: 'invalid' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['order must be one of the following values: asc, desc'])); + }); + + it('should reject an orderBy as not an enum', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ orderBy: 'invalid' }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest(['orderBy must be one of the following values: DATE_ADDED, DATE_TAKEN']), + ); + }); + }); + + describe('POST /search/random', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/search/random'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should reject if withStacked is not a boolean', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/random') + .send({ withStacked: 'immich' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value'])); + }); + + it('should reject if withPeople is not a boolean', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/search/random').send({ withPeople: 'immich' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value'])); + }); + }); + + describe('POST /search/smart', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/search/smart'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /search/explore', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/explore'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('POST /search/person', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/person'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a name', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); + }); + }); + + describe('GET /search/places', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/places'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a name', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); + }); + }); + + describe('GET /search/cities', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/cities'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /search/suggestions', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/suggestions'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a type', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([ + 'type should not be empty', + expect.stringContaining('type must be one of the following values:'), + ]), + ); }); }); }); diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index b6e09add19972..8b5e846708d8b 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; +import { AssetOrder, AssetOrderBy } from 'src/enum'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -23,6 +24,37 @@ describe(SearchService.name, () => { expect(sut).toBeDefined(); }); + describe('searchMetadata', () => { + it('should pass options to search', async () => { + mocks.search.searchMetadata.mockResolvedValue({ + items: [], + hasNextPage: false, + }); + + await sut.searchMetadata(authStub.user1, {}); + + expect(mocks.search.searchMetadata).toHaveBeenCalledWith( + { page: 1, size: 250 }, + { + orderBy: AssetOrderBy.DateTaken, + orderDirection: AssetOrder.Desc, + userIds: [authStub.user1.user.id], + }, + ); + + const pagination = { page: 2, size: 100 }; + const dto = { order: AssetOrder.Asc, orderBy: AssetOrderBy.DateAdded, ...pagination }; + await sut.searchMetadata(authStub.user1, dto); + + expect(mocks.search.searchMetadata).toHaveBeenCalledWith(pagination, { + ...dto, + orderBy: AssetOrderBy.DateAdded, + orderDirection: AssetOrder.Asc, + userIds: [authStub.user1.user.id], + }); + }); + }); + describe('searchPerson', () => { it('should pass options to search', async () => { const { name } = personStub.withName; From fdd78e30a7dfd28fd0a3cbbdb02dfa5a8f60f4fc Mon Sep 17 00:00:00 2001 From: Jeremy <161660+jamarzka@users.noreply.github.com> Date: Sun, 5 Oct 2025 15:18:03 -0500 Subject: [PATCH 6/6] Added e2e test --- e2e/src/api/specs/search.e2e-spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index 2f6ea75f77f2d..7b4087bab0dfb 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -156,6 +156,20 @@ describe('/search', () => { should: 'should sort my assets in reverse', deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }), }, + { + should: 'should sort by date added', + deferred: () => ({ + dto: { order: 'desc', orderBy: 'DATE_ADDED', size: 1 }, + assets: [assetLast], + }), + }, + { + should: 'should sort by date added in reverse', + deferred: () => ({ + dto: { order: 'asc', orderBy: 'DATE_ADDED', size: 2 }, + assets: [assetFalcon, assetDenali], + }), + }, { should: 'should support pagination', deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }),