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
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ XP/gamification is **Free**, enabled by a feature flag (migration `EnableGamific
| Language | English / Brazilian Portuguese; app, AI, and emails follow | Free | Both | Full en + pt-BR parity |
| AI memory | AI learns preferences/routines from conversations | Pro | Both | — |
| AI daily summary toggle | Enable the Today summary card | Pro | Both | — |
| Marketing email consent | One-time opt-in prompt + "Product updates by email" toggle; consent stored in-app (DB is the source of truth for the code-based sender), self-hosted unsubscribe flips it back | Free | Both | en + pt-BR |
| What Orbit Knows | Manually add facts the AI uses to personalize (max 50) | Pro | Both | — |
| Timezone | Auto-detected; drives due dates and "today" | Free | Both | — |
| Week start day | Monday or Sunday across all calendars | Free | Both | — |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}))

const performQueuedApiMutation = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/queued-api-mutation', () => ({
performQueuedApiMutation: (input: unknown) => performQueuedApiMutation(input),
}))

const patchProfile = vi.fn()
let profileValue: { marketingEmailConsent: boolean | null } | undefined
vi.mock('@/hooks/use-profile', () => ({
useProfile: () => ({ profile: profileValue, patchProfile }),
}))

vi.mock('@tanstack/react-query', () => ({
useMutation: (options: {
mutationFn: (vars: boolean) => Promise<unknown>
onMutate?: (vars: boolean) => unknown
onError?: (err: unknown, vars: boolean, ctx: unknown) => void
}) => ({
isPending: false,
mutate: (vars: boolean) => {
const ctx = options.onMutate?.(vars)
Promise.resolve(options.mutationFn(vars)).catch((err) =>
options.onError?.(err, vars, ctx),
)
},
}),
}))

vi.mock('@/lib/use-app-theme', () => ({
useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }),
}))

vi.mock('@/lib/theme', () => ({
createTokensV2: () =>
new Proxy({}, { get: () => '#ffffff' }) as Record<string, string>,
tintFromPrimary: () => 'rgba(0,0,0,0.15)',
}))

vi.mock('@/components/bottom-sheet-modal', () => ({
BottomSheetModal: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
onClose?: () => void
}) => (open ? React.createElement('BottomSheetOpen', {}, children) : null),
}))

vi.mock('@/components/ui/pill-button', () => ({
PillButton: ({
children,
onPress,
}: {
children: React.ReactNode
onPress?: () => void
}) => React.createElement('PillButtonStub', { onPress }, children),
}))

import { MarketingConsentPrompt } from '@/components/marketing-consent/marketing-consent-prompt'
import { useUIStore } from '@/stores/ui-store'
import { useReferralPromptStore } from '@/stores/referral-prompt-store'
import { MARKETING_CONSENT_MILESTONE_KEY } from '@orbit/shared/stores'

const TestRenderer = require('react-test-renderer')

type RenderedNode = {
type: unknown
props: { onPress?: () => void; accessibilityLabel?: string } & Record<
string,
unknown
>
}
type RenderedTree = {
root: { findAll: (predicate: (node: RenderedNode) => boolean) => RenderedNode[] }
unmount: () => void
}

let currentTree: RenderedTree | null = null

function findByType(tree: RenderedTree, typeName: string) {
return tree.root.findAll((node) => node.type === typeName)
}

function resetStores() {
useReferralPromptStore.setState({
promptedMilestoneKeys: [],
lastPromptedAtIso: null,
homeEntryDismissed: false,
armedPrompt: null,
})
useUIStore.setState({ activeCelebration: null, queuedCelebrations: [] })
}

describe('MarketingConsentPrompt (mobile)', () => {
beforeEach(() => {
vi.useFakeTimers()
resetStores()
patchProfile.mockClear()
performQueuedApiMutation.mockClear()
profileValue = { marketingEmailConsent: null }
})

afterEach(() => {
if (currentTree) {
TestRenderer.act(() => currentTree!.unmount())
currentTree = null
}
vi.clearAllTimers()
vi.useRealTimers()
})

async function render() {
await TestRenderer.act(async () => {
currentTree = TestRenderer.create(<MarketingConsentPrompt />)
await Promise.resolve()
})
return currentTree!
}

async function renderArmed() {
const tree = await render()
await TestRenderer.act(async () => {
useReferralPromptStore
.getState()
.armConsentPrompt(MARKETING_CONSENT_MILESTONE_KEY)
await Promise.resolve()
})
return tree
}

async function settle() {
await TestRenderer.act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
}

it('renders nothing when no consent prompt is armed', async () => {
const tree = await render()
expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0)
})

it('does not render when a different kind is armed', async () => {
const tree = await render()
await TestRenderer.act(async () => {
useReferralPromptStore.getState().armReferralPrompt('level-3')
await Promise.resolve()
})
await settle()
expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0)
})

it('shows after the settle delay and records markEngagementPrompted', async () => {
const tree = await renderArmed()
expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0)

await settle()

expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(1)
expect(useReferralPromptStore.getState().promptedMilestoneKeys).toContain(
MARKETING_CONSENT_MILESTONE_KEY,
)
})

it('stays hidden while a celebration is in flight', async () => {
useUIStore.getState().enqueueCelebration('streak', { streak: 7 })
const tree = await renderArmed()

await TestRenderer.act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})

expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0)
})

it('opts in through the offline queue and patches the profile on accept', async () => {
const tree = await renderArmed()
await settle()

const [accept] = findByType(tree, 'PillButtonStub')
await TestRenderer.act(async () => {
accept!.props.onPress?.()
await Promise.resolve()
})

expect(performQueuedApiMutation).toHaveBeenCalledWith(
expect.objectContaining({
type: 'setMarketingConsent',
scope: 'profile',
payload: { enabled: true },
dedupeKey: 'profile-marketing-consent',
}),
)
expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: true })
expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0)
})

it('opts out through the offline queue on decline', async () => {
const tree = await renderArmed()
await settle()

const [decline] = tree.root.findAll(
(node) => node.props.accessibilityLabel === 'marketingConsent.prompt.decline',
)
await TestRenderer.act(async () => {
decline!.props.onPress?.()
await Promise.resolve()
})

expect(performQueuedApiMutation).toHaveBeenCalledWith(
expect.objectContaining({ payload: { enabled: false } }),
)
expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: false })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}))

const performQueuedApiMutation = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/queued-api-mutation', () => ({
performQueuedApiMutation: (input: unknown) => performQueuedApiMutation(input),
}))

const patchProfile = vi.fn()
let profileValue: { marketingEmailConsent: boolean | null } | undefined
vi.mock('@/hooks/use-profile', () => ({
useProfile: () => ({ profile: profileValue, patchProfile }),
}))

vi.mock('@tanstack/react-query', () => ({
useMutation: (options: {
mutationFn: (vars: boolean) => Promise<unknown>
onMutate?: (vars: boolean) => unknown
onError?: (err: unknown, vars: boolean, ctx: unknown) => void
}) => ({
isPending: false,
mutate: (vars: boolean) => {
const ctx = options.onMutate?.(vars)
Promise.resolve(options.mutationFn(vars)).catch((err) =>
options.onError?.(err, vars, ctx),
)
},
}),
}))

vi.mock('@/components/ui/section-label', () => ({
SectionLabel: ({ children }: { children: React.ReactNode }) =>
React.createElement('SectionLabelStub', {}, children),
}))

vi.mock('@/components/ui/settings-row', () => ({
SettingsRow: ({ children }: { children: React.ReactNode }) =>
React.createElement('SettingsRowStub', {}, children),
Switch: ({ on, onToggle }: { on: boolean; onToggle: () => void }) =>
React.createElement('SwitchStub', { on, onToggle }),
}))

import { MarketingConsentSection } from '@/components/marketing-consent/marketing-consent-section'

const TestRenderer = require('react-test-renderer')

type RenderedNode = {
type: unknown
props: { on?: boolean; onToggle?: () => void } & Record<string, unknown>
}
type RenderedTree = {
root: { findAll: (predicate: (node: RenderedNode) => boolean) => RenderedNode[] }
unmount: () => void
}

let currentTree: RenderedTree | null = null

function getSwitch(tree: RenderedTree) {
return tree.root.findAll((node) => node.type === 'SwitchStub')[0]
}

async function render() {
await TestRenderer.act(async () => {
currentTree = TestRenderer.create(<MarketingConsentSection />)
await Promise.resolve()
})
return currentTree!
}

describe('MarketingConsentSection (mobile)', () => {
beforeEach(() => {
patchProfile.mockClear()
performQueuedApiMutation.mockClear()
performQueuedApiMutation.mockResolvedValue(undefined)
profileValue = { marketingEmailConsent: null }
})

afterEach(() => {
if (currentTree) {
TestRenderer.act(() => currentTree!.unmount())
currentTree = null
}
})

it('reflects consent off when the profile has not opted in', async () => {
profileValue = { marketingEmailConsent: null }
const tree = await render()
expect(getSwitch(tree).props.on).toBe(false)
})

it('reflects consent on when the profile opted in', async () => {
profileValue = { marketingEmailConsent: true }
const tree = await render()
expect(getSwitch(tree).props.on).toBe(true)
})

it('opts in through the offline queue and patches optimistically', async () => {
profileValue = { marketingEmailConsent: false }
const tree = await render()

await TestRenderer.act(async () => {
getSwitch(tree).props.onToggle?.()
await Promise.resolve()
})

expect(performQueuedApiMutation).toHaveBeenCalledWith(
expect.objectContaining({
type: 'setMarketingConsent',
scope: 'profile',
payload: { enabled: true },
dedupeKey: 'profile-marketing-consent',
}),
)
expect(patchProfile).toHaveBeenCalledWith({ marketingEmailConsent: true })
})

it('rolls the optimistic patch back when the mutation fails', async () => {
profileValue = { marketingEmailConsent: true }
performQueuedApiMutation.mockRejectedValueOnce(new Error('network'))
const tree = await render()

await TestRenderer.act(async () => {
getSwitch(tree).props.onToggle?.()
await Promise.resolve()
await Promise.resolve()
})

expect(patchProfile).toHaveBeenNthCalledWith(1, { marketingEmailConsent: false })
expect(patchProfile).toHaveBeenLastCalledWith({ marketingEmailConsent: true })
})
})
Loading