From 4c26a7ca761c8facc1d1e506880f5e5c88c05b26 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 21 Aug 2026 06:57:50 -0400 Subject: [PATCH] refactor: activity e2e (#30896) --- e2e/src/specs/server/api/activity.e2e-spec.ts | 378 ------------------ server/test/medium.factory.ts | 11 + .../specs/services/activity.service.spec.ts | 310 ++++++++++++++ 3 files changed, 321 insertions(+), 378 deletions(-) delete mode 100644 e2e/src/specs/server/api/activity.e2e-spec.ts create mode 100644 server/test/medium/specs/services/activity.service.spec.ts diff --git a/e2e/src/specs/server/api/activity.e2e-spec.ts b/e2e/src/specs/server/api/activity.e2e-spec.ts deleted file mode 100644 index 9ce9b4b91..000000000 --- a/e2e/src/specs/server/api/activity.e2e-spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { - ActivityCreateDto, - AlbumResponseDto, - AlbumUserRole, - AssetMediaResponseDto, - LoginResponseDto, - ReactionType, - createActivity as create, - createAlbum, - removeAssetFromAlbum, -} from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -describe('/activities', () => { - let admin: LoginResponseDto; - let nonOwner: LoginResponseDto; - let asset: AssetMediaResponseDto; - let album: AlbumResponseDto; - - const createActivity = (dto: ActivityCreateDto, accessToken?: string) => - create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) }); - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1); - asset = await utils.createAsset(admin.accessToken); - album = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 1', - assetIds: [asset.id], - albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - }); - - beforeEach(async () => { - await utils.resetDatabase(['activity']); - }); - - describe('GET /activities', () => { - it('should start off empty', async () => { - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual([]); - expect(status).toEqual(200); - }); - - it('should filter by album id', async () => { - const album2 = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 2', - assetIds: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const [reaction] = await Promise.all([ - createActivity({ albumId: album.id, type: ReactionType.Like }), - createActivity({ albumId: album2.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by type=comment', async () => { - const [reaction] = await Promise.all([ - createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'comment', - }), - createActivity({ albumId: album.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, type: 'comment' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by type=like', async () => { - const [reaction] = await Promise.all([ - createActivity({ albumId: album.id, type: ReactionType.Like }), - createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'comment', - }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, type: 'like' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by userId', async () => { - const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); - - const response1 = await request(app) - .get('/activities') - .query({ albumId: album.id, userId: uuidDto.notFound }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response1.status).toEqual(200); - expect(response1.body.length).toBe(0); - - const response2 = await request(app) - .get('/activities') - .query({ albumId: album.id, userId: admin.userId }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response2.status).toEqual(200); - expect(response2.body.length).toBe(1); - expect(response2.body[0]).toEqual(reaction); - }); - - it('should filter by assetId', async () => { - const [reaction] = await Promise.all([ - createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }), - createActivity({ albumId: album.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, assetId: asset.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - }); - - describe('POST /activities', () => { - it('should add a comment to an album', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - albumId: album.id, - type: 'comment', - comment: 'This is my first comment', - }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: null, - createdAt: expect.any(String), - type: 'comment', - comment: 'This is my first comment', - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should add a like to an album', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: null, - createdAt: expect.any(String), - type: 'like', - comment: null, - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should return a 200 for a duplicate like on the album', async () => { - const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(200); - expect(body).toEqual(reaction); - }); - - it('should not confuse an album like with an asset like', async () => { - const reaction = await createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }); - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(201); - expect(body.id).not.toEqual(reaction.id); - }); - - it('should add a comment to an asset', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - albumId: album.id, - assetId: asset.id, - type: 'comment', - comment: 'This is my first comment', - }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: asset.id, - createdAt: expect.any(String), - type: 'comment', - comment: 'This is my first comment', - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should add a like to an asset', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, assetId: asset.id, type: 'like' }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: asset.id, - createdAt: expect.any(String), - type: 'like', - comment: null, - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should return a 200 for a duplicate like on an asset', async () => { - const reaction = await createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }); - - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, assetId: asset.id, type: 'like' }); - expect(status).toEqual(200); - expect(body).toEqual(reaction); - }); - }); - - describe('DELETE /activities/:id', () => { - it('should remove a comment from an album', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(204); - }); - - it('should remove a like from an album', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Like, - }); - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(204); - }); - - it('should let the owner remove a comment by another user', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toEqual(204); - }); - - it('should not let a user remove a comment by another user', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - - const { status, body } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${nonOwner.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access')); - }); - - it('should let a non-owner remove their own comment', async () => { - const reaction = await createActivity( - { - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }, - nonOwner.accessToken, - ); - - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${nonOwner.accessToken}`); - - expect(status).toBe(204); - }); - - it('should return empty list when asset is removed', async () => { - const album3 = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 3', - assetIds: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like }); - - await removeAssetFromAlbum( - { - id: album3.id, - bulkIdsDto: { - ids: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body).toEqual([]); - }); - }); -}); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index c66e9d4a7..a754d7bc2 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -253,6 +253,17 @@ export class MediumTestContext = return { albumUser: { albumId, userId, role }, result }; } + /** An album owned by one user, containing one asset, shared with a second user */ + async newSharedAlbum(dto: { role?: AlbumUserRole } = {}) { + const { user: owner } = await this.newUser(); + const { user: sharedWith } = await this.newUser(); + const { asset } = await this.newAsset({ ownerId: owner.id }); + const { album } = await this.newAlbum({ ownerId: owner.id }, [asset.id]); + await this.newAlbumUser({ albumId: album.id, userId: sharedWith.id, role: dto.role ?? AlbumUserRole.Editor }); + + return { album, asset, owner, sharedWith }; + } + async softDeleteAsset(assetId: string) { await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute(); } diff --git a/server/test/medium/specs/services/activity.service.spec.ts b/server/test/medium/specs/services/activity.service.spec.ts new file mode 100644 index 000000000..c568f05a2 --- /dev/null +++ b/server/test/medium/specs/services/activity.service.spec.ts @@ -0,0 +1,310 @@ +import { Kysely } from 'kysely'; +import { ReactionType } from 'src/dtos/activity.dto'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { ActivityService } from 'src/services/activity.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory, newUuid } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(ActivityService, { + database: db || defaultDatabase, + real: [AccessRepository, ActivityRepository, AlbumRepository, AlbumUserRepository, AssetRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ActivityService.name, () => { + describe('getAll', () => { + it('should start off empty', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + + await expect(sut.getAll(factory.auth({ user: owner }), { albumId: album.id })).resolves.toEqual([]); + }); + + it('should filter by album id', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { album: other } = await ctx.newAlbum({ ownerId: owner.id }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + await sut.create(auth, { albumId: other.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([value]); + }); + + it('should filter by type=comment', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }); + await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, type: ReactionType.COMMENT })).resolves.toEqual([value]); + }); + + it('should filter by type=like', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + await sut.create(auth, { albumId: album.id, type: ReactionType.COMMENT, comment: 'comment' }); + + await expect(sut.getAll(auth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual([value]); + }); + + it('should filter by userId', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, userId: newUuid() })).resolves.toEqual([]); + await expect(sut.getAll(auth, { albumId: album.id, userId: owner.id })).resolves.toEqual([value]); + }); + + it('should filter by assetId', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, assetId: asset.id })).resolves.toEqual([value]); + }); + }); + + describe('create', () => { + it('should add a comment to an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + + await expect( + sut.create(auth, { albumId: album.id, type: ReactionType.COMMENT, comment: 'This is my first comment' }), + ).resolves.toEqual({ + duplicate: false, + value: { + id: expect.any(String), + assetId: null, + createdAt: expect.any(Date), + type: ReactionType.COMMENT, + comment: 'This is my first comment', + user: expect.objectContaining({ id: owner.id }), + }, + }); + }); + + it('should add a like to an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + + await expect(sut.create(auth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual({ + duplicate: false, + value: { + id: expect.any(String), + assetId: null, + createdAt: expect.any(Date), + type: ReactionType.LIKE, + comment: null, + user: expect.objectContaining({ id: owner.id }), + }, + }); + }); + + it('should report a duplicate like on an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const ownerAuth = factory.auth({ user: owner }); + const { value } = await sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual({ + duplicate: true, + value, + }); + }); + + it('should not confuse an album like with an asset like', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const ownerAuth = factory.auth({ user: owner }); + const { value } = await sut.create(ownerAuth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + + const result = await sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE }); + + expect(result.duplicate).toBe(false); + expect(result.value.id).not.toEqual(value.id); + }); + + it('should add a comment to an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + + await expect( + sut.create(factory.auth({ user: owner }), { + albumId: album.id, + assetId: asset.id, + type: ReactionType.COMMENT, + comment: 'This is my first comment', + }), + ).resolves.toEqual( + expect.objectContaining({ + duplicate: false, + value: expect.objectContaining({ assetId: asset.id, comment: 'This is my first comment' }), + }), + ); + }); + + it('should add a like to an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + + await expect( + sut.create(factory.auth({ user: owner }), { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }), + ).resolves.toEqual( + expect.objectContaining({ + duplicate: false, + value: expect.objectContaining({ assetId: asset.id, type: ReactionType.LIKE, comment: null }), + }), + ); + }); + + it('should report a duplicate like on an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + + await expect( + sut.create(auth, { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }), + ).resolves.toEqual({ duplicate: true, value }); + }); + + it('should not let a user comment on an album they cannot access', async () => { + const { sut, ctx } = setup(); + const { album } = await ctx.newSharedAlbum(); + const { user: outsider } = await ctx.newUser(); + + await expect( + sut.create(factory.auth({ user: outsider }), { albumId: album.id, type: ReactionType.LIKE }), + ).rejects.toThrow('Not found or no activity.create access'); + }); + }); + + describe('delete', () => { + it('should remove a comment from an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should remove a like from an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should let the album owner remove a comment by another user', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(sharedWithAuth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should not let a user remove a comment by another user', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(sharedWithAuth, value.id)).rejects.toThrow('Not found or no activity.delete access'); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([value]); + }); + + it('should let a non-owner remove their own comment', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(sharedWithAuth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(sharedWithAuth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should drop activities when the asset is removed from the album', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + await sut.create(auth, { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }); + + await ctx.database + .deleteFrom('album_asset') + .where('albumId', '=', album.id) + .where('assetId', '=', asset.id) + .execute(); + + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + }); +});