mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Add tests for Flows operations (#16580)
* Add tests for Flows operations * fix notifications test env * tweaks * fix env mock
This commit is contained in:
76
api/src/operations/item-create/index.test.ts
Normal file
76
api/src/operations/item-create/index.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services', () => {
|
||||
const ItemsService = vi.fn();
|
||||
ItemsService.prototype.createMany = vi.fn();
|
||||
return { ItemsService };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/get-accountability-for-role', () => ({
|
||||
getAccountabilityForRole: vi.fn((role: string | null, _context) => Promise.resolve(role)),
|
||||
}));
|
||||
|
||||
import { ItemsService } from '../../services';
|
||||
import config from './index';
|
||||
|
||||
const testCollection = 'test';
|
||||
const testId = '00000000-0000-0000-0000-000000000000';
|
||||
const testAccountability = { user: testId, role: testId };
|
||||
|
||||
const getSchema = vi.fn().mockResolvedValue({});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ permissions: undefined, expected: testAccountability },
|
||||
{ permissions: '$trigger', expected: testAccountability },
|
||||
{ permissions: '$full', expected: 'system' },
|
||||
{ permissions: '$public', expected: null },
|
||||
{ permissions: 'test', expected: 'test' },
|
||||
])('accountability for permissions "$permissions" should be $expected', async ({ permissions, expected }) => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, permissions } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService)).toHaveBeenCalledWith(
|
||||
testCollection,
|
||||
expect.objectContaining({ schema: {}, accountability: expected, knex: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ payload: null, expected: null },
|
||||
{ payload: { test: 'test' }, expected: [{ test: 'test' }] },
|
||||
])('payload $payload should be passed as $expected', async ({ payload, expected }) => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
if (expected) {
|
||||
expect(vi.mocked(ItemsService).prototype.createMany).toHaveBeenCalledWith(expected, expect.anything());
|
||||
} else {
|
||||
expect(vi.mocked(ItemsService).prototype.createMany).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test('should emit events when true', async () => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: {}, emitEvents: true } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.createMany).toHaveBeenCalledWith([{}], { emitEvents: true });
|
||||
});
|
||||
|
||||
test.each([undefined, false])('should not emit events when %s', async (emitEvents) => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: {}, emitEvents } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.createMany).toHaveBeenCalledWith([{}], { emitEvents: false });
|
||||
});
|
||||
157
api/src/operations/item-update/index.test.ts
Normal file
157
api/src/operations/item-update/index.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services', () => {
|
||||
const ItemsService = vi.fn();
|
||||
ItemsService.prototype.updateByQuery = vi.fn();
|
||||
ItemsService.prototype.updateOne = vi.fn();
|
||||
ItemsService.prototype.updateMany = vi.fn();
|
||||
return { ItemsService };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/get-accountability-for-role', () => ({
|
||||
getAccountabilityForRole: vi.fn((role: string | null, _context) => Promise.resolve(role)),
|
||||
}));
|
||||
|
||||
import { ItemsService } from '../../services';
|
||||
import config from './index';
|
||||
|
||||
const testCollection = 'test';
|
||||
const testPayload = {};
|
||||
const testId = '00000000-0000-0000-0000-000000000000';
|
||||
const testAccountability = { user: testId, role: testId };
|
||||
|
||||
const getSchema = vi.fn().mockResolvedValue({});
|
||||
|
||||
describe('Operations / Item Update', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ permissions: undefined, expected: testAccountability },
|
||||
{ permissions: '$trigger', expected: testAccountability },
|
||||
{ permissions: '$full', expected: 'system' },
|
||||
{ permissions: '$public', expected: null },
|
||||
{ permissions: 'test', expected: 'test' },
|
||||
])('accountability for permissions "$permissions" should be $expected', async ({ permissions, expected }) => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, permissions } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService)).toHaveBeenCalledWith(
|
||||
testCollection,
|
||||
expect.objectContaining({ schema: {}, accountability: expected, knex: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
test('should return null when payload is not defined', async () => {
|
||||
const result = await config.handler(
|
||||
{ collection: testCollection } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(vi.mocked(ItemsService).prototype.updateByQuery).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateOne).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([undefined, []])('should call updateByQuery with correct query when key is $payload', async (key) => {
|
||||
const query = { limit: -1 };
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, query, key } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateByQuery).toHaveBeenCalledWith(query, testPayload, expect.anything());
|
||||
expect(vi.mocked(ItemsService).prototype.updateOne).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should emit events for updateByQuery when true', async () => {
|
||||
const query = { limit: -1 };
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, query, emitEvents: true } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateByQuery).toHaveBeenCalledWith(query, testPayload, {
|
||||
emitEvents: true,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([undefined, false])('should not emit events for updateByQuery when %s', async (emitEvents) => {
|
||||
const query = { limit: -1 };
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, query, emitEvents } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateByQuery).toHaveBeenCalledWith(query, testPayload, {
|
||||
emitEvents: false,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([1, [1]])('should call updateOne when key is $payload', async (key) => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, key } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateByQuery).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateOne).toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should emit events for updateOne when true', async () => {
|
||||
const key = 1;
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, key, emitEvents: true } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateOne).toHaveBeenCalledWith(key, testPayload, { emitEvents: true });
|
||||
});
|
||||
|
||||
test.each([undefined, false])('should not emit events for updateOne when %s', async (emitEvents) => {
|
||||
const key = 1;
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, key: key, emitEvents } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateOne).toHaveBeenCalledWith(key, testPayload, { emitEvents: false });
|
||||
});
|
||||
|
||||
test('should call updateMany when key is an array with more than one item', async () => {
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, key: [1, 2, 3] } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateByQuery).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateOne).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ItemsService).prototype.updateMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should emit events for updateMany when true', async () => {
|
||||
const keys = [1, 2, 3];
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, key: keys, emitEvents: true } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateMany).toHaveBeenCalledWith(keys, testPayload, { emitEvents: true });
|
||||
});
|
||||
|
||||
test.each([undefined, false])('should not emit events for updateMany when %s', async (emitEvents) => {
|
||||
const keys = [1, 2, 3];
|
||||
await config.handler(
|
||||
{ collection: testCollection, payload: testPayload, key: keys, emitEvents } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(ItemsService).prototype.updateMany).toHaveBeenCalledWith(keys, testPayload, { emitEvents: false });
|
||||
});
|
||||
});
|
||||
31
api/src/operations/log/index.test.ts
Normal file
31
api/src/operations/log/index.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const loggerInfo = vi.fn();
|
||||
|
||||
vi.mock('../../logger', () => ({
|
||||
default: {
|
||||
info: loggerInfo,
|
||||
},
|
||||
}));
|
||||
|
||||
import config from './index';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('logs number message as string', () => {
|
||||
const message = 1;
|
||||
|
||||
config.handler({ message }, {} as any);
|
||||
|
||||
expect(loggerInfo).toHaveBeenCalledWith(String(1));
|
||||
});
|
||||
|
||||
test('logs json message as stringified json', () => {
|
||||
const message = { test: 'message' };
|
||||
|
||||
config.handler({ message }, {} as any);
|
||||
|
||||
expect(loggerInfo).toHaveBeenCalledWith(JSON.stringify(message));
|
||||
});
|
||||
54
api/src/operations/notification/index.test.ts
Normal file
54
api/src/operations/notification/index.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services', () => {
|
||||
const NotificationsService = vi.fn();
|
||||
NotificationsService.prototype.createMany = vi.fn();
|
||||
return { NotificationsService };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/get-accountability-for-role', () => ({
|
||||
getAccountabilityForRole: vi.fn((role: string | null, _context) => Promise.resolve(role)),
|
||||
}));
|
||||
|
||||
import { NotificationsService } from '../../services';
|
||||
|
||||
import config from './index';
|
||||
|
||||
const testId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const testAccountability = { user: testId, role: testId };
|
||||
|
||||
const testRecipient = [testId];
|
||||
|
||||
const getSchema = vi.fn().mockResolvedValue({});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ permissions: undefined, expected: testAccountability },
|
||||
{ permissions: '$trigger', expected: testAccountability },
|
||||
{ permissions: '$full', expected: null },
|
||||
{ permissions: '$public', expected: null },
|
||||
{ permissions: 'test', expected: 'test' },
|
||||
])('accountability for permissions "$permissions" should be $expected', async ({ permissions, expected }) => {
|
||||
await config.handler({ permissions } as any, { accountability: testAccountability, getSchema } as any);
|
||||
|
||||
expect(vi.mocked(NotificationsService)).toHaveBeenCalledWith(expect.objectContaining({ accountability: expected }));
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ message: null, expected: null },
|
||||
{ message: 123, expected: '123' },
|
||||
{ message: { test: 'test' }, expected: '{"test":"test"}' },
|
||||
])('message $message should be sent as string $expected', async ({ message, expected }) => {
|
||||
await config.handler(
|
||||
{ recipient: testRecipient, message } as any,
|
||||
{ accountability: testAccountability, getSchema } as any
|
||||
);
|
||||
|
||||
expect(vi.mocked(NotificationsService).prototype.createMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ message: expected })])
|
||||
);
|
||||
});
|
||||
109
api/src/operations/request/index.test.ts
Normal file
109
api/src/operations/request/index.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const axiosDefault = vi.fn();
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosDefault.mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
data: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const url = '/';
|
||||
const method = 'POST';
|
||||
|
||||
import config from './index';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('no headers configured', async () => {
|
||||
const body = 'body';
|
||||
const headers = undefined;
|
||||
await config.handler({ url, method, body, headers }, {} as any);
|
||||
|
||||
expect(axiosDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url,
|
||||
method,
|
||||
data: body,
|
||||
headers: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('headers array is converted to object', async () => {
|
||||
const body = 'body';
|
||||
const headers = [
|
||||
{ header: 'header1', value: 'value1' },
|
||||
{ header: 'header2', value: 'value2' },
|
||||
];
|
||||
await config.handler({ url, method, body, headers }, {} as any);
|
||||
|
||||
expect(axiosDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url,
|
||||
method,
|
||||
data: body,
|
||||
headers: expect.objectContaining({
|
||||
header1: 'value1',
|
||||
header2: 'value2',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should not automatically set Content-Type header when it is already defined', async () => {
|
||||
const body = 'body';
|
||||
const headers = [{ header: 'Content-Type', value: 'application/octet-stream' }];
|
||||
await config.handler({ url, method, body, headers }, {} as any);
|
||||
|
||||
expect(axiosDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url,
|
||||
method,
|
||||
data: body,
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': expect.not.stringContaining('application/json'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should not automatically set Content-Type header to "application/json" when the body is not a valid JSON string', async () => {
|
||||
const body = '"a": "b"';
|
||||
const headers = [{ header: 'header1', value: 'value1' }];
|
||||
await config.handler({ url, method, body, headers }, {} as any);
|
||||
|
||||
expect(axiosDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url,
|
||||
method,
|
||||
data: body,
|
||||
headers: expect.not.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should automatically set Content-Type header to "application/json" when the body is a valid JSON string', async () => {
|
||||
const body = '{ "a": "b" }';
|
||||
const headers = [{ header: 'header1', value: 'value1' }];
|
||||
await config.handler({ url, method, body, headers }, {} as any);
|
||||
|
||||
expect(axiosDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url,
|
||||
method,
|
||||
data: body,
|
||||
headers: expect.objectContaining({
|
||||
header1: 'value1',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
47
api/src/operations/sleep/index.test.ts
Normal file
47
api/src/operations/sleep/index.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import config from './index';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('promise resolves after the configured duration in milliseconds', () => {
|
||||
const milliseconds = 1000;
|
||||
|
||||
// asserts there is no timer (setTimeout) running yet
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
// intentionally don't await to assert the timer
|
||||
config.handler({ milliseconds }, {} as any);
|
||||
|
||||
// asserts there is 1 timer (setTimeout) running now
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(milliseconds);
|
||||
|
||||
// asserts there is no longer any timer (setTimeout) running
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('casts string input for milliseconds to number', () => {
|
||||
const milliseconds = '1000';
|
||||
|
||||
// asserts there is no timer (setTimeout) running yet
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
// intentionally don't await to assert the timer
|
||||
config.handler({ milliseconds }, {} as any);
|
||||
|
||||
// asserts there is 1 timer (setTimeout) running now
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(Number(milliseconds));
|
||||
|
||||
// asserts there is no longer any timer (setTimeout) running
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
19
api/src/operations/transform/index.test.ts
Normal file
19
api/src/operations/transform/index.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import config from './index';
|
||||
|
||||
test('runs the same object as the input', async () => {
|
||||
const json = { test: 'item' };
|
||||
|
||||
const result = await config.handler({ json }, {} as any);
|
||||
|
||||
expect(result).toEqual(json);
|
||||
});
|
||||
|
||||
test('runs parsed JSON for stringified JSON input', async () => {
|
||||
const json = '{"test":"item"}';
|
||||
|
||||
const result = await config.handler({ json }, {} as any);
|
||||
|
||||
expect(result).toEqual({ test: 'item' });
|
||||
});
|
||||
43
api/src/operations/trigger/index.test.ts
Normal file
43
api/src/operations/trigger/index.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const runOperationFlow = vi.fn();
|
||||
|
||||
vi.mock('../../flows', () => ({
|
||||
getFlowManager: vi.fn().mockReturnValue({
|
||||
runOperationFlow,
|
||||
}),
|
||||
}));
|
||||
|
||||
import config from './index';
|
||||
|
||||
const testFlowId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('runs the target flow one time for payload', async () => {
|
||||
const payload = { test: 'payload' };
|
||||
|
||||
await config.handler({ flow: testFlowId, payload }, {} as any);
|
||||
|
||||
expect(runOperationFlow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('runs the target flow N times for number of items in payload array', async () => {
|
||||
const payload = [1, 2, 3, 4, 5];
|
||||
|
||||
await config.handler({ flow: testFlowId, payload }, {} as any);
|
||||
|
||||
expect(runOperationFlow).toHaveBeenCalledTimes(payload.length);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ payload: null, expected: null },
|
||||
{ payload: { test: 'test' }, expected: { test: 'test' } },
|
||||
{ payload: '{ "test": "test" }', expected: { test: 'test' } },
|
||||
])('payload $payload should be sent as $expected', async ({ payload, expected }) => {
|
||||
await config.handler({ flow: testFlowId, payload }, {} as any);
|
||||
|
||||
expect(runOperationFlow).toHaveBeenCalledWith(testFlowId, expected, expect.anything());
|
||||
});
|
||||
@@ -1,6 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, SpyInstance, vi } from 'vitest';
|
||||
import { ItemsService, NotificationsService } from '.';
|
||||
|
||||
vi.mock('../env', async () => {
|
||||
const actual = (await vi.importActual('../env')) as { default: Record<string, any> };
|
||||
const MOCK_ENV = {
|
||||
...actual.default,
|
||||
PUBLIC_URL: '/',
|
||||
};
|
||||
return {
|
||||
default: MOCK_ENV,
|
||||
getEnv: () => MOCK_ENV,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/database/index', () => ({
|
||||
default: vi.fn(),
|
||||
getDatabaseClient: vi.fn().mockReturnValue('postgres'),
|
||||
|
||||
Reference in New Issue
Block a user