mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
fix(admin): repair system maintenance controls
Implement server-side searchable user filtering for usage records, including backend search parameters and a shared frontend selector with loading, empty, and pinned-selected states. Fix announcement deletion by cascading announcement read rows through a Postgres migration and defensive repository cleanup across supported backends. Wire admin system purge and cleanup endpoints to real data deletion/maintenance flows, improve DataManagement messages, rebuild stats after stats purge, and add runtime OAuth token refresh maintenance when enabled. Verified with rust-ci equivalent checks: cargo fmt, split clippy, split cargo tests, SQLite/Postgres/MySQL smoke tests, plus frontend npm ci, build, pages build, type-check, and targeted usage selector tests.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import ServerUserSelector from '../ServerUserSelector.vue'
|
||||
|
||||
const getAllUsersMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/api/users', () => ({
|
||||
usersApi: {
|
||||
getAllUsers: getAllUsersMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
|
||||
return {
|
||||
Input: defineComponent({
|
||||
name: 'InputStub',
|
||||
props: { modelValue: String },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
value: props.modelValue ?? '',
|
||||
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-vue-next', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const Icon = defineComponent({
|
||||
name: 'IconStub',
|
||||
setup() {
|
||||
return () => h('span')
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
Check: Icon,
|
||||
ChevronDown: Icon,
|
||||
Search: Icon,
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function flushPromises() {
|
||||
return Promise.resolve().then(() => undefined)
|
||||
}
|
||||
|
||||
function mountSelector(props: Record<string, unknown> = {}) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ServerUserSelector, {
|
||||
modelValue: '__all__',
|
||||
initialUsers: [],
|
||||
dropdown: true,
|
||||
...props,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
getAllUsersMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('ServerUserSelector', () => {
|
||||
it('loads the initial user batch when opened', async () => {
|
||||
getAllUsersMock.mockResolvedValue([
|
||||
{ id: 'user-1', username: 'alice', email: '[email protected]' },
|
||||
])
|
||||
const root = mountSelector()
|
||||
|
||||
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(getAllUsersMock).toHaveBeenCalledWith({
|
||||
search: '',
|
||||
skip: 0,
|
||||
limit: 50,
|
||||
cacheTtlMs: 30_000,
|
||||
})
|
||||
expect(root.textContent).toContain('alice')
|
||||
})
|
||||
|
||||
it('debounces remote search and bypasses cache for typed queries', async () => {
|
||||
vi.useFakeTimers()
|
||||
getAllUsersMock.mockResolvedValue([])
|
||||
const root = mountSelector()
|
||||
|
||||
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
const input = root.querySelector('input') as HTMLInputElement
|
||||
input.value = 'bob'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
expect(getAllUsersMock).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(getAllUsersMock).toHaveBeenLastCalledWith({
|
||||
search: 'bob',
|
||||
skip: 0,
|
||||
limit: 50,
|
||||
cacheTtlMs: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the selected user pinned when search results do not include it', async () => {
|
||||
getAllUsersMock.mockResolvedValue([
|
||||
{ id: 'user-1', username: 'alice', email: '[email protected]' },
|
||||
])
|
||||
const root = mountSelector({
|
||||
modelValue: 'user-99',
|
||||
initialUsers: [
|
||||
{ id: 'user-99', username: 'pinned', email: '[email protected]' },
|
||||
],
|
||||
})
|
||||
|
||||
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(root.textContent).toContain('pinned')
|
||||
expect(root.textContent).toContain('alice')
|
||||
})
|
||||
})
|
||||
@@ -76,6 +76,8 @@ vi.mock('lucide-vue-next', async () => {
|
||||
return {
|
||||
RefreshCcw: Icon,
|
||||
Search: Icon,
|
||||
ChevronDown: Icon,
|
||||
Check: Icon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -88,6 +90,15 @@ vi.mock('../ElapsedTimeText.vue', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../ServerUserSelector.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ServerUserSelectorStub',
|
||||
setup() {
|
||||
return () => h('div', 'user selector')
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function buildRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
|
||||
|
||||
Reference in New Issue
Block a user