refactor: controller tests (#18035)
* feat: controller unit tests * refactor: controller tests
This commit is contained in:
@@ -47,7 +47,6 @@ export const errorDto = {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
|
||||
|
||||
describe(AuthController.name, () => {
|
||||
let app: TestControllerApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createControllerTestApp();
|
||||
});
|
||||
|
||||
describe('POST /auth/admin-sign-up', () => {
|
||||
const name = 'admin';
|
||||
const email = 'admin@immich.cloud';
|
||||
const password = 'password';
|
||||
|
||||
const invalid = [
|
||||
{
|
||||
should: 'require an email address',
|
||||
data: { name, password },
|
||||
},
|
||||
{
|
||||
should: 'require a password',
|
||||
data: { name, email },
|
||||
},
|
||||
{
|
||||
should: 'require a name',
|
||||
data: { email, password },
|
||||
},
|
||||
{
|
||||
should: 'require a valid email',
|
||||
data: { name, email: 'immich', password },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { should, data } of invalid) {
|
||||
it(`should ${should}`, async () => {
|
||||
const { status, body } = await request(app.getHttpServer()).post('/auth/admin-sign-up').send(data);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
const { status } = await request(app.getHttpServer())
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name: 'admin', password: 'password', email: 'aDmIn@IMMICH.cloud' });
|
||||
expect(status).toEqual(201);
|
||||
expect(app.getMockedService(AuthService).adminSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { NotificationController } from 'src/controllers/notification.controller';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { NotificationService } from 'src/services/notification.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
|
||||
import { factory } from 'test/small.factory';
|
||||
|
||||
describe(NotificationController.name, () => {
|
||||
let realApp: TestControllerApp;
|
||||
let mockApp: TestControllerApp;
|
||||
|
||||
beforeEach(async () => {
|
||||
realApp = await createControllerTestApp({ authType: 'real' });
|
||||
mockApp = await createControllerTestApp({ authType: 'mock' });
|
||||
});
|
||||
|
||||
describe('GET /notifications', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get('/notifications');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should call the service with an auth dto', async () => {
|
||||
const auth = factory.auth({ user: factory.user() });
|
||||
mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth);
|
||||
const service = mockApp.getMockedService(NotificationService);
|
||||
|
||||
const { status } = await request(mockApp.getHttpServer())
|
||||
.get('/notifications')
|
||||
.set('Authorization', `Bearer token`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(service.search).toHaveBeenCalledWith(auth, {});
|
||||
});
|
||||
|
||||
it(`should reject an invalid notification level`, async () => {
|
||||
const auth = factory.auth({ user: factory.user() });
|
||||
mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth);
|
||||
const service = mockApp.getMockedService(NotificationService);
|
||||
|
||||
const { status, body } = await request(mockApp.getHttpServer())
|
||||
.get(`/notifications`)
|
||||
.query({ level: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')]));
|
||||
expect(service.search).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /notifications', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer())
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [], readAt: new Date().toISOString() });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /notifications/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /notifications/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer())
|
||||
.put(`/notifications/${factory.uuid()}`)
|
||||
.send({ readAt: factory.date() });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await realApp.close();
|
||||
await mockApp.close();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
|
||||
import { factory } from 'test/small.factory';
|
||||
|
||||
describe(UserController.name, () => {
|
||||
let realApp: TestControllerApp;
|
||||
let mockApp: TestControllerApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
realApp = await createControllerTestApp({ authType: 'real' });
|
||||
mockApp = await createControllerTestApp({ authType: 'mock' });
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get('/users');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should call the service with an auth dto', async () => {
|
||||
const user = factory.user();
|
||||
const authService = mockApp.getMockedService(AuthService);
|
||||
const auth = factory.auth({ user });
|
||||
authService.authenticate.mockResolvedValue(auth);
|
||||
|
||||
const userService = mockApp.getMockedService(UserService);
|
||||
const { status } = await request(mockApp.getHttpServer()).get('/users').set('Authorization', `Bearer token`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(userService.search).toHaveBeenCalledWith(auth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get(`/users/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).put(`/users/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of ['email', 'name']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const dto = { [key]: null };
|
||||
const { status, body } = await request(mockApp.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(realApp.getHttpServer()).get(`/users/${factory.uuid()}`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get('/users/me/license');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(realApp.getHttpServer()).put(`/users/me/license`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/me/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(realApp.getHttpServer()).put(`/users/me/license`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await realApp.close();
|
||||
await mockApp.close();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { middleware } from 'src/app.module';
|
||||
import { controllers } from 'src/controllers';
|
||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { services } from 'src/services';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { automock } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
export const createControllerTestApp = async (options?: { authType?: 'mock' | 'real' }) => {
|
||||
const { authType = 'mock' } = options || {};
|
||||
|
||||
const configMock = { getEnv: () => ({ noColor: true }) };
|
||||
const clsMock = { getId: vitest.fn().mockReturnValue('cls-id') };
|
||||
const loggerMock = automock(LoggingRepository, { args: [clsMock, configMock], strict: false });
|
||||
loggerMock.setContext.mockReturnValue(void 0);
|
||||
loggerMock.error.mockImplementation((...args: any[]) => {
|
||||
console.log('Logger.error was called with', ...args);
|
||||
});
|
||||
|
||||
const mockBaseService = (service: ClassConstructor<BaseService>) => {
|
||||
return automock(service, { args: [loggerMock], strict: false });
|
||||
};
|
||||
|
||||
const clsServiceMock = clsMock;
|
||||
|
||||
const FAKE_MOCK = vitest.fn();
|
||||
|
||||
const providers: Provider[] = [
|
||||
...middleware,
|
||||
...services.map((Service) => {
|
||||
if ((authType === 'real' && Service === AuthService) || Service === ApiService) {
|
||||
return Service;
|
||||
}
|
||||
return { provide: Service, useValue: mockBaseService(Service as ClassConstructor<BaseService>) };
|
||||
}),
|
||||
GlobalExceptionFilter,
|
||||
{ provide: LoggingRepository, useValue: loggerMock },
|
||||
{ provide: ClsService, useValue: clsServiceMock },
|
||||
];
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [],
|
||||
controllers: [...controllers],
|
||||
providers,
|
||||
})
|
||||
.useMocker((token) => {
|
||||
if (token === LoggingRepository) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (token === SchedulerRegistry) {
|
||||
return FAKE_MOCK;
|
||||
}
|
||||
|
||||
if (typeof token === 'function' && token.name.endsWith('Repository')) {
|
||||
return FAKE_MOCK;
|
||||
}
|
||||
|
||||
if (typeof token === 'string' && token === 'KyselyModuleConnectionToken') {
|
||||
return FAKE_MOCK;
|
||||
}
|
||||
})
|
||||
|
||||
.compile();
|
||||
|
||||
const app = moduleRef.createNestApplication();
|
||||
|
||||
await app.init();
|
||||
|
||||
const getMockedRepository = <T>(token: ClassConstructor<T>) => {
|
||||
return app.get(token) as Mocked<T>;
|
||||
};
|
||||
|
||||
return {
|
||||
getHttpServer: () => app.getHttpServer(),
|
||||
getMockedService: <T>(token: ClassConstructor<T>) => {
|
||||
if (authType === 'real' && token === AuthService) {
|
||||
throw new Error('Auth type is real, cannot get mocked service');
|
||||
}
|
||||
return app.get(token) as Mocked<T>;
|
||||
},
|
||||
getMockedRepository,
|
||||
close: () => app.close(),
|
||||
};
|
||||
};
|
||||
|
||||
export type TestControllerApp = {
|
||||
getHttpServer: () => any;
|
||||
getMockedService: <T>(token: ClassConstructor<T>) => Mocked<T>;
|
||||
getMockedRepository: <T>(token: ClassConstructor<T>) => Mocked<T>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
@@ -315,4 +315,11 @@ export const factory = {
|
||||
},
|
||||
uuid: newUuid,
|
||||
date: newDate,
|
||||
responses: {
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { CallHandler, Provider, ValidationPipe } from '@nestjs/common';
|
||||
import { APP_GUARD, APP_PIPE } from '@nestjs/core';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { Kysely } from 'kysely';
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
@@ -5,6 +8,9 @@ import { Writable } from 'node:stream';
|
||||
import { PNG } from 'pngjs';
|
||||
import postgres from 'postgres';
|
||||
import { DB } from 'src/db';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
@@ -48,6 +54,7 @@ import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { RepositoryInterface } from 'src/types';
|
||||
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||
@@ -64,7 +71,47 @@ import { newStorageRepositoryMock } from 'test/repositories/storage.repository.m
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||
import { Readable } from 'typeorm/platform/PlatformTools';
|
||||
import { assert, Mocked, vitest } from 'vitest';
|
||||
import { assert, Mock, Mocked, vitest } from 'vitest';
|
||||
|
||||
export type ControllerContext = {
|
||||
authenticate: Mock;
|
||||
getHttpServer: () => any;
|
||||
reset: () => void;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const controllerSetup = async (controller: ClassConstructor<unknown>, providers: Provider[]) => {
|
||||
const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() };
|
||||
const authenticate = vi.fn();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [controller],
|
||||
providers: [
|
||||
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
{ provide: LoggingRepository, useValue: LoggingRepository.create() },
|
||||
{ provide: AuthService, useValue: { authenticate } },
|
||||
...providers,
|
||||
],
|
||||
})
|
||||
.overrideInterceptor(FileUploadInterceptor)
|
||||
.useValue(noopInterceptor)
|
||||
.overrideInterceptor(AssetUploadInterceptor)
|
||||
.useValue(noopInterceptor)
|
||||
.compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
return {
|
||||
authenticate,
|
||||
getHttpServer: () => app.getHttpServer(),
|
||||
reset: () => {
|
||||
authenticate.mockReset();
|
||||
},
|
||||
close: async () => {
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockFn = (label: string, { strict }: { strict: boolean }) => {
|
||||
const message = `Called a mock function without a mock implementation (${label})`;
|
||||
@@ -77,6 +124,10 @@ const mockFn = (label: string, { strict }: { strict: boolean }) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const mockBaseService = <T extends BaseService>(service: ClassConstructor<T>) => {
|
||||
return automock(service, { args: [{ setContext: () => {} }], strict: false });
|
||||
};
|
||||
|
||||
export const automock = <T>(
|
||||
Dependency: ClassConstructor<T>,
|
||||
options?: {
|
||||
|
||||
Reference in New Issue
Block a user