refactor: e2e tests (#30870)

This commit is contained in:
Jason Rasmussen 2026-08-19 14:31:57 -04:00 committed by GitHub
parent c63824bcea
commit 7cd0a7d30c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 618 additions and 648 deletions

View file

@ -10,6 +10,61 @@ const UNAUTHENTICATED_ADMIN_ROUTES = new Set([
'POST admin/database-backups/start-restore',
]);
/** Admin-only routes that live outside `admin/`, i.e. `@Authenticated({ admin: true })` */
const ADMIN_ROUTES = new Set([
'DELETE libraries/:id',
'DELETE queues/:name/jobs',
'DELETE server/license',
'GET jobs',
'GET libraries',
'GET libraries/:id',
'GET libraries/:id/statistics',
'GET queues',
'GET queues/:name',
'GET queues/:name/jobs',
'GET server/license',
'GET server/statistics',
'GET system-config',
'GET system-config/defaults',
'GET system-config/storage-template-options',
'GET system-metadata/admin-onboarding',
'GET system-metadata/reverse-geocoding-state',
'GET system-metadata/version-check-state',
'PATCH libraries/:id',
'POST jobs',
'POST libraries',
'POST libraries/:id/scan',
'POST libraries/:id/validate',
'POST system-metadata/admin-onboarding',
'PUT jobs/:name',
'PUT libraries/:id',
'PUT queues/:name',
'PUT server/license',
'PUT system-config',
]);
/** Routes a shared link (`?key=`) is allowed to reach, i.e. `@Authenticated({ sharedLink: true })` */
const SHARED_LINK_ROUTES = new Set([
'DELETE assets/:id/video/stream/:sessionId',
'GET albums/:id',
'GET albums/:id/map-markers',
'GET assets/:id',
'GET assets/:id/original',
'GET assets/:id/thumbnail',
'GET assets/:id/video/playback',
'GET assets/:id/video/stream/:sessionId/:variantIndex/:filename',
'GET assets/:id/video/stream/:sessionId/:variantIndex/playlist.m3u8',
'GET assets/:id/video/stream/main.m3u8',
'GET shared-links/me',
'GET timeline/bucket',
'GET timeline/buckets',
'POST assets',
'POST download/archive',
'POST download/info',
'POST search/metadata',
'POST shared-links/login',
]);
const isAdminPermission = (permission: AuthenticatedOptions['permission']) =>
typeof permission === 'string' && permission.startsWith('admin');
@ -45,9 +100,9 @@ const getRoutes = () => {
describe('controllers', () => {
const routes = getRoutes();
const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/'));
it('should only allow non-admin access to bootstrap routes under admin/', () => {
const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/'));
const reachableByNonAdmins = adminRoutes.filter((route) => !route.auth?.admin).map((route) => route.id);
expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES);
@ -59,6 +114,20 @@ describe('controllers', () => {
expect(undeclared).toEqual([]);
});
it('should only allow shared link access to expected routes', () => {
const sharedLinkRoutes = routes.filter((route) => route.auth?.sharedLink).map((route) => route.id);
expect(new Set(sharedLinkRoutes)).toEqual(SHARED_LINK_ROUTES);
});
it('should only require admin access on expected routes outside /admin', () => {
const adminRoutes = routes
.filter((route) => route.auth?.admin && route.path !== 'admin' && !route.path.startsWith('admin/'))
.map((route) => route.id);
expect(new Set(adminRoutes)).toEqual(ADMIN_ROUTES);
});
it('should require admin access for routes with an admin permission', () => {
const offenders = routes
.filter((route) => isAdminPermission(route.auth?.permission) && !route.auth?.admin)

View file

@ -0,0 +1,45 @@
import { JobController } from 'src/controllers/job.controller';
import { JobService } from 'src/services/job.service';
import { QueueService } from 'src/services/queue.service';
import request from 'supertest';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(JobController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(JobService);
const queueService = mockBaseService(QueueService);
beforeAll(async () => {
ctx = await controllerSetup(JobController, [
{ provide: JobService, useValue: service },
{ provide: QueueService, useValue: queueService },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
queueService.resetAllMocks();
ctx.reset();
});
describe('PUT /jobs/:name', () => {
it('should require a valid queue name', async () => {
const { status } = await request(ctx.getHttpServer())
.put('/jobs/invalid')
.send({ command: 'start', force: false });
expect(status).toBe(400);
expect(queueService.runCommandLegacy).not.toHaveBeenCalled();
});
it('should require a valid command', async () => {
const { status } = await request(ctx.getHttpServer())
.put('/jobs/metadataExtraction')
.send({ command: 'invalid', force: false });
expect(status).toBe(400);
expect(queueService.runCommandLegacy).not.toHaveBeenCalled();
});
});
});

View file

@ -20,28 +20,6 @@ describe(LibraryController.name, () => {
const id = factory.uuid();
describe('authentication', () => {
const routes = [
{ method: 'get', path: '/libraries' },
{ method: 'post', path: '/libraries' },
{ method: 'get', path: `/libraries/${id}` },
{ method: 'put', path: `/libraries/${id}` },
{ method: 'patch', path: `/libraries/${id}` },
{ method: 'delete', path: `/libraries/${id}` },
{ method: 'post', path: `/libraries/${id}/validate` },
{ method: 'get', path: `/libraries/${id}/statistics` },
{ method: 'post', path: `/libraries/${id}/scan` },
] as const;
it.each(routes)('$method $path should be an admin route', async ({ method, path }) => {
await request(ctx.getHttpServer())[method](path).send({});
expect(ctx.authenticate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }),
);
});
});
describe('POST /libraries', () => {
it('should require an owner id', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/libraries').send({});

View file

@ -0,0 +1,66 @@
import { MapController } from 'src/controllers/map.controller';
import { MapService } from 'src/services/map.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(MapController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(MapService);
beforeAll(async () => {
ctx = await controllerSetup(MapController, [{ provide: MapService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /map/reverse-geocode', () => {
it('should require a lat', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lon: 123 });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
expect(service.reverseGeocode).not.toHaveBeenCalled();
});
it('should require a lat that is a number', async () => {
const { status, body } = await request(ctx.getHttpServer())
.get('/map/reverse-geocode')
.query({ lat: 'abc', lon: 123.456 });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
expect(service.reverseGeocode).not.toHaveBeenCalled();
});
it('should require a lat that is in range', async () => {
const { status, body } = await request(ctx.getHttpServer())
.get('/map/reverse-geocode')
.query({ lat: 91, lon: 123.456 });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
);
expect(service.reverseGeocode).not.toHaveBeenCalled();
});
it('should require a lon', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lat: 75 });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
);
expect(service.reverseGeocode).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,71 @@
import { OAuthController } from 'src/controllers/oauth.controller';
import { AuthService } from 'src/services/auth.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(OAuthController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(AuthService);
beforeAll(async () => {
ctx = await controllerSetup(OAuthController, [{ provide: AuthService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /oauth/authorize', () => {
it('should require a redirect uri', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
]),
);
expect(service.authorize).not.toHaveBeenCalled();
});
});
describe('POST /oauth/callback', () => {
it('should require a url', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['url'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it('should not allow an empty url', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['url'], message: 'Too small: expected string to have >=1 characters' },
]),
);
});
});
describe('POST /oauth/backchannel-logout', () => {
it('should require a logout token', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/oauth/backchannel-logout').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
});
});

View file

@ -47,6 +47,15 @@ describe(SharedLinkController.name, () => {
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
});
it('should require an albumId for share type Album', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/shared-links')
.send({ type: SharedLinkType.Album });
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }]));
expect(service.create).not.toHaveBeenCalled();
});
it('should not allow an albumId for share type Individual', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/shared-links')

View file

@ -0,0 +1,28 @@
import { SystemMetadataController } from 'src/controllers/system-metadata.controller';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import request from 'supertest';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SystemMetadataController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(SystemMetadataService);
beforeAll(async () => {
ctx = await controllerSetup(SystemMetadataController, [{ provide: SystemMetadataService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /system-metadata/admin-onboarding', () => {
it('should require isOnboarded', async () => {
const { status } = await request(ctx.getHttpServer()).post('/system-metadata/admin-onboarding').send({});
expect(status).toBe(400);
expect(service.updateAdminOnboarding).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,44 @@
import { TrashController } from 'src/controllers/trash.controller';
import { TrashService } from 'src/services/trash.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(TrashController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(TrashService);
beforeAll(async () => {
ctx = await controllerSetup(TrashController, [{ provide: TrashService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /trash/restore/assets', () => {
it('should require asset ids', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/trash/restore/assets').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['ids'], message: 'Invalid input: expected array, received undefined' },
]),
);
expect(service.restoreAssets).not.toHaveBeenCalled();
});
it('should require valid asset ids', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/trash/restore/assets')
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
expect(service.restoreAssets).not.toHaveBeenCalled();
});
});
});

View file

@ -1,10 +1,13 @@
import { Kysely } from 'kysely';
import { AccessRepository } from 'src/repositories/access.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 { AlbumService } from 'src/services/album.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
@ -12,7 +15,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AlbumService, {
database: db || defaultDatabase,
real: [AlbumRepository, UserRepository],
real: [AccessRepository, AlbumRepository, AssetRepository, UserRepository],
mock: [LoggingRepository],
});
};
@ -22,16 +25,35 @@ beforeAll(async () => {
});
describe(AlbumService.name, () => {
describe('removeAssets', () => {
it('should not remove assets from an album of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
await expect(sut.removeAssets(factory.auth({ user: otherUser }), album.id, { ids: [asset.id] })).rejects.toThrow(
'Not found or no albumAsset.delete access',
);
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
});
describe('database triggers', () => {
it('should cascade delete an album when the owner is deleted', async () => {
const { ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAlbum({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.get(UserRepository).delete({ id: user.id }, true);
await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]);
await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]);
await expect(ctx.database.selectFrom('album').selectAll().where('id', '=', album.id).execute()).resolves.toEqual(
[],
);
await expect(
ctx.database.selectFrom('album_user').selectAll().where('albumId', '=', album.id).execute(),
).resolves.toEqual([]);
});
});
});

View file

@ -24,6 +24,44 @@ beforeAll(async () => {
});
describe(ApiKeyService.name, () => {
describe('getById', () => {
it('should not return an api key of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] });
await expect(sut.getById(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found');
});
});
describe('update', () => {
it('should not update an api key of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] });
await expect(sut.update(factory.auth({ user: otherUser }), apiKey.id, { name: 'new name' })).rejects.toThrow(
'API Key not found',
);
});
});
describe('delete', () => {
it('should not delete an api key of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] });
await expect(sut.delete(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found');
await expect(sut.getById(factory.auth({ user }), apiKey.id)).resolves.toEqual(
expect.objectContaining({ id: apiKey.id }),
);
});
});
describe('rotate', () => {
it('should not rotate an api key of another user', async () => {
const { sut, ctx } = setup();

View file

@ -45,6 +45,19 @@ beforeAll(async () => {
});
describe(AssetService.name, () => {
describe('get', () => {
it('should not return an asset of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect(sut.get(factory.auth({ user: otherUser }), asset.id)).rejects.toThrow(
'Not found or no asset.read access',
);
});
});
describe('getStatistics', () => {
it('should return stats as numbers, not strings', async () => {
const { sut, ctx } = setup();
@ -334,6 +347,17 @@ describe(AssetService.name, () => {
});
describe('update', () => {
it('should not update an asset of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await expect(sut.update(factory.auth({ user: otherUser }), asset.id, {})).rejects.toThrow(
'Not found or no asset.update access',
);
});
it('should automatically lock lockable columns', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();

View file

@ -34,7 +34,70 @@ const setup = (db?: Kysely<DB>) => {
});
};
/** A memory owned by one user, plus another user's auth to attempt access with */
const newMemoryOfAnotherUser = async (ctx: ReturnType<typeof setup>['ctx']) => {
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { memory } = await ctx.newMemory({ ownerId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user.id });
return { memory, asset, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) };
};
describe(MemoryService.name, () => {
describe('get', () => {
it('should not return a memory of another user', async () => {
const { sut, ctx } = setup();
const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx);
await expect(sut.get(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.read access');
});
});
describe('update', () => {
it('should not update a memory of another user', async () => {
const { sut, ctx } = setup();
const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx);
await expect(sut.update(otherAuth, memory.id, { isSaved: true })).rejects.toThrow(
'Not found or no memory.update access',
);
});
});
describe('remove', () => {
it('should not remove a memory of another user', async () => {
const { sut, ctx } = setup();
const { memory, auth, otherAuth } = await newMemoryOfAnotherUser(ctx);
await expect(sut.remove(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.delete access');
await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ id: memory.id }));
});
});
describe('addAssets', () => {
it('should not add assets to a memory of another user', async () => {
const { sut, ctx } = setup();
const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx);
await expect(sut.addAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow(
'Not found or no memory.read access',
);
});
});
describe('removeAssets', () => {
it('should not remove assets from a memory of another user', async () => {
const { sut, ctx } = setup();
const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx);
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
await expect(sut.removeAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow(
'Not found or no memory.update access',
);
});
});
beforeEach(async () => {
defaultDatabase = await getKyselyDB();
});

View file

@ -0,0 +1,44 @@
import { Kysely } from 'kysely';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { DB } from 'src/schema';
import { StackService } from 'src/services/stack.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(StackService, {
database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, StackRepository],
mock: [EventRepository, LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(StackService.name, () => {
describe('create', () => {
it('should not stack an asset of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { asset: otherAsset } = await ctx.newAsset({ ownerId: otherUser.id });
await expect(sut.create(factory.auth({ user }), { assetIds: [asset.id, otherAsset.id] })).rejects.toThrow(
'Not found or no asset.update access',
);
await expect(
ctx.database.selectFrom('stack').selectAll().where('ownerId', '=', user.id).execute(),
).resolves.toEqual([]);
});
});
});

View file

@ -22,12 +22,61 @@ const setup = (db?: Kysely<DB>) => {
});
};
/** A tag owned by one user, plus another user's auth to attempt access with */
const newTagOfAnotherUser = async (ctx: ReturnType<typeof setup>['ctx']) => {
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] });
return { tag, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(TagService.name, () => {
describe('get', () => {
it('should not return a tag of another user', async () => {
const { sut, ctx } = setup();
const { tag, otherAuth } = await newTagOfAnotherUser(ctx);
await expect(sut.get(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.read access');
});
});
describe('update', () => {
it('should not update a tag of another user', async () => {
const { sut, ctx } = setup();
const { tag, otherAuth } = await newTagOfAnotherUser(ctx);
await expect(sut.update(otherAuth, tag.id, { color: '#000000' })).rejects.toThrow(
'Not found or no tag.update access',
);
});
});
describe('remove', () => {
it('should not remove a tag of another user', async () => {
const { sut, ctx } = setup();
const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx);
await expect(sut.remove(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.delete access');
await expect(sut.get(auth, tag.id)).resolves.toEqual(expect.objectContaining({ id: tag.id }));
});
});
describe('addAssets', () => {
it('should not add assets to a tag of another user', async () => {
const { sut, ctx } = setup();
const { tag, otherAuth } = await newTagOfAnotherUser(ctx);
const { asset } = await ctx.newAsset({ ownerId: otherAuth.user.id });
await expect(sut.addAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow(
'Not found or no tag.asset access',
);
});
it('should lock exif column', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
@ -53,6 +102,22 @@ describe(TagService.name, () => {
await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id);
});
});
describe('removeAssets', () => {
it('should not remove assets from a tag of another user', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await sut.addAssets(auth, tag.id, { ids: [asset.id] });
await expect(sut.removeAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow(
'Not found or no tag.asset access',
);
await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id);
});
});
describe('deleteEmptyTags', () => {
it('single tag exists, not connected to any assets, and is deleted', async () => {
const { sut, ctx } = setup();