refactor: e2e user tests (#30716)

This commit is contained in:
Jason Rasmussen 2026-08-11 12:39:25 -04:00 committed by GitHub
parent 1b8cbaab97
commit 00d10dab63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 198 additions and 260 deletions

View file

@ -1,34 +1,18 @@
import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk'; import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils'; import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
const userLicense = {
licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4',
activationKey:
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
};
describe('/users', () => { describe('/users', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let nonAdmin: LoginResponseDto; let nonAdmin: LoginResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false }); admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
[deletedUser, nonAdmin] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
await deleteUserAdmin(
{ id: deletedUser.userId, userAdminDeleteDto: {} },
{ headers: asBearerAuth(admin.accessToken) },
);
}); });
describe('GET /users/me', () => { describe('GET /users/me', () => {
@ -42,55 +26,9 @@ describe('/users', () => {
expect(status).toBe(403); expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
}); });
it('should get my user', async () => {
const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
quotaUsageInBytes: 0,
});
});
it('should get my user with license info', async () => {
const { status: licenseStatus } = await request(app)
.put(`/users/me/license`)
.send(userLicense)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(licenseStatus).toBe(200);
const { status, body } = await request(app)
.get(`/users/me`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: nonAdmin.userId,
email: nonAdmin.userEmail,
quotaUsageInBytes: 0,
license: userLicense,
});
});
}); });
describe('PUT /users/me', () => { describe('PUT /users/me', () => {
it('should update first and last name', async () => {
const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/users/me`)
.send({ name: 'Name' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...before,
updatedAt: expect.any(String),
profileChangedAt: expect.any(String),
createdAt: expect.any(String),
name: 'Name',
});
});
/** @deprecated */ /** @deprecated */
it('should allow a user to change their password (deprecated)', async () => { it('should allow a user to change their password (deprecated)', async () => {
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
@ -112,189 +50,5 @@ describe('/users', () => {
expect(token.accessToken).toBeDefined(); expect(token.accessToken).toBeDefined();
}); });
it('should not allow user to change to a taken email', async () => {
const { status, body } = await request(app)
.put(`/users/me`)
.send({ email: 'admin@immich.cloud' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(400);
expect(body).toMatchObject(errorDto.badRequest('Email is not available'));
});
it('should update my email', async () => {
const before = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
const { status, body } = await request(app)
.put(`/users/me`)
.send({ email: 'non-admin@immich.cloud' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
...before,
email: 'non-admin@immich.cloud',
updatedAt: expect.anything(),
createdAt: expect.anything(),
profileChangedAt: expect.anything(),
});
});
it('should update avatar color', async () => {
const { status, body } = await request(app)
.put(`/users/me`)
.send({ avatarColor: 'blue' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatarColor: 'blue' });
const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatarColor: 'blue' });
});
});
describe('PUT /users/me/preferences', () => {
it('should update memories enabled', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ memories: { enabled: true } });
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ memories: { enabled: false } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ memories: { enabled: false } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ memories: { enabled: false } });
});
it('should update download archive size', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ download: { archiveSize: 4 * 2 ** 30 } });
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ download: { archiveSize: 1_234_567 } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } });
});
it('should update download include embedded videos', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } });
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ download: { includeEmbeddedVideos: true } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
});
it('should update minimum face count to display people', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
const { status, body } = await request(app)
.put('/users/me/preferences')
.send({ people: { minimumFaces: 2 } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
});
});
describe('GET /users/:id', () => {
it('should get the user', async () => {
const { status, body } = await request(app)
.get(`/users/${admin.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
expect(body).not.toMatchObject({
shouldChangePassword: expect.anything(),
storageLabel: expect.anything(),
});
});
});
describe('GET /server/license', () => {
it('should return the user license', async () => {
await request(app)
.put('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send(userLicense);
const { status, body } = await request(app)
.get('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...userLicense,
activatedAt: expect.any(String),
});
});
});
describe('PUT /users/me/license', () => {
it('should set the user license', async () => {
const { status, body } = await request(app)
.put(`/users/me/license`)
.send(userLicense)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ ...userLicense, activatedAt: expect.any(String) });
expect(status).toBe(200);
expect(body).toEqual({ ...userLicense, activatedAt: expect.any(String) });
const { body: licenseBody } = await request(app)
.get('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(licenseBody).toEqual({ ...userLicense, activatedAt: expect.any(String) });
});
it('should reject license not starting with IMCL-', async () => {
const { status, body } = await request(app)
.put('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' });
expect(status).toBe(400);
expect(body.message).toBe('Invalid license key');
});
it('should reject license with invalid activation key', async () => {
const { status, body } = await request(app)
.put('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ licenseKey: userLicense.licenseKey, activationKey: `invalid${userLicense.activationKey}` });
expect(status).toBe(400);
expect(body.message).toBe('Invalid license key');
});
});
describe('DELETE /users/me/license', () => {
it('should delete the user license', async () => {
const { status } = await request(app)
.delete(`/users/me/license`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(204);
});
}); });
}); });

View file

@ -1,6 +1,6 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { ImmichEnvironment, JobName, JobStatus } from 'src/enum'; import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
@ -10,10 +10,17 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { HumanReadableSize } from 'src/utils/bytes';
import { mediumFactory, newMediumService } from 'test/medium.factory'; import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils'; import { getKyselyDB } from 'test/utils';
const userLicense = {
licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4',
activationKey:
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
};
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
@ -38,9 +45,10 @@ describe(UserService.name, () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue(); ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert(); const user = mediumFactory.userInsert();
await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual( const created = await sut.createUser({ name: user.name, email: user.email });
expect.objectContaining({ name: user.name, email: user.email }), expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email }));
);
await expect(sut.get(created.id)).resolves.toMatchObject({ name: user.name, email: user.email });
}); });
it('should reject user with duplicate email', async () => { it('should reject user with duplicate email', async () => {
@ -97,6 +105,38 @@ describe(UserService.name, () => {
expect((result as any).password).toBeUndefined(); expect((result as any).password).toBeUndefined();
}); });
it('should not expose private fields', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.get(user.id)).resolves.not.toMatchObject({
shouldChangePassword: expect.anything(),
storageLabel: expect.anything(),
});
});
});
describe('getMe', () => {
it('should get my user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
await expect(sut.getMe(auth)).resolves.toEqual(
expect.objectContaining({ id: user.id, email: user.email, quotaUsageInBytes: 0 }),
);
});
it('should include license info', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
await sut.setLicense(auth, userLicense);
await expect(sut.getMe(auth)).resolves.toMatchObject({ license: userLicense });
});
}); });
describe('updateMe', () => { describe('updateMe', () => {
@ -109,25 +149,169 @@ describe(UserService.name, () => {
expect(before.updatedAt).toBeDefined(); expect(before.updatedAt).toBeDefined();
expect(after.updatedAt).toBeDefined(); expect(after.updatedAt).toBeDefined();
expect(before.updatedAt).not.toEqual(after.updatedAt); expect(before.updatedAt).not.toEqual(after.updatedAt);
await expect(sut.getMe(auth)).resolves.toMatchObject({
name: `${before.name} Updated`,
updatedAt: after.updatedAt,
});
});
it('should update the name', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { name: 'Name' };
await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMe(auth)).resolves.toMatchObject(dto);
});
it('should update the email', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { email: 'updated@immich.cloud' };
await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMe(auth)).resolves.toMatchObject(dto);
});
it('should not allow an email that is already taken', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth = factory.auth({ user: { id: user2.id } });
await expect(sut.updateMe(auth, { email: user1.email })).rejects.toThrow('Email is not available');
});
it('should update the avatar color', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { avatarColor: UserAvatarColor.Blue };
await expect(sut.updateMe(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMe(auth)).resolves.toMatchObject(dto);
});
it('should clear shouldChangePassword when the password is updated', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser({ shouldChangePassword: true });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.updateMe(auth, { password: 'super-secret' })).resolves.toMatchObject({
shouldChangePassword: false,
});
await expect(sut.getMe(auth)).resolves.toMatchObject({ shouldChangePassword: false });
});
});
describe('updateMyPreferences', () => {
it('should update memories enabled', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { memories: { enabled: false } };
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ memories: { enabled: true } });
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
});
it('should update the download archive size', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { download: { archiveSize: 1_234_567 } };
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({
download: { archiveSize: 4 * HumanReadableSize.GiB },
});
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
});
it('should update download include embedded videos', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { download: { includeEmbeddedVideos: true } };
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({
download: { includeEmbeddedVideos: false },
});
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
});
it('should update the minimum face count', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dto = { people: { minimumFaces: 2 } };
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({ people: { minimumFaces: 3 } });
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
}); });
}); });
describe('setLicense', () => { describe('setLicense', () => {
it('should set a license', async () => { it('should set a license', async () => {
const license = {
licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4',
activationKey:
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
};
const { sut, ctx } = setup(); const { sut, ctx } = setup();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } }); const auth = factory.auth({ user: { id: user.id } });
await expect(sut.getLicense(auth)).rejects.toThrowError(); await expect(sut.getLicense(auth)).rejects.toThrowError();
const after = await sut.setLicense(auth, license); const after = await sut.setLicense(auth, userLicense);
expect(after.licenseKey).toEqual(license.licenseKey); expect(after.licenseKey).toEqual(userLicense.licenseKey);
expect(after.activationKey).toEqual(license.activationKey); expect(after.activationKey).toEqual(userLicense.activationKey);
const response = await sut.getLicense(auth); const response = await sut.getLicense(auth);
expect(response).toEqual(after); expect(response).toEqual(after);
await expect(sut.getMe(auth)).resolves.toMatchObject({ license: after });
});
it('should reject a license key that does not start with IMCL-', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
await expect(
sut.setLicense(auth, {
licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD',
activationKey: 'activationKey',
}),
).rejects.toThrow('Invalid license key');
});
it('should reject an invalid activation key', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
await expect(
sut.setLicense(auth, { ...userLicense, activationKey: `invalid${userLicense.activationKey}` }),
).rejects.toThrow('Invalid license key');
});
});
describe('deleteLicense', () => {
it('should delete the license', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
await sut.setLicense(auth, userLicense);
await sut.deleteLicense(auth);
await expect(sut.getLicense(auth)).rejects.toThrowError();
}); });
}); });