Replace redux with TanStack Query and an auth context
CI / checks (push) Successful in 1m42s

This commit is contained in:
ponas committed 2026-07-31 03:16:32 +02:00
1 parent f18deffa7b
commit ecbeecab23
45 files changed
+1141 -906

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import React from 'react';
import { AuthProvider, useAuth } from '@/auth/AuthContext';
import { queryKeys } from '@/queries/queryKeys';
const TOKEN_KEY = 'cnapp.authToken';
const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: true };
// gcTime: Infinity so no GC timers keep Jest alive
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } });
const renderAuth = ({
initialToken,
queryClient = createQueryClient(),
}: { initialToken?: string | null; queryClient?: QueryClient } = {}) => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={initialToken}>{children}</AuthProvider>
</QueryClientProvider>
);
return { queryClient, ...renderHook(() => useAuth(), { wrapper }) };
};
beforeEach(async () => {
// The AsyncStorage mock's methods are shared jest.fn()s; clear their history
jest.clearAllMocks();
await AsyncStorage.clear();
});
it('renders nothing until the stored session is restored', async () => {
await AsyncStorage.setItem(TOKEN_KEY, 'token-123');
const { result } = renderAuth();
// The gate: children (and thus the hook) have not rendered yet
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current.token).toBe('token-123');
expect(result.current.isAuthenticated).toBe(true);
});
it('starts logged out when nothing is stored', async () => {
const { result } = renderAuth();
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current.token).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});
it('runs the redux-persist migration and seeds the cached user', async () => {
await AsyncStorage.setItem(
'persist:root',
JSON.stringify({ userToken: JSON.stringify('9a8b'), user: JSON.stringify(grisen) }),
);
const { result, queryClient } = renderAuth();
await waitFor(() => expect(result.current?.token).toBe('9a8b'));
expect(queryClient.getQueryData(queryKeys.user)).toEqual(grisen);
expect(await AsyncStorage.getItem('persist:root')).toBeNull();
});
it('signIn stores the token and flips isAuthenticated', async () => {
const { result } = renderAuth({ initialToken: null });
act(() => result.current.signIn('token-1'));
expect(result.current.token).toBe('token-1');
expect(result.current.isAuthenticated).toBe(true);
await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('token-1'));
});
it('signOut clears the token and drops the cached user', async () => {
const queryClient = createQueryClient();
queryClient.setQueryData(queryKeys.user, grisen);
await AsyncStorage.setItem(TOKEN_KEY, 'token-1');
const { result } = renderAuth({ initialToken: 'token-1', queryClient });
act(() => result.current.signOut());
expect(result.current.token).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
expect(queryClient.getQueryData(queryKeys.user)).toBeUndefined();
await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull());
});
it('skips the storage bootstrap when given an initialToken', () => {
const getItemSpy = jest.spyOn(AsyncStorage, 'getItem');
const { result } = renderAuth({ initialToken: 'token-x' });
// Synchronously ready: no gate, no storage reads
expect(result.current.token).toBe('token-x');
expect(getItemSpy).not.toHaveBeenCalled();
});
+87
View File
@@ -0,0 +1,87 @@
import { useQueryClient } from '@tanstack/react-query';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { migrateReduxPersistAuth } from '@/auth/migrateFromReduxPersist';
import { getStoredToken, setStoredToken } from '@/auth/tokenStorage';
import { queryKeys } from '@/queries/queryKeys';
interface AuthContextValue {
token: string | null;
isAuthenticated: boolean;
signIn: (token: string) => void;
signOut: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
interface AuthProviderProps {
children: React.ReactNode;
/** Test-only: start with this token and skip the storage bootstrap. */
initialToken?: string | null;
}
/**
* Owns the auth token. Renders nothing until the stored session is known.
* Must sit under the query provider.
*/
export function AuthProvider({ children, initialToken }: AuthProviderProps) {
const skipBootstrap = initialToken !== undefined;
const [token, setToken] = useState<string | null>(initialToken ?? null);
const [ready, setReady] = useState(skipBootstrap);
const queryClient = useQueryClient();
useEffect(() => {
if (skipBootstrap) {
return;
}
let cancelled = false;
(async () => {
const migrated = await migrateReduxPersistAuth();
if (migrated.user) {
queryClient.setQueryData(queryKeys.user, migrated.user);
}
let storedToken: string | null = null;
try {
storedToken = await getStoredToken();
} catch {
// Unreadable storage means logged out for this launch
}
if (!cancelled) {
setToken(storedToken);
setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [skipBootstrap, queryClient]);
const signIn = useCallback((newToken: string) => {
setToken(newToken);
setStoredToken(newToken).catch(() => {});
}, []);
const signOut = useCallback(() => {
setToken(null);
setStoredToken(null).catch(() => {});
// So an in-flight /api/me/ can't resurrect the user after logout
queryClient.cancelQueries({ queryKey: queryKeys.user }).catch(() => {});
queryClient.removeQueries({ queryKey: queryKeys.user });
}, [queryClient]);
const value = useMemo(() => ({ token, isAuthenticated: token !== null, signIn, signOut }), [token, signIn, signOut]);
if (!ready) {
return null;
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const value = useContext(AuthContext);
if (!value) {
throw new Error('useAuth must be used inside an AuthProvider');
}
return value;
}
+135
View File
@@ -0,0 +1,135 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { migrateReduxPersistAuth } from '@/auth/migrateFromReduxPersist';
const TOKEN_KEY = 'cnapp.authToken';
const PERSIST_KEY = 'persist:root';
const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: true };
// redux-persist double-encodes: each value in the blob is its own JSON document
const persistBlob = (fields: Record<string, unknown>) =>
JSON.stringify(Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, JSON.stringify(value)])));
const seedBlob = (fields: Record<string, unknown>) =>
AsyncStorage.setItem(PERSIST_KEY, persistBlob({ _persist: { version: -1, rehydrated: true }, ...fields }));
beforeEach(async () => {
// The AsyncStorage mock's methods are shared jest.fn()s; clear their history
jest.clearAllMocks();
await AsyncStorage.clear();
});
it('reports logged out when no redux-persist blob exists', async () => {
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull();
});
it('moves the token to the new key and returns the cached user', async () => {
await seedBlob({ userToken: '9a8b', user: grisen });
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: '9a8b', user: grisen });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b');
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('treats a malformed outer blob as logged out and removes it', async () => {
await AsyncStorage.setItem(PERSIST_KEY, 'not-json{');
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull();
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('treats a blob without userToken as logged out and removes it', async () => {
await seedBlob({ user: grisen });
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('treats a logged out blob (userToken null) as logged out and removes it', async () => {
await seedBlob({ userToken: null });
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull();
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('treats a non-string token as logged out and removes the blob', async () => {
await seedBlob({ userToken: 42 });
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('treats an unparseable inner token as logged out and removes the blob', async () => {
await AsyncStorage.setItem(PERSIST_KEY, JSON.stringify({ userToken: 'not{json' }));
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('returns logged out and touches nothing when storage is unreadable', async () => {
await seedBlob({ userToken: '9a8b' });
jest.spyOn(AsyncStorage, 'getItem').mockRejectedValueOnce(new Error('storage unreadable'));
const removeSpy = jest.spyOn(AsyncStorage, 'removeItem');
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(removeSpy).not.toHaveBeenCalled();
expect(await AsyncStorage.getItem(PERSIST_KEY)).not.toBeNull();
});
it('keeps an already-migrated token, without seeding, and still removes the blob', async () => {
await AsyncStorage.setItem(TOKEN_KEY, 'existing-token');
await seedBlob({ userToken: 'old-token', user: grisen });
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: 'existing-token', user: null });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('existing-token');
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('migrates dev fake tokens verbatim', async () => {
await seedBlob({ userToken: 'dev-fake-token:member' });
const result = await migrateReduxPersistAuth();
expect(result.token).toBe('dev-fake-token:member');
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('dev-fake-token:member');
});
it('keeps the token when the user blob is unparseable', async () => {
await AsyncStorage.setItem(PERSIST_KEY, JSON.stringify({ userToken: JSON.stringify('9a8b'), user: 'not{json' }));
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: '9a8b', user: null });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b');
expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull();
});
it('keeps the blob for a retry when the token cannot be stored', async () => {
await seedBlob({ userToken: '9a8b' });
jest.spyOn(AsyncStorage, 'setItem').mockRejectedValueOnce(new Error('disk full'));
await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null });
expect(await AsyncStorage.getItem(PERSIST_KEY)).not.toBeNull();
});
it('never throws when removing the blob fails', async () => {
await seedBlob({ userToken: '9a8b' });
jest.spyOn(AsyncStorage, 'removeItem').mockRejectedValueOnce(new Error('storage error'));
await expect(migrateReduxPersistAuth()).resolves.toMatchObject({ token: '9a8b' });
expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b');
});
+76
View File
@@ -0,0 +1,76 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getStoredToken, setStoredToken } from '@/auth/tokenStorage';
import type { UserType } from '@/types/data';
const REDUX_PERSIST_KEY = 'persist:root';
interface MigratedAuth {
token: string | null;
user: UserType | null;
}
const LOGGED_OUT: MigratedAuth = { token: null, user: null };
/**
* One-shot move of the session out of the redux-persist blob into
* tokenStorage.
* The blob is only removed once the token is safely stored or provably
* absent, so failures retry next launch.
*/
export async function migrateReduxPersistAuth(): Promise<MigratedAuth> {
let blob: string | null;
try {
blob = await AsyncStorage.getItem(REDUX_PERSIST_KEY);
} catch {
return LOGGED_OUT;
}
if (blob === null) {
return LOGGED_OUT;
}
// The blob is a JSON object whose values are each their own JSON document
let token: string | null = null;
let user: UserType | null = null;
try {
const root = JSON.parse(blob);
const innerToken = JSON.parse(root.userToken);
if (typeof innerToken === 'string') {
token = innerToken;
try {
const innerUser = JSON.parse(root.user);
if (innerUser && typeof innerUser === 'object') {
user = innerUser;
}
} catch {
// The token still migrates; a refetch covers the user
}
}
} catch {
// Unparseable blob or no token → logged out
}
if (token !== null) {
try {
const existingToken = await getStoredToken();
if (existingToken !== null) {
// An earlier run already migrated; don't overwrite, don't seed
token = existingToken;
user = null;
} else {
await setStoredToken(token);
}
} catch {
// The token never reached its new home; keep the blob for a retry
return LOGGED_OUT;
}
}
try {
await AsyncStorage.removeItem(REDUX_PERSIST_KEY);
} catch {
// Re-runs next launch
}
return { token, user };
}
+26
View File
@@ -0,0 +1,26 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getStoredToken, setStoredToken } from '@/auth/tokenStorage';
beforeEach(async () => {
await AsyncStorage.clear();
});
it('returns null when no token is stored', async () => {
expect(await getStoredToken()).toBeNull();
});
it('round-trips a token through AsyncStorage', async () => {
await setStoredToken('token-123');
expect(await getStoredToken()).toBe('token-123');
expect(await AsyncStorage.getItem('cnapp.authToken')).toBe('token-123');
});
it('removes the token when set to null', async () => {
await setStoredToken('token-123');
await setStoredToken(null);
expect(await getStoredToken()).toBeNull();
expect(await AsyncStorage.getItem('cnapp.authToken')).toBeNull();
});
+15
View File
@@ -0,0 +1,15 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
const TOKEN_KEY = 'cnapp.authToken';
export async function getStoredToken(): Promise<string | null> {
return AsyncStorage.getItem(TOKEN_KEY);
}
export async function setStoredToken(token: string | null): Promise<void> {
if (token === null) {
await AsyncStorage.removeItem(TOKEN_KEY);
} else {
await AsyncStorage.setItem(TOKEN_KEY, token);
}
}