feat: add admin auth migration reports view

This commit is contained in:
IanShaw027
2026-04-21 00:07:14 +08:00
parent 85fc54b205
commit f73117f9b1
4 changed files with 864 additions and 1 deletions

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: {
get,
post,
},
}))
import {
getAuthIdentityMigrationReportSummary,
listAuthIdentityMigrationReports,
resolveAuthIdentityMigrationReport,
} from '@/api/admin/users'
describe('admin users auth identity migration reports API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
})
it('lists migration reports with pagination and report type filter', async () => {
const response = {
items: [],
total: 0,
page: 2,
page_size: 10,
pages: 0,
}
get.mockResolvedValue({ data: response })
const result = await listAuthIdentityMigrationReports({
page: 2,
pageSize: 10,
reportType: 'oidc_synthetic_email_requires_manual_recovery',
})
expect(get).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports', {
params: {
page: 2,
page_size: 10,
report_type: 'oidc_synthetic_email_requires_manual_recovery',
},
})
expect(result).toBe(response)
})
it('loads migration report summary', async () => {
const response = {
total: 2,
open_total: 1,
resolved_total: 1,
by_type: {
oidc_synthetic_email_requires_manual_recovery: 2,
},
}
get.mockResolvedValue({ data: response })
const result = await getAuthIdentityMigrationReportSummary()
expect(get).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports/summary')
expect(result).toBe(response)
})
it('submits report resolution note', async () => {
const response = {
id: 7,
resolution_note: 'resolved by admin',
}
post.mockResolvedValue({ data: response })
const result = await resolveAuthIdentityMigrationReport(7, 'resolved by admin')
expect(post).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports/7/resolve', {
resolution_note: 'resolved by admin',
})
expect(result).toBe(response)
})
})

View File

@@ -6,6 +6,30 @@
import { apiClient } from '../client'
import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types'
export interface AuthIdentityMigrationReport {
id: number
report_type: string
report_key: string
details: Record<string, unknown>
created_at: string
resolved_at?: string | null
resolved_by_user_id?: number | null
resolution_note?: string
}
export interface AuthIdentityMigrationReportSummary {
total: number
open_total: number
resolved_total: number
by_type: Record<string, number>
}
export interface ListAuthIdentityMigrationReportsParams {
page?: number
pageSize?: number
reportType?: string
}
/**
* List all users with pagination
* @param page - Page number (default: 1)
@@ -248,6 +272,42 @@ export async function replaceGroup(
return data
}
export async function getAuthIdentityMigrationReportSummary(): Promise<AuthIdentityMigrationReportSummary> {
const { data } = await apiClient.get<AuthIdentityMigrationReportSummary>(
'/admin/users/auth-identity-migration-reports/summary'
)
return data
}
export async function listAuthIdentityMigrationReports(
params: ListAuthIdentityMigrationReportsParams = {}
): Promise<PaginatedResponse<AuthIdentityMigrationReport>> {
const { data } = await apiClient.get<PaginatedResponse<AuthIdentityMigrationReport>>(
'/admin/users/auth-identity-migration-reports',
{
params: {
page: params.page ?? 1,
page_size: params.pageSize ?? 20,
report_type: params.reportType ?? ''
}
}
)
return data
}
export async function resolveAuthIdentityMigrationReport(
id: number,
resolutionNote: string
): Promise<AuthIdentityMigrationReport> {
const { data } = await apiClient.post<AuthIdentityMigrationReport>(
`/admin/users/auth-identity-migration-reports/${id}/resolve`,
{
resolution_note: resolutionNote
}
)
return data
}
export const usersAPI = {
list,
getById,
@@ -260,7 +320,10 @@ export const usersAPI = {
getUserApiKeys,
getUserUsageStats,
getUserBalanceHistory,
replaceGroup
replaceGroup,
getAuthIdentityMigrationReportSummary,
listAuthIdentityMigrationReports,
resolveAuthIdentityMigrationReport
}
export default usersAPI

View File

@@ -0,0 +1,473 @@
<template>
<AppLayout>
<div class="space-y-6">
<section class="grid gap-4 md:grid-cols-3">
<div class="card p-5">
<p class="text-sm font-medium text-gray-500 dark:text-dark-400">
{{ copy.total }}
</p>
<p data-test="summary-total" class="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">
{{ summary.total }}
</p>
</div>
<div class="card p-5">
<p class="text-sm font-medium text-gray-500 dark:text-dark-400">
{{ copy.open }}
</p>
<p data-test="summary-open" class="mt-2 text-3xl font-semibold text-amber-600 dark:text-amber-400">
{{ summary.open_total }}
</p>
</div>
<div class="card p-5">
<p class="text-sm font-medium text-gray-500 dark:text-dark-400">
{{ copy.resolved }}
</p>
<p data-test="summary-resolved" class="mt-2 text-3xl font-semibold text-emerald-600 dark:text-emerald-400">
{{ summary.resolved_total }}
</p>
</div>
</section>
<TablePageLayout>
<template #actions>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ copy.title }}
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
{{ copy.subtitle }}
</p>
</div>
<button type="button" class="btn btn-secondary" :disabled="loading || resolving" @click="refreshAll">
<Icon name="refresh" size="md" :class="loading || summaryLoading ? 'animate-spin' : ''" />
</button>
</div>
</template>
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<div class="w-full sm:w-80">
<label class="input-label" for="report-type-filter">{{ copy.reportType }}</label>
<select
id="report-type-filter"
v-model="filters.reportType"
data-test="report-type-filter"
class="input"
@change="handleReportTypeChange"
>
<option value="">{{ copy.allReportTypes }}</option>
<option
v-for="option in reportTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
</template>
<template #table>
<DataTable :columns="columns" :data="reports" :loading="loading">
<template #cell-status="{ row }">
<span :class="['badge', row.resolved_at ? 'badge-success' : 'badge-warning']">
{{ row.resolved_at ? copy.resolvedBadge : copy.openBadge }}
</span>
</template>
<template #cell-report_type="{ value }">
<span class="font-mono text-xs text-gray-600 dark:text-dark-300">{{ value }}</span>
</template>
<template #cell-report_key="{ value }">
<span class="font-medium text-gray-900 dark:text-gray-100">{{ value }}</span>
</template>
<template #cell-details_preview="{ row }">
<div class="flex flex-wrap gap-2">
<span
v-for="entry in getDetailHighlights(row.details)"
:key="entry.key"
class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-dark-700 dark:text-dark-200"
>
{{ entry.key }}: {{ entry.value }}
</span>
</div>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-resolved_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? formatDateTime(value) : copy.notResolved }}
</span>
</template>
<template #cell-actions="{ row }">
<button
type="button"
class="btn btn-secondary btn-sm"
:data-test="`select-report-${row.id}`"
@click="selectReport(row)"
>
{{ copy.viewDetails }}
</button>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
<div class="card p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ copy.detailTitle }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
{{ selectedReport ? selectedReport.report_key : copy.selectPrompt }}
</p>
</div>
<span
v-if="selectedReport"
:class="['badge', selectedReport.resolved_at ? 'badge-success' : 'badge-warning']"
>
{{ selectedReport.resolved_at ? copy.resolvedBadge : copy.openBadge }}
</span>
</div>
<div v-if="selectedReport" class="mt-6 space-y-5">
<dl class="grid gap-4 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ copy.reportType }}</dt>
<dd class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.report_type }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ copy.reportKey }}</dt>
<dd class="mt-1 break-all text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.report_key }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ copy.createdAt }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ formatDateTime(selectedReport.created_at) }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ copy.resolvedAt }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ selectedReport.resolved_at ? formatDateTime(selectedReport.resolved_at) : copy.notResolved }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ copy.resolvedBy }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.resolved_by_user_id ?? '-' }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ copy.resolutionNote }}</dt>
<dd class="mt-1 whitespace-pre-wrap text-sm text-gray-900 dark:text-gray-100">
{{ selectedReport.resolution_note || copy.emptyResolutionNote }}
</dd>
</div>
</dl>
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ copy.keyFields }}</h3>
<div class="mt-3 flex flex-wrap gap-2">
<span
v-for="entry in getDetailHighlights(selectedReport.details)"
:key="entry.key"
class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-dark-700 dark:text-dark-200"
>
{{ entry.key }}: {{ entry.value }}
</span>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ copy.rawDetails }}</h3>
<pre class="mt-3 max-h-96 overflow-auto rounded-xl bg-gray-950 p-4 text-xs text-gray-100">{{ formatDetailsJson(selectedReport.details) }}</pre>
</div>
</div>
<div v-else class="mt-6 rounded-2xl border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400">
{{ copy.selectPrompt }}
</div>
</div>
<div class="card p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ copy.resolveTitle }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
{{ copy.resolveSubtitle }}
</p>
<div class="mt-6 space-y-4">
<div>
<label class="input-label" for="resolution-note">{{ copy.resolutionNote }}</label>
<textarea
id="resolution-note"
v-model="resolutionNote"
data-test="resolution-note"
class="input min-h-40"
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || resolving"
:placeholder="copy.resolvePlaceholder"
></textarea>
</div>
<button
type="button"
class="btn btn-primary w-full"
data-test="resolve-submit"
:disabled="!canResolve"
@click="submitResolve"
>
{{ resolving ? copy.resolving : copy.resolveAction }}
</button>
</div>
</div>
</section>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type {
AuthIdentityMigrationReport,
AuthIdentityMigrationReportSummary,
} from '@/api/admin/users'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
const { locale } = useI18n()
const appStore = useAppStore()
const isZh = computed(() => locale.value.toLowerCase().startsWith('zh'))
const text = (zh: string, en: string) => (isZh.value ? zh : en)
const copy = computed(() => ({
title: text('Auth Identity Migration Reports', 'Auth Identity Migration Reports'),
subtitle: text('处理 auth identity 迁移过程中需要人工收口的异常记录。', 'Review and resolve auth identity migration records that require manual follow-up.'),
total: text('总报告数', 'Total reports'),
open: text('待处理', 'Open'),
resolved: text('已解决', 'Resolved'),
reportType: text('报告类型', 'Report type'),
allReportTypes: text('全部类型', 'All report types'),
resolvedBadge: text('已解决', 'Resolved'),
openBadge: text('待处理', 'Open'),
notResolved: text('未解决', 'Not resolved'),
viewDetails: text('查看', 'View'),
detailTitle: text('报告详情', 'Report details'),
selectPrompt: text('从列表中选择一条报告以查看详情和处理意见。', 'Select a report from the list to inspect details and submit a resolution note.'),
reportKey: text('报告键', 'Report key'),
createdAt: text('创建时间', 'Created at'),
resolvedAt: text('解决时间', 'Resolved at'),
resolvedBy: text('处理人 ID', 'Resolved by'),
resolutionNote: text('处理备注', 'Resolution note'),
emptyResolutionNote: text('暂无处理备注', 'No resolution note'),
keyFields: text('关键字段', 'Key fields'),
rawDetails: text('原始详情', 'Raw details'),
resolveTitle: text('提交处理结果', 'Submit resolution'),
resolveSubtitle: text('填写运营备注后提交 resolve后端会记录处理人和处理时间。', 'Submit an operational note to resolve the selected report. The backend will record the resolver and timestamp.'),
resolvePlaceholder: text('填写本次处理动作、用户沟通结果或后续追踪信息。', 'Describe the action taken, user communication, or follow-up context.'),
resolveAction: text('提交 Resolve', 'Submit resolve'),
resolving: text('提交中...', 'Submitting...'),
}))
const summary = ref<AuthIdentityMigrationReportSummary>({
total: 0,
open_total: 0,
resolved_total: 0,
by_type: {},
})
const reports = ref<AuthIdentityMigrationReport[]>([])
const selectedReport = ref<AuthIdentityMigrationReport | null>(null)
const resolutionNote = ref('')
const loading = ref(false)
const summaryLoading = ref(false)
const resolving = ref(false)
const filters = reactive({
reportType: '',
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
})
const columns: Column[] = [
{ key: 'status', label: text('状态', 'Status') },
{ key: 'report_type', label: text('报告类型', 'Report type') },
{ key: 'report_key', label: text('报告键', 'Report key') },
{ key: 'details_preview', label: text('关键字段', 'Key fields') },
{ key: 'created_at', label: text('创建时间', 'Created at') },
{ key: 'resolved_at', label: text('解决时间', 'Resolved at') },
{ key: 'actions', label: text('操作', 'Actions') },
]
const reportTypeOptions = computed(() =>
Object.entries(summary.value.by_type)
.sort(([left], [right]) => left.localeCompare(right))
.map(([value, count]) => ({
value,
label: `${value} (${count})`,
}))
)
const canResolve = computed(() =>
Boolean(
selectedReport.value &&
!selectedReport.value.resolved_at &&
resolutionNote.value.trim() &&
!resolving.value
)
)
const loadSummary = async () => {
summaryLoading.value = true
try {
summary.value = await adminAPI.users.getAuthIdentityMigrationReportSummary()
} catch (error) {
console.error('Failed to load auth identity migration report summary:', error)
appStore.showError(text('加载 migration reports 汇总失败', 'Failed to load migration report summary'))
} finally {
summaryLoading.value = false
}
}
const loadReports = async () => {
loading.value = true
try {
const response = await adminAPI.users.listAuthIdentityMigrationReports({
page: pagination.page,
pageSize: pagination.pageSize,
reportType: filters.reportType,
})
reports.value = response.items
pagination.total = response.total
if (selectedReport.value) {
const refreshed = response.items.find((report) => report.id === selectedReport.value?.id) ?? null
selectedReport.value = refreshed
resolutionNote.value = refreshed?.resolved_at
? refreshed.resolution_note ?? ''
: resolutionNote.value
}
} catch (error) {
console.error('Failed to load auth identity migration reports:', error)
appStore.showError(text('加载 migration reports 列表失败', 'Failed to load migration reports'))
} finally {
loading.value = false
}
}
const refreshAll = async () => {
await Promise.all([loadSummary(), loadReports()])
}
const handleReportTypeChange = async () => {
pagination.page = 1
await loadReports()
}
const handlePageChange = async (page: number) => {
pagination.page = page
await loadReports()
}
const handlePageSizeChange = async (pageSize: number) => {
pagination.page = 1
pagination.pageSize = pageSize
await loadReports()
}
const selectReport = (report: AuthIdentityMigrationReport) => {
selectedReport.value = report
resolutionNote.value = report.resolution_note ?? ''
}
const formatDetailsJson = (details: Record<string, unknown>) => JSON.stringify(details ?? {}, null, 2)
const isDisplayableValue = (value: unknown) =>
['string', 'number', 'boolean'].includes(typeof value)
const getDetailHighlights = (details: Record<string, unknown>) => {
const preferredKeys = [
'user_id',
'legacy_email',
'provider_key',
'provider_subject',
'email',
'subject',
]
const entries = preferredKeys
.filter((key) => key in details && isDisplayableValue(details[key]))
.map((key) => ({ key, value: String(details[key]) }))
if (entries.length > 0) {
return entries
}
return Object.entries(details)
.filter(([, value]) => isDisplayableValue(value))
.slice(0, 4)
.map(([key, value]) => ({ key, value: String(value) }))
}
const submitResolve = async () => {
if (!selectedReport.value) {
appStore.showError(text('请先选择一条报告', 'Select a report first'))
return
}
const note = resolutionNote.value.trim()
if (!note) {
appStore.showError(text('请填写处理备注', 'Enter a resolution note'))
return
}
resolving.value = true
try {
const updated = await adminAPI.users.resolveAuthIdentityMigrationReport(selectedReport.value.id, note)
selectedReport.value = updated
resolutionNote.value = updated.resolution_note ?? ''
appStore.showSuccess(text('处理结果已提交', 'Resolution submitted'))
await refreshAll()
} catch (error) {
console.error('Failed to resolve auth identity migration report:', error)
appStore.showError(text('提交 resolve 失败', 'Failed to resolve report'))
} finally {
resolving.value = false
}
}
onMounted(async () => {
await refreshAll()
})
</script>

View File

@@ -0,0 +1,243 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import AuthIdentityMigrationReportsView from '../AuthIdentityMigrationReportsView.vue'
const { getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport } = vi.hoisted(() => ({
getAuthIdentityMigrationReportSummary: vi.fn(),
listAuthIdentityMigrationReports: vi.fn(),
resolveAuthIdentityMigrationReport: vi.fn(),
}))
const { showError, showSuccess } = vi.hoisted(() => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
users: {
getAuthIdentityMigrationReportSummary,
listAuthIdentityMigrationReports,
resolveAuthIdentityMigrationReport,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess,
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
locale: { value: 'en' },
t: (key: string) => key,
}),
}
})
vi.mock('@/utils/format', () => ({
formatDateTime: (value: string | null | undefined) => value ?? '',
}))
const sampleReport = {
id: 1,
report_type: 'oidc_synthetic_email_requires_manual_recovery',
report_key: 'legacy@example.invalid',
details: {
user_id: 42,
legacy_email: 'legacy@example.invalid',
provider_key: 'https://issuer.example',
provider_subject: 'subject-123',
},
created_at: '2026-04-20T01:02:03Z',
resolved_at: null,
resolved_by_user_id: null,
resolution_note: '',
}
const summaryResponse = {
total: 2,
open_total: 1,
resolved_total: 1,
by_type: {
oidc_synthetic_email_requires_manual_recovery: 2,
},
}
const listResponse = {
items: [sampleReport],
total: 1,
page: 1,
page_size: 20,
pages: 1,
}
const AppLayoutStub = defineComponent({
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
const TablePageLayoutStub = defineComponent({
setup(_, { slots }) {
return () => h('div', [
slots.actions?.(),
slots.filters?.(),
slots.table?.(),
slots.default?.(),
slots.pagination?.(),
])
},
})
const DataTableStub = defineComponent({
props: {
columns: { type: Array, default: () => [] },
data: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
},
setup(props, { slots }) {
return () => h('div', { 'data-test': 'data-table' }, [
props.loading
? h('div', 'loading')
: (props.data as Array<Record<string, unknown>>).map((row) =>
h(
'div',
{ key: String(row.id ?? row.report_key) },
(props.columns as Array<{ key: string }>).map((column) => {
const slot = slots[`cell-${column.key}`]
return h(
'div',
{ key: column.key, [`data-test-cell`]: `${String(row.id)}-${column.key}` },
slot
? slot({ row, value: row[column.key] })
: String(row[column.key] ?? '')
)
})
)
),
])
},
})
const PaginationStub = defineComponent({
props: {
total: { type: Number, required: true },
page: { type: Number, required: true },
pageSize: { type: Number, required: true },
},
emits: ['update:page', 'update:pageSize'],
setup(props, { emit }) {
return () => h('div', { 'data-test': 'pagination' }, [
h('button', {
type: 'button',
'data-test': 'next-page',
onClick: () => emit('update:page', props.page + 1),
}, 'next'),
h('button', {
type: 'button',
'data-test': 'page-size-50',
onClick: () => emit('update:pageSize', 50),
}, '50'),
])
},
})
describe('AuthIdentityMigrationReportsView', () => {
beforeEach(() => {
getAuthIdentityMigrationReportSummary.mockReset()
listAuthIdentityMigrationReports.mockReset()
resolveAuthIdentityMigrationReport.mockReset()
showError.mockReset()
showSuccess.mockReset()
getAuthIdentityMigrationReportSummary.mockResolvedValue(summaryResponse)
listAuthIdentityMigrationReports.mockResolvedValue(listResponse)
resolveAuthIdentityMigrationReport.mockResolvedValue({
...sampleReport,
resolved_at: '2026-04-20T02:00:00Z',
resolved_by_user_id: 100,
resolution_note: 'resolved by admin',
})
})
const mountView = () =>
mount(AuthIdentityMigrationReportsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
Pagination: PaginationStub,
Icon: true,
},
},
})
it('loads summary and first page of reports on mount', async () => {
const wrapper = mountView()
await flushPromises()
expect(getAuthIdentityMigrationReportSummary).toHaveBeenCalledTimes(1)
expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({
page: 1,
pageSize: 20,
reportType: '',
})
expect(wrapper.get('[data-test="summary-total"]').text()).toContain('2')
expect(wrapper.get('[data-test="summary-open"]').text()).toContain('1')
expect(wrapper.get('[data-test="summary-resolved"]').text()).toContain('1')
expect(wrapper.text()).toContain('legacy@example.invalid')
})
it('reloads list when the report type filter changes', async () => {
const wrapper = mountView()
await flushPromises()
listAuthIdentityMigrationReports.mockClear()
await wrapper.get('[data-test="report-type-filter"]').setValue(
'oidc_synthetic_email_requires_manual_recovery'
)
await flushPromises()
expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({
page: 1,
pageSize: 20,
reportType: 'oidc_synthetic_email_requires_manual_recovery',
})
})
it('submits resolve note for the selected report and refreshes data', async () => {
const wrapper = mountView()
await flushPromises()
getAuthIdentityMigrationReportSummary.mockClear()
listAuthIdentityMigrationReports.mockClear()
await wrapper.get('[data-test="select-report-1"]').trigger('click')
await wrapper.get('[data-test="resolution-note"]').setValue('resolved by admin')
await wrapper.get('[data-test="resolve-submit"]').trigger('click')
await flushPromises()
expect(resolveAuthIdentityMigrationReport).toHaveBeenCalledWith(1, 'resolved by admin')
expect(showSuccess).toHaveBeenCalled()
expect(getAuthIdentityMigrationReportSummary).toHaveBeenCalledTimes(1)
expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({
page: 1,
pageSize: 20,
reportType: '',
})
})
})