From acfd40b77a23fe7456e89ef65be4b07fc77f4033 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 25 Aug 2025 17:10:31 -0400 Subject: [PATCH] fix: album start/end dates on shared links (#21268) --- server/src/queries/shared.link.repository.sql | 6 +- .../repositories/shared-link.repository.ts | 11 +++- server/test/medium.factory.ts | 4 +- .../services/shared-link.service.spec.ts | 65 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 server/test/medium/specs/services/shared-link.service.spec.ts diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 0e13b98b5d..0f46846c14 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -38,7 +38,11 @@ from select "album".*, coalesce( - json_agg("assets") filter ( + json_agg( + "assets" + order by + "assets"."fileCreatedAt" asc + ) filter ( where "assets"."id" is not null ), diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 54eab7c86f..cdade25f76 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -86,7 +86,16 @@ export class SharedLinkRepository { (join) => join.onTrue(), ) .select((eb) => - eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'), + eb.fn + .coalesce( + eb.fn + .jsonAgg('assets') + .orderBy('assets.fileCreatedAt', 'asc') + .filterWhere('assets.id', 'is not', null), + + sql`'[]'`, + ) + .as('assets'), ) .select((eb) => eb.fn.toJson('owner').as('owner')) .groupBy(['album.id', sql`"owner".*`]) diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 8b0878a35a..87c8406f55 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository'; @@ -286,6 +287,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case PersonRepository: case SearchRepository: case SessionRepository: + case SharedLinkRepository: case StackRepository: case SyncRepository: case SyncCheckpointRepository: @@ -391,7 +393,7 @@ const assetInsert = (asset: Partial> = {}) => { checksum: randomBytes(32), type: AssetType.Image, originalPath: '/path/to/something.jpg', - ownerId: '@immich.cloud', + ownerId: 'not-a-valid-uuid', isFavorite: false, fileCreatedAt: now, fileModifiedAt: now, diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts new file mode 100644 index 0000000000..88e7e86df5 --- /dev/null +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -0,0 +1,65 @@ +import { Kysely } from 'kysely'; +import { randomBytes } from 'node:crypto'; +import { SharedLinkType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { DB } from 'src/schema'; +import { SharedLinkService } from 'src/services/shared-link.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(SharedLinkService, { + database: db || defaultDatabase, + real: [AccessRepository, DatabaseRepository, SharedLinkRepository], + mock: [LoggingRepository, StorageRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SharedLinkService.name, () => { + describe('get', () => { + it('should return the correct dates on the shared link album', async () => { + const { sut, ctx } = setup(); + + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z']; + + for (const date of dates) { + const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + } + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: true, + type: SharedLinkType.Album, + }); + + await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({ + album: expect.objectContaining({ + startDate: '2020-01-01T00:00:00+00:00', + endDate: '2022-01-01T00:00:00+00:00', + }), + }); + }); + }); +});