refactor: convert activity stub to a factory (#16702)

This commit is contained in:
Jason Rasmussen 2025-03-07 15:20:04 -05:00 committed by GitHub
parent f82786a297
commit 2d106755f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 135 additions and 127 deletions

View File

@ -15,6 +15,14 @@ export type AuthApiKey = {
permissions: Permission[]; permissions: Permission[];
}; };
export type User = {
id: string;
name: string;
email: string;
profileImagePath: string;
profileChangedAt: Date;
};
export type AuthSharedLink = { export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;

View File

@ -1,8 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto'; import { ReactionType } from 'src/dtos/activity.dto';
import { ActivityService } from 'src/services/activity.service'; import { ActivityService } from 'src/services/activity.service';
import { activityStub } from 'test/fixtures/activity.stub'; import { factory, newUuid, newUuids } from 'test/small.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(ActivityService.name, () => { describe(ActivityService.name, () => {
@ -19,137 +18,118 @@ describe(ActivityService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should get all', async () => { it('should get all', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId, userId] = newUuids();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); await expect(sut.getAll(factory.auth({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
assetId: 'asset-id',
albumId: 'album-id',
isLiked: undefined,
});
}); });
it('should filter by type=like', async () => { it('should filter by type=like', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId, userId] = newUuids();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await expect( await expect(
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), sut.getAll(factory.auth({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
assetId: 'asset-id',
albumId: 'album-id',
isLiked: true,
});
}); });
it('should filter by type=comment', async () => { it('should filter by type=comment', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId] = newUuids();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await expect( await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }),
).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
assetId: 'asset-id',
albumId: 'album-id',
isLiked: false,
});
}); });
}); });
describe('getStatistics', () => { describe('getStatistics', () => {
it('should get the comment count', async () => { it('should get the comment count', async () => {
const [albumId, assetId] = newUuids();
mocks.activity.getStatistics.mockResolvedValue(1); mocks.activity.getStatistics.mockResolvedValue(1);
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
await expect(
sut.getStatistics(authStub.admin, { await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1 });
assetId: 'asset-id',
albumId: activityStub.oneComment.albumId,
}),
).resolves.toEqual({ comments: 1 });
}); });
}); });
describe('addComment', () => { describe('addComment', () => {
it('should require access to the album', async () => { it('should require access to the album', async () => {
const [albumId, assetId] = newUuids();
await expect( await expect(
sut.create(authStub.admin, { sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
albumId: 'album-id',
assetId: 'asset-id',
type: ReactionType.COMMENT,
comment: 'comment',
}),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it('should create a comment', async () => { it('should create a comment', async () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId, userId] = newUuids();
mocks.activity.create.mockResolvedValue(activityStub.oneComment); const activity = factory.activity({ albumId, assetId, userId });
await sut.create(authStub.admin, { mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
albumId: 'album-id', mocks.activity.create.mockResolvedValue(activity);
assetId: 'asset-id',
await sut.create(factory.auth({ id: userId }), {
albumId,
assetId,
type: ReactionType.COMMENT, type: ReactionType.COMMENT,
comment: 'comment', comment: 'comment',
}); });
expect(mocks.activity.create).toHaveBeenCalledWith({ expect(mocks.activity.create).toHaveBeenCalledWith({
userId: 'admin_id', userId: activity.userId,
albumId: 'album-id', albumId: activity.albumId,
assetId: 'asset-id', assetId: activity.assetId,
comment: 'comment', comment: 'comment',
isLiked: false, isLiked: false,
}); });
}); });
it('should fail because activity is disabled for the album', async () => { it('should fail because activity is disabled for the album', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId] = newUuids();
mocks.activity.create.mockResolvedValue(activityStub.oneComment); const activity = factory.activity({ albumId, assetId });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity);
await expect( await expect(
sut.create(authStub.admin, { sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
albumId: 'album-id',
assetId: 'asset-id',
type: ReactionType.COMMENT,
comment: 'comment',
}),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it('should create a like', async () => { it('should create a like', async () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId, userId] = newUuids();
mocks.activity.create.mockResolvedValue(activityStub.liked); const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity);
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await sut.create(authStub.admin, { await sut.create(factory.auth({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
albumId: 'album-id',
assetId: 'asset-id',
type: ReactionType.LIKE,
});
expect(mocks.activity.create).toHaveBeenCalledWith({ expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
userId: 'admin_id',
albumId: 'album-id',
assetId: 'asset-id',
isLiked: true,
});
}); });
it('should skip if like exists', async () => { it('should skip if like exists', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); const [albumId, assetId] = newUuids();
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); const activity = factory.activity({ albumId, assetId, isLiked: true });
mocks.activity.search.mockResolvedValue([activityStub.liked]);
await sut.create(authStub.admin, { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
albumId: 'album-id', mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
assetId: 'asset-id', mocks.activity.search.mockResolvedValue([activity]);
type: ReactionType.LIKE,
}); await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
expect(mocks.activity.create).not.toHaveBeenCalled(); expect(mocks.activity.create).not.toHaveBeenCalled();
}); });
@ -157,20 +137,29 @@ describe(ActivityService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should require access', async () => { it('should require access', async () => {
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.activity.delete).not.toHaveBeenCalled(); expect(mocks.activity.delete).not.toHaveBeenCalled();
}); });
it('should let the activity owner delete a comment', async () => { it('should let the activity owner delete a comment', async () => {
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); const activity = factory.activity();
await sut.delete(authStub.admin, 'activity-id');
expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
await sut.delete(factory.auth(), activity.id);
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
}); });
it('should let the album owner delete a comment', async () => { it('should let the album owner delete a comment', async () => {
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); const activity = factory.activity();
await sut.delete(authStub.admin, 'activity-id');
expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
await sut.delete(factory.auth(), activity.id);
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
}); });
}); });
}); });

View File

@ -1,5 +1,5 @@
import { Insertable, Kysely } from 'kysely'; import { Insertable, Kysely } from 'kysely';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { Assets, DB, Partners, Sessions, Users } from 'src/db'; import { Assets, DB, Partners, Sessions, Users } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -36,6 +36,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
import { ViewRepository } from 'src/repositories/view-repository'; import { ViewRepository } from 'src/repositories/view-repository';
import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { newUuid } from 'test/small.factory';
class CustomWritable extends Writable { class CustomWritable extends Writable {
private data = ''; private data = '';
@ -59,8 +60,6 @@ type User = Partial<Insertable<Users>>;
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string }; type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
type Partner = Insertable<Partners>; type Partner = Insertable<Partners>;
export const newUuid = () => randomUUID() as string;
export class TestFactory { export class TestFactory {
private assets: Asset[] = []; private assets: Asset[] = [];
private sessions: Session[] = []; private sessions: Session[] = [];

View File

@ -1,42 +0,0 @@
import { ActivityItem } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
export const activityStub = {
oneComment: Object.freeze<ActivityItem>({
id: 'activity-1',
comment: 'comment',
isLiked: false,
userId: 'admin_id',
user: {
id: 'admin_id',
name: 'admin',
email: 'admin@test.com',
profileImagePath: '',
profileChangedAt: new Date('2021-01-01'),
},
assetId: assetStub.image.id,
albumId: albumStub.oneAsset.id,
createdAt: new Date(),
updatedAt: new Date(),
updateId: 'uuid-v7',
}),
liked: Object.freeze<ActivityItem>({
id: 'activity-2',
comment: null,
isLiked: true,
userId: 'admin_id',
user: {
id: 'admin_id',
name: 'admin',
email: 'admin@test.com',
profileImagePath: '',
profileChangedAt: new Date('2021-01-01'),
},
assetId: assetStub.image.id,
albumId: albumStub.oneAsset.id,
createdAt: new Date(),
updatedAt: new Date(),
updateId: 'uuid-v7',
}),
};

View File

@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { AuthUser, User } from 'src/database';
import { ActivityItem } from 'src/types';
export const newUuid = () => randomUUID() as string;
export const newUuids = () =>
Array.from({ length: 100 })
.fill(0)
.map(() => newUuid());
export const newDate = () => new Date();
export const newUpdateId = () => 'uuid-v7';
const authUser = (authUser: Partial<AuthUser>) => ({
id: newUuid(),
isAdmin: false,
name: 'Test User',
email: 'test@immich.cloud',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
...authUser,
});
const user = (user: Partial<User>) => ({
id: newUuid(),
name: 'Test User',
email: 'test@immich.cloud',
profileImagePath: '',
profileChangedAt: newDate(),
...user,
});
export const factory = {
auth: (user: Partial<AuthUser> = {}) => ({
user: authUser(user),
}),
authUser,
user,
activity: (activity: Partial<ActivityItem> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: user({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
...activity,
};
},
};