0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor: add and fix tests

This commit is contained in:
Gao Sun 2022-12-26 19:59:33 +08:00
parent f993da8795
commit 84014b37d2
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
9 changed files with 85 additions and 30 deletions

View file

@ -1,4 +1,4 @@
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import Koa from 'koa';
import { emptyMiddleware } from '#src/utils/test-utils.js';
@ -14,7 +14,10 @@ const middlewareList = [
'spa-proxy',
].map((name) => {
const mock = jest.fn(() => emptyMiddleware);
mockEsmDefault(`#src/middleware/koa-${name}.js`, () => mock);
mockEsm(`#src/middleware/koa-${name}.js`, () => ({
default: mock,
...(name === 'audit-log' && { LogEntry: jest.fn() }),
}));
return mock;
});

View file

@ -1,3 +1,4 @@
import { InteractionEvent, LogResult } from '@logto/schemas';
import { HookEvent } from '@logto/schemas/lib/models/hooks.js';
import { mockEsm, mockEsmDefault } from '@logto/shared/esm';
import type { InferModelType } from '@withtyped/server';
@ -24,8 +25,20 @@ const readAll = jest
.spyOn(modelRouters.hook.client, 'readAll')
.mockResolvedValue({ rows: [hook], rowCount: 1 });
// @ts-expect-error for testing
const post = jest.spyOn(got, 'post').mockImplementation(jest.fn(() => ({ json: jest.fn() })));
const post = jest
.spyOn(got, 'post')
// @ts-expect-error for testing
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
// TODO: @Gao fix `mockEsm()` import issue
const nanoIdMock = 'mockId';
jest.unstable_mockModule('@logto/core-kit', () => ({
generateStandardId: () => nanoIdMock,
}));
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
mockEsm('#src/queries/user.js', () => ({
findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }),
@ -46,7 +59,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
});
it('should return if no user ID found', async () => {
await triggerInteractionHooksIfNeeded(Event.SignIn);
await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn);
expect(queryFunction).not.toBeCalled();
});
@ -55,7 +68,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
jest.useFakeTimers().setSystemTime(100_000);
await triggerInteractionHooksIfNeeded(
Event.SignIn,
InteractionEvent.SignIn,
// @ts-expect-error for testing
{
jti: 'some_jti',
@ -80,6 +93,18 @@ describe('triggerInteractionHooksIfNeeded()', () => {
retry: { limit: 3 },
timeout: { request: 10_000 },
});
const calledPayload: unknown = insertLog.mock.calls[0][0];
expect(calledPayload).toHaveProperty('id', nanoIdMock);
expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + HookEvent.PostSignIn);
expect(calledPayload).toHaveProperty('payload.result', LogResult.Success);
expect(calledPayload).toHaveProperty('payload.hookId', 'foo');
expect(calledPayload).toHaveProperty('payload.json.event', HookEvent.PostSignIn);
expect(calledPayload).toHaveProperty('payload.json.interactionEvent', InteractionEvent.SignIn);
expect(calledPayload).toHaveProperty('payload.json.hookId', 'foo');
expect(calledPayload).toHaveProperty('payload.json.userId', '123');
expect(calledPayload).toHaveProperty('payload.response.statusCode', 200);
expect(calledPayload).toHaveProperty('payload.response.body.message', 'ok');
jest.useRealTimers();
});
});

View file

@ -28,7 +28,7 @@ const eventToHook: Record<InteractionEvent, HookEvent> = {
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export const triggerInteractionHooksIfNeeded = async (
event: Event,
event: InteractionEvent,
details?: Interaction,
userAgent?: string
) => {
@ -87,7 +87,8 @@ export const triggerInteractionHooksIfNeeded = async (
.catch(async (error) => {
logEntry.append({
result: LogResult.Error,
response: error instanceof HTTPError ? parseResponse(error.response) : String(error),
response: conditional(error instanceof HTTPError && parseResponse(error.response)),
error: conditional(error instanceof Error && String(error)),
});
});

View file

@ -10,14 +10,14 @@ import type { WithLogContext, LogPayload } from './koa-audit-log.js';
const { jest } = import.meta;
const nanoIdMock = 'mockId';
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
mockEsm('nanoid', () => ({
nanoid: () => nanoIdMock,
// TODO: @Gao fix `mockEsm()` import issue
const nanoIdMock = 'mockId';
jest.unstable_mockModule('@logto/core-kit', () => ({
generateStandardId: () => nanoIdMock,
}));
const koaLog = await pickDefault(import('./koa-audit-log.js'));

View file

@ -92,15 +92,17 @@ const { sendPasscodeToIdentifier } = await mockEsmWithActual(
const { createLog, prependAllLogEntries } = createMockLogContext();
mockEsmDefault(
await mockEsmWithActual(
'#src/middleware/koa-audit-log.js',
// eslint-disable-next-line unicorn/consistent-function-scoping
(): typeof koaAuditLog => () => async (ctx, next) => {
ctx.createLog = createLog;
ctx.prependAllLogEntries = prependAllLogEntries;
(): { default: typeof koaAuditLog } => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
default: () => async (ctx, next) => {
ctx.createLog = createLog;
ctx.prependAllLogEntries = prependAllLogEntries;
return next();
}
return next();
},
})
);
const {
@ -272,7 +274,7 @@ describe('session -> interactionRoutes', () => {
});
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
getInteractionStorage.mockReturnValueOnce({
getInteractionStorage.mockReturnValue({
event: InteractionEvent.ForgotPassword,
});

View file

@ -218,7 +218,6 @@ export default function interactionRoutes<T extends AnonymousRouter>(
async (ctx, next) => {
const { interactionDetails } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);
const { event } = interactionStorage;
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);

View file

@ -16,4 +16,4 @@ global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
/* eslint-enable @silverhand/fp/no-mutation */
jest.setTimeout(10_000);
jest.setTimeout(15_000);

View file

@ -1,13 +1,15 @@
import type { LogKey } from '@logto/schemas';
import { LogResult, Event } from '@logto/schemas';
import { SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
import type { Hooks } from '@logto/schemas/models';
import { HookEvent } from '@logto/schemas/models';
import type { InferModelType } from '@withtyped/server';
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
import { createMockServer } from '#src/helpers.js';
import { waitFor } from '#src/utils.js';
import { initClient, processSession } from './interaction/utils/client.js';
import { enableAllPasswordSignInMethods } from './interaction/utils/sign-in-experience.js';
import { generateNewUser, generateNewUserProfile } from './interaction/utils/user.js';
type Hook = InferModelType<typeof Hooks>;
@ -22,6 +24,21 @@ const createPayload = (event: HookEvent, url = 'not_work_url'): Partial<Hook> =>
});
describe('hooks', () => {
const { listen, close } = createMockServer(9999);
beforeAll(async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
await listen();
});
afterAll(async () => {
await close();
});
it('should be able to create, query, and delete a hook', async () => {
const payload = createPayload(HookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
@ -51,19 +68,23 @@ describe('hooks', () => {
} = await generateNewUser({ username: true, password: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: Event.SignIn,
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});
await client.submitInteraction();
await waitFor(500); // Wait for hooks execution
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === createdHook.id && result === LogResult.Error
({ payload: { hookId, result, error } }) =>
hookId === createdHook.id &&
result === LogResult.Error &&
error === 'RequestError: Invalid URL'
)
).toBeTruthy();
@ -80,14 +101,12 @@ describe('hooks', () => {
.json<Hook>(),
]);
const logKey: LogKey = 'TriggerHook.PostRegister';
const { listen, close } = createMockServer(9999);
await listen(); // Start mock server
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: Event.Register,
event: InteractionEvent.Register,
profile: {
username,
password,
@ -95,13 +114,14 @@ describe('hooks', () => {
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await close(); // Stop mock server early
await waitFor(500); // Wait for hooks execution
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook1.id && result === LogResult.Error
({ payload: { hookId, result, error } }) =>
hookId === hook1.id && result === LogResult.Error && error === 'RequestError: Invalid URL'
)
).toBeTruthy();
expect(

View file

@ -12,3 +12,8 @@ export const generatePhone = () => {
return crypto.getRandomValues(array).join('');
};
export const waitFor = async (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});