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: 12 additions & 1 deletion apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,18 @@ vi.mock('@/lib/trpc', () => ({
TRPCProvider: 'TRPCProvider',
trpcClient: {},
useTRPC: () => ({
user: { getMe: { queryKey: () => [] } },
user: {
getMe: { queryKey: () => [] },
// The (app) layout's TourAutoOpen reads the account's gateway usage;
// give it a settled zero-usage answer so the tour gate stays closed in
// these layout scenes (the signed-out user id already blocks the push).
hasGatewayUsage: {
queryOptions: () => ({
queryKey: ['user', 'hasGatewayUsage'],
queryFn: () => ({ hasUsage: false }),
}),
},
},
organizations: { list: { queryKey: () => [] } },
}),
}));
Expand Down
207 changes: 202 additions & 5 deletions apps/mobile/src/components/tour/tour-auto-open.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ const state = vi.hoisted(() => ({
pathname: '/(app)/(tabs)/(0_home)',
}));

// The once-per-process cold-boot marker. Controllable so a case can start a
// launch already spent (a warm entry) or assert the attempt was consumed. The
// launch-account binding lives in the same module state, so it also survives a
// remount of the gate (a sign-out then a different sign-in).
const boot = vi.hoisted(() => ({ spent: false, attemptUserId: null as string | null }));

// The account's gateway-usage read. `isLoaded` true means the server answered;
// `hasUsage` is the account's answer once it did.
const usage = vi.hoisted(() => ({ isLoaded: true, hasUsage: false }));

// The account's consent-gate state, standing in for the SecureStore record the
// bootstrap gate reads. `accepted` by default: the pre-existing cases assume
// consent is already on file.
Expand Down Expand Up @@ -68,6 +78,24 @@ vi.mock('@/lib/tour/tour-completion', () => ({
}),
}));

vi.mock('@/lib/tour/tour-auto-open-boot', () => ({
isTourAutoOpenAttemptSpent: () => boot.spent,
spendTourAutoOpenAttempt: () => {
boot.spent = true;
},
claimTourAutoOpenAttempt: (userId: string) => {
if (boot.attemptUserId === null) {
boot.attemptUserId = userId;
return true;
}
return boot.attemptUserId === userId;
},
}));

vi.mock('@/lib/tour/use-tour-gate-usage', () => ({
useTourGatewayUsage: () => ({ isLoaded: usage.isLoaded, hasUsage: usage.hasUsage }),
}));

vi.mock('@/lib/consent-gate', () => ({
checkConsentGate: () => consentGate.check(),
}));
Expand Down Expand Up @@ -109,18 +137,23 @@ describe('TourAutoOpen', () => {
beforeEach(() => {
routerPush.mockReset();
consentGate.reset();
boot.spent = false;
boot.attemptUserId = null;
usage.isLoaded = true;
usage.hasUsage = false;
state.userId = 'user-1';
state.isLoaded = true;
state.isCompleted = false;
state.pathname = '/(app)/(tabs)/(0_home)';
});

it('pushes the tour exactly once on a first sign-in that has not completed it', async () => {
it('pushes the tour exactly once on a cold boot with zero gateway usage', async () => {
const renderer = mountAutoOpen();
await flushConsentRead();

expect(routerPush).toHaveBeenCalledTimes(1);
expect(routerPush).toHaveBeenCalledWith('/(app)/tour');
expect(boot.spent).toBe(true);

// A later render for the same account must not re-open the tour.
rerender(renderer);
Expand All @@ -129,6 +162,156 @@ describe('TourAutoOpen', () => {
renderer.unmount();
});

it('never pushes for an account with gateway usage, and spends the attempt', async () => {
usage.hasUsage = true;
const renderer = mountAutoOpen();
await flushConsentRead();

expect(routerPush).not.toHaveBeenCalled();
// The launch attempt is consumed, so an account switch later in this
// process cannot auto-open for a zero-usage account either.
expect(boot.spent).toBe(true);

rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();

renderer.unmount();
});

it('never pushes on a warm entry after the attempt is spent', async () => {
boot.spent = true;
const renderer = mountAutoOpen();
await flushConsentRead();

expect(routerPush).not.toHaveBeenCalled();

rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();

renderer.unmount();
});

it('holds on an unanswered usage read, then opens when the read succeeds in the same launch', async () => {
usage.isLoaded = false;
const renderer = mountAutoOpen();
await flushConsentRead();

// The read has not answered: hold, and do not spend the launch attempt.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(false);

// The read succeeds with zero usage later in the same launch.
usage.isLoaded = true;
rerender(renderer);
expect(routerPush).toHaveBeenCalledTimes(1);
expect(routerPush).toHaveBeenCalledWith('/(app)/tour');
expect(boot.spent).toBe(true);

renderer.unmount();
});

// A warm account switch must not inherit the launch's unspent attempt: if the
// launch account held (a completion or usage read never answered), a different
// account signing in later in the same process must not auto-open the tour.
it.each(['completion', 'usage'])(
'never auto-opens after a warm switch during %s loading',
async hold => {
state.isLoaded = hold !== 'completion';
usage.isLoaded = hold !== 'usage';
const renderer = mountAutoOpen();
await flushConsentRead();

// The launch account holds on the unanswered read: no push, unspent.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(false);

// A different account signs in later in the same process with zero usage.
state.userId = 'user-2';
state.isLoaded = true;
usage.isLoaded = true;
rerender(renderer);
await flushConsentRead();

// The launch attempt belongs to the first account, so the switch spends it
// instead of opening the tour.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(true);

rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();

renderer.unmount();
}
);

// The consent hold is the other hold that leaves the attempt unspent. The
// launch account never answered the gate, so a different account signing in
// later in the same process must not inherit the attempt either.
it('does not auto-open for a warm account switch after a consent hold', async () => {
consentGate.status = 'needs-consent';
const renderer = mountAutoOpen();
await flushConsentRead();

// The launch account holds on the unanswered gate: no push, unspent.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(false);

// A different account signs in later in the same process, with consent on
// file and zero usage.
state.userId = 'user-2';
consentGate.status = 'accepted';
rerender(renderer);
await flushConsentRead();

// The launch attempt belongs to the first account, so the switch spends it
// instead of opening the tour.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(true);

rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();

renderer.unmount();
});

// Sign-out unmounts the `(app)` tree, including this gate, while the
// process-wide attempt survives. A different account signing in remounts the
// gate: the launch-account binding must survive that remount, or the second
// account looks like the launch account and auto-opens the tour.
it.each(['completion', 'usage'])(
'never auto-opens after remounting during %s loading',
async hold => {
state.isLoaded = hold !== 'completion';
usage.isLoaded = hold !== 'usage';
const first = mountAutoOpen();
await flushConsentRead();

// The launch account held its unspent attempt; the (app) tree then unmounts
// on the sign-out redirect.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(false);
first.unmount();

// A different account signs in and remounts the gate, with consent on file
// and zero usage.
state.userId = 'user-2';
state.isLoaded = true;
usage.isLoaded = true;
const second = mountAutoOpen();
await flushConsentRead();

// The launch attempt belongs to the first account, so the remount spends it
// instead of opening the tour.
expect(routerPush).not.toHaveBeenCalled();
expect(boot.spent).toBe(true);

rerender(second);
expect(routerPush).not.toHaveBeenCalled();

second.unmount();
}
);

it('never pushes once the account has finished or skipped the tour', async () => {
state.isCompleted = true;
const renderer = mountAutoOpen();
Expand All @@ -148,30 +331,44 @@ describe('TourAutoOpen', () => {

rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();
expect(boot.attemptUserId).toBeNull();
expect(boot.spent).toBe(false);

renderer.unmount();
});

it('never pushes before the stored decision has loaded', async () => {
it('holds the launch account attempt until its stored decision has loaded', async () => {
state.isLoaded = false;
const renderer = mountAutoOpen();
await flushConsentRead();

rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();
expect(boot.attemptUserId).toBe('user-1');
expect(boot.spent).toBe(false);

state.isLoaded = true;
rerender(renderer);
expect(routerPush).toHaveBeenCalledWith('/(app)/tour');
expect(boot.spent).toBe(true);

rerender(renderer);
expect(routerPush).toHaveBeenCalledTimes(1);

renderer.unmount();
});

it('does not push when the tour route is already active and never pushes later', async () => {
it('does not push when the tour route is already active and never re-arms the automatic open', async () => {
// A manual replay (the Profile Tutorial row) put the tour on screen.
state.pathname = '/(app)/tour';
const renderer = mountAutoOpen();
await flushConsentRead();

expect(routerPush).not.toHaveBeenCalled();
// The explicit open consumed the launch attempt.
expect(boot.spent).toBe(true);

// The person dismissed the tour (or opened it from Profile); navigating
// away must not push it behind them.
// Navigating away must not push it behind them.
state.pathname = '/(app)/(tabs)/(3_profile)';
rerender(renderer);
expect(routerPush).not.toHaveBeenCalled();
Expand Down
Loading
Loading