Skip to content
Merged
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
9 changes: 6 additions & 3 deletions .claude/rules/sim-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,16 @@ it('reads a row', async () => {
```

**Default chains supported:**
- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()`
- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()/for()`
- `insert() → values() → returning()/onConflictDoUpdate()/onConflictDoNothing()`
- `update() → set() → where() → limit()/orderBy()/returning()`
- `delete() → where() → limit()/orderBy()/returning()`
- `update() → set() → where() → limit()/orderBy()/returning()/for()`
- `delete() → where() → limit()/orderBy()/returning()/for()`
- `db.execute()` resolves `[]`
- `db.transaction(cb)` calls cb with `dbChainMock.db`

`.for('update')` (Postgres row-level locking) is supported as a terminal on
`where` builders. Override with `dbChainMockFns.for.mockResolvedValueOnce([...])`.

All terminals default to `Promise.resolve([])`. Override per-test with `dbChainMockFns.<terminal>.mockResolvedValueOnce(...)`.

Use `resetDbChainMock()` in `beforeEach` only when tests replace wiring with `.mockReturnValue` / `.mockResolvedValue` (permanent). Tests using only `...Once` variants don't need it.
Expand Down
203 changes: 68 additions & 135 deletions apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,185 +1,118 @@
/**
* @vitest-environment node
*/
import { createSession, loggerMock } from '@sim/testing'
import {
authMock,
authMockFns,
createSession,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockDbState, mockGetSession, mockHasPaidSubscription } = vi.hoisted(() => ({
mockDbState: {
selectResults: [] as any[],
updateCalls: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
},
mockGetSession: vi.fn(),
mockHasPaidSubscription: vi.fn(),
}))

vi.mock('@sim/db', () => ({
db: {
select: vi.fn().mockImplementation(() => {
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.then = vi
.fn()
.mockImplementation((callback: (rows: any[]) => any) =>
Promise.resolve(callback(mockDbState.selectResults.shift() ?? []))
)
return chain
}),
update: vi.fn().mockImplementation((table: unknown) => ({
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
mockDbState.updateCalls.push({ table, values })
return {
where: vi.fn().mockResolvedValue(undefined),
}
}),
})),
},
}))

vi.mock('@sim/db/schema', () => ({
member: {
userId: 'member.userId',
organizationId: 'member.organizationId',
},
organization: {
id: 'organization.id',
},
subscription: {
id: 'subscription.id',
referenceId: 'subscription.referenceId',
},
}))

vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
}))

vi.mock('@sim/logger', () => loggerMock)

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))

vi.mock('@/lib/billing', () => ({
hasPaidSubscription: mockHasPaidSubscription,
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/auth', () => authMock)

vi.mock('@/lib/billing/plan-helpers', () => ({
isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise',
}))

vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'],
hasPaidSubscriptionStatus: (status: string) => status === 'active' || status === 'past_due',
}))

import { POST } from '@/app/api/users/me/subscription/[id]/transfer/route'

function makeRequest(body: unknown, id = 'sub-1') {
return POST(
new Request(`http://localhost/api/users/me/subscription/${id}/transfer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}) as any,
{ params: Promise.resolve({ id }) }
)
}

describe('POST /api/users/me/subscription/[id]/transfer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDbState.selectResults = []
mockDbState.updateCalls = []
mockHasPaidSubscription.mockResolvedValue(false)
})

it('rejects transfers for non-organization subscriptions', async () => {
mockGetSession.mockResolvedValue(
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue(
createSession({
userId: 'user-1',
email: 'owner@example.com',
name: 'Owner',
})
)
mockDbState.selectResults = [
[{ id: 'sub-1', referenceId: 'user-1', plan: 'pro', status: 'active' }],
]

const response = await POST(
new Request('http://localhost/api/users/me/subscription/sub-1/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: 'org-1' }),
}) as any,
{ params: Promise.resolve({ id: 'sub-1' }) }
)
})

it('rejects transfers for non-organization subscriptions', async () => {
dbChainMockFns.for.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'user-1', plan: 'pro', status: 'active' },
])

const response = await makeRequest({ organizationId: 'org-1' })

expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
error: 'Only active Team or Enterprise subscriptions can be transferred to an organization.',
})
expect(mockDbState.updateCalls).toEqual([])
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})

it('transfers an active organization subscription to an admin-owned organization', async () => {
mockGetSession.mockResolvedValue(
createSession({
userId: 'user-1',
email: 'owner@example.com',
name: 'Owner',
})
)
mockDbState.selectResults = [
[{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' }],
[{ id: 'org-1' }],
[{ id: 'member-1', role: 'owner' }],
]

const response = await POST(
new Request('http://localhost/api/users/me/subscription/sub-1/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: 'org-1' }),
}) as any,
{ params: Promise.resolve({ id: 'sub-1' }) }
)
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }]).mockResolvedValueOnce([])

const response = await makeRequest({ organizationId: 'org-1' })

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
message: 'Subscription transferred successfully',
})
expect(mockDbState.updateCalls).toEqual([
{
table: expect.objectContaining({
id: 'subscription.id',
referenceId: 'subscription.referenceId',
}),
values: { referenceId: 'org-1' },
},
])
expect(dbChainMockFns.update).toHaveBeenCalled()
expect(dbChainMockFns.set).toHaveBeenCalledWith({ referenceId: 'org-1' })
})

it('treats an already-transferred organization subscription as a successful no-op', async () => {
mockGetSession.mockResolvedValue(
createSession({
userId: 'user-1',
email: 'owner@example.com',
name: 'Owner',
})
)
mockDbState.selectResults = [
[{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' }],
[{ id: 'org-1' }],
[{ id: 'member-1', role: 'owner' }],
]

const response = await POST(
new Request('http://localhost/api/users/me/subscription/sub-1/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: 'org-1' }),
}) as any,
{ params: Promise.resolve({ id: 'sub-1' }) }
)
dbChainMockFns.for.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' },
])

const response = await makeRequest({ organizationId: 'org-1' })

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
message: 'Subscription already belongs to this organization',
})
expect(mockDbState.updateCalls).toEqual([])
expect(mockHasPaidSubscription).not.toHaveBeenCalled()
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})

it('rejects the transfer when the target organization already has an active subscription', async () => {
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit
.mockResolvedValueOnce([{ role: 'owner' }])
.mockResolvedValueOnce([{ id: 'existing-sub' }])

const response = await makeRequest({ organizationId: 'org-1' })

expect(response.status).toBe(409)
await expect(response.json()).resolves.toEqual({
error: 'Organization already has an active subscription',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
})
Loading
Loading