test: 补齐关键路径交互式集成测试
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
|
import Login from '@/components/Login.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import { login } from '@/api/login'
|
||||||
|
|
||||||
|
vi.mock('@/api/login', () => ({
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式集成测试:真实挂载 Login,通过 setValue / trigger('click') 模拟用户行为,
|
||||||
|
* 断言提交链路(凭证传递、成功/失败分支、localStorage 与路由结果)。
|
||||||
|
*
|
||||||
|
* 注意:jsdom 下 Element Plus 的 callback 式表单校验不可靠(空表单也可能判有效),
|
||||||
|
* “空账号被校验拦截”这类行为由 e2e/login.spec 在真实浏览器中覆盖,此处不重复断言。
|
||||||
|
*/
|
||||||
|
describe('Login 交互流程(集成)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
await router.push('/login')
|
||||||
|
await router.isReady()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountLogin = async () => {
|
||||||
|
const wrapper = mount(Login, {
|
||||||
|
global: { plugins: [ElementPlus, router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
const findSubmitButton = (wrapper: ReturnType<typeof mount> extends Promise<infer T> ? T : never) =>
|
||||||
|
wrapper.findAll('button').find((b) => b.text().includes('进入系统'))!
|
||||||
|
|
||||||
|
it('填写账号密码后点击“进入系统”,提交凭证并跳转 /welcome', async () => {
|
||||||
|
vi.mocked(login).mockResolvedValue({ code: 200, message: '登录成功' } as any)
|
||||||
|
const wrapper = await mountLogin()
|
||||||
|
|
||||||
|
await wrapper.find('input[placeholder="请输入账号"]').setValue('admin')
|
||||||
|
await wrapper.find('input[placeholder="请输入密码"]').setValue('123456')
|
||||||
|
await findSubmitButton(wrapper).trigger('click')
|
||||||
|
// /welcome 是懒加载路由,动态 import 需要宏任务,用 waitFor 轮询等待到达
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/welcome'))
|
||||||
|
|
||||||
|
expect(login).toHaveBeenCalledWith('admin', '123456')
|
||||||
|
expect(localStorage.getItem('isLoggedIn')).toBe('true')
|
||||||
|
expect(ElMessage.success).toHaveBeenCalledWith('登录成功')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('后端返回失败时提示错误、不写登录标记、不跳转', async () => {
|
||||||
|
vi.mocked(login).mockResolvedValue({ code: 500, message: '账号或密码错误' } as any)
|
||||||
|
const wrapper = await mountLogin()
|
||||||
|
|
||||||
|
await wrapper.find('input[placeholder="请输入账号"]').setValue('admin')
|
||||||
|
await wrapper.find('input[placeholder="请输入密码"]').setValue('wrong-pass')
|
||||||
|
await findSubmitButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(login).toHaveBeenCalledWith('admin', 'wrong-pass')
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledWith('账号或密码错误')
|
||||||
|
expect(localStorage.getItem('isLoggedIn')).toBeNull()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import ReviewRecall from '@/components/ReviewRecall.vue'
|
||||||
|
import {
|
||||||
|
getStandardMindMap,
|
||||||
|
listRecallRecords,
|
||||||
|
recallCompare,
|
||||||
|
} from '@/api/standardMindMap'
|
||||||
|
|
||||||
|
vi.mock('@/api/standardMindMap', () => ({
|
||||||
|
findNode: vi.fn(),
|
||||||
|
getStandardMindMap: vi.fn(),
|
||||||
|
listRecallRecords: vi.fn(),
|
||||||
|
recallCompare: vi.fn(),
|
||||||
|
regenerateStandardMindMap: vi.fn(),
|
||||||
|
updateStandardMindMap: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ok = (data: any = null) => ({ code: 200, message: '请求成功', data })
|
||||||
|
|
||||||
|
const standardMap = {
|
||||||
|
id: 1,
|
||||||
|
taskNum: 'T001',
|
||||||
|
title: '测试任务',
|
||||||
|
content: JSON.stringify({ title: '根主题', children: [{ title: '子节点' }] }),
|
||||||
|
outline: '根主题\n- 子节点',
|
||||||
|
summary: '',
|
||||||
|
generator: 'AI',
|
||||||
|
sourceReportCount: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MindMapViewer 依赖 mind-elixir(无法在 jsdom 运行),用可编程 stub 模拟导出的大纲 */
|
||||||
|
const toOutline = vi.fn((): string => '根主题\n- 子节点')
|
||||||
|
const MindMapViewerStub = {
|
||||||
|
name: 'MindMapViewerStub',
|
||||||
|
props: ['modelValue', 'nodeSelectable', 'colorByCompare', 'readonly'],
|
||||||
|
template: '<div class="mindmap-stub" />',
|
||||||
|
methods: { toOutline },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式集成测试:真实挂载 ReviewRecall,
|
||||||
|
* 点击“提交对比”模拟回忆对比流,断言 API 参数与对比结果渲染。
|
||||||
|
*/
|
||||||
|
describe('ReviewRecall 交互流程(集成)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
localStorage.setItem('isLoggedIn', 'true')
|
||||||
|
|
||||||
|
vi.mocked(getStandardMindMap).mockResolvedValue(ok(standardMap))
|
||||||
|
vi.mocked(listRecallRecords).mockResolvedValue(
|
||||||
|
ok([
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
taskNum: 'T001',
|
||||||
|
standardMapId: 1,
|
||||||
|
recallContent: '根主题',
|
||||||
|
compareResult: JSON.stringify({
|
||||||
|
matchedTree: { title: '根主题', children: [] },
|
||||||
|
recallRatio: 0.6,
|
||||||
|
matchedCount: 3,
|
||||||
|
missedCount: 2,
|
||||||
|
extraCount: 0,
|
||||||
|
}),
|
||||||
|
recallRatio: 0.6,
|
||||||
|
matchedCount: 3,
|
||||||
|
missedCount: 2,
|
||||||
|
extraCount: 0,
|
||||||
|
createdTime: '2026-08-27 10:00:00',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
vi.mocked(recallCompare).mockResolvedValue(ok(standardMap))
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountPage = async () => {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/review/recall/:taskNum', component: ReviewRecall, meta: { wide: true } }],
|
||||||
|
})
|
||||||
|
await router.push('/review/recall/T001')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(ReviewRecall, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus, router],
|
||||||
|
stubs: { ElTag: true, MindMapViewer: MindMapViewerStub },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return { wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('加载后展示标准导图标题,点击“提交对比”调用对比接口并渲染结果', async () => {
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
// 默认折叠标准导图(防剧透),回忆面板可见
|
||||||
|
expect(wrapper.text()).toContain('你的回忆')
|
||||||
|
const submit = wrapper.findAll('button').find((b) => b.text().trim() === '提交对比')!
|
||||||
|
expect(submit).toBeTruthy()
|
||||||
|
await submit.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(recallCompare).toHaveBeenCalledWith('T001', '根主题\n- 子节点', undefined)
|
||||||
|
expect(ElMessage.success).toHaveBeenCalledWith('回忆对比完成')
|
||||||
|
// 对比完成后出现“清空重写”入口(v-if=hasCompared)
|
||||||
|
expect(wrapper.findAll('button').some((b) => b.text().trim() === '清空重写')).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('回忆导图内容为单行时点击“提交对比”,提示先添加节点且不调接口', async () => {
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
toOutline.mockReturnValue('只有一个根')
|
||||||
|
|
||||||
|
const submit = wrapper.findAll('button').find((b) => b.text().trim() === '提交对比')!
|
||||||
|
await submit.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const { ElMessage: Msg } = await import('element-plus')
|
||||||
|
expect(Msg.warning).toHaveBeenCalledWith('请先在回忆导图中添加节点(选中节点后按 Tab 加子节点)')
|
||||||
|
expect(recallCompare).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import StartTask from '@/components/StartTask.vue'
|
||||||
|
import {
|
||||||
|
continueSession,
|
||||||
|
endSession,
|
||||||
|
getActiveSession,
|
||||||
|
getExpectation,
|
||||||
|
getReportDraft,
|
||||||
|
getSessionDetail,
|
||||||
|
pauseSession,
|
||||||
|
startOrContinueStudySession,
|
||||||
|
} from '@/api/studySessions'
|
||||||
|
import { getFragmentsBySession } from '@/api/reportFragments'
|
||||||
|
|
||||||
|
vi.mock('@/api/studySessions', () => ({
|
||||||
|
abortSession: vi.fn(),
|
||||||
|
continueSession: vi.fn(),
|
||||||
|
endSession: vi.fn(),
|
||||||
|
getActiveSession: vi.fn(),
|
||||||
|
getExpectation: vi.fn(),
|
||||||
|
getReportDraft: vi.fn(),
|
||||||
|
getSessionDetail: vi.fn(),
|
||||||
|
getTaskFragments: vi.fn(),
|
||||||
|
getTaskReports: vi.fn(),
|
||||||
|
pauseSession: vi.fn(),
|
||||||
|
startOrContinueStudySession: vi.fn(),
|
||||||
|
upsertExpectation: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/reportFragments', () => ({
|
||||||
|
createFragments: vi.fn(),
|
||||||
|
getFragmentsBySession: vi.fn(),
|
||||||
|
updateFragments: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sessionOngoing = {
|
||||||
|
sessionNum: 'SESSION_001',
|
||||||
|
sessionState: 'ONGOING',
|
||||||
|
taskName: '测试任务',
|
||||||
|
taskNum: 'T001',
|
||||||
|
taskId: 1,
|
||||||
|
startTime: '2026-08-27 10:00:00',
|
||||||
|
endTime: '',
|
||||||
|
lastStartTime: '2026-08-27 10:00:00',
|
||||||
|
actualTime: 0,
|
||||||
|
effectiveTime: 0,
|
||||||
|
effectivenessRatio: '--',
|
||||||
|
pointerPosition: 1_500_000,
|
||||||
|
systemMessage: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = (data: any = null) => ({ code: 200, message: '请求成功', data })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式集成测试:真实挂载 StartTask(不 shallow),
|
||||||
|
* 通过 trigger('click') 模拟“暂停 → 开始”完整用户流,断言 DOM 状态与 API 调用序列。
|
||||||
|
*/
|
||||||
|
describe('StartTask 交互流程(集成)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
localStorage.setItem('isLoggedIn', 'true')
|
||||||
|
|
||||||
|
vi.mocked(getActiveSession).mockResolvedValue(ok(null))
|
||||||
|
vi.mocked(startOrContinueStudySession).mockResolvedValue(ok(sessionOngoing))
|
||||||
|
vi.mocked(getExpectation).mockResolvedValue(ok({ description: '本次学习预期' }))
|
||||||
|
vi.mocked(getFragmentsBySession).mockResolvedValue(ok([]))
|
||||||
|
vi.mocked(pauseSession).mockResolvedValue(ok())
|
||||||
|
vi.mocked(continueSession).mockResolvedValue(ok())
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountPage = async () => {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/start-task/:taskNum', component: StartTask },
|
||||||
|
{ path: '/study', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await router.push('/start-task/T001')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(StartTask, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus, router],
|
||||||
|
// el-tag 在 jsdom + VTU 下 vnode mounted 钩子崩溃(EP 2.8 已知问题),纯视觉组件,stub 掉
|
||||||
|
stubs: { ElTag: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return { wrapper, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
const findButton = (wrapper: any, text: string) =>
|
||||||
|
wrapper.findAll('button').find((b: any) => b.text().trim() === text)
|
||||||
|
|
||||||
|
it('进行中的会话:点击“暂停”同步后端状态,再点“开始”触发 continue 并恢复进行中', async () => {
|
||||||
|
// 暂停后从详情接口同步回 PAUSED 状态
|
||||||
|
vi.mocked(getSessionDetail).mockResolvedValue(
|
||||||
|
ok({ ...sessionOngoing, sessionState: 'PAUSED', actualTime: 10, effectiveTime: 20 }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
expect(wrapper.find('.status-text').text()).toContain('进行中')
|
||||||
|
|
||||||
|
// 点击暂停:调用 pause API,页面状态变为已暂停
|
||||||
|
const pauseBtn = findButton(wrapper, '暂停')
|
||||||
|
expect(pauseBtn).toBeTruthy()
|
||||||
|
await pauseBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(pauseSession).toHaveBeenCalledWith('SESSION_001')
|
||||||
|
expect(wrapper.find('.status-text').text()).toContain('已暂停')
|
||||||
|
|
||||||
|
// 点击开始:会话为 PAUSED,应触发 continue API 并恢复进行中
|
||||||
|
const startBtn = findButton(wrapper, '开始')
|
||||||
|
expect(startBtn).toBeTruthy()
|
||||||
|
await startBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(continueSession).toHaveBeenCalledWith('SESSION_001')
|
||||||
|
expect(wrapper.find('.status-text').text()).toContain('进行中')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击“结束会话”打开总结弹窗,无内容时确认结束被拦截', async () => {
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
|
||||||
|
const endBtn = findButton(wrapper, '结束会话')
|
||||||
|
await endBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 总结弹窗出现(无残片 → 不触发 AI 草稿等待)
|
||||||
|
expect(wrapper.text()).toContain('结束会话总结')
|
||||||
|
expect(getReportDraft).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// 总结内容为空时点击确认结束:弹警示、不调接口
|
||||||
|
const confirmEnd = findButton(wrapper, '确认结束')
|
||||||
|
await confirmEnd.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
expect(ElMessage.warning).toHaveBeenCalledWith('请输入学习总结内容')
|
||||||
|
expect(endSession).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus, { ElMessage } from 'element-plus'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import TaskForm from '@/components/TaskForm.vue'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
vi.mock('@/utils/request', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn().mockResolvedValue({ code: 200, data: null }),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn().mockResolvedValue({ code: 200, data: null }),
|
||||||
|
del: vi.fn().mockResolvedValue({ code: 200, data: null }),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedPost = vi.mocked(request.post)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式集成测试:真实挂载 TaskForm(add 模式),
|
||||||
|
* 填写任务名称 → 点击“添加”提交 → 断言请求载荷、成功提示与路由跳转。
|
||||||
|
*/
|
||||||
|
describe('TaskForm 交互流程(集成)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
localStorage.setItem('isLoggedIn', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountAddForm = async () => {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/add-task',
|
||||||
|
name: 'add-task',
|
||||||
|
component: TaskForm,
|
||||||
|
meta: { requiresAuth: true, title: '创建学习任务', action: 'add', buttonText: '添加' },
|
||||||
|
},
|
||||||
|
{ path: '/study', name: 'study', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await router.push('/add-task')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(TaskForm, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus, router],
|
||||||
|
// el-tag 在 jsdom + VTU 下 vnode mounted 钩子崩溃(EP 2.8 已知问题),纯视觉组件,stub 掉
|
||||||
|
stubs: { ElTag: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return { wrapper, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('填写任务名称后点击“添加”,提交 POST /tasks 并跳转学习任务页', async () => {
|
||||||
|
mockedPost.mockResolvedValue({ code: 200, data: null } as any)
|
||||||
|
const { wrapper, router } = await mountAddForm()
|
||||||
|
|
||||||
|
const nameInput = wrapper.find('input[placeholder="例如:Vue3 组件通信实践"]')
|
||||||
|
expect(nameInput.exists()).toBe(true)
|
||||||
|
await nameInput.setValue('集成测试任务')
|
||||||
|
|
||||||
|
const submit = wrapper.findAll('button').find((b) => b.text().trim() === '添加')!
|
||||||
|
expect(submit).toBeTruthy()
|
||||||
|
await submit.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith(
|
||||||
|
'/tasks',
|
||||||
|
expect.objectContaining({
|
||||||
|
taskName: '集成测试任务',
|
||||||
|
taskDescription: '',
|
||||||
|
materialUrl: '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(ElMessage.success).toHaveBeenCalledWith('创建任务成功')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/study')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('后端返回失败时提示错误、不跳转', async () => {
|
||||||
|
mockedPost.mockResolvedValue({ code: 500, message: '任务名称重复' } as any)
|
||||||
|
const { wrapper, router } = await mountAddForm()
|
||||||
|
|
||||||
|
await wrapper.find('input[placeholder="例如:Vue3 组件通信实践"]').setValue('重复任务')
|
||||||
|
const submit = wrapper.findAll('button').find((b) => b.text().trim() === '添加')!
|
||||||
|
await submit.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledWith('任务创建失败:任务名称重复')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/add-task')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user