diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts deleted file mode 100644 index e633c8694d..0000000000 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - AssetMediaResponseDto, - AssetVisibility, - LoginResponseDto, - SharedLinkType, - TimeBucketAssetResponseDto, -} from '@immich/sdk'; -import { DateTime } from 'luxon'; -import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; - -// TODO this should probably be a test util function -const today = DateTime.fromObject({ - year: 2023, - month: 11, - day: 3, -}) as DateTime; -const yesterday = today.minus({ days: 1 }); - -describe('/timeline', () => { - let admin: LoginResponseDto; - let user: LoginResponseDto; - let timeBucketUser: LoginResponseDto; - - let user1Assets: AssetMediaResponseDto[]; - let user2Assets: AssetMediaResponseDto[]; - - beforeAll(async () => { - await utils.resetDatabase(); - admin = await utils.adminSetup({ onboarding: false }); - [user, timeBucketUser] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.create('1')), - utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), - ]); - - user1Assets = await Promise.all([ - utils.createAsset(user.accessToken), - utils.createAsset(user.accessToken), - utils.createAsset(user.accessToken, { - isFavorite: true, - fileCreatedAt: yesterday.toISO(), - fileModifiedAt: yesterday.toISO(), - assetData: { filename: 'example.mp4' }, - }), - utils.createAsset(user.accessToken), - utils.createAsset(user.accessToken), - ]); - - user2Assets = await Promise.all([ - utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), - utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), - utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), - utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), - utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-12').toISOString() }), - ]); - - await utils.deleteAssets(timeBucketUser.accessToken, [user2Assets[4].id]); - }); - - describe('GET /timeline/buckets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/timeline/buckets'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get time buckets by month', async () => { - const { status, body } = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 3, timeBucket: '1970-02-01' }, - { count: 1, timeBucket: '1970-01-01' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await utils.createSharedLink(user.accessToken, { - type: SharedLinkType.Individual, - assetIds: user1Assets.map(({ id }) => id), - }); - - const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ withPartners: true, visibility: AssetVisibility.Archive }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${user.accessToken}`) - .query({ withPartners: true, visibility: undefined }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${user.accessToken}`) - .query({ withPartners: true, isTrashed: true }); - - expect(req.status).toBe(400); - expect(req.body).toEqual(errorDto.badRequest()); - }); - }); - - describe('GET /timeline/bucket', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/timeline/bucket').query({ - timeBucket: '1900-01-01', - }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should handle 5 digit years', async () => { - const { status, body } = await request(app) - .get('/timeline/bucket') - .query({ timeBucket: '012345-01-01' }) - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ - city: [], - country: [], - duration: [], - id: [], - visibility: [], - isFavorite: [], - isImage: [], - isTrashed: [], - livePhotoVideoId: [], - fileCreatedAt: [], - localOffsetHours: [], - ownerId: [], - projectionType: [], - ratio: [], - status: [], - thumbhash: [], - }); - }); - - // TODO enable date string validation while still accepting 5 digit years - // it('should fail if time bucket is invalid', async () => { - // const { status, body } = await request(app) - // .get('/timeline/bucket') - // .set('Authorization', `Bearer ${user.accessToken}`) - // .query({ timeBucket: 'foo' }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorDto.badRequest); - // }); - - it('should return time bucket', async () => { - const { status, body } = await request(app) - .get('/timeline/bucket') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ timeBucket: '1970-02-10' }); - - expect(status).toBe(200); - expect(body).toEqual({ - city: [], - country: [], - duration: [], - id: [], - visibility: [], - isFavorite: [], - isImage: [], - isTrashed: [], - livePhotoVideoId: [], - fileCreatedAt: [], - localOffsetHours: [], - ownerId: [], - projectionType: [], - ratio: [], - status: [], - thumbhash: [], - }); - }); - - it('should return time bucket in trash', async () => { - const { status, body } = await request(app) - .get('/timeline/bucket') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ timeBucket: '1970-02-01T00:00:00.000Z', isTrashed: true }); - - expect(status).toBe(200); - - const timeBucket: TimeBucketAssetResponseDto = body; - expect(timeBucket.isTrashed).toEqual([true]); - }); - }); -}); diff --git a/server/src/controllers/timeline.controller.spec.ts b/server/src/controllers/timeline.controller.spec.ts new file mode 100644 index 0000000000..6d0276c6a3 --- /dev/null +++ b/server/src/controllers/timeline.controller.spec.ts @@ -0,0 +1,41 @@ +import { TimelineController } from 'src/controllers/timeline.controller'; +import { TimelineService } from 'src/services/timeline.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(TimelineController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(TimelineService); + + beforeAll(async () => { + ctx = await controllerSetup(TimelineController, [{ provide: TimelineService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /timeline/buckets', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/timeline/buckets'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /timeline/bucket', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/timeline/bucket?timeBucket=1900-01-01'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + // TODO enable date string validation while still accepting 5 digit years + it.fails('should fail if time bucket is invalid', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/timeline/bucket').query({ timeBucket: 'foo' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Invalid time bucket format')); + }); + }); +}); diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts new file mode 100644 index 0000000000..6af936eb49 --- /dev/null +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -0,0 +1,159 @@ +import { BadRequestException } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { AssetVisibility } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { TimelineService } from 'src/services/timeline.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(TimelineService, { + database: db || defaultDatabase, + real: [AssetRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(TimelineService.name, () => { + describe('getTimeBuckets', () => { + it('should get time buckets by month', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const dates = [new Date('1970-01-01'), new Date('1970-02-10'), new Date('1970-02-11'), new Date('1970-02-11')]; + for (const localDateTime of dates) { + const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + } + + const response = sut.getTimeBuckets(auth, {}); + await expect(response).resolves.toEqual([ + { count: 3, timeBucket: '1970-02-01' }, + { count: 1, timeBucket: '1970-01-01' }, + ]); + }); + + it('should return error if time bucket is requested with partners asset and archived', async () => { + const { sut } = setup(); + const auth = factory.auth(); + const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.ARCHIVE }); + await expect(response1).rejects.toBeInstanceOf(BadRequestException); + await expect(response1).rejects.toThrow( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + + const response2 = sut.getTimeBuckets(auth, { withPartners: true }); + await expect(response2).rejects.toBeInstanceOf(BadRequestException); + await expect(response2).rejects.toThrow( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + }); + + it('should return error if time bucket is requested with partners asset and favorite', async () => { + const { sut } = setup(); + const auth = factory.auth(); + const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false }); + await expect(response1).rejects.toBeInstanceOf(BadRequestException); + await expect(response1).rejects.toThrow( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + + const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true }); + await expect(response2).rejects.toBeInstanceOf(BadRequestException); + await expect(response2).rejects.toThrow( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + }); + + it('should return error if time bucket is requested with partners asset and trash', async () => { + const { sut } = setup(); + const auth = factory.auth(); + const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true }); + await expect(response).rejects.toBeInstanceOf(BadRequestException); + await expect(response).rejects.toThrow( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + }); + + it('should not allow access for unrelated shared links', async () => { + const { sut } = setup(); + const auth = factory.auth({ sharedLink: {} }); + const response = sut.getTimeBuckets(auth, {}); + await expect(response).rejects.toBeInstanceOf(BadRequestException); + await expect(response).rejects.toThrow('Not found or no timeline.read access'); + }); + }); + + describe('getTimeBucket', () => { + it('should return time bucket', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: new Date('1970-02-12'), + deletedAt: new Date(), + }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + const auth = factory.auth({ user: { id: user.id } }); + const rawResponse = await sut.getTimeBucket(auth, { timeBucket: '1970-02-01', isTrashed: true }); + const response = JSON.parse(rawResponse); + expect(response).toEqual(expect.objectContaining({ isTrashed: [true] })); + }); + + it('should handle a bucket without any assets', async () => { + const { sut } = setup(); + const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '1970-02-01' }); + const response = JSON.parse(rawResponse); + expect(response).toEqual({ + city: [], + country: [], + duration: [], + id: [], + visibility: [], + isFavorite: [], + isImage: [], + isTrashed: [], + livePhotoVideoId: [], + fileCreatedAt: [], + localOffsetHours: [], + ownerId: [], + projectionType: [], + ratio: [], + status: [], + thumbhash: [], + }); + }); + + it('should handle 5 digit years', async () => { + const { sut } = setup(); + const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '012345-01-01' }); + const response = JSON.parse(rawResponse); + expect(response).toEqual(expect.objectContaining({ id: [] })); + }); + + it('should return time bucket in trash', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: new Date('1970-02-12'), + deletedAt: new Date(), + }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + const auth = factory.auth({ user: { id: user.id } }); + const rawResponse = await sut.getTimeBucket(auth, { timeBucket: '1970-02-01', isTrashed: true }); + const response = JSON.parse(rawResponse); + expect(response).toEqual(expect.objectContaining({ isTrashed: [true] })); + }); + }); +});