Billing UI (Nightly & Dev only) and Jest component unit testing (#6640)
A bajillion commits hi * all the shit i'll git reset origin/main && git add -p . later * fmt * wip * fmt * rebase; fmt; tsc; lint; * fmt * Add jest tests * fmt * ok * add nightly checks * More is_nightly checks * be happy codespell * Make vitest ignore my shit * nightly OR debug; try vitest fixing again * Add this back * fix * Update src/components/LowerRightControls.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/LowerRightControls.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/LowerRightControls.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingDialog.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update tailwind.config.js Co-authored-by: Frank Noirot <frank@zoo.dev> * Update tailwind.config.js Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * fixes * tid bits * Fix tests * color * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * fix someone else's problem --------- Co-authored-by: Frank Noirot <frank@zoo.dev>
This commit is contained in:
		
							
								
								
									
										3
									
								
								jest-component-unit-tests/babel.config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								jest-component-unit-tests/babel.config.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
{
 | 
			
		||||
  "presets": ["babel-preset-vite"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										299
									
								
								jest-component-unit-tests/billing.test.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								jest-component-unit-tests/billing.test.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,299 @@
 | 
			
		||||
// Test runner
 | 
			
		||||
import {expect, test, beforeAll, afterEach, afterAll} from '@jest/globals';
 | 
			
		||||
 | 
			
		||||
// Render mocking, DOM querying
 | 
			
		||||
import {act, render, within} from '@testing-library/react'
 | 
			
		||||
import '@testing-library/jest-dom' // Required for .toHaveStyle
 | 
			
		||||
 | 
			
		||||
// Request mocking
 | 
			
		||||
import {http, HttpResponse} from 'msw'
 | 
			
		||||
import {setupServer} from 'msw/node'
 | 
			
		||||
 | 
			
		||||
// React and XState code to test
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import { createActor } from 'xstate'
 | 
			
		||||
import {
 | 
			
		||||
  BillingRemaining,
 | 
			
		||||
  BillingRemainingMode,
 | 
			
		||||
} from '@src/components/BillingRemaining'
 | 
			
		||||
import { BillingDialog, } from '@src/components/BillingDialog'
 | 
			
		||||
import { BILLING_CONTEXT_DEFAULTS, billingMachine, BillingTransition } from '@src/machines/billingMachine'
 | 
			
		||||
 | 
			
		||||
// Setup basic request mocking
 | 
			
		||||
const server = setupServer()
 | 
			
		||||
beforeAll(() => server.listen())
 | 
			
		||||
afterEach(() => server.resetHandlers())
 | 
			
		||||
afterAll(() => server.close())
 | 
			
		||||
 | 
			
		||||
// Data ripped from docs.zoo.dev
 | 
			
		||||
const createUserPaymentBalanceResponse = (opts: {
 | 
			
		||||
  monthlyApiCreditsRemaining,
 | 
			
		||||
  stableApiCreditsRemaining,
 | 
			
		||||
}): Models['CustomerBalance_type'] => ({
 | 
			
		||||
  "created_at": "2025-05-05T16:05:47.317Z",
 | 
			
		||||
  "id": "de607b7e-90ba-4977-8561-16e8a9ea0e50",
 | 
			
		||||
  "map_id": "d7f7de34-9bc3-4b8b-9951-cdee03fc792d",
 | 
			
		||||
  "modeling_app_enterprise_price": {
 | 
			
		||||
    "type": "enterprise"
 | 
			
		||||
  },
 | 
			
		||||
  "monthly_api_credits_remaining": opts.monthlyApiCreditsRemaining,
 | 
			
		||||
  "monthly_api_credits_remaining_monetary_value": "22.47",
 | 
			
		||||
  "stable_api_credits_remaining": opts.stableApiCreditsRemaining,
 | 
			
		||||
  "stable_api_credits_remaining_monetary_value": "18.91",
 | 
			
		||||
  "subscription_details": undefined,
 | 
			
		||||
  "subscription_id": "Hnd3jalJkHA3lb1YexOTStZtPYHTM",
 | 
			
		||||
  "total_due": "100.08",
 | 
			
		||||
  "updated_at": "2025-05-05T16:05:47.317Z"
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const createOrgResponse = (opts: {
 | 
			
		||||
}): Models['Org_type'] => ({
 | 
			
		||||
  "allow_users_in_domain_to_auto_join": true,
 | 
			
		||||
  "billing_email": "m@dN9MCH.com",
 | 
			
		||||
  "billing_email_verified": "2025-05-05T18:52:02.021Z",
 | 
			
		||||
  "block": "payment_method_failed",
 | 
			
		||||
  "can_train_on_data": true,
 | 
			
		||||
  "created_at": "2025-05-05T18:52:02.021Z",
 | 
			
		||||
  "domain": "Ctxde1hpG8xTvvlef5SEPm7",
 | 
			
		||||
  "id": "78432284-8660-46bf-ac65-d00bf9b18c3e",
 | 
			
		||||
  "image": "https://Rt0.yK.com/R2SoRtl/tpUdckyDJ",
 | 
			
		||||
  "name": "AevRR4w42KdkA487dh",
 | 
			
		||||
  "phone": "+1-696-641-2790",
 | 
			
		||||
  "stripe_id": "sCfjVscpLyOBYUWO7Vlx",
 | 
			
		||||
  "updated_at": "2025-05-05T18:52:02.021Z"
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const createUserPaymentSubscriptionsResponse = (opts: {
 | 
			
		||||
  monthlyPayAsYouGoApiCreditsTotal,
 | 
			
		||||
  name,
 | 
			
		||||
}): Models['ZooProductSubscriptions_type'] => ({
 | 
			
		||||
  "modeling_app": {
 | 
			
		||||
    "annual_discount": 10,
 | 
			
		||||
    "description": "1ztERftrU3L3yOnv5epTLcM",
 | 
			
		||||
    "endpoints_included": [
 | 
			
		||||
      "modeling"
 | 
			
		||||
    ],
 | 
			
		||||
    "features": [
 | 
			
		||||
      {
 | 
			
		||||
        "info": "zZcZKHejXabT5HMZDkSkDGD2bfzkAt"
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
    "monthly_pay_as_you_go_api_credits": opts.monthlyPayAsYouGoApiCreditsTotal,
 | 
			
		||||
    "monthly_pay_as_you_go_api_credits_monetary_value": "55.85",
 | 
			
		||||
    "name": opts.name,
 | 
			
		||||
    "pay_as_you_go_api_credit_price": "18.49",
 | 
			
		||||
    "price": {
 | 
			
		||||
      "interval": "year",
 | 
			
		||||
      "price": "50.04",
 | 
			
		||||
      "type": "per_user"
 | 
			
		||||
    },
 | 
			
		||||
    "share_links": [
 | 
			
		||||
      "password_protected"
 | 
			
		||||
    ],
 | 
			
		||||
    "support_tier": "community",
 | 
			
		||||
    "training_data_behavior": "default_on",
 | 
			
		||||
    "type": {
 | 
			
		||||
      "saml_sso": true,
 | 
			
		||||
      "type": "organization"
 | 
			
		||||
    },
 | 
			
		||||
    "zoo_tools_included": [
 | 
			
		||||
      "text_to_cad"
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test('Shows a loading spinner when uninitialized credit count', async () => {
 | 
			
		||||
  server.use(
 | 
			
		||||
    http.get('*/user/payment/balance', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json({})
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/user/payment/subscriptions', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json({})
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/org', (req, res, ctx) => {
 | 
			
		||||
      return new HttpResponse(403)
 | 
			
		||||
    }),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
 | 
			
		||||
 | 
			
		||||
  const { queryByTestId } = render(<BillingRemaining
 | 
			
		||||
    mode={BillingRemainingMode.ProgressBarFixed}
 | 
			
		||||
    billingActor={billingActor}
 | 
			
		||||
  />)
 | 
			
		||||
 | 
			
		||||
  await expect(queryByTestId('spinner')).toBeVisible()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test('Shows the total credits for Unknown subscription', async () => {
 | 
			
		||||
  const data = {
 | 
			
		||||
    balance: {
 | 
			
		||||
      monthlyApiCreditsRemaining: 10,
 | 
			
		||||
      stableApiCreditsRemaining: 25,
 | 
			
		||||
    },
 | 
			
		||||
    subscriptions: {
 | 
			
		||||
      monthlyPayAsYouGoApiCreditsTotal: 20,
 | 
			
		||||
      name: "unknown",
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  server.use(
 | 
			
		||||
    http.get('*/user/payment/balance', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/user/payment/subscriptions', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/org', (req, res, ctx) => {
 | 
			
		||||
      return new HttpResponse(403)
 | 
			
		||||
    }),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
 | 
			
		||||
 | 
			
		||||
  const { queryByTestId } = render(<BillingRemaining
 | 
			
		||||
    mode={BillingRemainingMode.ProgressBarFixed}
 | 
			
		||||
    billingActor={billingActor}
 | 
			
		||||
  />)
 | 
			
		||||
 | 
			
		||||
  await act(() => {
 | 
			
		||||
    billingActor.send({ type: BillingTransition.Update, apiToken: "it doesn't matter wtf this is :)" })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const totalCredits = data.balance.monthlyApiCreditsRemaining + data.balance.stableApiCreditsRemaining
 | 
			
		||||
  await expect(billingActor.getSnapshot().context.credits).toBe(totalCredits)
 | 
			
		||||
  await within(queryByTestId('billing-credits')).getByText(totalCredits)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test('Progress bar reflects ratio left of Free subscription', async () => {
 | 
			
		||||
  const data = {
 | 
			
		||||
    balance: {
 | 
			
		||||
      monthlyApiCreditsRemaining: 10,
 | 
			
		||||
      stableApiCreditsRemaining: 0,
 | 
			
		||||
    },
 | 
			
		||||
    subscriptions: {
 | 
			
		||||
      monthlyPayAsYouGoApiCreditsTotal: 20,
 | 
			
		||||
      name: "free",
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  server.use(
 | 
			
		||||
    http.get('*/user/payment/balance', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/user/payment/subscriptions', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/org', (req, res, ctx) => {
 | 
			
		||||
      return new HttpResponse(403)
 | 
			
		||||
    }),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
 | 
			
		||||
 | 
			
		||||
  const { queryByTestId } = render(<BillingRemaining
 | 
			
		||||
    mode={BillingRemainingMode.ProgressBarFixed}
 | 
			
		||||
    billingActor={billingActor}
 | 
			
		||||
  />)
 | 
			
		||||
 | 
			
		||||
  await act(() => {
 | 
			
		||||
    billingActor.send({ type: BillingTransition.Update, apiToken: "it doesn't matter wtf this is :)" })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const totalCredits = data.balance.monthlyApiCreditsRemaining + data.balance.stableApiCreditsRemaining
 | 
			
		||||
  const monthlyCredits = data.subscriptions.monthlyPayAsYouGoApiCreditsTotal
 | 
			
		||||
  const context = billingActor.getSnapshot().context
 | 
			
		||||
  await expect(context.credits).toBe(totalCredits)
 | 
			
		||||
  await expect(context.allowance).toBe(monthlyCredits)
 | 
			
		||||
 | 
			
		||||
  await within(queryByTestId('billing-credits')).getByText(totalCredits)
 | 
			
		||||
  await expect(queryByTestId('billing-remaining-progress-bar-inner')).toHaveStyle({
 | 
			
		||||
    width: "50.00%"
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
test('Shows infinite credits for Pro subscription', async () => {
 | 
			
		||||
  const data = {
 | 
			
		||||
    // These are all ignored
 | 
			
		||||
    balance: {
 | 
			
		||||
      monthlyApiCreditsRemaining: 10,
 | 
			
		||||
      stableApiCreditsRemaining: 0,
 | 
			
		||||
    },
 | 
			
		||||
    subscriptions: {
 | 
			
		||||
      // This should be ignored because it's Pro tier.
 | 
			
		||||
      monthlyPayAsYouGoApiCreditsTotal: 20,
 | 
			
		||||
      name: "pro",
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  server.use(
 | 
			
		||||
    http.get('*/user/payment/balance', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/user/payment/subscriptions', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/org', (req, res, ctx) => {
 | 
			
		||||
      return new HttpResponse(403)
 | 
			
		||||
    }),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
 | 
			
		||||
 | 
			
		||||
  const { queryByTestId } = render(<BillingRemaining
 | 
			
		||||
    mode={BillingRemainingMode.ProgressBarFixed}
 | 
			
		||||
    billingActor={billingActor}
 | 
			
		||||
  />)
 | 
			
		||||
 | 
			
		||||
  await act(() => {
 | 
			
		||||
    billingActor.send({ type: BillingTransition.Update, apiToken: "aosetuhsatuh" })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await expect(queryByTestId('infinity')).toBeVisible()
 | 
			
		||||
  // You can't do `.not.toBeVisible` folks. When the query fails it's because
 | 
			
		||||
  // no element could be found. toBeVisible should be used on an element
 | 
			
		||||
  // that's found but may not be visible due to `display` or others.
 | 
			
		||||
  await expect(queryByTestId('billing-remaining-progress-bar-inline')).toBe(null)
 | 
			
		||||
})
 | 
			
		||||
test('Shows infinite credits for Enterprise subscription', async () => {
 | 
			
		||||
  const data = {
 | 
			
		||||
    // These are all ignored, user is part of an org.
 | 
			
		||||
    balance: {
 | 
			
		||||
      monthlyApiCreditsRemaining: 10,
 | 
			
		||||
      stableApiCreditsRemaining: 0,
 | 
			
		||||
    },
 | 
			
		||||
    subscriptions: {
 | 
			
		||||
      // This should be ignored because it's Pro tier.
 | 
			
		||||
      monthlyPayAsYouGoApiCreditsTotal: 20,
 | 
			
		||||
      // This should be ignored because the user is part of an Org.
 | 
			
		||||
      name: "free",
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  server.use(
 | 
			
		||||
    http.get('*/user/payment/balance', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
 | 
			
		||||
    }),
 | 
			
		||||
    http.get('*/user/payment/subscriptions', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
 | 
			
		||||
    }),
 | 
			
		||||
    // Ok finally the first use of an org lol
 | 
			
		||||
    http.get('*/org', (req, res, ctx) => {
 | 
			
		||||
      return HttpResponse.json(createOrgResponse())
 | 
			
		||||
    }),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
 | 
			
		||||
 | 
			
		||||
  const { queryByTestId } = render(<BillingRemaining
 | 
			
		||||
    mode={BillingRemainingMode.ProgressBarFixed}
 | 
			
		||||
    billingActor={billingActor}
 | 
			
		||||
  />)
 | 
			
		||||
 | 
			
		||||
  await act(() => {
 | 
			
		||||
    billingActor.send({ type: BillingTransition.Update, apiToken: "aosetuhsatuh" })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // The result should be the same as Pro users.
 | 
			
		||||
  await expect(queryByTestId('infinity')).toBeVisible()
 | 
			
		||||
  await expect(queryByTestId('billing-remaining-progress-bar-inline')).toBe(null)
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										19
									
								
								jest-component-unit-tests/jest.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								jest-component-unit-tests/jest.config.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
import { pathsToModuleNameMapper } from 'ts-jest'
 | 
			
		||||
// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file
 | 
			
		||||
// which contains the path mapping (ie the `compilerOptions.paths` option):
 | 
			
		||||
import { compilerOptions } from './tsconfig.json'
 | 
			
		||||
import type { Config } from 'jest'
 | 
			
		||||
 | 
			
		||||
const jestConfig: Config = {
 | 
			
		||||
  // [...]
 | 
			
		||||
  preset: "ts-jest",
 | 
			
		||||
  transform: {
 | 
			
		||||
    "^.+\.tsx?$": ["ts-jest",{ babelConfig: true }],
 | 
			
		||||
  },
 | 
			
		||||
  testEnvironment: "jest-fixed-jsdom",
 | 
			
		||||
  // TAG: paths, path, baseUrl, alias
 | 
			
		||||
  // This is necessary to use tsconfig path aliases.
 | 
			
		||||
  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/../' }),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default jestConfig
 | 
			
		||||
							
								
								
									
										35
									
								
								jest-component-unit-tests/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								jest-component-unit-tests/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "noErrorTruncation": true,
 | 
			
		||||
    "baseUrl": "../../",
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@kittycad/codemirror-lsp-client": [
 | 
			
		||||
        "./packages/codemirror-lsp-client/src/index.ts"
 | 
			
		||||
      ],
 | 
			
		||||
      "@kittycad/codemirror-lang-kcl": [
 | 
			
		||||
        "./packages/codemirror-lang-kcl/src/index.ts"
 | 
			
		||||
      ],
 | 
			
		||||
      "@rust/*": ["./rust/*"],
 | 
			
		||||
      "@e2e/*": ["./e2e/*"],
 | 
			
		||||
      "@src/*": ["./src/*"],
 | 
			
		||||
      "@public/*": ["./public/*"],
 | 
			
		||||
      "@root/*": ["./*"]
 | 
			
		||||
    },
 | 
			
		||||
    "target": "esnext",
 | 
			
		||||
    "lib": ["dom", "dom.iterable", "esnext"],
 | 
			
		||||
    "allowJs": true,
 | 
			
		||||
    "skipLibCheck": true,
 | 
			
		||||
    "esModuleInterop": true,
 | 
			
		||||
    "allowSyntheticDefaultImports": true,
 | 
			
		||||
    "strict": true,
 | 
			
		||||
    "forceConsistentCasingInFileNames": true,
 | 
			
		||||
    "noFallthroughCasesInSwitch": true,
 | 
			
		||||
    "module": "commonjs",
 | 
			
		||||
    "moduleResolution": "node",
 | 
			
		||||
    "resolveJsonModule": true,
 | 
			
		||||
    "composite": true,
 | 
			
		||||
    "isolatedModules": true,
 | 
			
		||||
    "noEmit": true,
 | 
			
		||||
    "jsx": "react-jsx"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user