d3ro-voice/apps/desktop/tests/unit/checkout-flow.spec.ts
2026-08-29 18:33:45 +09:00

252 lines
8.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IPCResult } from '@d3ro/core/errors'
import type { CheckoutSessionResult, SubscriptionStatusResult } from '@d3ro/core/types'
import {
CheckoutFlowController,
type CheckoutFlowPorts,
isTrustedStripeCheckoutUrl
} from '../../src/renderer/components/payment/checkout-flow'
type CreateResult = IPCResult<CheckoutSessionResult>
type StatusResult = IPCResult<SubscriptionStatusResult>
const trustedUrl = 'https://checkout.stripe.com/c/pay/cs_test_renderer_safe#fragment'
const checkoutSuccess: CreateResult = {
success: true,
data: { checkoutUrl: trustedUrl, provider: 'stripe', status: 'pending' }
}
const openSuccess: IPCResult<void> = { success: true, data: undefined }
const activeSubscription: StatusResult = {
success: true,
data: { tier: 'pro_plus', valid: true, expiresAt: Date.parse('2099-01-01T00:00:00.000Z') }
}
const ipcFailure = {
success: false as const,
error: { code: 999, message: 'private provider details' }
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('renderer checkout flow', () => {
let createCheckoutSession: ReturnType<typeof vi.fn>
let openExternal: ReturnType<typeof vi.fn>
let getSubscriptionStatus: ReturnType<typeof vi.fn>
let controller: CheckoutFlowController
beforeEach(() => {
createCheckoutSession = vi.fn().mockResolvedValue(checkoutSuccess)
openExternal = vi.fn().mockResolvedValue(openSuccess)
getSubscriptionStatus = vi.fn().mockResolvedValue(activeSubscription)
const ports: CheckoutFlowPorts = {
createCheckoutSession,
openExternal,
getSubscriptionStatus
}
controller = new CheckoutFlowController(ports)
})
it('opens only the authenticated IPC checkout result and waits for server confirmation', async () => {
const opened = await controller.start('pro')
expect(opened).toBe(true)
expect(createCheckoutSession).toHaveBeenCalledWith({ tier: 'pro', provider: 'stripe' })
expect(openExternal).toHaveBeenCalledWith({ url: trustedUrl })
expect(getSubscriptionStatus).not.toHaveBeenCalled()
expect(controller.getState()).toEqual({
phase: 'awaiting',
error: null,
verifiedTier: null
})
})
it('fails closed when checkout IPC returns a provider error', async () => {
createCheckoutSession.mockResolvedValue(ipcFailure)
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'checkout-failed' })
expect(openExternal).not.toHaveBeenCalled()
})
it('fails closed when checkout IPC throws a network error', async () => {
createCheckoutSession.mockRejectedValue(new Error('private network details'))
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'network-failed' })
expect(openExternal).not.toHaveBeenCalled()
})
it.each([
{
checkoutUrl: 'http://checkout.stripe.com/c/pay/cs_test_bad',
provider: 'stripe',
status: 'pending'
},
{ checkoutUrl: 'https://evil.test/c/pay/cs_test_bad', provider: 'stripe', status: 'pending' },
{ checkoutUrl: trustedUrl, provider: 'toss', status: 'pending' },
{ checkoutUrl: trustedUrl, provider: 'stripe', status: 'completed' }
])('rejects an unsafe or forged checkout response %#', async (data) => {
createCheckoutSession.mockResolvedValue({ success: true, data })
await controller.start('pro')
expect(controller.getState()).toMatchObject({
phase: 'idle',
error: 'unsafe-checkout-response'
})
expect(openExternal).not.toHaveBeenCalled()
})
it('fails closed when the OS cannot open the checkout page', async () => {
openExternal.mockResolvedValue(ipcFailure)
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
})
it('fails closed when opening the checkout page throws', async () => {
openExternal.mockRejectedValue(new Error('private shell details'))
await controller.start('pro')
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
})
it('coalesces duplicate checkout clicks into one IPC request', async () => {
const pending = deferred<CreateResult>()
createCheckoutSession.mockReturnValue(pending.promise)
const first = controller.start('pro')
const duplicate = controller.start('pro_plus')
expect(await duplicate).toBe(false)
expect(createCheckoutSession).toHaveBeenCalledTimes(1)
pending.resolve(checkoutSuccess)
expect(await first).toBe(true)
expect(openExternal).toHaveBeenCalledTimes(1)
})
it('invalidates a pending checkout when the user closes the modal', async () => {
const pending = deferred<CreateResult>()
createCheckoutSession.mockReturnValue(pending.promise)
const start = controller.start('pro')
controller.cancel()
pending.resolve(checkoutSuccess)
expect(await start).toBe(false)
expect(openExternal).not.toHaveBeenCalled()
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
})
it('does not treat an opened or cancelled provider page as payment success', async () => {
await controller.start('pro')
getSubscriptionStatus.mockResolvedValue({
success: true,
data: { tier: 'free', valid: false, expiresAt: null }
})
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'not-confirmed' })
})
it('fails closed when subscription readback returns an IPC error', async () => {
await controller.start('pro')
getSubscriptionStatus.mockResolvedValue(ipcFailure)
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({
phase: 'awaiting',
error: 'verification-failed'
})
})
it('fails closed when subscription readback throws a network error', async () => {
await controller.start('pro')
getSubscriptionStatus.mockRejectedValue(new Error('private subscription details'))
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'network-failed' })
})
it('rejects an arbitrary paid tier even when a forged response claims it is valid', async () => {
await controller.start('pro')
getSubscriptionStatus.mockResolvedValue({
success: true,
data: { tier: 'enterprise', valid: true, expiresAt: null }
})
const tier = await controller.verify()
expect(tier).toBeNull()
expect(controller.getState()).toMatchObject({
phase: 'awaiting',
error: 'invalid-entitlement'
})
})
it('returns and displays only the server-read subscription tier', async () => {
await controller.start('pro')
const tier = await controller.verify()
expect(tier).toBe('pro_plus')
expect(controller.getState()).toEqual({
phase: 'complete',
error: null,
verifiedTier: 'pro_plus'
})
})
it('coalesces duplicate subscription verification clicks', async () => {
await controller.start('pro')
const pending = deferred<StatusResult>()
getSubscriptionStatus.mockReturnValue(pending.promise)
const first = controller.verify()
const duplicate = controller.verify()
expect(await duplicate).toBeNull()
expect(getSubscriptionStatus).toHaveBeenCalledTimes(1)
pending.resolve(activeSubscription)
expect(await first).toBe('pro_plus')
})
it('ignores a successful subscription response after cancellation', async () => {
await controller.start('pro')
const pending = deferred<StatusResult>()
getSubscriptionStatus.mockReturnValue(pending.promise)
const verification = controller.verify()
controller.cancel()
pending.resolve(activeSubscription)
expect(await verification).toBeNull()
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
})
it('accepts only the trusted Stripe HTTPS checkout origin and path', () => {
expect(isTrustedStripeCheckoutUrl(trustedUrl)).toBe(true)
expect(isTrustedStripeCheckoutUrl('https://checkout.stripe.com/portal/cs_test_bad')).toBe(false)
expect(
isTrustedStripeCheckoutUrl('https://user:pass@checkout.stripe.com/c/pay/cs_test_bad')
).toBe(false)
})
})