mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
Merge branch 'main' into feat/album-asset-workflow-trigger
This commit is contained in:
commit
927a8a8dcf
350 changed files with 4864 additions and 6903 deletions
|
|
@ -56,7 +56,7 @@ FROM builder AS plugins
|
|||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./mise.toml ./mise.toml
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev
|
||||
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
|
|
|
|||
|
|
@ -2,13 +2,8 @@ import js from '@eslint/js';
|
|||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
||||
import globals from 'globals';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import typescriptEslint from 'typescript-eslint';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default typescriptEslint.config([
|
||||
eslintPluginUnicorn.configs.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
|
|
@ -29,7 +24,7 @@ export default typescriptEslint.config([
|
|||
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -76,6 +71,7 @@ export default typescriptEslint.config([
|
|||
curly: 2,
|
||||
'prettier/prettier': 0,
|
||||
'object-shorthand': ['error', 'always'],
|
||||
eqeqeq: 'error',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ describe(ActivityController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /activities', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/activities');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
|
||||
expect(status).toEqual(400);
|
||||
|
|
@ -50,11 +45,6 @@ describe(ActivityController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /activities', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/activities');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/activities')
|
||||
|
|
@ -77,11 +67,6 @@ describe(ActivityController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /activities/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/activities/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ describe(AlbumController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /albums', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/albums');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invalid shared param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?isShared=invalid');
|
||||
expect(status).toEqual(400);
|
||||
|
|
@ -40,60 +35,4 @@ describe(AlbumController.name, () => {
|
|||
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /albums/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /albums/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/albums/statistics');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /albums', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/albums').send({ albumName: 'New album' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /albums/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /albums/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /albums/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /albums/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/albums/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT :id/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/${factory.uuid()}/users`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,33 +19,7 @@ describe(ApiKeyController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /api-keys', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/api-keys').send({ name: 'API Key' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/api-keys');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/api-keys/me`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -54,11 +28,6 @@ describe(ApiKeyController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/api-keys/${factory.uuid()}`).send({ name: 'new name' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/api-keys/123`)
|
||||
|
|
@ -76,11 +45,6 @@ describe(ApiKeyController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/api-keys/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -44,11 +44,6 @@ describe(AssetMediaController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept metadata', async () => {
|
||||
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
|
|
@ -171,20 +166,9 @@ describe(AssetMediaController.name, () => {
|
|||
});
|
||||
|
||||
// TODO figure out how to deal with `sendFile`
|
||||
describe.skip('GET /assets/:id/original', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO figure out how to deal with `sendFile`
|
||||
describe('GET /assets/:id/thumbnail', () => {
|
||||
it.skip('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redirect if size=original is requested', async () => {
|
||||
const { status } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail?size=original`);
|
||||
expect(status).toBe(302);
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
|
|
@ -59,13 +54,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.delete(`/assets`)
|
||||
.send({ ids: [factory.uuid()] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete(`/assets`)
|
||||
|
|
@ -77,11 +65,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -90,11 +73,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /assets/copy', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/copy`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require target and source id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -115,11 +93,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
|
|
@ -151,11 +124,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
|
|
@ -187,11 +155,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -267,26 +230,7 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/statistics`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -353,11 +297,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /assets/:id/metadata/:key', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -366,11 +305,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /assets/:id/edits', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept valid edits and pass to service correctly', async () => {
|
||||
const edits = [
|
||||
{
|
||||
|
|
@ -456,11 +390,6 @@ describe(AssetController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /assets/:id/metadata/:key', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -199,28 +199,7 @@ describe(AuthController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/auth/logout');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/change-password', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/auth/change-password')
|
||||
.send({ password: 'password', newPassword: 'Password1234', invalidateSessions: false });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/pin-code', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject 5 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
|
||||
expect(status).toEqual(400);
|
||||
|
|
@ -251,25 +230,4 @@ describe(AuthController.name, () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /auth/pin-code', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /auth/pin-code', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/status', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/auth/status');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,13 +25,6 @@ describe(DatabaseBackupController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /admin/database-backups', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/admin/database-backups').send();
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /admin/database-backups/start-restore', () => {
|
||||
it('should not be an authenticated route', async () => {
|
||||
maintenanceService.startRestoreFlow.mockResolvedValue({ jwt: 'jwt' });
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
import { Readable } from 'node:stream';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(DownloadController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(DownloadService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(DownloadController, [{ provide: DownloadService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /download/info', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/download/info')
|
||||
.send({ assetIds: [factory.uuid()] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/archive', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
const stream = new Readable({
|
||||
read() {
|
||||
// eslint-disable-next-line unicorn/no-this-outside-of-class
|
||||
this.push('test');
|
||||
// eslint-disable-next-line unicorn/no-this-outside-of-class
|
||||
this.push(null);
|
||||
},
|
||||
});
|
||||
service.downloadArchive.mockResolvedValue({ stream });
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/download/archive')
|
||||
.send({ assetIds: [factory.uuid()] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -18,26 +18,7 @@ describe(DuplicateController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /duplicates', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/duplicates');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /duplicates', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/duplicates');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /duplicates/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/duplicates/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ describe(MaintenanceController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /admin/maintenance', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/maintenance').send();
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a backup file when action is restore', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/admin/maintenance').send({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ describe(MemoryController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /memories', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/memories');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not require any parameters', async () => {
|
||||
await request(ctx.getHttpServer()).get('/memories').query({});
|
||||
expect(service.search).toHaveBeenCalled();
|
||||
|
|
@ -32,11 +27,6 @@ describe(MemoryController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /memories', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/memories');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate data when type is on this day', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
|
|
@ -69,19 +59,7 @@ describe(MemoryController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /memories/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/memories/statistics');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /memories/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/memories/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -90,11 +68,6 @@ describe(MemoryController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /memories/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -114,19 +87,7 @@ describe(MemoryController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DELETE /memories/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /memories/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -143,11 +104,6 @@ describe(MemoryController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /memories/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -21,11 +21,6 @@ describe(NotificationAdminController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /admin/notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a null readAt', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post(`/admin/notifications`)
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ describe(NotificationController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should reject an invalid notification level`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/notifications`)
|
||||
|
|
@ -40,11 +35,6 @@ describe(NotificationController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('ids', () => {
|
||||
it('should require a list', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
|
||||
|
|
@ -75,11 +65,6 @@ describe(NotificationController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /notifications/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/notifications/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -88,11 +73,6 @@ describe(NotificationController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /notifications/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/notifications/${factory.uuid()}`).send({ readAt: factory.date() });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a null readAt', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/notifications/${id}`).send({ readAt: null });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||
import { PartnerService } from 'src/services/partner.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(PartnerController.name, () => {
|
||||
|
|
@ -24,11 +23,6 @@ describe(PartnerController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /partners', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/partners');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require a direction`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -54,11 +48,6 @@ describe(PartnerController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /partners', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/partners');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require sharedWithId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/partners`)
|
||||
|
|
@ -70,11 +59,6 @@ describe(PartnerController.name, () => {
|
|||
});
|
||||
|
||||
describe('PUT /partners/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/partners/invalid`)
|
||||
|
|
@ -86,11 +70,6 @@ describe(PartnerController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /partners/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete(`/partners/invalid`)
|
||||
|
|
|
|||
|
|
@ -24,11 +24,6 @@ describe(PersonController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /people', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require closestPersonId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/people`)
|
||||
|
|
@ -48,19 +43,7 @@ describe(PersonController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /people', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require uuids in the body', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/people')
|
||||
|
|
@ -78,19 +61,7 @@ describe(PersonController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /people/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /people/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -177,11 +148,6 @@ describe(PersonController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /people/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -194,18 +160,4 @@ describe(PersonController.name, () => {
|
|||
expect(service.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /people/:id/merge', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /people/:id/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}/statistics`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||
import { PluginService } from 'src/services/plugin.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(PluginController.name, () => {
|
||||
|
|
@ -24,11 +23,6 @@ describe(PluginController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /plugins', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/plugins');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/plugins`)
|
||||
|
|
@ -40,11 +34,6 @@ describe(PluginController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /plugins/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/plugins/invalid`)
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ describe(SearchController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /search/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/search/metadata');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject page as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -121,11 +116,6 @@ describe(SearchController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/search/random');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject if withStacked is not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/random')
|
||||
|
|
@ -151,26 +141,7 @@ describe(SearchController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /search/smart', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/search/smart');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/explore', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/explore');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search/person', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/person');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -181,11 +152,6 @@ describe(SearchController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /search/places', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/places');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -195,19 +161,7 @@ describe(SearchController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /search/cities', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/cities');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/suggestions', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/suggestions');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a type', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { ServerController } from 'src/controllers/server.controller';
|
||||
import { ServerService } from 'src/services/server.service';
|
||||
import { SystemMetadataService } from 'src/services/system-metadata.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(ServerController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const serverService = mockBaseService(ServerService);
|
||||
const systemMetadataService = mockBaseService(SystemMetadataService);
|
||||
const versionService = mockBaseService(VersionService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(ServerController, [
|
||||
{ provide: ServerService, useValue: serverService },
|
||||
{ provide: SystemMetadataService, useValue: systemMetadataService },
|
||||
{ provide: VersionService, useValue: versionService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
serverService.resetAllMocks();
|
||||
versionService.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/server/license');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
|||
import { Permission, SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
|
|
@ -19,10 +20,24 @@ describe(SharedLinkController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /shared-links/me', () => {
|
||||
it('should be a shared link route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/shared-links/me');
|
||||
expect(ctx.authenticate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadata: expect.objectContaining({ sharedLinkRoute: true }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /shared-links', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/shared-links');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
it('should require a type and the correspondent asset/album id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/shared-links')
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow an null expiresAt', async () => {
|
||||
|
|
|
|||
46
server/src/controllers/stack.controller.spec.ts
Normal file
46
server/src/controllers/stack.controller.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { StackController } from 'src/controllers/stack.controller';
|
||||
import { StackService } from 'src/services/stack.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(StackController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(StackService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(StackController, [{ provide: StackService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /stacks', () => {
|
||||
it('should require at least two assets', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/stacks')
|
||||
.send({ assetIds: [factory.uuid()] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/stacks')
|
||||
.send({ assetIds: ['invalid', 'invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['assetIds', 0], message: 'Invalid UUID' },
|
||||
{ path: ['assetIds', 1], message: 'Invalid UUID' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -25,11 +25,6 @@ describe(SyncController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /sync/stream', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/sync/stream');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require sync request type enums', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/sync/stream')
|
||||
|
|
@ -44,19 +39,7 @@ describe(SyncController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /sync/ack', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/sync/ack');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /sync/ack', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/sync/ack');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow more than 1,000 entries', async () => {
|
||||
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
|
||||
|
|
@ -69,11 +52,6 @@ describe(SyncController.name, () => {
|
|||
});
|
||||
|
||||
describe('DELETE /sync/ack', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/sync/ack');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require sync response type enums', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/sync/ack')
|
||||
|
|
|
|||
|
|
@ -40,26 +40,7 @@ describe(SystemConfigController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /system-config', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/system-config');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /system-config/defaults', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/system-config/defaults');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /system-config', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/system-config');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('nightlyTasks', () => {
|
||||
it('should validate nightly jobs start time', async () => {
|
||||
const config = validConfig();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { TagController } from 'src/controllers/tag.controller';
|
|||
import { TagService } from 'src/services/tag.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(TagController.name, () => {
|
||||
|
|
@ -19,38 +18,14 @@ describe(TagController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should a null parentId', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/tags`).send({ name: 'tag', parentId: null });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ parentId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
|
||||
expect(status).toBe(400);
|
||||
|
|
@ -58,10 +33,11 @@ describe(TagController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
describe('DELETE /tags/:id', () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/tags/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ describe(TimelineController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /timeline/buckets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/timeline/buckets');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse bbox query string into an object', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.get('/timeline/buckets')
|
||||
|
|
@ -58,11 +53,6 @@ describe(TimelineController.name, () => {
|
|||
});
|
||||
|
||||
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' });
|
||||
|
|
|
|||
|
|
@ -24,26 +24,7 @@ describe(UserAdminController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /admin/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/admin/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /admin/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a null pinCode', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/admin/users`).send({
|
||||
name: 'Test user',
|
||||
|
|
@ -64,25 +45,22 @@ describe(UserAdminController.name, () => {
|
|||
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
|
||||
it(`should `, async () => {
|
||||
const dto: UserAdminCreateDto = {
|
||||
email: 'user@immich.app',
|
||||
password: 'test',
|
||||
name: 'Test User',
|
||||
quotaSizeInBytes: 1.2,
|
||||
};
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
['notify', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ email: 'user@immich.app', password: 'test', name: 'Test User', [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
|
||||
it(`should not allow decimal quota`, async () => {
|
||||
const dto: UserAdminCreateDto = {
|
||||
|
|
@ -105,19 +83,7 @@ describe(UserAdminController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should not allow decimal quota`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/admin/users/${factory.uuid()}`)
|
||||
|
|
@ -142,5 +108,21 @@ describe(UserAdminController.name, () => {
|
|||
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ avatarColor: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/admin/users/${factory.uuid()}`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||
import { UserService } from 'src/services/user.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(UserController.name, () => {
|
||||
|
|
@ -23,26 +22,7 @@ describe(UserController.name, () => {
|
|||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/users/me');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/users/me');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
|
|
@ -66,24 +46,31 @@ describe(UserController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
describe('PUT /users/me/preferences', () => {
|
||||
it('should require an integer for download archive size', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/users/me/preferences`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ download: { archiveSize: 1_234_567.89 } });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me/license', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/users/me/license');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/me/license', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/users/me/license');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
it('should require a boolean for download include embedded videos', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/users/me/preferences`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ download: { includeEmbeddedVideos: 1_234_567.89 } });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(WorkflowController.name, () => {
|
||||
|
|
@ -25,11 +24,6 @@ describe(WorkflowController.name, () => {
|
|||
});
|
||||
|
||||
describe('POST /workflows', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/workflows').send({});
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require a valid trigger`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/workflows`)
|
||||
|
|
@ -65,11 +59,6 @@ describe(WorkflowController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /workflows', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/workflows');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/workflows`)
|
||||
|
|
@ -81,11 +70,6 @@ describe(WorkflowController.name, () => {
|
|||
});
|
||||
|
||||
describe('GET /workflows/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/workflows/invalid`)
|
||||
|
|
@ -96,11 +80,6 @@ describe(WorkflowController.name, () => {
|
|||
});
|
||||
|
||||
describe('PATCH /workflows/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/workflows/${factory.uuid()}`).send({});
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/workflows/invalid`)
|
||||
|
|
|
|||
|
|
@ -838,7 +838,7 @@ export class AssetRepository {
|
|||
)
|
||||
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(options.visibility === undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
|
||||
.$if(!!options.bbox, (qb) => {
|
||||
const bbox = options.bbox!;
|
||||
|
|
@ -899,7 +899,7 @@ export class AssetRepository {
|
|||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||
.orderBy(
|
||||
options.orderBy == AssetOrderBy.CreatedAt
|
||||
options.orderBy === AssetOrderBy.CreatedAt
|
||||
? sql`"createdAt"`
|
||||
: sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`,
|
||||
order,
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export class TelemetryRepository {
|
|||
const unit = 'ms';
|
||||
|
||||
for (const [propName, descriptor] of Object.entries(descriptors)) {
|
||||
const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor';
|
||||
const isMethod = typeof descriptor.value === 'function' && propName !== 'constructor';
|
||||
if (!isMethod) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ export class UserRepository {
|
|||
updatedAt: new Date(),
|
||||
})
|
||||
.where('user.deletedAt', 'is', null)
|
||||
.$if(id != undefined, (eb) => eb.where('user.id', '=', asUuid(id!)));
|
||||
.$if(id !== undefined, (eb) => eb.where('user.id', '=', asUuid(id!)));
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,20 @@ export const album_asset_delete_audit = registerFunction({
|
|||
END`,
|
||||
});
|
||||
|
||||
export const album_user_delete = registerFunction({
|
||||
name: 'album_user_delete',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
DELETE FROM "album"
|
||||
WHERE "album"."id" = OLD."albumId"
|
||||
AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');
|
||||
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
export const album_user_delete_audit = registerFunction({
|
||||
name: 'album_user_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from 'src/schema/enums';
|
||||
import {
|
||||
album_user_after_insert,
|
||||
album_user_delete,
|
||||
album_user_delete_audit,
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
|
|
@ -175,6 +176,7 @@ export class ImmichDatabase {
|
|||
asset_metadata_audit,
|
||||
asset_face_audit,
|
||||
asset_ocr_delete_audit,
|
||||
album_user_delete,
|
||||
];
|
||||
|
||||
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`DELETE FROM "album" WHERE NOT EXISTS (SELECT * FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');`.execute(db);
|
||||
await sql`CREATE OR REPLACE FUNCTION album_user_delete()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM "album"
|
||||
WHERE "album"."id" = OLD."albumId"
|
||||
AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');
|
||||
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_user_delete"
|
||||
AFTER DELETE ON "album_user"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION album_user_delete();`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_delete', '{"type":"function","name":"album_user_delete","sql":"CREATE OR REPLACE FUNCTION album_user_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM \\"album\\"\\n WHERE \\"album\\".\\"id\\" = OLD.\\"albumId\\"\\n AND NOT EXISTS (SELECT \\"albumId\\" FROM \\"album_user\\" WHERE \\"album_user\\".\\"albumId\\" = \\"album\\".\\"id\\" AND \\"album_user\\".\\"role\\" = ''owner'');\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_delete', '{"type":"trigger","name":"album_user_delete","sql":"CREATE OR REPLACE TRIGGER \\"album_user_delete\\"\\n AFTER DELETE ON \\"album_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION album_user_delete();"}'::jsonb);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TRIGGER "album_user_delete" ON "album_user";`.execute(db);
|
||||
await sql`DROP FUNCTION album_user_delete;`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_delete';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_delete';`.execute(db);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { album_user_role_enum } from 'src/schema/enums';
|
||||
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
|
||||
import { album_user_after_insert, album_user_delete, album_user_delete_audit } from 'src/schema/functions';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
|
|
@ -38,6 +38,7 @@ import { UserTable } from 'src/schema/tables/user.table';
|
|||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() <= 1',
|
||||
})
|
||||
@AfterDeleteTrigger({ scope: 'row', function: album_user_delete, referencingOldTableAs: 'old' })
|
||||
export class AlbumUserTable {
|
||||
@ForeignKeyColumn(() => AlbumTable, {
|
||||
onDelete: 'CASCADE',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.reposi
|
|||
import { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
import { asDateTimeString } from 'src/utils/date';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -353,11 +354,7 @@ export class AlbumService extends BaseService {
|
|||
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
|
||||
const album = await this.albumRepository.getById(id, options, authUserId);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
return album;
|
||||
private findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
|
||||
return findOrFail(() => this.albumRepository.getById(id, options, authUserId), 'Album');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
|
|
@ -46,6 +45,7 @@ import {
|
|||
} from 'src/utils/asset.util';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
import { extractTimeZone } from 'src/utils/date';
|
||||
import { batched, findOrFail } from 'src/utils/misc';
|
||||
import { transformOcrBoundingBox } from 'src/utils/transform';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -278,31 +278,12 @@ export class AssetService extends BaseService {
|
|||
.minus(Duration.fromObject({ days: trashedDays }))
|
||||
.toJSDate();
|
||||
|
||||
let chunk: Array<{ id: string; isOffline: boolean }> = [];
|
||||
const queueChunk = async () => {
|
||||
if (chunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const assets of batched(this.assetJobRepository.streamForDeletedJob(trashedBefore))) {
|
||||
await this.jobRepository.queueAll(
|
||||
chunk.map(({ id, isOffline }) => ({
|
||||
name: JobName.AssetDelete,
|
||||
data: { id, deleteOnDisk: !isOffline },
|
||||
})),
|
||||
assets.map(({ id, isOffline }) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: !isOffline } })),
|
||||
);
|
||||
chunk = [];
|
||||
};
|
||||
|
||||
const assets = this.assetJobRepository.streamForDeletedJob(trashedBefore);
|
||||
for await (const asset of assets) {
|
||||
chunk.push(asset);
|
||||
if (chunk.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueChunk();
|
||||
}
|
||||
}
|
||||
|
||||
await queueChunk();
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
@ -496,12 +477,8 @@ export class AssetService extends BaseService {
|
|||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const asset = await this.assetRepository.getById(id);
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
return asset;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.assetRepository.getById(id), 'Asset');
|
||||
}
|
||||
|
||||
private async updateExif(dto: {
|
||||
|
|
|
|||
|
|
@ -549,7 +549,7 @@ export class AuthService extends BaseService {
|
|||
const now = DateTime.now();
|
||||
const updatedAt = DateTime.fromJSDate(session.updatedAt);
|
||||
const diff = now.diff(updatedAt, ['hours']);
|
||||
if (diff.hours > 1 || appVersion != session.appVersion) {
|
||||
if (diff.hours > 1 || appVersion !== session.appVersion) {
|
||||
await this.sessionRepository.update(session.id, {
|
||||
id: session.id,
|
||||
updatedAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
|
|
@ -8,9 +7,9 @@ import { DuplicateResolveDto, DuplicateResolveGroupDto, DuplicateResponseDto } f
|
|||
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { JobOf } from 'src/types';
|
||||
import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
|
||||
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
||||
import { batched, isDuplicateDetectionEnabled } from 'src/utils/misc';
|
||||
|
||||
type ResolveRequest = {
|
||||
assetUpdate: {
|
||||
|
|
@ -307,22 +306,12 @@ export class DuplicateService extends BaseService {
|
|||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
let jobs: JobItem[] = [];
|
||||
const queueAll = async () => {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
};
|
||||
|
||||
const assets = this.assetJobRepository.streamForSearchDuplicates(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.AssetDetectDuplicates, data: { id: asset.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForSearchDuplicates(force))) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.AssetDetectDuplicates, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await queueAll();
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import {
|
|||
IIntegrityUntrackedFilesJob,
|
||||
} from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
import { batched, handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
/**
|
||||
* Untracked Files:
|
||||
|
|
@ -201,7 +201,7 @@ export class IntegrityService extends BaseService {
|
|||
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.UntrackedFile);
|
||||
|
||||
let total = 0;
|
||||
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
for await (const batchReports of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityUntrackedFilesRefresh,
|
||||
data: {
|
||||
|
|
@ -338,7 +338,7 @@ export class IntegrityService extends BaseService {
|
|||
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.MissingFile);
|
||||
|
||||
let total = 0;
|
||||
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
for await (const batchReports of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityMissingFilesRefresh,
|
||||
data: {
|
||||
|
|
@ -365,7 +365,7 @@ export class IntegrityService extends BaseService {
|
|||
const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
|
||||
|
||||
let total = 0;
|
||||
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
for await (const batchPaths of batched(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityMissingFiles,
|
||||
data: {
|
||||
|
|
@ -450,7 +450,7 @@ export class IntegrityService extends BaseService {
|
|||
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.ChecksumFail);
|
||||
|
||||
let total = 0;
|
||||
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
for await (const batchReports of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityChecksumFilesRefresh,
|
||||
data: {
|
||||
|
|
@ -656,7 +656,7 @@ export class IntegrityService extends BaseService {
|
|||
|
||||
for (const property of properties) {
|
||||
const reports = this.integrityRepository.streamIntegrityReportsByProperty(property, type);
|
||||
for await (const batch of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
for await (const batch of batched(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityDeleteReports,
|
||||
data: {
|
||||
|
|
@ -705,19 +705,3 @@ export class IntegrityService extends BaseService {
|
|||
return JobStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
async function* chunk<T>(generator: AsyncIterableIterator<T>, n: number) {
|
||||
let chunk: T[] = [];
|
||||
for await (const item of generator) {
|
||||
chunk.push(item);
|
||||
|
||||
if (chunk.length === n) {
|
||||
yield chunk;
|
||||
chunk = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.length > 0) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { AssetTable } from 'src/schema/tables/asset.table';
|
|||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf } from 'src/types';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
import { batched, findOrFail, handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService extends BaseService {
|
||||
|
|
@ -375,35 +375,20 @@ export class LibraryService extends BaseService {
|
|||
|
||||
await this.assetRepository.updateByLibraryId(libraryId, { deletedAt: new Date() });
|
||||
|
||||
let isAssetsFound = false;
|
||||
let chunk: string[] = [];
|
||||
|
||||
const queueChunk = async () => {
|
||||
if (chunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAssetsFound = true;
|
||||
this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`);
|
||||
await this.jobRepository.queueAll(
|
||||
chunk.map((id) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: false } })),
|
||||
);
|
||||
chunk = [];
|
||||
};
|
||||
|
||||
this.logger.debug(`Will delete all assets in library ${libraryId}`);
|
||||
const assets = this.libraryRepository.streamAssetIds(libraryId);
|
||||
for await (const asset of assets) {
|
||||
chunk.push(asset.id);
|
||||
|
||||
if (chunk.length >= JOBS_LIBRARY_PAGINATION_SIZE) {
|
||||
await queueChunk();
|
||||
}
|
||||
let hasAssets = false;
|
||||
for await (const assets of batched(
|
||||
this.libraryRepository.streamAssetIds(libraryId),
|
||||
JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
)) {
|
||||
this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`);
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.AssetDelete, data: { id: asset.id, deleteOnDisk: false } })),
|
||||
);
|
||||
hasAssets = true;
|
||||
}
|
||||
|
||||
await queueChunk();
|
||||
|
||||
if (!isAssetsFound) {
|
||||
if (!hasAssets) {
|
||||
this.logger.log(`Deleting library ${libraryId}`);
|
||||
await this.libraryRepository.delete(libraryId);
|
||||
}
|
||||
|
|
@ -746,15 +731,12 @@ export class LibraryService extends BaseService {
|
|||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
let chunk: string[] = [];
|
||||
this.logger.log(`Scanning library ${library.id} for assets missing from disk...`);
|
||||
|
||||
let count = 0;
|
||||
|
||||
const queueChunk = async () => {
|
||||
if (chunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
count += chunk.length;
|
||||
const existingAssets = this.libraryRepository.streamAssetIds(library.id);
|
||||
for await (const assets of batched(existingAssets, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
count += assets.length;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncAssets,
|
||||
|
|
@ -762,42 +744,25 @@ export class LibraryService extends BaseService {
|
|||
libraryId: library.id,
|
||||
importPaths: library.importPaths,
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
assetIds: chunk.map((id) => id),
|
||||
assetIds: assets.map(({ id }) => id),
|
||||
progressCounter: count,
|
||||
totalAssets: assetCount,
|
||||
},
|
||||
});
|
||||
chunk = [];
|
||||
|
||||
const completePercentage = ((100 * count) / assetCount).toFixed(1);
|
||||
|
||||
this.logger.log(
|
||||
`Queued check of ${count} of ${assetCount} (${completePercentage} %) existing asset(s) so far in library ${library.id}`,
|
||||
);
|
||||
};
|
||||
|
||||
this.logger.log(`Scanning library ${library.id} for assets missing from disk...`);
|
||||
const existingAssets = this.libraryRepository.streamAssetIds(library.id);
|
||||
|
||||
for await (const asset of existingAssets) {
|
||||
chunk.push(asset.id);
|
||||
if (chunk.length === JOBS_LIBRARY_PAGINATION_SIZE) {
|
||||
await queueChunk();
|
||||
}
|
||||
}
|
||||
|
||||
await queueChunk();
|
||||
|
||||
this.logger.log(`Finished queuing ${count} asset check(s) for library ${library.id}`);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const library = await this.libraryRepository.get(id);
|
||||
if (!library) {
|
||||
throw new BadRequestException('Library not found');
|
||||
}
|
||||
return library;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.libraryRepository.get(id), 'Library');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,8 +207,7 @@ describe(MediaService.name, () => {
|
|||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
|
||||
|
|
@ -237,7 +236,10 @@ describe(MediaService.name, () => {
|
|||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } },
|
||||
]);
|
||||
|
||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
|
|
@ -43,7 +43,7 @@ import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
|||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { clamp } from 'src/utils/misc';
|
||||
import { batched, clamp } from 'src/utils/misc';
|
||||
import { getOutputDimensions } from 'src/utils/transform';
|
||||
|
||||
interface UpsertFileOptions {
|
||||
|
|
@ -69,52 +69,42 @@ export class MediaService extends BaseService {
|
|||
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
||||
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.AssetGenerateThumbnailsQueueAll>): Promise<JobStatus> {
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
let jobs: JobItem[] = [];
|
||||
|
||||
const queueAll = async () => {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
};
|
||||
|
||||
const isFullsizeEnabled = config.image.fullsize.enabled;
|
||||
for await (const asset of this.assetJobRepository.streamForThumbnailJob({
|
||||
force,
|
||||
fullsizeEnabled: isFullsizeEnabled,
|
||||
})) {
|
||||
if (force || !asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
}
|
||||
|
||||
await queueAll();
|
||||
|
||||
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
|
||||
|
||||
for await (const person of people) {
|
||||
if (!person.faceAssetId) {
|
||||
const face = await this.personRepository.getRandomFace(person.id);
|
||||
if (!face) {
|
||||
continue;
|
||||
for await (const assets of batched(
|
||||
this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled: isFullsizeEnabled }),
|
||||
)) {
|
||||
const jobs: JobItem[] = [];
|
||||
for (const asset of assets) {
|
||||
if (force || !asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
||||
if (asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
await queueAll();
|
||||
for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
|
||||
const jobs: JobItem[] = [];
|
||||
for (const person of people) {
|
||||
if (!person.faceAssetId) {
|
||||
const face = await this.personRepository.getRandomFace(person.id);
|
||||
if (!face) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
||||
}
|
||||
|
||||
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
|
@ -127,30 +117,18 @@ export class MediaService extends BaseService {
|
|||
await this.storageCore.removeEmptyDirs(StorageFolder.EncodedVideo);
|
||||
}
|
||||
|
||||
let jobs: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForMigrationJob();
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.AssetFileMigration, data: { id: asset.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForMigrationJob())) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.AssetFileMigration, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
|
||||
for await (const person of this.personRepository.getAll()) {
|
||||
jobs.push({ name: JobName.PersonFileMigration, data: { id: person.id } });
|
||||
|
||||
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
for await (const people of batched(this.personRepository.getAll())) {
|
||||
await this.jobRepository.queueAll(
|
||||
people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
@ -551,18 +529,12 @@ export class MediaService extends BaseService {
|
|||
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
|
||||
let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = [];
|
||||
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
|
||||
queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
|
||||
|
||||
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(queue);
|
||||
queue = [];
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForVideoConversion(force))) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.AssetEncodeVideo, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(queue);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Memory } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
|
|
@ -8,6 +8,7 @@ import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, m
|
|||
import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
|
||||
const DAYS = 3;
|
||||
|
||||
|
|
@ -162,11 +163,7 @@ export class MemoryService extends BaseService {
|
|||
return results;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const memory = await this.memoryRepository.get(id);
|
||||
if (!memory) {
|
||||
throw new BadRequestException('Memory not found');
|
||||
}
|
||||
return memory;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.memoryRepository.get(id), 'Memory');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { DateTime, Duration } from 'luxon';
|
|||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import { join, parse } from 'node:path';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset, AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
|
|
@ -30,12 +30,12 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
|||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { JobOf } from 'src/types';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { mergeTimeZone } from 'src/utils/date';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||
import { batched, isFaceImportEnabled } from 'src/utils/misc';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
import { Tasks } from 'src/utils/tasks';
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
|
|||
const val = validate(value);
|
||||
|
||||
// check if the value is within the range
|
||||
if (val == null || val < min || val > max) {
|
||||
if (val === null || val < min || val > max) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -218,17 +218,12 @@ export class MetadataService extends BaseService {
|
|||
async handleQueueMetadataExtraction(job: JobOf<JobName.AssetExtractMetadataQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
|
||||
let queue: { name: JobName.AssetExtractMetadata; data: { id: string } }[] = [];
|
||||
for await (const asset of this.assetJobRepository.streamForMetadataExtraction(force)) {
|
||||
queue.push({ name: JobName.AssetExtractMetadata, data: { id: asset.id } });
|
||||
|
||||
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(queue);
|
||||
queue = [];
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForMetadataExtraction(force))) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.AssetExtractMetadata, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(queue);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
@ -377,8 +372,8 @@ export class MetadataService extends BaseService {
|
|||
fileModifiedAt: stats.mtime,
|
||||
|
||||
// Keep unedited assets in sync with the file on disk, but don't overwrite edited dimensions.
|
||||
width: !asset.isEdited || asset.width == null ? assetWidth : undefined,
|
||||
height: !asset.isEdited || asset.height == null ? assetHeight : undefined,
|
||||
width: !asset.isEdited || asset.width === null ? assetWidth : undefined,
|
||||
height: !asset.isEdited || asset.height === null ? assetHeight : undefined,
|
||||
}),
|
||||
async () => {
|
||||
await this.assetRepository.upsertExif({
|
||||
|
|
@ -417,22 +412,12 @@ export class MetadataService extends BaseService {
|
|||
|
||||
@OnJob({ name: JobName.SidecarQueueAll, queue: QueueName.Sidecar })
|
||||
async handleQueueSidecar({ force }: JobOf<JobName.SidecarQueueAll>): Promise<JobStatus> {
|
||||
let jobs: JobItem[] = [];
|
||||
const queueAll = async () => {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
};
|
||||
|
||||
const assets = this.assetJobRepository.streamForSidecar(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.SidecarCheck, data: { id: asset.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForSidecar(force))) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.SidecarCheck, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await queueAll();
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
@ -1003,7 +988,7 @@ export class MetadataService extends BaseService {
|
|||
|
||||
// timezone
|
||||
let timeZone = exifTags.zone ?? null;
|
||||
if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) {
|
||||
if (timeZone === null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) {
|
||||
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
|
||||
// https://github.com/photostructure/exiftool-vendored.js/issues/203
|
||||
timeZone = 'UTC+0';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { OCR } from 'src/repositories/machine-learning.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { JobOf } from 'src/types';
|
||||
import { tokenizeForSearch } from 'src/utils/database';
|
||||
import { isOcrEnabled } from 'src/utils/misc';
|
||||
import { batched, isOcrEnabled } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class OcrService extends BaseService {
|
||||
|
|
@ -21,19 +21,10 @@ export class OcrService extends BaseService {
|
|||
await this.ocrRepository.deleteAll();
|
||||
}
|
||||
|
||||
let jobs: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForOcrJob(force);
|
||||
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.Ocr, data: { id: asset.id } });
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForOcrJob(force))) {
|
||||
await this.jobRepository.queueAll(assets.map((asset) => ({ name: JobName.Ocr, data: { id: asset.id } })));
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Insertable, Updateable } from 'kysely';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { Person } from 'src/database';
|
||||
import { Chunked, OnJob } from 'src/decorators';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
|
|
@ -43,7 +42,7 @@ import { JobItem, JobOf } from 'src/types';
|
|||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { Point, transformPoints } from 'src/utils/transform';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -277,19 +276,12 @@ export class PersonService extends BaseService {
|
|||
await this.personRepository.vacuum({ reindexVectors: true });
|
||||
}
|
||||
|
||||
let jobs: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForDetectFacesJob(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.AssetDetectFaces, data: { id: asset.id } });
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForDetectFacesJob(force))) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.AssetDetectFaces, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
if (force === undefined) {
|
||||
await this.jobRepository.queue({ name: JobName.PersonCleanup });
|
||||
}
|
||||
|
|
@ -435,22 +427,16 @@ export class PersonService extends BaseService {
|
|||
await this.databaseRepository.prewarm(VectorIndex.Face);
|
||||
|
||||
const lastRun = new Date().toISOString();
|
||||
const facePagination = this.personRepository.getAllFaces(
|
||||
|
||||
const faces = this.personRepository.getAllFaces(
|
||||
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
|
||||
);
|
||||
|
||||
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
|
||||
for await (const face of facePagination) {
|
||||
jobs.push({ name: JobName.FacialRecognition, data: { id: face.id, deferred: false } });
|
||||
|
||||
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
for await (const batch of batched(faces)) {
|
||||
await this.jobRepository.queueAll(
|
||||
batch.map((face) => ({ name: JobName.FacialRecognition, data: { id: face.id, deferred: false } })),
|
||||
);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.FacialRecognitionState, { lastRun });
|
||||
|
||||
return JobStatus.Success;
|
||||
|
|
@ -614,12 +600,8 @@ export class PersonService extends BaseService {
|
|||
return results;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const person = await this.personRepository.getById(id);
|
||||
if (!person) {
|
||||
throw new BadRequestException('Person not found');
|
||||
}
|
||||
return person;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.personRepository.getById(id), 'Person');
|
||||
}
|
||||
|
||||
// TODO return a asset face response
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from 'src/dtos/shared-link.dto';
|
||||
import { Permission, SharedLinkType } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
|
||||
import { findOrFail, getExternalDomain, OpenGraphTags } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class SharedLinkService extends BaseService {
|
||||
|
|
@ -143,12 +143,8 @@ export class SharedLinkService extends BaseService {
|
|||
}
|
||||
|
||||
// TODO: replace `userId` with permissions and access control checks
|
||||
private async findOrFail(userId: string, id: string) {
|
||||
const sharedLink = await this.sharedLinkRepository.get(userId, id);
|
||||
if (!sharedLink) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
return sharedLink;
|
||||
private findOrFail(userId: string, id: string) {
|
||||
return findOrFail(() => this.sharedLinkRepository.get(userId, id), 'Shared link');
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
|
||||
import { JobOf } from 'src/types';
|
||||
import { batched, getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class SmartInfoService extends BaseService {
|
||||
|
|
@ -77,18 +77,10 @@ export class SmartInfoService extends BaseService {
|
|||
await this.databaseRepository.setDimensionSize(dimSize);
|
||||
}
|
||||
|
||||
let queue: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForEncodeClip(force);
|
||||
for await (const asset of assets) {
|
||||
queue.push({ name: JobName.SmartSearch, data: { id: asset.id } });
|
||||
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(queue);
|
||||
queue = [];
|
||||
}
|
||||
for await (const assets of batched(this.assetJobRepository.streamForEncodeClip(force))) {
|
||||
await this.jobRepository.queueAll(assets.map((asset) => ({ name: JobName.SmartSearch, data: { id: asset.id } })));
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(queue);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,9 @@ describe(StackService.name, () => {
|
|||
|
||||
it('should fail if stack could not be found', async () => {
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.getById.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error);
|
||||
await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
||||
|
|
@ -124,8 +125,9 @@ describe(StackService.name, () => {
|
|||
|
||||
it('should fail if stack could not be found', async () => {
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.getById.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error);
|
||||
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
|||
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
import { UUIDAssetIDParamDto } from 'src/validation';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -77,12 +78,7 @@ export class StackService extends BaseService {
|
|||
await this.eventRepository.emit('StackUpdate', { stackId, userId: auth.user.id });
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const stack = await this.stackRepository.getById(id);
|
||||
if (!stack) {
|
||||
throw new Error('Asset stack not found');
|
||||
}
|
||||
|
||||
return stack;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.stackRepository.getById(id), 'Asset stack');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,8 +402,8 @@ export class StorageTemplateService extends BaseService {
|
|||
const substitutions: Record<string, string> = {
|
||||
filename,
|
||||
ext: extension,
|
||||
filetype: asset.type == AssetType.Image ? 'IMG' : 'VID',
|
||||
filetypefull: asset.type == AssetType.Image ? 'IMAGE' : 'VIDEO',
|
||||
filetype: asset.type === AssetType.Image ? 'IMG' : 'VID',
|
||||
filetypefull: asset.type === AssetType.Image ? 'IMAGE' : 'VIDEO',
|
||||
assetId: asset.id,
|
||||
assetIdShort: asset.id.slice(-12),
|
||||
//just throw into the root if it doesn't belong to an album
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
|||
import { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -146,12 +147,8 @@ export class TagService extends BaseService {
|
|||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const tag = await this.tagRepository.get(id);
|
||||
if (!tag) {
|
||||
throw new BadRequestException('Tag not found');
|
||||
}
|
||||
return tag;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.tagRepository.get(id), 'Tag');
|
||||
}
|
||||
|
||||
private async updateTags(assetId: string) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TrashResponseDto } from 'src/dtos/trash.dto';
|
||||
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { batched } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class TrashService extends BaseService {
|
||||
|
|
@ -47,39 +47,16 @@ export class TrashService extends BaseService {
|
|||
|
||||
@OnJob({ name: JobName.AssetEmptyTrash, queue: QueueName.BackgroundTask })
|
||||
async handleEmptyTrash() {
|
||||
const assets = this.trashRepository.getDeletedIds();
|
||||
|
||||
let count = 0;
|
||||
const batch: string[] = [];
|
||||
for await (const { id } of assets) {
|
||||
batch.push(id);
|
||||
|
||||
if (batch.length === JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.handleBatch(batch);
|
||||
count += batch.length;
|
||||
batch.length = 0;
|
||||
}
|
||||
for await (const assets of batched(this.trashRepository.getDeletedIds())) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map(({ id }) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: true } })),
|
||||
);
|
||||
count += assets.length;
|
||||
}
|
||||
|
||||
await this.handleBatch(batch);
|
||||
count += batch.length;
|
||||
batch.length = 0;
|
||||
|
||||
this.logger.log(`Queued ${count} asset(s) for deletion from the trash`);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private async handleBatch(ids: string[]) {
|
||||
this.logger.debug(`Queueing ${ids.length} asset(s) for deletion from the trash`);
|
||||
await this.jobRepository.queueAll(
|
||||
ids.map((assetId) => ({
|
||||
name: JobName.AssetDelete,
|
||||
data: {
|
||||
id: assetId,
|
||||
deleteOnDisk: true,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { JobName, UserMetadataKey, UserStatus } from 'src/enum';
|
|||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getCalendarHeatmap } from 'src/services/shared/user-methods';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -158,11 +159,7 @@ export class UserAdminService extends BaseService {
|
|||
return mapPreferences(newPreferences);
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, options: UserFindOptions) {
|
||||
const user = await this.userRepository.get(id, options);
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
return user;
|
||||
private findOrFail(id: string, options: UserFindOptions) {
|
||||
return findOrFail(() => this.userRepository.get(id, options), 'User');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { getCalendarHeatmap } from 'src/services/shared/user-methods';
|
|||
import { JobOf, UserMetadataItem } from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||
import { generateProfileImage } from 'src/utils/profile-image';
|
||||
|
||||
|
|
@ -302,11 +303,7 @@ export class UserService extends BaseService {
|
|||
return DateTime.now().minus({ days: delayUntilDeletion }) > DateTime.fromJSDate(user.deletedAt);
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, options: UserFindOptions) {
|
||||
const user = await this.userRepository.get(id, options);
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
return user;
|
||||
private findOrFail(id: string, options: UserFindOptions) {
|
||||
return findOrFail(() => this.userRepository.get(id, options), 'User');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { Permission } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { findOrFail } from 'src/utils/misc';
|
||||
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -104,11 +105,7 @@ export class WorkflowService extends BaseService {
|
|||
return results;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const workflow = await this.workflowRepository.get(id);
|
||||
if (!workflow) {
|
||||
throw new BadRequestException('Workflow not found');
|
||||
}
|
||||
return workflow;
|
||||
private findOrFail(id: string) {
|
||||
return findOrFail(() => this.workflowRepository.get(id), 'Workflow');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function asHumanReadable(bytes: number, precision = 1): string {
|
|||
}
|
||||
}
|
||||
|
||||
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
|
||||
return `${remainder.toFixed(magnitude === 0 ? 0 : precision)} ${units[magnitude]}`;
|
||||
}
|
||||
|
||||
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { INestApplication } from '@nestjs/common';
|
||||
import { BadRequestException, INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
ApiBodyOptions,
|
||||
DocumentBuilder,
|
||||
|
|
@ -14,7 +14,7 @@ import path from 'node:path';
|
|||
import picomatch from 'picomatch';
|
||||
import parse from 'picomatch/lib/parse';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants';
|
||||
import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants';
|
||||
import { extraModels } from 'src/decorators';
|
||||
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
|
@ -111,6 +111,32 @@ export const handlePromiseError = <T>(promise: Promise<T>, logger: LoggingReposi
|
|||
promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack));
|
||||
};
|
||||
|
||||
export const findOrFail = async <T>(find: () => Promise<T>, entity: string): Promise<NonNullable<T>> => {
|
||||
const value = await find();
|
||||
if (!value) {
|
||||
throw new BadRequestException(`${entity} not found`);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export async function* batched<T>(items: AsyncIterable<T>, size = JOBS_ASSET_PAGINATION_SIZE): AsyncGenerator<T[]> {
|
||||
let batch: T[] = [];
|
||||
|
||||
for await (const item of items) {
|
||||
batch.push(item);
|
||||
|
||||
if (batch.length >= size) {
|
||||
yield batch;
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
yield batch;
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenGraphTags {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export const transformOcrBoundingBox = (
|
|||
const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions);
|
||||
|
||||
// Reorder points to maintain semantic ordering (topLeft, topRight, bottomRight, bottomLeft)
|
||||
const netRotation = edits.find((e) => e.action == AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360;
|
||||
const netRotation = edits.find((e) => e.action === AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360;
|
||||
const reorderedPoints = reorderQuadPointsForRotation(transformedPoints, netRotation);
|
||||
|
||||
const [p1, p2, p3, p4] = reorderedPoints;
|
||||
|
|
|
|||
37
server/test/medium/specs/services/album.service.spec.ts
Normal file
37
server/test/medium/specs/services/album.service.spec.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { AlbumRepository } from 'src/repositories/album.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 { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(AlbumService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AlbumRepository, UserRepository],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(AlbumService.name, () => {
|
||||
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 });
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Kysely } from 'kysely';
|
||||
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 { CryptoRepository } from 'src/repositories/crypto.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 { DB } from 'src/schema';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
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>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
|
|
@ -38,9 +45,10 @@ describe(UserService.name, () => {
|
|||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const user = mediumFactory.userInsert();
|
||||
await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual(
|
||||
expect.objectContaining({ name: user.name, email: user.email }),
|
||||
);
|
||||
const created = await sut.createUser({ 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 () => {
|
||||
|
|
@ -97,6 +105,38 @@ describe(UserService.name, () => {
|
|||
|
||||
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', () => {
|
||||
|
|
@ -109,25 +149,169 @@ describe(UserService.name, () => {
|
|||
expect(before.updatedAt).toBeDefined();
|
||||
expect(after.updatedAt).toBeDefined();
|
||||
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', () => {
|
||||
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 { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
await expect(sut.getLicense(auth)).rejects.toThrowError();
|
||||
const after = await sut.setLicense(auth, license);
|
||||
expect(after.licenseKey).toEqual(license.licenseKey);
|
||||
expect(after.activationKey).toEqual(license.activationKey);
|
||||
const after = await sut.setLicense(auth, userLicense);
|
||||
expect(after.licenseKey).toEqual(userLicense.licenseKey);
|
||||
expect(after.activationKey).toEqual(userLicense.activationKey);
|
||||
const response = await sut.getLicense(auth);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue