mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
refactor: timeline tests (#19641)
This commit is contained in:
parent
58ca1402ed
commit
93f9e118ad
@ -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<true>;
|
|
||||||
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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
41
server/src/controllers/timeline.controller.spec.ts
Normal file
41
server/src/controllers/timeline.controller.spec.ts
Normal file
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
159
server/test/medium/specs/services/timeline.service.spec.ts
Normal file
159
server/test/medium/specs/services/timeline.service.spec.ts
Normal file
@ -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<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
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] }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user