Files
simstudioai--sim/apps/sim/lib/billing/core/plan.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

194 lines
6.8 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Drizzle mock for `getHighestPrioritySubscription`. It issues up to four
* queries keyed by table:
* - `subscription` for the user's personal subs (parallelized with members)
* - `member` for the user's org memberships (parallelized with subs)
* - `organization` for the org-existence follow-up
* - `subscription` again for the org-scoped subs follow-up
*
* The mock routes results by the table object passed to `.from()`, serving the
* (twice-read) `subscription` table from a FIFO queue (first read = personal,
* second = org). It records which tables were queried so we can assert the
* parallelized pair both run and that follow-ups are skipped when appropriate.
*
* Table sentinels and shared mock state live inside `vi.hoisted` so the
* `vi.mock` factories (hoisted to the top of the file) can reference them.
*/
const { SUBSCRIPTION_TABLE, MEMBER_TABLE, ORGANIZATION_TABLE, resultsByTable, fromCalls, select } =
vi.hoisted(() => {
const SUBSCRIPTION_TABLE = { __table: 'subscription' }
const MEMBER_TABLE = { __table: 'member' }
const ORGANIZATION_TABLE = { __table: 'organization' }
const resultsByTable: Record<string, unknown[][]> = {
subscription: [],
member: [],
organization: [],
}
const fromCalls: string[] = []
const select = vi.fn(() => ({
from: (table: { __table: string }) => {
fromCalls.push(table.__table)
const where = () => {
const queue = resultsByTable[table.__table]
const next = queue.length > 0 ? queue.shift() : []
return Promise.resolve(next ?? [])
}
return { where }
},
}))
return {
SUBSCRIPTION_TABLE,
MEMBER_TABLE,
ORGANIZATION_TABLE,
resultsByTable,
fromCalls,
select,
}
})
vi.mock('@sim/db', () => ({
db: { select },
}))
vi.mock('@sim/db/schema', () => ({
subscription: SUBSCRIPTION_TABLE,
member: MEMBER_TABLE,
organization: ORGANIZATION_TABLE,
}))
/**
* Realistic plan-check predicates so `pickHighestPrioritySubscription` exercises
* the real Enterprise > Team > Pro priority ordering over the rows we feed it.
*/
vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'],
checkEnterprisePlan: (s: any) =>
s?.plan === 'enterprise' && ['active', 'past_due'].includes(s?.status),
checkTeamPlan: (s: any) => s?.plan === 'team' && ['active', 'past_due'].includes(s?.status),
checkProPlan: (s: any) => s?.plan === 'pro' && ['active', 'past_due'].includes(s?.status),
}))
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
interface SubRow {
id: string
referenceId: string
plan: string
status: string
}
function personalPro(userId: string): SubRow {
return { id: 'sub-personal-pro', referenceId: userId, plan: 'pro', status: 'active' }
}
function orgEnterprise(orgId: string): SubRow {
return { id: 'sub-org-enterprise', referenceId: orgId, plan: 'enterprise', status: 'active' }
}
function queue(table: 'subscription' | 'member' | 'organization', rows: unknown[]) {
resultsByTable[table].push(rows)
}
describe('getHighestPrioritySubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
resultsByTable.subscription = []
resultsByTable.member = []
resultsByTable.organization = []
fromCalls.length = 0
})
it('picks the org Enterprise sub over a personal Pro sub (priority order)', async () => {
queue('subscription', [personalPro('user-1')]) // personalSubs query
queue('member', [{ organizationId: 'org-1' }]) // memberships query
queue('organization', [{ id: 'org-1' }]) // org-existence query
queue('subscription', [orgEnterprise('org-1')]) // org-subscriptions query
const result = await getHighestPrioritySubscription('user-1')
expect(result).not.toBeNull()
expect(result?.id).toBe('sub-org-enterprise')
expect(result?.plan).toBe('enterprise')
})
it('selection is deterministic regardless of which parallelized query resolves first', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [{ organizationId: 'org-1' }])
queue('organization', [{ id: 'org-1' }])
queue('subscription', [orgEnterprise('org-1')])
const result = await getHighestPrioritySubscription('user-1')
expect(result?.id).toBe('sub-org-enterprise')
})
it('issues BOTH the personal-subscriptions and memberships queries (parallelized pair)', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [{ organizationId: 'org-1' }])
queue('organization', [{ id: 'org-1' }])
queue('subscription', [orgEnterprise('org-1')])
await getHighestPrioritySubscription('user-1')
expect(fromCalls).toContain('subscription')
expect(fromCalls).toContain('member')
// First two queries are exactly the parallelized pair (in either order).
expect(fromCalls.slice(0, 2).sort()).toEqual(['member', 'subscription'])
})
it('returns the personal sub and skips org follow-ups when there are no memberships', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [])
const result = await getHighestPrioritySubscription('user-1')
expect(result?.id).toBe('sub-personal-pro')
expect(result?.plan).toBe('pro')
// org-existence + org-subscription follow-ups are NOT issued.
expect(fromCalls).not.toContain('organization')
expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1)
})
it('returns null when neither personal nor org subscriptions exist', async () => {
queue('subscription', [])
queue('member', [])
const result = await getHighestPrioritySubscription('user-1')
expect(result).toBeNull()
})
it('excludes orphaned org memberships whose organization row no longer exists', async () => {
queue('subscription', [])
queue('member', [{ organizationId: 'ghost-org' }]) // membership points at a deleted org
queue('organization', [])
const result = await getHighestPrioritySubscription('user-1')
// Org subs are never fetched (no valid org ids) -> falls back to null.
expect(result).toBeNull()
expect(fromCalls).toContain('organization')
// Only the initial personal-subs read on `subscription`; org-subs query skipped.
expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1)
})
it('falls back to the personal sub when the only org is orphaned', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [{ organizationId: 'ghost-org' }])
queue('organization', [])
const result = await getHighestPrioritySubscription('user-1')
expect(result?.id).toBe('sub-personal-pro')
expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1)
})
})