Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apis/api-gateway/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS
integrationGoogleCreate(input: IntegrationGoogleCreateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN)
integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN)
integrationDelete(id: ID!) : Integration! @join__field(graph: API_JOURNEYS_MODERN)
journeyTransferFromAnonymous(journeyId: ID!, teamId: ID) : Journey! @join__field(graph: API_JOURNEYS_MODERN)
journeyAiTranslateCreate(input: JourneyAiTranslateInput!) : Journey! @join__field(graph: API_JOURNEYS_MODERN)
createJourneyEventsExportLog(input: JourneyEventsExportLogInput!) : JourneyEventsExportLog! @join__field(graph: API_JOURNEYS_MODERN)
journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!) : Boolean! @join__field(graph: API_JOURNEYS_MODERN)
Expand Down
1 change: 1 addition & 0 deletions apis/api-journeys-modern/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,7 @@ type Mutation {
integrationGoogleCreate(input: IntegrationGoogleCreateInput!): IntegrationGoogle!
integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!): IntegrationGoogle!
integrationDelete(id: ID!): Integration!
journeyTransferFromAnonymous(journeyId: ID!, teamId: ID): Journey!
journeyAiTranslateCreate(input: JourneyAiTranslateInput!): Journey!
createJourneyEventsExportLog(input: JourneyEventsExportLogInput!): JourneyEventsExportLog!
journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!): Boolean!
Expand Down
1 change: 1 addition & 0 deletions apis/api-journeys-modern/src/schema/journey/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import './adminJourney.query'
import './adminJourneys.query'
import './journey'
import './journeyTransferFromAnonymous.mutation'
import './inputs'
import './enums'
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
import { ExecutionResult } from 'graphql'

import { UserJourneyRole, UserTeamRole } from '@core/prisma/journeys/client'
import { getUserFromPayload } from '@core/yoga/firebaseClient'

import { getClient } from '../../../test/client'
import { prismaMock } from '../../../test/prismaMock'
import { graphql } from '../../lib/graphql/subgraphGraphql'

jest.mock('@core/yoga/firebaseClient', () => ({
getUserFromPayload: jest.fn()
}))

jest.mock('@core/prisma/users/client', () => ({
prisma: {
user: {
findFirst: jest.fn()
}
}
}))

const { prisma: mockPrismaUsers } = jest.requireMock(
'@core/prisma/users/client'
)

const mockGetUserFromPayload = getUserFromPayload as jest.MockedFunction<
typeof getUserFromPayload
>

const JOURNEY_TRANSFER_MUTATION = graphql(`
mutation JourneyTransferFromAnonymous($journeyId: ID!, $teamId: ID) {
journeyTransferFromAnonymous(journeyId: $journeyId, teamId: $teamId) {
id
}
}
`)

describe('journeyTransferFromAnonymous', () => {
const mockUser = {
id: 'authUserId',
email: 'test@example.com',
emailVerified: true,
firstName: 'Test',
lastName: 'User',
imageUrl: null,
roles: []
}

const authClient = getClient({
headers: { authorization: 'token' },
context: { currentUser: mockUser }
})

const mockJourney = {
id: 'journeyId',
teamId: 'anonTeamId',
userJourneys: [
{
id: 'ujId',
userId: 'anonUserId',
journeyId: 'journeyId',
role: UserJourneyRole.owner,
updatedAt: new Date()
}
],
team: {
id: 'anonTeamId',
userTeams: [
{
id: 'utId',
userId: 'anonUserId',
teamId: 'anonTeamId',
role: UserTeamRole.manager
}
]
}
}

const mockUserTeam = {
id: 'targetUtId',
userId: mockUser.id,
teamId: 'targetTeamId',
role: UserTeamRole.manager,
createdAt: new Date(),
updatedAt: new Date()
}

beforeEach(() => {
jest.clearAllMocks()
mockGetUserFromPayload.mockReturnValue(mockUser)
prismaMock.userRole.findUnique.mockResolvedValue({
id: 'userRoleId',
userId: mockUser.id,
roles: []
})
})

function makeTxMock(targetTeamId = 'targetTeamId') {
return {
userJourney: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
create: jest.fn().mockResolvedValue({
id: 'newUjId',
userId: mockUser.id,
journeyId: 'journeyId',
role: UserJourneyRole.owner
})
},
journey: {
update: jest.fn().mockResolvedValue({
id: 'journeyId',
teamId: targetTeamId
}),
count: jest.fn().mockResolvedValue(0)
},
userTeam: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 })
},
team: {
delete: jest.fn().mockResolvedValue({})
}
}
}

it('should transfer journey with explicit teamId', async () => {
prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any)
mockPrismaUsers.user.findFirst.mockResolvedValue({ email: null })
prismaMock.userTeam.findFirst.mockResolvedValue(mockUserTeam)

const txMock = makeTxMock()
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(txMock))

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId', teamId: 'targetTeamId' }
})) as ExecutionResult<{
journeyTransferFromAnonymous: { id: string }
}>

expect(result.errors).toBeUndefined()
expect(result.data?.journeyTransferFromAnonymous.id).toBe('journeyId')
expect(txMock.journey.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'journeyId' },
data: { teamId: 'targetTeamId' }
})
)
})

it('should auto-resolve to first managed team when teamId is omitted', async () => {
prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any)
mockPrismaUsers.user.findFirst.mockResolvedValue({ email: null })

prismaMock.userTeam.findFirst
.mockResolvedValueOnce(mockUserTeam)
.mockResolvedValueOnce(mockUserTeam)

const txMock = makeTxMock()
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(txMock))

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId' }
})) as ExecutionResult<{
journeyTransferFromAnonymous: { id: string }
}>

expect(result.errors).toBeUndefined()
expect(result.data?.journeyTransferFromAnonymous.id).toBe('journeyId')
expect(prismaMock.userTeam.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: mockUser.id,
role: UserTeamRole.manager
},
orderBy: { createdAt: 'asc' }
})
)
expect(txMock.journey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { teamId: 'targetTeamId' }
})
)
})

it('should return error if journey is not found', async () => {
prismaMock.journey.findUnique.mockResolvedValue(null)

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'nonExistent' }
})) as ExecutionResult

expect(result.errors).toBeDefined()
expect(result.errors?.[0].message).toBe('Journey not found')
})

it('should move team without changing ownership when user already owns (linked account)', async () => {
const ownedJourney = {
...mockJourney,
userJourneys: [
{
...mockJourney.userJourneys[0],
userId: mockUser.id
}
]
}
prismaMock.journey.findUnique.mockResolvedValue(ownedJourney as any)
prismaMock.userTeam.findFirst.mockResolvedValue(mockUserTeam)

const txMock = makeTxMock()
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(txMock))

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId' }
})) as ExecutionResult<{
journeyTransferFromAnonymous: { id: string }
}>

expect(result.errors).toBeUndefined()
expect(result.data?.journeyTransferFromAnonymous.id).toBe('journeyId')
expect(txMock.userJourney.deleteMany).not.toHaveBeenCalled()
expect(txMock.userJourney.create).not.toHaveBeenCalled()
expect(txMock.journey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { teamId: 'targetTeamId' }
})
)
})

it('should no-op when user already owns and team is already correct', async () => {
const ownedJourney = {
...mockJourney,
teamId: 'targetTeamId',
userJourneys: [
{
...mockJourney.userJourneys[0],
userId: mockUser.id
}
]
}
prismaMock.journey.findUnique.mockResolvedValue(ownedJourney as any)
prismaMock.userTeam.findFirst.mockResolvedValue(mockUserTeam)
prismaMock.journey.findUniqueOrThrow.mockResolvedValue({
id: 'journeyId',
teamId: 'targetTeamId'
} as any)

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId' }
})) as ExecutionResult<{
journeyTransferFromAnonymous: { id: string }
}>

expect(result.errors).toBeUndefined()
expect(result.data?.journeyTransferFromAnonymous.id).toBe('journeyId')
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})

it('should return error if journey owner is not anonymous', async () => {
prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any)
mockPrismaUsers.user.findFirst.mockResolvedValue({
email: 'owner@example.com'
})

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId' }
})) as ExecutionResult

expect(result.errors).toBeDefined()
expect(result.errors?.[0].message).toContain('not an anonymous user')
})

it('should fall through to auto-resolve when provided teamId user is not a member of', async () => {
prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any)
mockPrismaUsers.user.findFirst.mockResolvedValue({ email: null })

prismaMock.userTeam.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockUserTeam)
.mockResolvedValueOnce(mockUserTeam)

const txMock = makeTxMock()
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(txMock))

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId', teamId: 'invalidTeamId' }
})) as ExecutionResult<{
journeyTransferFromAnonymous: { id: string }
}>

expect(result.errors).toBeUndefined()
expect(result.data?.journeyTransferFromAnonymous.id).toBe('journeyId')
expect(txMock.journey.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { teamId: 'targetTeamId' }
})
)
})

it('should return error if user has no teams', async () => {
prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any)
mockPrismaUsers.user.findFirst.mockResolvedValue({ email: null })
prismaMock.userTeam.findFirst.mockResolvedValue(null)

const result = (await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId' }
})) as ExecutionResult

expect(result.errors).toBeDefined()
expect(result.errors?.[0].message).toContain('No team found')
})

it('should not delete old team if other journeys remain', async () => {
prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any)
mockPrismaUsers.user.findFirst.mockResolvedValue({ email: null })
prismaMock.userTeam.findFirst.mockResolvedValue(mockUserTeam)

const txMock = makeTxMock()
txMock.journey.count.mockResolvedValue(2)
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(txMock))

await authClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId', teamId: 'targetTeamId' }
})

expect(txMock.userTeam.deleteMany).not.toHaveBeenCalled()
expect(txMock.team.delete).not.toHaveBeenCalled()
})

it('should require authentication', async () => {
const publicClient = getClient()

const result = (await publicClient({
document: JOURNEY_TRANSFER_MUTATION,
variables: { journeyId: 'journeyId' }
})) as ExecutionResult

expect(result.errors).toBeDefined()
})
})
Loading
Loading