mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
usage log table (#4288)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -41,7 +41,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mt-8">
|
<div class="flex justify-between items-center">
|
||||||
|
<h3>{{ $t('credits.activity') }}</h3>
|
||||||
<Button
|
<Button
|
||||||
:label="$t('credits.invoiceHistory')"
|
:label="$t('credits.invoiceHistory')"
|
||||||
text
|
text
|
||||||
@@ -81,6 +82,8 @@
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
<UsageLogsTable ref="usageLogsTableRef" />
|
||||||
|
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
:label="$t('credits.faqs')"
|
:label="$t('credits.faqs')"
|
||||||
@@ -108,10 +111,11 @@ import DataTable from 'primevue/datatable'
|
|||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import Skeleton from 'primevue/skeleton'
|
import Skeleton from 'primevue/skeleton'
|
||||||
import TabPanel from 'primevue/tabpanel'
|
import TabPanel from 'primevue/tabpanel'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import UserCredit from '@/components/common/UserCredit.vue'
|
import UserCredit from '@/components/common/UserCredit.vue'
|
||||||
|
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
@@ -131,12 +135,23 @@ const authActions = useFirebaseAuthActions()
|
|||||||
const loading = computed(() => authStore.loading)
|
const loading = computed(() => authStore.loading)
|
||||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||||
|
|
||||||
|
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||||
|
|
||||||
const formattedLastUpdateTime = computed(() =>
|
const formattedLastUpdateTime = computed(() =>
|
||||||
authStore.lastBalanceUpdateTime
|
authStore.lastBalanceUpdateTime
|
||||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.lastBalanceUpdateTime,
|
||||||
|
(newTime, oldTime) => {
|
||||||
|
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||||
|
usageLogsTableRef.value.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const handlePurchaseCreditsClick = () => {
|
const handlePurchaseCreditsClick = () => {
|
||||||
dialogService.showTopUpCreditsDialog()
|
dialogService.showTopUpCreditsDialog()
|
||||||
}
|
}
|
||||||
|
|||||||
399
src/components/dialog/content/setting/UsageLogsTable.spec.ts
Normal file
399
src/components/dialog/content/setting/UsageLogsTable.spec.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import Badge from 'primevue/badge'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Message from 'primevue/message'
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { EventType } from '@/services/customerEventsService'
|
||||||
|
|
||||||
|
import UsageLogsTable from './UsageLogsTable.vue'
|
||||||
|
|
||||||
|
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
events: any[]
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
dataTableFirst: number
|
||||||
|
tooltipContentMap: Map<string, string>
|
||||||
|
loadEvents: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
onPageChange: (event: { page: number }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock the customerEventsService
|
||||||
|
const mockCustomerEventsService = vi.hoisted(() => ({
|
||||||
|
getMyEvents: vi.fn(),
|
||||||
|
formatEventType: vi.fn(),
|
||||||
|
getEventSeverity: vi.fn(),
|
||||||
|
formatAmount: vi.fn(),
|
||||||
|
formatDate: vi.fn(),
|
||||||
|
hasAdditionalInfo: vi.fn(),
|
||||||
|
getTooltipContent: vi.fn(),
|
||||||
|
error: { value: null },
|
||||||
|
isLoading: { value: false }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/services/customerEventsService', () => ({
|
||||||
|
useCustomerEventsService: () => mockCustomerEventsService,
|
||||||
|
EventType: {
|
||||||
|
CREDIT_ADDED: 'credit_added',
|
||||||
|
ACCOUNT_CREATED: 'account_created',
|
||||||
|
API_USAGE_STARTED: 'api_usage_started',
|
||||||
|
API_USAGE_COMPLETED: 'api_usage_completed'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Create i18n instance
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
credits: {
|
||||||
|
eventType: 'Event Type',
|
||||||
|
details: 'Details',
|
||||||
|
time: 'Time',
|
||||||
|
additionalInfo: 'Additional Info',
|
||||||
|
added: 'Added',
|
||||||
|
accountInitialized: 'Account initialized',
|
||||||
|
model: 'Model'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UsageLogsTable', () => {
|
||||||
|
const mockEventsResponse = {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event_id: 'event-1',
|
||||||
|
event_type: 'credit_added',
|
||||||
|
params: {
|
||||||
|
amount: 1000,
|
||||||
|
transaction_id: 'txn-123'
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: 'event-2',
|
||||||
|
event_type: 'api_usage_completed',
|
||||||
|
params: {
|
||||||
|
api_name: 'Image Generation',
|
||||||
|
model: 'sdxl-base',
|
||||||
|
duration: 5000
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-02T10:00:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 7,
|
||||||
|
totalPages: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
// Setup default service mock implementations
|
||||||
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||||
|
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
|
||||||
|
switch (type) {
|
||||||
|
case EventType.CREDIT_ADDED:
|
||||||
|
return 'Credits Added'
|
||||||
|
case EventType.ACCOUNT_CREATED:
|
||||||
|
return 'Account Created'
|
||||||
|
case EventType.API_USAGE_COMPLETED:
|
||||||
|
return 'API Usage'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
|
||||||
|
switch (type) {
|
||||||
|
case EventType.CREDIT_ADDED:
|
||||||
|
return 'success'
|
||||||
|
case EventType.ACCOUNT_CREATED:
|
||||||
|
return 'info'
|
||||||
|
case EventType.API_USAGE_COMPLETED:
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
|
||||||
|
if (!amount) return '0.00'
|
||||||
|
return (amount / 100).toFixed(2)
|
||||||
|
})
|
||||||
|
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString()
|
||||||
|
})
|
||||||
|
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
|
||||||
|
const { amount, api_name, model, ...otherParams } = event.params || {}
|
||||||
|
return Object.keys(otherParams).length > 0
|
||||||
|
})
|
||||||
|
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
|
||||||
|
return '<strong>Transaction Id:</strong> txn-123'
|
||||||
|
})
|
||||||
|
mockCustomerEventsService.error.value = null
|
||||||
|
mockCustomerEventsService.isLoading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountComponent = (options = {}) => {
|
||||||
|
return mount(UsageLogsTable, {
|
||||||
|
global: {
|
||||||
|
plugins: [PrimeVue, i18n, createTestingPinia()],
|
||||||
|
components: {
|
||||||
|
DataTable,
|
||||||
|
Column,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Message,
|
||||||
|
ProgressSpinner
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
tooltip: Tooltip
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loading states', () => {
|
||||||
|
it('shows loading spinner when loading is true', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.loading = true
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||||
|
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message when error exists', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.error = 'Failed to load events'
|
||||||
|
vm.loading = false
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const messageComponent = wrapper.findComponent(Message)
|
||||||
|
expect(messageComponent.exists()).toBe(true)
|
||||||
|
expect(messageComponent.props('severity')).toBe('error')
|
||||||
|
expect(messageComponent.text()).toContain('Failed to load events')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows data table when loaded successfully', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
// Wait for component to mount and load data
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||||
|
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||||
|
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('data rendering', () => {
|
||||||
|
it('renders events data correctly', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const dataTable = wrapper.findComponent(DataTable)
|
||||||
|
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
|
||||||
|
expect(dataTable.props('rows')).toBe(7)
|
||||||
|
expect(dataTable.props('paginator')).toBe(true)
|
||||||
|
expect(dataTable.props('lazy')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders badge for event types correctly', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const badges = wrapper.findAllComponents(Badge)
|
||||||
|
expect(badges.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Check if formatEventType and getEventSeverity are called
|
||||||
|
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
|
||||||
|
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders different event details based on event type', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Check if formatAmount is called for credit_added events
|
||||||
|
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders tooltip buttons for events with additional info', async () => {
|
||||||
|
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pagination', () => {
|
||||||
|
it('handles page change correctly', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Simulate page change
|
||||||
|
const dataTable = wrapper.findComponent(DataTable)
|
||||||
|
await dataTable.vm.$emit('page', { page: 1 })
|
||||||
|
|
||||||
|
expect(vm.pagination.page).toBe(1) // page + 1
|
||||||
|
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||||
|
page: 2,
|
||||||
|
limit: 7
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates dataTableFirst correctly', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tooltip functionality', () => {
|
||||||
|
it('generates tooltip content map correctly', async () => {
|
||||||
|
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||||
|
mockCustomerEventsService.getTooltipContent.mockReturnValue(
|
||||||
|
'<strong>Test:</strong> value'
|
||||||
|
)
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const tooltipMap = vm.tooltipContentMap
|
||||||
|
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes events without additional info from tooltip map', async () => {
|
||||||
|
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
|
||||||
|
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = mockEventsResponse.events
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const tooltipMap = vm.tooltipContentMap
|
||||||
|
expect(tooltipMap.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('component methods', () => {
|
||||||
|
it('exposes refresh method', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(typeof wrapper.vm.refresh).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets to first page on refresh', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
|
||||||
|
vm.pagination.page = 3
|
||||||
|
|
||||||
|
await vm.refresh()
|
||||||
|
|
||||||
|
expect(vm.pagination.page).toBe(1)
|
||||||
|
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||||
|
page: 1,
|
||||||
|
limit: 7
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('component lifecycle', () => {
|
||||||
|
it('initializes with correct default values', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
|
||||||
|
expect(vm.events).toEqual([])
|
||||||
|
expect(vm.loading).toBe(true)
|
||||||
|
expect(vm.error).toBeNull()
|
||||||
|
expect(vm.pagination).toEqual({
|
||||||
|
page: 1,
|
||||||
|
limit: 7,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EventType integration', () => {
|
||||||
|
it('uses EventType enum in template conditions', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const vm = wrapper.vm as ComponentInstance
|
||||||
|
|
||||||
|
vm.loading = false
|
||||||
|
vm.events = [
|
||||||
|
{
|
||||||
|
event_id: 'event-1',
|
||||||
|
event_type: EventType.CREDIT_ADDED,
|
||||||
|
params: { amount: 1000 },
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Verify that the component can access EventType enum
|
||||||
|
expect(EventType.CREDIT_ADDED).toBe('credit_added')
|
||||||
|
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
|
||||||
|
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
188
src/components/dialog/content/setting/UsageLogsTable.vue
Normal file
188
src/components/dialog/content/setting/UsageLogsTable.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="loading" class="flex items-center justify-center p-8">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="p-4">
|
||||||
|
<Message severity="error" :closable="false">{{ error }}</Message>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
v-else
|
||||||
|
:value="events"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pagination.limit"
|
||||||
|
:total-records="pagination.total"
|
||||||
|
:first="dataTableFirst"
|
||||||
|
:lazy="true"
|
||||||
|
class="p-datatable-sm custom-datatable"
|
||||||
|
@page="onPageChange"
|
||||||
|
>
|
||||||
|
<Column field="event_type" :header="$t('credits.eventType')">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Badge
|
||||||
|
:value="customerEventService.formatEventType(data.event_type)"
|
||||||
|
:severity="customerEventService.getEventSeverity(data.event_type)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="details" :header="$t('credits.details')">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="event-details">
|
||||||
|
<!-- Credits Added -->
|
||||||
|
<template v-if="data.event_type === EventType.CREDIT_ADDED">
|
||||||
|
<div class="text-green-500 font-semibold">
|
||||||
|
{{ $t('credits.added') }} ${{
|
||||||
|
customerEventService.formatAmount(data.params?.amount)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Account Created -->
|
||||||
|
<template v-else-if="data.event_type === EventType.ACCOUNT_CREATED">
|
||||||
|
<div>{{ $t('credits.accountInitialized') }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- API Usage -->
|
||||||
|
<template
|
||||||
|
v-else-if="data.event_type === EventType.API_USAGE_COMPLETED"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ data.params?.api_name || 'API' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
{{ $t('credits.model') }}: {{ data.params?.model || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="createdAt" :header="$t('credits.time')">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ customerEventService.formatDate(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="params" :header="$t('credits.additionalInfo')">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Button
|
||||||
|
v-if="customerEventService.hasAdditionalInfo(data)"
|
||||||
|
v-tooltip.top="{
|
||||||
|
escape: false,
|
||||||
|
value: tooltipContentMap.get(data.event_id) || '',
|
||||||
|
pt: {
|
||||||
|
text: {
|
||||||
|
style: {
|
||||||
|
width: 'max-content !important'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
icon="pi pi-info-circle"
|
||||||
|
class="p-button-text p-button-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Badge from 'primevue/badge'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Message from 'primevue/message'
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuditLog,
|
||||||
|
EventType,
|
||||||
|
useCustomerEventsService
|
||||||
|
} from '@/services/customerEventsService'
|
||||||
|
|
||||||
|
const events = ref<AuditLog[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const customerEventService = useCustomerEventsService()
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
limit: 7,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const dataTableFirst = computed(
|
||||||
|
() => (pagination.value.page - 1) * pagination.value.limit
|
||||||
|
)
|
||||||
|
|
||||||
|
const tooltipContentMap = computed(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
events.value.forEach((event) => {
|
||||||
|
if (customerEventService.hasAdditionalInfo(event) && event.event_id) {
|
||||||
|
map.set(event.event_id, customerEventService.getTooltipContent(event))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await customerEventService.getMyEvents({
|
||||||
|
page: pagination.value.page,
|
||||||
|
limit: pagination.value.limit
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
if (response.events) {
|
||||||
|
events.value = response.events
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.page) {
|
||||||
|
pagination.value.page = response.page
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.limit) {
|
||||||
|
pagination.value.limit = response.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.total) {
|
||||||
|
pagination.value.total = response.total
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.totalPages) {
|
||||||
|
pagination.value.totalPages = response.totalPages
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = customerEventService.error.value || 'Failed to load events'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
console.error('Error loading events:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (event: { page: number }) => {
|
||||||
|
pagination.value.page = event.page + 1
|
||||||
|
void loadEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
pagination.value.page = 1
|
||||||
|
await loadEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -1422,6 +1422,7 @@
|
|||||||
"personalDataConsentRequired": "You must agree to the processing of your personal data."
|
"personalDataConsentRequired": "You must agree to the processing of your personal data."
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"activity": "Activity",
|
||||||
"credits": "Credits",
|
"credits": "Credits",
|
||||||
"yourCreditBalance": "Your credit balance",
|
"yourCreditBalance": "Your credit balance",
|
||||||
"purchaseCredits": "Purchase Credits",
|
"purchaseCredits": "Purchase Credits",
|
||||||
@@ -1438,7 +1439,14 @@
|
|||||||
"buyNow": "Buy now",
|
"buyNow": "Buy now",
|
||||||
"seeDetails": "See details",
|
"seeDetails": "See details",
|
||||||
"topUp": "Top Up"
|
"topUp": "Top Up"
|
||||||
}
|
},
|
||||||
|
"eventType": "Event Type",
|
||||||
|
"details": "Details",
|
||||||
|
"time": "Time",
|
||||||
|
"additionalInfo": "Additional Info",
|
||||||
|
"model": "Model",
|
||||||
|
"added": "Added",
|
||||||
|
"accountInitialized": "Account initialized"
|
||||||
},
|
},
|
||||||
"userSettings": {
|
"userSettings": {
|
||||||
"title": "User Settings",
|
"title": "User Settings",
|
||||||
|
|||||||
@@ -138,13 +138,21 @@
|
|||||||
"Unpin": "Desanclar"
|
"Unpin": "Desanclar"
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"accountInitialized": "Cuenta inicializada",
|
||||||
|
"activity": "Actividad",
|
||||||
|
"added": "Añadido",
|
||||||
|
"additionalInfo": "Información adicional",
|
||||||
"apiPricing": "Precios de la API",
|
"apiPricing": "Precios de la API",
|
||||||
"credits": "Créditos",
|
"credits": "Créditos",
|
||||||
|
"details": "Detalles",
|
||||||
|
"eventType": "Tipo de evento",
|
||||||
"faqs": "Preguntas frecuentes",
|
"faqs": "Preguntas frecuentes",
|
||||||
"invoiceHistory": "Historial de facturas",
|
"invoiceHistory": "Historial de facturas",
|
||||||
"lastUpdated": "Última actualización",
|
"lastUpdated": "Última actualización",
|
||||||
"messageSupport": "Contactar soporte",
|
"messageSupport": "Contactar soporte",
|
||||||
|
"model": "Modelo",
|
||||||
"purchaseCredits": "Comprar créditos",
|
"purchaseCredits": "Comprar créditos",
|
||||||
|
"time": "Hora",
|
||||||
"topUp": {
|
"topUp": {
|
||||||
"buyNow": "Comprar ahora",
|
"buyNow": "Comprar ahora",
|
||||||
"insufficientMessage": "No tienes suficientes créditos para ejecutar este flujo de trabajo.",
|
"insufficientMessage": "No tienes suficientes créditos para ejecutar este flujo de trabajo.",
|
||||||
|
|||||||
@@ -138,13 +138,21 @@
|
|||||||
"Unpin": "Désépingler"
|
"Unpin": "Désépingler"
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"accountInitialized": "Compte initialisé",
|
||||||
|
"activity": "Activité",
|
||||||
|
"added": "Ajouté",
|
||||||
|
"additionalInfo": "Informations supplémentaires",
|
||||||
"apiPricing": "Tarification de l’API",
|
"apiPricing": "Tarification de l’API",
|
||||||
"credits": "Crédits",
|
"credits": "Crédits",
|
||||||
|
"details": "Détails",
|
||||||
|
"eventType": "Type d'événement",
|
||||||
"faqs": "FAQ",
|
"faqs": "FAQ",
|
||||||
"invoiceHistory": "Historique des factures",
|
"invoiceHistory": "Historique des factures",
|
||||||
"lastUpdated": "Dernière mise à jour",
|
"lastUpdated": "Dernière mise à jour",
|
||||||
"messageSupport": "Contacter le support",
|
"messageSupport": "Contacter le support",
|
||||||
|
"model": "Modèle",
|
||||||
"purchaseCredits": "Acheter des crédits",
|
"purchaseCredits": "Acheter des crédits",
|
||||||
|
"time": "Heure",
|
||||||
"topUp": {
|
"topUp": {
|
||||||
"buyNow": "Acheter maintenant",
|
"buyNow": "Acheter maintenant",
|
||||||
"insufficientMessage": "Vous n'avez pas assez de crédits pour exécuter ce workflow.",
|
"insufficientMessage": "Vous n'avez pas assez de crédits pour exécuter ce workflow.",
|
||||||
|
|||||||
@@ -138,13 +138,21 @@
|
|||||||
"Unpin": "ピンを解除"
|
"Unpin": "ピンを解除"
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"accountInitialized": "アカウントが初期化されました",
|
||||||
|
"activity": "アクティビティ",
|
||||||
|
"added": "追加済み",
|
||||||
|
"additionalInfo": "追加情報",
|
||||||
"apiPricing": "API料金",
|
"apiPricing": "API料金",
|
||||||
"credits": "クレジット",
|
"credits": "クレジット",
|
||||||
|
"details": "詳細",
|
||||||
|
"eventType": "イベントタイプ",
|
||||||
"faqs": "よくある質問",
|
"faqs": "よくある質問",
|
||||||
"invoiceHistory": "請求履歴",
|
"invoiceHistory": "請求履歴",
|
||||||
"lastUpdated": "最終更新",
|
"lastUpdated": "最終更新",
|
||||||
"messageSupport": "サポートにメッセージ",
|
"messageSupport": "サポートにメッセージ",
|
||||||
|
"model": "モデル",
|
||||||
"purchaseCredits": "クレジットを購入",
|
"purchaseCredits": "クレジットを購入",
|
||||||
|
"time": "時間",
|
||||||
"topUp": {
|
"topUp": {
|
||||||
"buyNow": "今すぐ購入",
|
"buyNow": "今すぐ購入",
|
||||||
"insufficientMessage": "このワークフローを実行するのに十分なクレジットがありません。",
|
"insufficientMessage": "このワークフローを実行するのに十分なクレジットがありません。",
|
||||||
|
|||||||
@@ -138,13 +138,21 @@
|
|||||||
"Unpin": "고정 해제"
|
"Unpin": "고정 해제"
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"accountInitialized": "계정이 초기화됨",
|
||||||
|
"activity": "활동",
|
||||||
|
"added": "추가됨",
|
||||||
|
"additionalInfo": "추가 정보",
|
||||||
"apiPricing": "API 가격",
|
"apiPricing": "API 가격",
|
||||||
"credits": "크레딧",
|
"credits": "크레딧",
|
||||||
|
"details": "세부 정보",
|
||||||
|
"eventType": "이벤트 유형",
|
||||||
"faqs": "자주 묻는 질문",
|
"faqs": "자주 묻는 질문",
|
||||||
"invoiceHistory": "청구서 내역",
|
"invoiceHistory": "청구서 내역",
|
||||||
"lastUpdated": "마지막 업데이트",
|
"lastUpdated": "마지막 업데이트",
|
||||||
"messageSupport": "지원 문의",
|
"messageSupport": "지원 문의",
|
||||||
|
"model": "모델",
|
||||||
"purchaseCredits": "크레딧 구매",
|
"purchaseCredits": "크레딧 구매",
|
||||||
|
"time": "시간",
|
||||||
"topUp": {
|
"topUp": {
|
||||||
"buyNow": "지금 구매",
|
"buyNow": "지금 구매",
|
||||||
"insufficientMessage": "이 워크플로우를 실행하기에 크레딧이 부족합니다.",
|
"insufficientMessage": "이 워크플로우를 실행하기에 크레딧이 부족합니다.",
|
||||||
|
|||||||
@@ -138,13 +138,21 @@
|
|||||||
"Unpin": "Открепить"
|
"Unpin": "Открепить"
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"accountInitialized": "Аккаунт инициализирован",
|
||||||
|
"activity": "Активность",
|
||||||
|
"added": "Добавлено",
|
||||||
|
"additionalInfo": "Дополнительная информация",
|
||||||
"apiPricing": "Цены на API",
|
"apiPricing": "Цены на API",
|
||||||
"credits": "Кредиты",
|
"credits": "Кредиты",
|
||||||
|
"details": "Детали",
|
||||||
|
"eventType": "Тип события",
|
||||||
"faqs": "Часто задаваемые вопросы",
|
"faqs": "Часто задаваемые вопросы",
|
||||||
"invoiceHistory": "История счетов",
|
"invoiceHistory": "История счетов",
|
||||||
"lastUpdated": "Последнее обновление",
|
"lastUpdated": "Последнее обновление",
|
||||||
"messageSupport": "Связаться с поддержкой",
|
"messageSupport": "Связаться с поддержкой",
|
||||||
|
"model": "Модель",
|
||||||
"purchaseCredits": "Купить кредиты",
|
"purchaseCredits": "Купить кредиты",
|
||||||
|
"time": "Время",
|
||||||
"topUp": {
|
"topUp": {
|
||||||
"buyNow": "Купить сейчас",
|
"buyNow": "Купить сейчас",
|
||||||
"insufficientMessage": "У вас недостаточно кредитов для запуска этого рабочего процесса.",
|
"insufficientMessage": "У вас недостаточно кредитов для запуска этого рабочего процесса.",
|
||||||
|
|||||||
@@ -138,13 +138,21 @@
|
|||||||
"Unpin": "取消固定"
|
"Unpin": "取消固定"
|
||||||
},
|
},
|
||||||
"credits": {
|
"credits": {
|
||||||
|
"accountInitialized": "账户已初始化",
|
||||||
|
"activity": "活动",
|
||||||
|
"added": "已添加",
|
||||||
|
"additionalInfo": "附加信息",
|
||||||
"apiPricing": "API 价格",
|
"apiPricing": "API 价格",
|
||||||
"credits": "积分",
|
"credits": "积分",
|
||||||
|
"details": "详情",
|
||||||
|
"eventType": "事件类型",
|
||||||
"faqs": "常见问题",
|
"faqs": "常见问题",
|
||||||
"invoiceHistory": "发票历史",
|
"invoiceHistory": "发票历史",
|
||||||
"lastUpdated": "最近更新",
|
"lastUpdated": "最近更新",
|
||||||
"messageSupport": "联系客服",
|
"messageSupport": "联系客服",
|
||||||
|
"model": "模型",
|
||||||
"purchaseCredits": "购买积分",
|
"purchaseCredits": "购买积分",
|
||||||
|
"time": "时间",
|
||||||
"topUp": {
|
"topUp": {
|
||||||
"buyNow": "立即购买",
|
"buyNow": "立即购买",
|
||||||
"insufficientMessage": "您的积分不足,无法运行此工作流。",
|
"insufficientMessage": "您的积分不足,无法运行此工作流。",
|
||||||
|
|||||||
207
src/services/customerEventsService.ts
Normal file
207
src/services/customerEventsService.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||||
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
import { type components, operations } from '@/types/comfyRegistryTypes'
|
||||||
|
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||||
|
|
||||||
|
export enum EventType {
|
||||||
|
CREDIT_ADDED = 'credit_added',
|
||||||
|
ACCOUNT_CREATED = 'account_created',
|
||||||
|
API_USAGE_STARTED = 'api_usage_started',
|
||||||
|
API_USAGE_COMPLETED = 'api_usage_completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerEventsResponse =
|
||||||
|
operations['GetCustomerEvents']['responses']['200']['content']['application/json']
|
||||||
|
|
||||||
|
type CustomerEventsResponseQuery =
|
||||||
|
operations['GetCustomerEvents']['parameters']['query']
|
||||||
|
|
||||||
|
export type AuditLog = components['schemas']['AuditLog']
|
||||||
|
|
||||||
|
const customerApiClient = axios.create({
|
||||||
|
baseURL: COMFY_API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useCustomerEventsService = () => {
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const { d } = useI18n()
|
||||||
|
|
||||||
|
const handleRequestError = (
|
||||||
|
err: unknown,
|
||||||
|
context: string,
|
||||||
|
routeSpecificErrors?: Record<number, string>
|
||||||
|
) => {
|
||||||
|
// Don't treat cancellation as an error
|
||||||
|
if (isAbortError(err)) return
|
||||||
|
|
||||||
|
let message: string
|
||||||
|
if (!axios.isAxiosError(err)) {
|
||||||
|
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
} else {
|
||||||
|
const axiosError = err as AxiosError<{ message: string }>
|
||||||
|
const status = axiosError.response?.status
|
||||||
|
if (status && routeSpecificErrors?.[status]) {
|
||||||
|
message = routeSpecificErrors[status]
|
||||||
|
} else {
|
||||||
|
message =
|
||||||
|
axiosError.response?.data?.message ??
|
||||||
|
`${context} failed with status ${status}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeRequest = async <T>(
|
||||||
|
requestCall: () => Promise<AxiosResponse<T>>,
|
||||||
|
options: {
|
||||||
|
errorContext: string
|
||||||
|
routeSpecificErrors?: Record<number, string>
|
||||||
|
}
|
||||||
|
): Promise<T | null> => {
|
||||||
|
const { errorContext, routeSpecificErrors } = options
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await requestCall()
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleRequestError(err, errorContext, routeSpecificErrors)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventType(eventType: string) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'credit_added':
|
||||||
|
return 'Credits Added'
|
||||||
|
case 'account_created':
|
||||||
|
return 'Account Created'
|
||||||
|
case 'api_usage_completed':
|
||||||
|
return 'API Usage'
|
||||||
|
default:
|
||||||
|
return eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
|
||||||
|
return d(date, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonKey(key: string) {
|
||||||
|
return key
|
||||||
|
.split('_')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonValue(value: any) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
// Format numbers with commas and decimals if needed
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||||
|
// Format dates nicely
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventSeverity(eventType: string) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'credit_added':
|
||||||
|
return 'success'
|
||||||
|
case 'account_created':
|
||||||
|
return 'info'
|
||||||
|
case 'api_usage_completed':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAdditionalInfo(event: AuditLog) {
|
||||||
|
const { amount, api_name, model, ...otherParams } = event.params || {}
|
||||||
|
return Object.keys(otherParams).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTooltipContent(event: AuditLog) {
|
||||||
|
const { ...params } = event.params || {}
|
||||||
|
|
||||||
|
return Object.entries(params)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const formattedKey = formatJsonKey(key)
|
||||||
|
const formattedValue = formatJsonValue(value)
|
||||||
|
return `<strong>${formattedKey}:</strong> ${formattedValue}`
|
||||||
|
})
|
||||||
|
.join('<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amountMicros?: number) {
|
||||||
|
if (!amountMicros) return '0.00'
|
||||||
|
return (amountMicros / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMyEvents({
|
||||||
|
page = 1,
|
||||||
|
limit = 10
|
||||||
|
}: CustomerEventsResponseQuery = {}): Promise<CustomerEventsResponse | null> {
|
||||||
|
const errorContext = 'Fetching customer events'
|
||||||
|
const routeSpecificErrors = {
|
||||||
|
400: 'Invalid input, object invalid',
|
||||||
|
404: 'Not found'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth headers
|
||||||
|
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
|
||||||
|
if (!authHeaders) {
|
||||||
|
error.value = 'Authentication header is missing'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeRequest<CustomerEventsResponse>(
|
||||||
|
() =>
|
||||||
|
customerApiClient.get('/customers/events', {
|
||||||
|
params: { page, limit },
|
||||||
|
headers: authHeaders
|
||||||
|
}),
|
||||||
|
{ errorContext, routeSpecificErrors }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
getMyEvents,
|
||||||
|
formatEventType,
|
||||||
|
getEventSeverity,
|
||||||
|
formatAmount,
|
||||||
|
hasAdditionalInfo,
|
||||||
|
formatDate,
|
||||||
|
formatJsonKey,
|
||||||
|
formatJsonValue,
|
||||||
|
getTooltipContent
|
||||||
|
}
|
||||||
|
}
|
||||||
426
tests-ui/tests/services/customerEventsService.test.ts
Normal file
426
tests-ui/tests/services/customerEventsService.test.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
useCustomerEventsService
|
||||||
|
} from '@/services/customerEventsService'
|
||||||
|
|
||||||
|
// Hoist the mocks to avoid hoisting issues
|
||||||
|
const mockAxiosInstance = vi.hoisted(() => ({
|
||||||
|
get: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockFirebaseAuthStore = vi.hoisted(() => ({
|
||||||
|
getAuthHeader: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockI18n = vi.hoisted(() => ({
|
||||||
|
d: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('axios', () => ({
|
||||||
|
default: {
|
||||||
|
create: vi.fn(() => mockAxiosInstance),
|
||||||
|
isAxiosError: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||||
|
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: vi.fn(() => mockI18n)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||||
|
isAbortError: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useCustomerEventsService', () => {
|
||||||
|
let service: ReturnType<typeof useCustomerEventsService>
|
||||||
|
|
||||||
|
const mockAuthHeaders = {
|
||||||
|
Authorization: 'Bearer mock-token',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockEventsResponse = {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event_id: 'event-1',
|
||||||
|
event_type: 'credit_added',
|
||||||
|
params: {
|
||||||
|
amount: 1000,
|
||||||
|
transaction_id: 'txn-123',
|
||||||
|
payment_method: 'stripe'
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: 'event-2',
|
||||||
|
event_type: 'api_usage_completed',
|
||||||
|
params: {
|
||||||
|
api_name: 'Image Generation',
|
||||||
|
model: 'sdxl-base',
|
||||||
|
duration: 5000,
|
||||||
|
cost: 50
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-02T10:00:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
// Setup default mocks
|
||||||
|
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
|
||||||
|
mockI18n.d.mockImplementation((date, options) => {
|
||||||
|
// Mock i18n date formatting
|
||||||
|
if (options?.month === 'short') {
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return date.toLocaleString()
|
||||||
|
})
|
||||||
|
|
||||||
|
service = useCustomerEventsService()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize with default state', () => {
|
||||||
|
expect(service.isLoading.value).toBe(false)
|
||||||
|
expect(service.error.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize i18n date formatter', () => {
|
||||||
|
expect(mockI18n.d).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMyEvents', () => {
|
||||||
|
it('should fetch events successfully', async () => {
|
||||||
|
mockAxiosInstance.get.mockResolvedValue({ data: mockEventsResponse })
|
||||||
|
|
||||||
|
const result = await service.getMyEvents({
|
||||||
|
page: 1,
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalled()
|
||||||
|
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
|
||||||
|
params: { page: 1, limit: 10 },
|
||||||
|
headers: mockAuthHeaders
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual(mockEventsResponse)
|
||||||
|
expect(service.isLoading.value).toBe(false)
|
||||||
|
expect(service.error.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use default parameters when none provided', async () => {
|
||||||
|
mockAxiosInstance.get.mockResolvedValue({ data: mockEventsResponse })
|
||||||
|
|
||||||
|
await service.getMyEvents()
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
|
||||||
|
params: { page: 1, limit: 10 },
|
||||||
|
headers: mockAuthHeaders
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when auth headers are missing', async () => {
|
||||||
|
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await service.getMyEvents()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(service.error.value).toBe('Authentication header is missing')
|
||||||
|
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle 400 errors', async () => {
|
||||||
|
const errorResponse = {
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: { message: 'Invalid input' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockAxiosInstance.get.mockRejectedValue(errorResponse)
|
||||||
|
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||||
|
|
||||||
|
const result = await service.getMyEvents()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(service.error.value).toBe('Invalid input, object invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle 404 errors', async () => {
|
||||||
|
const errorResponse = {
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
data: { message: 'Not found' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockAxiosInstance.get.mockRejectedValue(errorResponse)
|
||||||
|
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||||
|
|
||||||
|
const result = await service.getMyEvents()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(service.error.value).toBe('Not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
const networkError = new Error('Network Error')
|
||||||
|
mockAxiosInstance.get.mockRejectedValue(networkError)
|
||||||
|
vi.mocked(axios.isAxiosError).mockReturnValue(false)
|
||||||
|
|
||||||
|
const result = await service.getMyEvents()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(service.error.value).toBe(
|
||||||
|
'Fetching customer events failed: Network Error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatEventType', () => {
|
||||||
|
it('should format known event types correctly', () => {
|
||||||
|
expect(service.formatEventType(EventType.CREDIT_ADDED)).toBe(
|
||||||
|
'Credits Added'
|
||||||
|
)
|
||||||
|
expect(service.formatEventType(EventType.ACCOUNT_CREATED)).toBe(
|
||||||
|
'Account Created'
|
||||||
|
)
|
||||||
|
expect(service.formatEventType(EventType.API_USAGE_COMPLETED)).toBe(
|
||||||
|
'API Usage'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the original string for unknown event types', () => {
|
||||||
|
expect(service.formatEventType('unknown_event')).toBe('unknown_event')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEventSeverity', () => {
|
||||||
|
it('should return correct severity for known event types', () => {
|
||||||
|
expect(service.getEventSeverity(EventType.CREDIT_ADDED)).toBe('success')
|
||||||
|
expect(service.getEventSeverity(EventType.ACCOUNT_CREATED)).toBe('info')
|
||||||
|
expect(service.getEventSeverity(EventType.API_USAGE_COMPLETED)).toBe(
|
||||||
|
'warning'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return default severity for unknown event types', () => {
|
||||||
|
expect(service.getEventSeverity('unknown_event')).toBe('info')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatAmount', () => {
|
||||||
|
it('should format amounts correctly', () => {
|
||||||
|
expect(service.formatAmount(1000)).toBe('10.00')
|
||||||
|
expect(service.formatAmount(2550)).toBe('25.50')
|
||||||
|
expect(service.formatAmount(100)).toBe('1.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined amounts', () => {
|
||||||
|
expect(service.formatAmount(undefined)).toBe('0.00')
|
||||||
|
expect(service.formatAmount(0)).toBe('0.00')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('should use i18n date formatter', () => {
|
||||||
|
const dateString = '2024-01-01T10:00:00Z'
|
||||||
|
|
||||||
|
service.formatDate(dateString)
|
||||||
|
|
||||||
|
expect(mockI18n.d).toHaveBeenCalledWith(new Date(dateString), {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return formatted date string', () => {
|
||||||
|
const dateString = '2024-01-01T10:00:00Z'
|
||||||
|
const result = service.formatDate(dateString)
|
||||||
|
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAdditionalInfo', () => {
|
||||||
|
it('should return true when event has additional parameters', () => {
|
||||||
|
const event = {
|
||||||
|
event_id: 'test',
|
||||||
|
event_type: 'api_usage_completed',
|
||||||
|
params: {
|
||||||
|
api_name: 'test-api',
|
||||||
|
model: 'test-model',
|
||||||
|
duration: 1000,
|
||||||
|
extra_param: 'extra_value'
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.hasAdditionalInfo(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when event only has known parameters', () => {
|
||||||
|
const event = {
|
||||||
|
event_id: 'test',
|
||||||
|
event_type: 'api_usage_completed',
|
||||||
|
params: {
|
||||||
|
amount: 1000,
|
||||||
|
api_name: 'test-api',
|
||||||
|
model: 'test-model'
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.hasAdditionalInfo(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when params is undefined', () => {
|
||||||
|
const event = {
|
||||||
|
event_id: 'test',
|
||||||
|
event_type: 'account_created',
|
||||||
|
params: undefined,
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.hasAdditionalInfo(event)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTooltipContent', () => {
|
||||||
|
it('should generate HTML tooltip content for all parameters', () => {
|
||||||
|
const event = {
|
||||||
|
event_id: 'test',
|
||||||
|
event_type: 'api_usage_completed',
|
||||||
|
params: {
|
||||||
|
transaction_id: 'txn-123',
|
||||||
|
duration: 5000,
|
||||||
|
status: 'completed'
|
||||||
|
},
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = service.getTooltipContent(event)
|
||||||
|
|
||||||
|
expect(result).toContain('<strong>Transaction Id:</strong> txn-123')
|
||||||
|
expect(result).toContain('<strong>Duration:</strong> 5,000')
|
||||||
|
expect(result).toContain('<strong>Status:</strong> completed')
|
||||||
|
expect(result).toContain('<br>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty string when no parameters', () => {
|
||||||
|
const event = {
|
||||||
|
event_id: 'test',
|
||||||
|
event_type: 'account_created',
|
||||||
|
params: {},
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.getTooltipContent(event)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined params', () => {
|
||||||
|
const event = {
|
||||||
|
event_id: 'test',
|
||||||
|
event_type: 'account_created',
|
||||||
|
params: undefined,
|
||||||
|
createdAt: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.getTooltipContent(event)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatJsonKey', () => {
|
||||||
|
it('should format keys correctly', () => {
|
||||||
|
expect(service.formatJsonKey('transaction_id')).toBe('Transaction Id')
|
||||||
|
expect(service.formatJsonKey('api_name')).toBe('Api Name')
|
||||||
|
expect(service.formatJsonKey('simple')).toBe('Simple')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatJsonValue', () => {
|
||||||
|
it('should format numbers with commas', () => {
|
||||||
|
expect(service.formatJsonValue(1000)).toBe('1,000')
|
||||||
|
expect(service.formatJsonValue(1234567)).toBe('1,234,567')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format date strings', () => {
|
||||||
|
const dateString = '2024-01-01T10:00:00Z'
|
||||||
|
const result = service.formatJsonValue(dateString)
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result).not.toBe(dateString) // Should be formatted
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling edge cases', () => {
|
||||||
|
it('should handle non-Error objects', async () => {
|
||||||
|
const stringError = 'String error'
|
||||||
|
mockAxiosInstance.get.mockRejectedValue(stringError)
|
||||||
|
vi.mocked(axios.isAxiosError).mockReturnValue(false)
|
||||||
|
|
||||||
|
const result = await service.getMyEvents()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(service.error.value).toBe(
|
||||||
|
'Fetching customer events failed: String error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset error state on new request', async () => {
|
||||||
|
// First request fails
|
||||||
|
mockAxiosInstance.get.mockRejectedValueOnce(new Error('First error'))
|
||||||
|
await service.getMyEvents()
|
||||||
|
expect(service.error.value).toBeTruthy()
|
||||||
|
|
||||||
|
// Second request succeeds
|
||||||
|
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockEventsResponse })
|
||||||
|
await service.getMyEvents()
|
||||||
|
expect(service.error.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EventType enum', () => {
|
||||||
|
it('should have correct enum values', () => {
|
||||||
|
expect(EventType.CREDIT_ADDED).toBe('credit_added')
|
||||||
|
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
|
||||||
|
expect(EventType.API_USAGE_STARTED).toBe('api_usage_started')
|
||||||
|
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases for formatting functions', () => {
|
||||||
|
it('formatJsonKey should handle empty strings', () => {
|
||||||
|
expect(service.formatJsonKey('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatJsonKey should handle single words', () => {
|
||||||
|
expect(service.formatJsonKey('test')).toBe('Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatAmount should handle very large numbers', () => {
|
||||||
|
expect(service.formatAmount(999999999)).toBe('9999999.99')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user