mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
## Summary Shields personal-workspace billing code paths behind the new `consolidated_billing_enabled` feature flag so they fall back to the **legacy** billing flow while the flag is `false`. Team workspaces are unaffected and continue to use the workspace-scoped billing flow. ## Changes - Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the `consolidated_billing_enabled` server flag / remote config, defaults to `false`) and to the `RemoteConfig` type. - New `useBillingRouting` composable — a single source of truth for whether the active workspace uses the workspace vs. legacy billing flow: - team workspaces disabled → legacy - personal workspace + consolidated billing off/missing → legacy - personal workspace + consolidated billing on → workspace - team workspace → workspace - workspace not loaded yet → legacy - Route `useBillingContext` and the affected UI sites (`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`, `TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead of keying on `teamWorkspacesEnabled` directly. - Update the storybook `useFeatureFlags` mock to stay in sync. ## Testing - `pnpm test:unit` for `useBillingRouting`, `useBillingContext`, `useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage for the routing matrix). Remaining quality gates (`typecheck`, `lint`) are being verified in CI. ## Related Requires the backend PR that adds the `consolidated_billing_enabled` flag to `/api/features`. --------- Co-authored-by: Amp <amp@ampcode.com>
514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import PrimeVue from 'primevue/config'
|
|
import Tooltip from 'primevue/tooltip'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
import { render, screen, waitFor } from '@testing-library/vue'
|
|
|
|
import type { AuditLog } from '@/services/customerEventsService'
|
|
import { EventType } from '@/services/customerEventsService'
|
|
|
|
import UsageLogsTable from './UsageLogsTable.vue'
|
|
|
|
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 as string | 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'
|
|
}
|
|
}))
|
|
|
|
const mockTelemetry = vi.hoisted(() => ({
|
|
checkForCompletedTopup: vi.fn()
|
|
}))
|
|
vi.mock('@/platform/telemetry', () => ({
|
|
useTelemetry: () => mockTelemetry
|
|
}))
|
|
|
|
const mockBillingRouting = vi.hoisted(() => ({
|
|
shouldUseWorkspaceBilling: false
|
|
}))
|
|
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
|
const { ref } = await import('vue')
|
|
const shouldUseWorkspaceBilling = ref(false)
|
|
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
|
get: () => shouldUseWorkspaceBilling.value,
|
|
set: (value: boolean) => {
|
|
shouldUseWorkspaceBilling.value = value
|
|
}
|
|
})
|
|
return {
|
|
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
|
}
|
|
})
|
|
|
|
const mockWorkspaceApi = vi.hoisted(() => ({
|
|
getBillingEvents: vi.fn()
|
|
}))
|
|
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
|
workspaceApi: mockWorkspaceApi
|
|
}))
|
|
|
|
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',
|
|
loadEventsError: 'Failed to load activity. Please try again.',
|
|
loadEventsUnknownError:
|
|
'Something went wrong while loading activity. Please refresh and try again.'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const globalConfig = {
|
|
plugins: [PrimeVue, i18n, createTestingPinia()],
|
|
directives: { tooltip: Tooltip }
|
|
}
|
|
|
|
/**
|
|
* The component starts with loading=true and only loads data when refresh()
|
|
* is called via template ref. This wrapper auto-calls refresh on mount.
|
|
*/
|
|
const AutoRefreshWrapper = defineComponent({
|
|
components: { UsageLogsTable },
|
|
setup() {
|
|
const tableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
|
onMounted(async () => {
|
|
await tableRef.value?.refresh()
|
|
})
|
|
return { tableRef }
|
|
},
|
|
template: '<UsageLogsTable ref="tableRef" />'
|
|
})
|
|
|
|
async function flushMicrotasks() {
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
await nextTick()
|
|
}
|
|
|
|
function makeEventsResponse(
|
|
events: Partial<AuditLog>[],
|
|
overrides: Record<string, unknown> = {}
|
|
) {
|
|
return {
|
|
events,
|
|
total: events.length,
|
|
page: 1,
|
|
limit: 7,
|
|
totalPages: 1,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('UsageLogsTable', () => {
|
|
const mockEventsResponse = makeEventsResponse([
|
|
{
|
|
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'
|
|
}
|
|
])
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
|
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
|
mockBillingRouting.shouldUseWorkspaceBilling = false
|
|
mockCustomerEventsService.formatEventType.mockImplementation(
|
|
(type: string) => {
|
|
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: string) => {
|
|
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: number) => {
|
|
if (!amount) return '0.00'
|
|
return (amount / 100).toFixed(2)
|
|
}
|
|
)
|
|
mockCustomerEventsService.formatDate.mockImplementation(
|
|
(dateString: string) => new Date(dateString).toLocaleDateString()
|
|
)
|
|
mockCustomerEventsService.hasAdditionalInfo.mockImplementation(
|
|
(event: AuditLog) => {
|
|
const { amount, api_name, model, ...otherParams } =
|
|
(event.params as Record<string, unknown>) ?? {}
|
|
return Object.keys(otherParams).length > 0
|
|
}
|
|
)
|
|
mockCustomerEventsService.getTooltipContent.mockImplementation(
|
|
() => '<strong>Transaction Id:</strong> txn-123'
|
|
)
|
|
mockCustomerEventsService.error.value = null
|
|
mockCustomerEventsService.isLoading.value = false
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
function renderComponent() {
|
|
return render(UsageLogsTable, { global: globalConfig })
|
|
}
|
|
|
|
function renderWithAutoRefresh() {
|
|
return render(AutoRefreshWrapper, { global: globalConfig })
|
|
}
|
|
|
|
async function renderLoaded() {
|
|
const result = renderWithAutoRefresh()
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('table')).toBeInTheDocument()
|
|
})
|
|
return result
|
|
}
|
|
|
|
describe('loading states', () => {
|
|
it('shows loading spinner before refresh is called', () => {
|
|
renderComponent()
|
|
|
|
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
|
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows error message when service returns null', async () => {
|
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
|
mockCustomerEventsService.error.value = 'Failed to load events'
|
|
|
|
renderWithAutoRefresh()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Failed to load events')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('shows a localized fallback instead of a raw Error message', async () => {
|
|
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
|
new Error('Network error')
|
|
)
|
|
|
|
renderWithAutoRefresh()
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(
|
|
'Something went wrong while loading activity. Please refresh and try again.'
|
|
)
|
|
).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows a localized fallback when the service reports no message', async () => {
|
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
|
mockCustomerEventsService.error.value = null
|
|
|
|
renderWithAutoRefresh()
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText('Failed to load activity. Please try again.')
|
|
).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('shows data table after loading completes', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(
|
|
screen.queryByText('Failed to load events')
|
|
).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('data rendering', () => {
|
|
it('renders event type badges', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
|
|
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders credit added details with formatted amount', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
|
|
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders API usage details with api name and model', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(screen.getByText('Image Generation')).toBeInTheDocument()
|
|
expect(screen.getByText(/sdxl-base/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders account created details', async () => {
|
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
|
makeEventsResponse([
|
|
{
|
|
event_id: 'event-3',
|
|
event_type: 'account_created',
|
|
params: {},
|
|
createdAt: '2024-01-01T10:00:00Z'
|
|
}
|
|
])
|
|
)
|
|
|
|
renderWithAutoRefresh()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Account initialized')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('renders formatted dates', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(mockCustomerEventsService.formatDate).toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders info buttons for events with additional info', async () => {
|
|
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
|
|
|
await renderLoaded()
|
|
|
|
const infoButtons = screen.getAllByRole('button', {
|
|
name: 'Additional Info'
|
|
})
|
|
expect(infoButtons.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('does not render info buttons when no additional info', async () => {
|
|
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
|
|
|
|
await renderLoaded()
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Additional Info' })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('pagination', () => {
|
|
it('calls getMyEvents with initial page params', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
|
page: 1,
|
|
limit: 7
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('component methods', () => {
|
|
it('calls getMyEvents on refresh with page 1', async () => {
|
|
await renderLoaded()
|
|
|
|
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
|
page: 1,
|
|
limit: 7
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('billing events source', () => {
|
|
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
|
mockBillingRouting.shouldUseWorkspaceBilling = true
|
|
|
|
await renderLoaded()
|
|
|
|
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
|
|
page: 1,
|
|
limit: 7
|
|
})
|
|
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
|
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
|
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveLegacy = resolve
|
|
})
|
|
)
|
|
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
|
makeEventsResponse([
|
|
{
|
|
event_id: 'workspace-1',
|
|
event_type: EventType.API_USAGE_COMPLETED,
|
|
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
|
createdAt: '2024-02-01T10:00:00Z'
|
|
}
|
|
])
|
|
)
|
|
|
|
renderWithAutoRefresh()
|
|
|
|
mockBillingRouting.shouldUseWorkspaceBilling = true
|
|
await waitFor(() => {
|
|
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
|
})
|
|
|
|
resolveLegacy(
|
|
makeEventsResponse([
|
|
{
|
|
event_id: 'legacy-1',
|
|
event_type: EventType.API_USAGE_COMPLETED,
|
|
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
|
createdAt: '2024-01-01T10:00:00Z'
|
|
}
|
|
])
|
|
)
|
|
|
|
await flushMicrotasks()
|
|
|
|
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
|
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('runs top-up completion telemetry for a superseded response', async () => {
|
|
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
|
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveLegacy = resolve
|
|
})
|
|
)
|
|
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
|
makeEventsResponse([
|
|
{
|
|
event_id: 'workspace-1',
|
|
event_type: EventType.API_USAGE_COMPLETED,
|
|
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
|
createdAt: '2024-02-01T10:00:00Z'
|
|
}
|
|
])
|
|
)
|
|
|
|
renderWithAutoRefresh()
|
|
|
|
mockBillingRouting.shouldUseWorkspaceBilling = true
|
|
await waitFor(() => {
|
|
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
|
})
|
|
|
|
const legacyResponse = makeEventsResponse([
|
|
{
|
|
event_id: 'legacy-1',
|
|
event_type: EventType.CREDIT_ADDED,
|
|
params: { amount: 1000 },
|
|
createdAt: '2024-01-01T10:00:00Z'
|
|
}
|
|
])
|
|
resolveLegacy(legacyResponse)
|
|
|
|
await waitFor(() => {
|
|
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
|
legacyResponse.events
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('EventType integration', () => {
|
|
it('renders credit_added event with correct detail template', async () => {
|
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
|
makeEventsResponse([
|
|
{
|
|
event_id: 'event-1',
|
|
event_type: EventType.CREDIT_ADDED,
|
|
params: { amount: 1000 },
|
|
createdAt: '2024-01-01T10:00:00Z'
|
|
}
|
|
])
|
|
)
|
|
|
|
await renderLoaded()
|
|
|
|
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
|
|
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders api_usage_completed event with correct detail template', async () => {
|
|
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
|
makeEventsResponse([
|
|
{
|
|
event_id: 'event-2',
|
|
event_type: EventType.API_USAGE_COMPLETED,
|
|
params: { api_name: 'Test API', model: 'test-model' },
|
|
createdAt: '2024-01-02T10:00:00Z'
|
|
}
|
|
])
|
|
)
|
|
|
|
await renderLoaded()
|
|
|
|
expect(screen.getByText('Test API')).toBeInTheDocument()
|
|
expect(screen.getByText(/test-model/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|