Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/platform/workspace/composables/useBillingCapabilities',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingCapabilities.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
Expand Down
4 changes: 4 additions & 0 deletions browser_tests/fixtures/ComfyPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
EMPTY_BILLING_PLANS,
LEGACY_PERSONAL_BILLING_STATUS
} from '@e2e/fixtures/data/cloudWorkspace'
import { createBillingCapabilities } from '@e2e/fixtures/data/billingCapabilities'
import {
UNSUBSCRIBED,
ZERO_BALANCE
Expand Down Expand Up @@ -658,6 +659,9 @@ export const comfyPageFixture = base.extend<{
await context.route('**/api/billing/status', (route) =>
route.fulfill({ json: LEGACY_PERSONAL_BILLING_STATUS })
)
await context.route('**/api/billing/capabilities', (route) =>
route.fulfill({ json: createBillingCapabilities('ws-personal') })
)
await context.route('**/api/billing/balance', (route) =>
route.fulfill({ json: EMPTY_BILLING_BALANCE })
)
Expand Down
26 changes: 26 additions & 0 deletions browser_tests/fixtures/data/billingCapabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type {
BillingCapabilities,
BillingCapabilitiesResponse
} from '@comfyorg/ingest-types'

export function createBillingCapabilities(
workspaceId: string,
overrides: Partial<BillingCapabilities> = {}
): BillingCapabilitiesResponse {
return {
resolved_for: {
user_id: 'e2e-user',
workspace_id: workspaceId
},
capabilities: {
can_cancel: false,
can_change_seats: false,
can_downgrade_to_personal: false,
can_invite_members: false,
can_reactivate: false,
can_subscribe_self_serve: true,
can_top_up: true,
...overrides
}
}
}
25 changes: 23 additions & 2 deletions browser_tests/fixtures/utils/cloudAppSetup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { Page } from '@playwright/test'
import type {
BillingCapabilitiesResponse,
BillingStatusResponse
} from '@comfyorg/ingest-types'

import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Expand Down Expand Up @@ -44,6 +48,11 @@ interface CloudAppSetupOptions {
members?: Member[]
/** Merged over the default boot features. */
features?: RemoteConfig
billingStatus?: BillingStatusResponse
billingCapabilities?:
| BillingCapabilitiesResponse
| Promise<BillingCapabilitiesResponse>
billingCapabilitiesStatus?: number
}

/**
Expand All @@ -53,14 +62,26 @@ interface CloudAppSetupOptions {
*/
export async function setupCloudApp(
page: Page,
{ workspace, members = [], features }: CloudAppSetupOptions
{
workspace,
members = [],
features,
billingStatus,
billingCapabilities,
billingCapabilitiesStatus
}: CloudAppSetupOptions
) {
await mockCloudBoot(page, {
features: features ?? {},
settings: DEFAULT_SETTINGS
})
await mockGraphBootExtras(page)
await mockBilling(page)
await mockBilling(page, {
workspaceId: workspace.id,
billingStatus,
billingCapabilities,
billingCapabilitiesStatus
})
await mockWorkspace(page, workspace, members)
await bootCloud(page)
}
57 changes: 42 additions & 15 deletions browser_tests/fixtures/utils/cloudBillingMocks.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,55 @@
import type { Page } from '@playwright/test'
import type { BillingStatusResponse } from '@comfyorg/ingest-types'
import type {
BillingCapabilitiesResponse,
BillingStatusResponse
} from '@comfyorg/ingest-types'

import { createBillingCapabilities } from '@e2e/fixtures/data/billingCapabilities'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'

interface MockBillingOptions {
workspaceId?: string
billingStatus?: BillingStatusResponse
billingCapabilities?:
| BillingCapabilitiesResponse
| Promise<BillingCapabilitiesResponse>
billingCapabilitiesStatus?: number
}

/**
* Minimal valid billing shapes so the billing facade resolves while a
* subscription dialog mounts. Active personal sub with zero balance.
*/
export async function mockBilling(page: Page) {
export async function mockBilling(
page: Page,
{
workspaceId = 'ws-personal',
billingStatus = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
billing_status: 'paid',
max_seats: 1,
occupied_seats: 1,
team_credit_stop: null
},
billingCapabilities = createBillingCapabilities(workspaceId),
billingCapabilitiesStatus
}: MockBillingOptions = {}
) {
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
billing_status: 'paid',
max_seats: 1,
occupied_seats: 1,
team_credit_stop: null
} satisfies BillingStatusResponse)
)
r.fulfill(jsonRoute(billingStatus))
)
await page.route('**/api/billing/capabilities', async (r) => {
if (r.request().method() !== 'GET') return r.fallback()
if (billingCapabilitiesStatus !== undefined) {
await r.fulfill({ status: billingCapabilitiesStatus })
return
}
await r.fulfill(jsonRoute(await billingCapabilities))
})
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
Expand Down
19 changes: 17 additions & 2 deletions browser_tests/fixtures/workspaceSwitcherFixture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'

import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createBillingCapabilities } from '@e2e/fixtures/data/billingCapabilities'
import {
EMPTY_BILLING_BALANCE,
EMPTY_BILLING_PLANS,
Expand All @@ -15,8 +16,8 @@ import { mockWorkspaceList } from '@e2e/fixtures/utils/workspaceMocks'

/**
* Boots the app with the workspace-switcher endpoints mocked: remote config
* (team workspaces enabled), the workspace list, workspace-token minting for
* whichever workspace is being switched to, and a no-op session refresh.
* (team workspaces enabled), the workspace list, workspace-token minting and
* billing capabilities for the selected workspace, and a no-op session refresh.
*/
export const workspaceSwitcherTest = comfyPageFixture.extend({
page: async ({ page }, use) => {
Expand Down Expand Up @@ -65,6 +66,20 @@ export const workspaceSwitcherTest = comfyPageFixture.extend({
await page.route('**/api/billing/plans', (route) =>
route.fulfill(jsonRoute(EMPTY_BILLING_PLANS))
)
await page.route('**/api/billing/capabilities', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
const token = route.request().headers().authorization
const workspaceId =
token?.replace('Bearer mock-workspace-token-', '') ?? 'ws-personal'
Comment thread
wei-hai marked this conversation as resolved.
Outdated
await route.fulfill(
jsonRoute(
createBillingCapabilities(workspaceId, {
can_subscribe_self_serve: workspaceId !== 'ws-team-long',
can_top_up: workspaceId !== 'ws-team-long'
})
)
)
})

await use(page)
}
Expand Down
104 changes: 80 additions & 24 deletions browser_tests/tests/dialogs/topUpDeepLink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'

import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createBillingCapabilities } from '@e2e/fixtures/data/billingCapabilities'
import { ENDED_STANDARD_BILLING_STATUS } from '@e2e/fixtures/data/cloudWorkspace'
import { CLOUD_SELF_EMAIL } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { APP_URL, setupCloudApp } from '@e2e/fixtures/utils/cloudAppSetup'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { member, workspace } from '@e2e/fixtures/utils/workspaceMocks'

/**
Expand All @@ -16,27 +17,39 @@ import { member, workspace } from '@e2e/fixtures/utils/workspaceMocks'
const topUpDialog = (page: Page) => page.getByTestId('top-up-pay-amount')

test.describe('Top-up deep link', { tag: '@cloud' }, () => {
test('opens the top-up dialog for a personal owner', async ({ page }) => {
test('opens the top-up dialog for a non-Enterprise owner', async ({
page
}) => {
test.slow()
await setupCloudApp(page, { workspace: workspace('personal', 'owner') })
const personalWorkspace = workspace('personal', 'owner')
await setupCloudApp(page, {
workspace: personalWorkspace,
billingCapabilities: createBillingCapabilities(personalWorkspace.id)
})
const capabilityRequest = page.waitForRequest('**/api/billing/capabilities')

await page.goto(`${APP_URL}/?topup=1`)

expect((await capabilityRequest).method()).toBe('GET')
await expect(topUpDialog(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]topup=/)
})

test('opens the top-up dialog for a team owner', async ({ page }) => {
test('opens the top-up dialog for an Enterprise owner', async ({ page }) => {
test.slow()
const teamWorkspace = workspace('team', 'owner')
await setupCloudApp(page, {
workspace: workspace('team', 'owner'),
workspace: teamWorkspace,
members: [
member({
email: CLOUD_SELF_EMAIL,
role: 'owner',
is_original_owner: true
})
]
],
billingCapabilities: createBillingCapabilities(teamWorkspace.id, {
can_subscribe_self_serve: false
})
})

await page.goto(`${APP_URL}/?topup=1`)
Expand All @@ -49,24 +62,16 @@ test.describe('Top-up deep link', { tag: '@cloud' }, () => {
page
}) => {
test.slow()
// The paywall fallthrough only renders when the remote config enforces
// subscriptions, matching production cloud.
const personalWorkspace = workspace('personal', 'owner')
await setupCloudApp(page, {
workspace: workspace('personal', 'owner'),
features: { subscription_required: true }
workspace: personalWorkspace,
features: { subscription_required: true },
billingStatus: ENDED_STANDARD_BILLING_STATUS,
billingCapabilities: createBillingCapabilities(personalWorkspace.id, {
can_top_up: false,
can_subscribe_self_serve: true
})
})
// Registered after setupCloudApp so this handler wins: the status fetch
// the loader awaits reports a canceled subscription.
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: false,
has_funds: false,
subscription_status: 'canceled',
billing_status: 'unpaid'
})
)
)

await page.goto(`${APP_URL}/?topup=1`)

Expand All @@ -79,16 +84,21 @@ test.describe('Top-up deep link', { tag: '@cloud' }, () => {

test('is a silent no-op for a team member', async ({ page }) => {
test.slow()
const teamWorkspace = workspace('team', 'member')
await setupCloudApp(page, {
workspace: workspace('team', 'member'),
workspace: teamWorkspace,
members: [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: CLOUD_SELF_EMAIL, role: 'member' })
]
],
billingCapabilities: createBillingCapabilities(teamWorkspace.id, {
can_top_up: false,
can_subscribe_self_serve: false
})
})

await page.goto(`${APP_URL}/?topup=1`)
Expand All @@ -102,4 +112,50 @@ test.describe('Top-up deep link', { tag: '@cloud' }, () => {
})
await expect(topUpDialog(page)).toBeHidden()
})

test('preserves the deep link until capabilities finish loading', async ({
page
}) => {
test.slow()
const personalWorkspace = workspace('personal', 'owner')
let resolveCapabilities!: (
value: ReturnType<typeof createBillingCapabilities>
) => void
const pendingCapabilities = new Promise<
ReturnType<typeof createBillingCapabilities>
>((resolve) => {
resolveCapabilities = resolve
})
await setupCloudApp(page, {
workspace: personalWorkspace,
billingCapabilities: pendingCapabilities
})
const capabilityRequest = page.waitForRequest('**/api/billing/capabilities')

await page.goto(`${APP_URL}/?topup=1`)
await capabilityRequest

await expect(page).toHaveURL(/[?&]topup=1/)
await expect(topUpDialog(page)).toBeHidden()

resolveCapabilities(createBillingCapabilities(personalWorkspace.id))

await expect(topUpDialog(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]topup=/)
})

test('uses the top-up fallback when capabilities are unavailable', async ({
page
}) => {
test.slow()
await setupCloudApp(page, {
workspace: workspace('personal', 'owner'),
billingCapabilitiesStatus: 503
})

await page.goto(`${APP_URL}/?topup=1`)

await expect(topUpDialog(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]topup=/)
})
})
Loading
Loading