Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 10 additions & 3 deletions .claude/rules/sim-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,20 @@ 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 on `where`
builders. It returns a thenable with `.limit` / `.orderBy` / `.returning` /
`.groupBy` attached, so both `await .where().for('update')` (terminal) and
`await .where().for('update').limit(1)` (chained) work. Override the terminal
result with `dbChainMockFns.for.mockResolvedValueOnce([...])`; for the chained
form, mock the downstream terminal (e.g. `dbChainMockFns.limit.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
223 changes: 88 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,138 @@
/**
* @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' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }])

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 noop probe when the requester is not a member of the target organization', async () => {
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit.mockResolvedValueOnce([])

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

expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({
error: 'Unauthorized - user is not admin of organization',
})
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