From 7c0594289e878599607b192880b71b156fde2fcc Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Thu, 15 Oct 2026 11:44:13 +0200 Subject: [PATCH 1/3] fix(mobile): stop asking GET /me in a loop on the Profil tab useMe gave a new refetch at each render, and ProfileScreen passes it to useFocusEffect: each render ran the effect again, hence a request, hence a render. Signed in, it asked /me about ten times a second; signed out, refetch ignored `enabled` and sent /me without token, refused in a loop. refetch is now stable and does nothing without account. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/profile/me.ts | 12 ++- apps/mobile/src/profile/meRequests.test.tsx | 106 ++++++++++++++++++++ apps/mobile/src/test/fakeApi.ts | 66 ++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/profile/meRequests.test.tsx create mode 100644 apps/mobile/src/test/fakeApi.ts diff --git a/apps/mobile/src/profile/me.ts b/apps/mobile/src/profile/me.ts index 0e79d438..5b957d50 100644 --- a/apps/mobile/src/profile/me.ts +++ b/apps/mobile/src/profile/me.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; import { ApiRequestError } from '@widoo/api-client'; import type { AppConfig, Me, UpdateMe } from '@widoo/shared'; import { api } from '../api/client'; @@ -54,7 +55,16 @@ export function useMe() { } else { state = { status: 'loading' }; } - return { state, refetch: () => void query.refetch(), isFetching: query.isFetching }; + // Stable, so that a screen may ask again on focus without asking again at each render; and + // without account, nothing: `refetch` would send the request even with the query disabled. + const { refetch: refetchQuery } = query; + const isSignedIn = uid !== null; + const refetch = useCallback(() => { + if (isSignedIn) { + void refetchQuery(); + } + }, [isSignedIn, refetchQuery]); + return { state, refetch, isFetching: query.isFetching }; } /** `/config`: the active anecdote questions, in the order of the sheet. */ diff --git a/apps/mobile/src/profile/meRequests.test.tsx b/apps/mobile/src/profile/meRequests.test.tsx new file mode 100644 index 00000000..2fb747c6 --- /dev/null +++ b/apps/mobile/src/profile/meRequests.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { onlineManager, QueryClientProvider } from '@tanstack/react-query'; +import { act, render, screen } from '@testing-library/react-native'; +import { queryClient } from '../api/query-client'; +import { fakeAuth } from '../test/fakeAuth'; +import type { createFakeAccountApi } from '../test/fakeApi'; +import { deleteAccount } from './me'; +import { ProfileScreen } from './ProfileScreen'; + +jest.mock('@gorhom/bottom-sheet', () => jest.requireActual('@gorhom/bottom-sheet/mock')); +jest.mock( + 'react-native-safe-area-context', + () => jest.requireActual<{ default: object }>('react-native-safe-area-context/jest/mock').default, +); +jest.mock('./CertificationSheet', () => ({ CertificationSheet: () => null })); +// The tab focused for the whole test: as React Navigation does, the effect runs again whenever +// the callback it is given changes. A loop fails the test instead of spinning in `act`. +let mockFocusRuns = 0; +jest.mock('expo-router', () => { + const { useEffect } = jest.requireActual('react'); + return { + router: { push: jest.fn() }, + useFocusEffect: (effect: () => void) => + useEffect(() => { + mockFocusRuns += 1; + if (mockFocusRuns > 20) { + throw new Error('The focus effect of the Profil tab runs in a loop'); + } + effect(); + }, [effect]), + }; +}); +jest.mock('expo-router/tabs', () => ({ + BottomTabBarHeightContext: jest.requireActual('react').createContext(0), +})); +jest.mock('../analytics', () => ({ + analytics: { track: jest.fn(), setAuthenticated: jest.fn() }, +})); +jest.mock('../api/query-client', () => { + const { QueryClient } = + jest.requireActual('@tanstack/react-query'); + const { shouldRetry } = jest.requireActual('../api/retry'); + return { queryClient: new QueryClient({ defaultOptions: { queries: { retry: shouldRetry } } }) }; +}); +jest.mock('../auth/signIn', () => ({ signInFlow: { requireAccount: jest.fn() } })); +// The API client of the app, on a simulated API that counts its requests. +jest.mock('../api/client', () => { + const { createFakeAccountApi } = + jest.requireActual('../test/fakeApi'); + const { testMe } = jest.requireActual('../test/me'); + const { session } = jest.requireActual('../auth'); + const fakeApi = createFakeAccountApi(testMe()); + return { api: fakeApi.clientFor(session), fakeApi }; +}); + +const { fakeApi } = jest.requireMock<{ fakeApi: ReturnType }>( + '../api/client', +); +const { server } = fakeApi; +const user = { uid: 'uid-1', email: 'camille@exemple.fr', displayName: null }; + +/** Lets the requests and renders that follow run, as long as a loop would need to show. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 200))); + +async function renderSignedIn() { + await act(() => fakeAuth.setUser(user)); + await render( + + + , + ); + expect(await screen.findByRole('button', { name: 'Modifier le profil' })).toBeTruthy(); + await settle(); +} + +const signedOutProfile = () => + screen.findByRole('button', { name: 'Créer un compte ou se connecter' }); + +describe('GET /me on the Profil tab', () => { + beforeEach(() => { + mockFocusRuns = 0; + onlineManager.setOnline(true); + Object.assign(server, { getMeCount: 0, deleteMeCount: 0, isDeleted: false }); + }); + afterEach(async () => { + jest.restoreAllMocks(); + await act(() => fakeAuth.setUser(null)); + queryClient.clear(); + }); + + it('asks once at the opening of the tab, not at each render', async () => { + await renderSignedIn(); + // The first request and the one of the focus: no more. + expect(server.getMeCount).toBeLessThanOrEqual(2); + }); + + it('asks at most once after the deletion of the account', async () => { + await renderSignedIn(); + const before = server.getMeCount; + await act(() => deleteAccount()); + expect(await signedOutProfile()).toBeTruthy(); + await settle(); + expect(server.deleteMeCount).toBe(1); + expect(server.getMeCount - before).toBeLessThanOrEqual(1); + }); +}); diff --git a/apps/mobile/src/test/fakeApi.ts b/apps/mobile/src/test/fakeApi.ts new file mode 100644 index 00000000..e6f237d2 --- /dev/null +++ b/apps/mobile/src/test/fakeApi.ts @@ -0,0 +1,66 @@ +import { createApiClient } from '@widoo/api-client'; +import type { Me } from '@widoo/shared'; +import type { Session } from '../auth/session'; + +type Reply = { status: number; body?: unknown }; + +const unauthorized = (message: string): Reply => ({ + status: 401, + body: { code: 'unauthorized', message }, +}); + +/** + * The account routes of the API, as the server answers them: `GET /me` and `DELETE /me`, which + * count their requests. Without token, « Missing bearer token »; after the deletion, the session + * opened before it gets « Account deleted » (D-027) until a new sign-in, a new account. + */ +export function createFakeAccountApi(me: Me) { + const server = { getMeCount: 0, deleteMeCount: 0, isDeleted: false }; + + function answer(method: string, path: string, hasToken: boolean): Reply { + if (path !== '/v1/me') { + return { status: 404, body: { code: 'not_found', message: 'Not found' } }; + } + if (method === 'GET') { + server.getMeCount += 1; + } else if (method === 'DELETE') { + server.deleteMeCount += 1; + } + if (!hasToken) { + return unauthorized('Missing bearer token'); + } + if (server.isDeleted) { + return unauthorized('Account deleted'); + } + if (method === 'DELETE') { + server.isDeleted = true; + return { status: 204 }; + } + return { status: 200, body: me }; + } + + const fetchFn = async (url: string | URL | globalThis.Request, init?: RequestInit) => { + // An answer comes back later, never within the same tick: a loop of requests shows as such. + await new Promise((resolve) => setTimeout(resolve, 5)); + const headers = (init?.headers ?? {}) as Record; + const path = new URL(String(url)).pathname; + const reply = answer(init?.method ?? 'GET', path, headers.authorization !== undefined); + const text = reply.body === undefined ? '' : JSON.stringify(reply.body); + return { status: reply.status, text: async () => text } as Response; + }; + + return { + server, + /** The API client of the app, on this server, with the token of `session`. */ + clientFor: (session: Session) => + createApiClient({ + baseUrl: 'http://api.test', + getToken: session.getToken, + fetch: fetchFn as typeof fetch, + }), + /** A new sign-in after the deletion: the API creates a new account (D-027, #128). */ + signInAfresh: () => { + server.isDeleted = false; + }, + }; +} From 9a48a2b845890ca8a6db1c0d9550cd410c7393c2 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Thu, 15 Oct 2026 11:46:19 +0200 Subject: [PATCH 2/3] fix(mobile): end a deleted account's session on the phone, once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After DELETE /me, or on a 401 « Account deleted », session.endSession makes the app anonymous at once, its token no longer sent, then asks Firebase to sign out: a failure no longer leaves the app signed in, and goes to Sentry. Called again while anonymous, it does nothing, so the sign-out runs once however many queries fail. requireAccount treats a deleted account as no account and opens the sheet, and a sign-in that goes through sets the session user without waiting for Firebase. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/auth/SignInSheet.test.tsx | 2 ++ apps/mobile/src/auth/flow.test.ts | 35 +++++++++++++++++++++ apps/mobile/src/auth/flow.ts | 28 +++++++++++++---- apps/mobile/src/auth/index.ts | 4 ++- apps/mobile/src/auth/rights.test.ts | 10 +++--- apps/mobile/src/auth/rights.ts | 9 +++--- apps/mobile/src/auth/session.test.ts | 31 ++++++++++++++++++ apps/mobile/src/auth/session.ts | 29 +++++++++++++++-- apps/mobile/src/auth/signIn.ts | 2 ++ apps/mobile/src/profile/me.ts | 7 +++-- apps/mobile/src/profile/meRequests.test.tsx | 34 ++++++++++++++++++++ 11 files changed, 170 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/auth/SignInSheet.test.tsx b/apps/mobile/src/auth/SignInSheet.test.tsx index abdb4193..464d8e7d 100644 --- a/apps/mobile/src/auth/SignInSheet.test.tsx +++ b/apps/mobile/src/auth/SignInSheet.test.tsx @@ -24,6 +24,8 @@ async function renderSheet(overrides: Partial = {}) { const flow = createSignInFlow({ provider: auth.provider, isSignedIn: () => false, + onSignedIn: () => undefined, + endSession: async () => undefined, getFirstName: async () => null, saveFirstName: async () => undefined, pendingEmail: { get: () => null, set: () => undefined }, diff --git a/apps/mobile/src/auth/flow.test.ts b/apps/mobile/src/auth/flow.test.ts index 588a21dc..cc4ffee7 100644 --- a/apps/mobile/src/auth/flow.test.ts +++ b/apps/mobile/src/auth/flow.test.ts @@ -1,3 +1,4 @@ +import { ApiRequestError } from '@widoo/api-client'; import { describe, expect, it, vi } from 'vitest'; import { createFakeAuth } from '../test/fakeAuth'; import { createSignInFlow, type PendingEmail, type SignInFlowDeps } from './flow'; @@ -17,6 +18,10 @@ function setup(overrides: Partial = {}) { const deps: SignInFlowDeps = { provider: auth.provider, isSignedIn: () => signedIn, + onSignedIn: vi.fn(), + endSession: vi.fn(async () => { + signedIn = false; + }), getFirstName: vi.fn(async () => 'Camille'), saveFirstName: vi.fn(async () => undefined), pendingEmail: { get: () => kept, set: (value) => (kept = value) }, @@ -55,6 +60,7 @@ describe('sign-in flow', () => { expect(deps.trackPrompted).toHaveBeenCalledWith('go'); auth.provider.signInWithGoogle = async () => googleResult; await flow.signInWithGoogle(); + expect(deps.onSignedIn).toHaveBeenCalledWith(googleResult.user); expect(deps.trackCompleted).toHaveBeenCalledWith(googleResult, 'go'); expect(action).toHaveBeenCalledTimes(1); expect(state().isOpen).toBe(false); @@ -165,4 +171,33 @@ describe('sign-in flow', () => { expect(state()).toMatchObject({ isOpen: true, step: 'welcome' }); expect(action).not.toHaveBeenCalled(); }); + + it('opens the sheet for an account deleted, never runs the action as signed in', async () => { + const deleted = new ApiRequestError('http', 401, { + code: 'unauthorized', + message: 'Account deleted', + }); + const { flow, deps, signIn, state } = setup({ + getFirstName: vi.fn(async () => Promise.reject(deleted)), + }); + signIn(); + const action = vi.fn(); + await flow.requireAccount('plan', action); + expect(deps.endSession).toHaveBeenCalledTimes(1); + expect(state()).toMatchObject({ isOpen: true, intent: 'plan', step: 'choices' }); + expect(deps.trackPrompted).toHaveBeenCalledWith('plan'); + expect(action).not.toHaveBeenCalled(); + }); + + it('runs the action when /me is out of reach, without ending the session', async () => { + const offline = new ApiRequestError('network', null, null); + const { flow, deps, signIn } = setup({ + getFirstName: vi.fn(async () => Promise.reject(offline)), + }); + signIn(); + const action = vi.fn(); + await flow.requireAccount('plan', action); + expect(action).toHaveBeenCalledTimes(1); + expect(deps.endSession).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile/src/auth/flow.ts b/apps/mobile/src/auth/flow.ts index 105c0173..d45db37d 100644 --- a/apps/mobile/src/auth/flow.ts +++ b/apps/mobile/src/auth/flow.ts @@ -1,6 +1,13 @@ +import { isAccountDeleted } from '@widoo/api-client'; import type { AnalyticsEventArgs } from '@widoo/shared'; import { createStore } from 'zustand/vanilla'; -import { SignInError, type AuthProvider, type SignInFailure, type SignInResult } from './provider'; +import { + SignInError, + type AuthProvider, + type AuthUser, + type SignInFailure, + type SignInResult, +} from './provider'; /** Why the sheet opens: the action waiting for an account (`auth_prompted`, D-033). */ export type AuthIntent = AnalyticsEventArgs<'auth_prompted'>[0]['intent']; @@ -37,6 +44,10 @@ export interface PendingEmail { export interface SignInFlowDeps { provider: AuthProvider; isSignedIn: () => boolean; + /** A sign-in went through: the session takes its user before `/me` is asked. */ + onSignedIn: (user: AuthUser) => void; + /** The account was deleted (401 « Account deleted »): the app becomes anonymous. */ + endSession: () => Promise; /** `firstName` of `/me`, created on the first call; null when the account has none yet. */ getFirstName: () => Promise; saveFirstName: (firstName: string) => Promise; @@ -118,6 +129,7 @@ export function createSignInFlow(deps: SignInFlowDeps) { async function signedIn(result: SignInResult) { const intent = store.getState().intent ?? deps.pendingEmail.get()?.intent ?? null; deps.pendingEmail.set(null); + deps.onSignedIn(result.user); if (intent) { deps.trackCompleted(result, intent); } @@ -160,16 +172,20 @@ export function createSignInFlow(deps: SignInFlowDeps) { * sheet otherwise. */ async requireAccount(intent: AuthIntent, action: () => void) { - if (deps.isSignedIn()) { - if (hasFirstName === null) { - try { - hasFirstName = (await deps.getFirstName()) !== null; - } catch { + if (deps.isSignedIn() && hasFirstName === null) { + try { + hasFirstName = (await deps.getFirstName()) !== null; + } catch (error) { + if (!isAccountDeleted(error)) { // Offline: the action tells its own failure; the name waits for the next one. action(); return; } + // A deleted account is no account: anonymous, the sheet opens below. + await deps.endSession(); } + } + if (deps.isSignedIn()) { if (hasFirstName) { action(); return; diff --git a/apps/mobile/src/auth/index.ts b/apps/mobile/src/auth/index.ts index 520b7851..8b1aeaa7 100644 --- a/apps/mobile/src/auth/index.ts +++ b/apps/mobile/src/auth/index.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/react-native'; import { useStore } from 'zustand'; import { analytics } from '../analytics'; import { queryClient } from '../api/query-client'; @@ -14,10 +15,11 @@ export const session = createSession(authProvider, { onSignedInChange: () => { void resetRightsDependentQueries(queryClient); }, + onSignOutFailed: (error) => Sentry.captureException(error), }); session.store.subscribe((state) => analytics.setAuthenticated(state.status === 'signedIn')); -signOutOnDeletedAccount(queryClient, session.signOut); +signOutOnDeletedAccount(queryClient, session.endSession); export function useSession(selector: (state: SessionState) => T): T { return useStore(session.store, selector); diff --git a/apps/mobile/src/auth/rights.test.ts b/apps/mobile/src/auth/rights.test.ts index 9981dc58..84624323 100644 --- a/apps/mobile/src/auth/rights.test.ts +++ b/apps/mobile/src/auth/rights.test.ts @@ -43,17 +43,17 @@ describe('deleted account', () => { }) .catch(() => undefined); - it('signs out on the 401 of a deleted account only', async () => { - const signOut = vi.fn(async () => undefined); - const unsubscribe = signOutOnDeletedAccount(client, signOut); + it('ends the session on the 401 of a deleted account only', async () => { + const endSession = vi.fn(async () => undefined); + const unsubscribe = signOutOnDeletedAccount(client, endSession); await failWith( new ApiRequestError('http', 401, { code: 'unauthorized', message: 'Invalid token' }), ); - expect(signOut).not.toHaveBeenCalled(); + expect(endSession).not.toHaveBeenCalled(); await failWith( new ApiRequestError('http', 401, { code: 'unauthorized', message: 'Account deleted' }), ); - expect(signOut).toHaveBeenCalledTimes(1); + expect(endSession).toHaveBeenCalledTimes(1); unsubscribe(); }); }); diff --git a/apps/mobile/src/auth/rights.ts b/apps/mobile/src/auth/rights.ts index 9f57a7f8..f4477a90 100644 --- a/apps/mobile/src/auth/rights.ts +++ b/apps/mobile/src/auth/rights.ts @@ -15,17 +15,18 @@ export function resetRightsDependentQueries(client: QueryClient): Promise } /** - * Signs out when the API says the account was deleted (401 « Account deleted »): its token is - * refused for good, refreshing it would not help. + * Ends the session when the API says the account was deleted (401 « Account deleted »): its token + * is refused for good, refreshing it would not help. `endSession` makes the app anonymous at once, + * which disables the account queries, and signs out only once however many queries fail. */ -export function signOutOnDeletedAccount(client: QueryClient, signOut: () => Promise) { +export function signOutOnDeletedAccount(client: QueryClient, endSession: () => Promise) { return client.getQueryCache().subscribe((event) => { if ( event.type === 'updated' && event.action.type === 'error' && isAccountDeleted(event.action.error) ) { - void signOut(); + void endSession(); } }); } diff --git a/apps/mobile/src/auth/session.test.ts b/apps/mobile/src/auth/session.test.ts index e2377e2c..3c734179 100644 --- a/apps/mobile/src/auth/session.test.ts +++ b/apps/mobile/src/auth/session.test.ts @@ -71,4 +71,35 @@ describe('session', () => { expect(error).toBeInstanceOf(ApiRequestError); expect(error).toMatchObject({ kind: 'network' }); }); + + it('ends a deleted session at once and once, even when Firebase fails to sign out', async () => { + const { provider, setUser } = fakeProvider(); + const failure = new Error('auth/internal-error'); + vi.mocked(provider.signOut).mockRejectedValue(failure); + const onSignedInChange = vi.fn(); + const onSignOutFailed = vi.fn(); + const session = createSession(provider, { onSignedInChange, onSignOutFailed }); + setUser(null); + setUser(camille); + await Promise.all([session.endSession(), session.endSession()]); + expect(session.store.getState()).toEqual({ status: 'anonymous', user: null }); + expect(await session.getToken({ forceRefresh: false })).toBeNull(); + expect(provider.signOut).toHaveBeenCalledTimes(1); + expect(onSignOutFailed).toHaveBeenCalledWith(failure); + expect(onSignedInChange.mock.calls).toEqual([[true], [false]]); + await session.endSession(); + expect(provider.signOut).toHaveBeenCalledTimes(1); + }); + + it('takes a new sign-in after the end of the session, Firebase silent or not', () => { + const { provider, setUser } = fakeProvider(); + const onSignedInChange = vi.fn(); + const session = createSession(provider, { onSignedInChange }); + setUser(camille); + void session.endSession(); + session.signedIn(camille); + setUser(camille); + expect(session.store.getState()).toEqual({ status: 'signedIn', user: camille }); + expect(onSignedInChange.mock.calls).toEqual([[false], [true]]); + }); }); diff --git a/apps/mobile/src/auth/session.ts b/apps/mobile/src/auth/session.ts index 469a1cac..4960957e 100644 --- a/apps/mobile/src/auth/session.ts +++ b/apps/mobile/src/auth/session.ts @@ -14,6 +14,8 @@ interface SessionOptions { * the caller's rights, so the answers kept for the previous caller go (D-075). */ onSignedInChange: (isSignedIn: boolean) => void; + /** Firebase could not sign out after `endSession`: the app is anonymous all the same. */ + onSignOutFailed?: (error: unknown) => void; } /** @@ -24,14 +26,16 @@ interface SessionOptions { export function createSession(provider: AuthProvider, options: SessionOptions) { const store = createStore(() => ({ status: 'loading', user: null })); - provider.onUserChanged((user) => { + function setUser(user: AuthUser | null) { const wasSignedIn = store.getState().status === 'signedIn'; const wasLoading = store.getState().status === 'loading'; store.setState({ status: user ? 'signedIn' : 'anonymous', user }); if (!wasLoading && wasSignedIn !== (user !== null)) { options.onSignedInChange(user !== null); } - }); + } + + provider.onUserChanged(setUser); /** * For the API client: null when signed out. A token Firebase cannot give (offline, its refresh @@ -53,6 +57,27 @@ export function createSession(provider: AuthProvider, options: SessionOptions) { getToken, isSignedIn: () => store.getState().status === 'signedIn', signOut: () => provider.signOut(), + /** + * The account is gone (deleted from this phone, or a 401 « Account deleted »): the app is + * anonymous at once, its token no longer sent, whatever Firebase does; Firebase signs out + * next. Once per account: called again while anonymous, it does nothing. + */ + endSession: async () => { + if (store.getState().status !== 'signedIn') { + return; + } + setUser(null); + try { + await provider.signOut(); + } catch (error) { + options.onSignOutFailed?.(error); + } + }, + /** + * A sign-in that went through: the session takes its user at once, without waiting for + * Firebase to tell it, which it may not do for the account it still holds. + */ + signedIn: (user: AuthUser) => setUser(user), }; } diff --git a/apps/mobile/src/auth/signIn.ts b/apps/mobile/src/auth/signIn.ts index da62a768..560df60e 100644 --- a/apps/mobile/src/auth/signIn.ts +++ b/apps/mobile/src/auth/signIn.ts @@ -29,6 +29,8 @@ function readPendingEmail(): PendingEmail | null { export const signInFlow = createSignInFlow({ provider: authProvider, isSignedIn: session.isSignedIn, + onSignedIn: session.signedIn, + endSession: session.endSession, getFirstName: async () => (await api.getMe()).firstName, saveFirstName: async (firstName) => { await api.updateMe({ firstName }); diff --git a/apps/mobile/src/profile/me.ts b/apps/mobile/src/profile/me.ts index 5b957d50..9f9553c0 100644 --- a/apps/mobile/src/profile/me.ts +++ b/apps/mobile/src/profile/me.ts @@ -93,12 +93,13 @@ export function useUpdateMe() { } /** - * Deletes the account (D-025): `DELETE /me`, then the sign-out, which brings the app back to its - * anonymous state; the profile kept on the phone goes with the session. + * Deletes the account (D-025): `DELETE /me`, then the end of the session, which brings the app + * back to its anonymous state at once, even if Firebase fails to sign out; the profile kept on + * the phone goes with the session. */ export async function deleteAccount(): Promise { await api.deleteMe(); - await session.signOut(); + await session.endSession(); } /** Forgets every profile kept on the phone and in memory. */ diff --git a/apps/mobile/src/profile/meRequests.test.tsx b/apps/mobile/src/profile/meRequests.test.tsx index 2fb747c6..79d77a5c 100644 --- a/apps/mobile/src/profile/meRequests.test.tsx +++ b/apps/mobile/src/profile/meRequests.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { onlineManager, QueryClientProvider } from '@tanstack/react-query'; import { act, render, screen } from '@testing-library/react-native'; +import { meQueryPrefix } from '../api/search-cache'; import { queryClient } from '../api/query-client'; import { fakeAuth } from '../test/fakeAuth'; import type { createFakeAccountApi } from '../test/fakeApi'; @@ -103,4 +104,37 @@ describe('GET /me on the Profil tab', () => { expect(server.deleteMeCount).toBe(1); expect(server.getMeCount - before).toBeLessThanOrEqual(1); }); + + it('signs out on the phone even when Firebase does not, and takes a new account after', async () => { + await renderSignedIn(); + const signOut = jest + .spyOn(fakeAuth.provider, 'signOut') + .mockRejectedValue(new Error('auth/internal-error')); + const before = server.getMeCount; + await act(() => deleteAccount()); + expect(await signedOutProfile()).toBeTruthy(); + await settle(); + expect(signOut).toHaveBeenCalledTimes(1); + expect(server.getMeCount - before).toBeLessThanOrEqual(1); + + // Google again: a new account (D-027, #128). + fakeApi.signInAfresh(); + await act(() => fakeAuth.setUser(user)); + expect(await screen.findByRole('button', { name: 'Modifier le profil' })).toBeTruthy(); + }); + + it('ends the session once on « Account deleted », Firebase silent, and asks no more', async () => { + await renderSignedIn(); + // Deleted from another phone; Firebase keeps its user and says nothing. + const signOut = jest.spyOn(fakeAuth.provider, 'signOut').mockResolvedValue(undefined); + server.isDeleted = true; + const before = server.getMeCount; + await act(() => queryClient.invalidateQueries({ queryKey: meQueryPrefix })); + expect(await signedOutProfile()).toBeTruthy(); + await settle(); + await act(() => queryClient.invalidateQueries({ queryKey: meQueryPrefix })); + await settle(); + expect(server.getMeCount - before).toBe(1); + expect(signOut).toHaveBeenCalledTimes(1); + }); }); From 74b3ff3748791e0201f1ea513a6cb59f51a8ab8d Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Thu, 15 Oct 2026 11:54:56 +0200 Subject: [PATCH 3/3] fix(mobile): open the sign-in sheet again after a close on the veil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed by the veil or a swipe, the sheet told the flow, whose closed state dismissed the sheet a second time: gorhom then no longer presented it, and every action that asks for an account sent auth_prompted with no sheet, on « Programmer » as on « Créer un compte ou se connecter ». Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/auth/SignInSheet.test.tsx | 22 ++++++++++++++++++++++ apps/mobile/src/auth/SignInSheet.tsx | 3 +++ 2 files changed, 25 insertions(+) diff --git a/apps/mobile/src/auth/SignInSheet.test.tsx b/apps/mobile/src/auth/SignInSheet.test.tsx index 464d8e7d..66cf7b95 100644 --- a/apps/mobile/src/auth/SignInSheet.test.tsx +++ b/apps/mobile/src/auth/SignInSheet.test.tsx @@ -1,4 +1,6 @@ import { describe, expect, it, jest } from '@jest/globals'; +// After `@jest/globals`: the mock of gorhom, hoisted, needs `jest`. +import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { createFakeAuth } from '../test/fakeAuth'; import { createSignInFlow, type SignInFlowDeps } from './flow'; @@ -39,6 +41,26 @@ async function renderSheet(overrides: Partial = {}) { } describe('SignInSheet', () => { + it('opens again after a close on the veil, without dismissing the sheet twice', async () => { + let onDismiss: () => void = () => undefined; + const present = jest + .spyOn(BottomSheetModal.prototype, 'present') + .mockImplementation(function (this: { props: { onDismiss: () => void } }) { + onDismiss = this.props.onDismiss; + }); + const dismiss = jest.spyOn(BottomSheetModal.prototype, 'dismiss'); + const { flow } = await renderSheet(); + await act(() => flow.requireAccount('profile', () => undefined)); + // gorhom closes the sheet on the veil, then tells it. + await act(() => onDismiss()); + expect(flow.store.getState().isOpen).toBe(false); + expect(dismiss).not.toHaveBeenCalled(); + await act(() => flow.requireAccount('plan', () => undefined)); + expect(present).toHaveBeenCalledTimes(2); + present.mockRestore(); + dismiss.mockRestore(); + }); + it('says the action waiting, with Google, the e-mail and the conditions', async () => { const { flow } = await renderSheet(); await act(() => flow.requireAccount('go', () => undefined)); diff --git a/apps/mobile/src/auth/SignInSheet.tsx b/apps/mobile/src/auth/SignInSheet.tsx index 9a5fde6b..2f3c1367 100644 --- a/apps/mobile/src/auth/SignInSheet.tsx +++ b/apps/mobile/src/auth/SignInSheet.tsx @@ -96,6 +96,9 @@ export function SignInSheet({ flow }: { flow: SignInFlow }) { handleComponent={parts.handle} onDismiss={() => { setModalSheetOpen(false); + // Already dismissed: the closed state must not dismiss it again, which would keep it + // from presenting at the next opening (gorhom). + wasOpen.current = false; // Closed by the user: by a swipe or on the veil. if (flow.store.getState().isOpen) { flow.dismiss();