diff --git a/src/__tests__/OnboardingScreen.test.tsx b/src/__tests__/OnboardingScreen.test.tsx
index e8363fe..6a67ea9 100644
--- a/src/__tests__/OnboardingScreen.test.tsx
+++ b/src/__tests__/OnboardingScreen.test.tsx
@@ -304,6 +304,44 @@ describe('OnboardingScreen', () => {
expect(authenticate).not.toHaveBeenCalled();
});
+ it('does not authenticate when the wallet is disconnected but a public key exists', async () => {
+ walletStoreState = { isConnected: false, publicKey: 'GNOTCONNECTED' };
+
+ tree = await renderScreen();
+
+ expect(authenticate).not.toHaveBeenCalled();
+ });
+
+ it('does not re-authenticate an unchanged connected wallet on re-render', async () => {
+ walletStoreState = { isConnected: true, publicKey: 'GSAME' };
+
+ tree = await renderScreen();
+
+ expect(authenticate).toHaveBeenCalledWith('GSAME');
+
+ walletStoreState = { isConnected: true, publicKey: 'GSAME' };
+ await act(async () => {
+ tree!.update();
+ });
+
+ expect(authenticate).toHaveBeenCalledTimes(1);
+ });
+
+ it('authenticates again when the connected public key changes', async () => {
+ walletStoreState = { isConnected: true, publicKey: 'GOLD' };
+
+ tree = await renderScreen();
+
+ expect(authenticate).toHaveBeenCalledWith('GOLD');
+
+ walletStoreState = { isConnected: true, publicKey: 'GNEW' };
+ await act(async () => {
+ tree!.update();
+ });
+
+ expect(authenticate).toHaveBeenCalledWith('GNEW');
+ });
+
it('displays wallet connection errors', async () => {
walletHookState.error = 'Freighter extension not detected';
diff --git a/src/__tests__/useAuth.test.ts b/src/__tests__/useAuth.test.ts
new file mode 100644
index 0000000..f81bc1a
--- /dev/null
+++ b/src/__tests__/useAuth.test.ts
@@ -0,0 +1,138 @@
+import { renderHook, act } from '@testing-library/react';
+import useAuth from '../hooks/useAuth';
+import * as api from '../api';
+import * as stellar from '../stellar';
+import * as lobstr from '../lobstr';
+import * as walletVault from '../walletVault';
+import { useWalletStore } from '../stores/walletStore';
+import { useUserStore } from '../stores/userStore';
+
+jest.mock('../api');
+jest.mock('../stellar');
+jest.mock('../lobstr');
+jest.mock('../walletVault');
+jest.mock('../stores/walletStore');
+jest.mock('../stores/userStore');
+
+let w = {}, u = {};
+const wh = useWalletStore as any, uh = useUserStore as any;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ w = { address: 'G1', walletType: 'freighter' };
+ u = { token: null, profile: null };
+ wh.mockImplementation((sel?: any) => sel ? sel(w) : w);
+ wh.getState = () => w;
+ wh.setState = (up: any) => w = typeof up === 'function' ? up(w) : { ...w, ...up };
+ uh.mockImplementation((sel?: any) => sel ? sel(u) : u);
+ uh.getState = () => u;
+ uh.setState = (up: any) => u = typeof up === 'function' ? up(u) : { ...u, ...up };
+ (api.getAuthChallenge as jest.Mock).mockResolved('ch');
+ (api.loginWithWallet as jest.Mock).mockResolved('jwt');
+ (api.fetchUserProfile as jest.Mock).mockResolved({});
+ (stellar.signChallengeXDR as jest.Mock).mockResolved('sig');
+ (lobstr.openLobstrForSigning as jest.Mock).mockResolved('lob');
+ (walletVault.getInAppSecret as jest.Mock).mockResolved('sec');
+});
+const render = () => renderHook(() => useAuth());
+const login = async (r: any) => act(async () => { await r.current.login(); });
+
+describe('useAuth', () => {
+ it('lobstr', async () => {
+ w.walletType = 'lobstr'; w.address = 'GLOB';
+ const r = render(); await login(r);
+ expect(lobstr.openLobstrForSigning).toHaveBeenCalledWith('ch');
+ expect(api.loginWithWallet).toHaveBeenCalledWith('lob');
+ });
+
+ it('freighter', async () => {
+ w.walletType = 'freighter'; w.address = 'GFRE';
+ const r = render(); await login(r);
+ expect(stellar.signChallengeXDR).toHaveBeenCalledWith('ch', { address: 'GFRE', walletType: 'freighter' });
+ });
+
+ it('keypair', async () => {
+ w.walletType = 'keypair'; w.address = 'GKEY';
+ const r = render(); await login(r);
+ expect(walletVault.getInAppSecret).toHaveBeenCalled();
+ expect(stellar.signChallengeXDR).toHaveBeenCalledWith('ch', { secretKey: 'sec' });
+ });
+
+ it('missing secret', async () => {
+ w.walletType = 'keypair'; w.address = 'GKEY';
+ (walletVault.getInAppSecret as jest.Mock).mockResolved(null);
+ const r = render();
+ await expect(r.current.login()).rejects.toThrow();
+ });
+
+ it('network error on challenge', async () => {
+ (api.getAuthChallenge as jest.Mock).mockRejected(new Error('network'));
+ const r = render();
+ await expect(r.current.login()).rejects.toThrow('network');
+ });
+
+ it('network error on login', async () => {
+ w.walletType = 'freighter'; w.address = 'GFRE';
+ (api.loginWithWallet as jest.Mock).mockRejected(new Error('network'));
+ const r = render();
+ await expect(r.current.login()).rejects.toThrow('network');
+ });
+
+ it('signing error', async () => {
+ w.walletType = 'freighter'; w.address = 'GFRE';
+ (stellar.signChallengeXDR as jest.Mock).mockRejected(new Error('signing'));
+ const r = render();
+ await expect(r.current.login()).rejects.toThrow('signing');
+ });
+
+ it('stores JWT and profile', async () => {
+ w.walletType = 'freighter'; w.address = 'GFRE';
+ (api.fetchUserProfile as jest.Mock).mockResolved({ id: 'u1', stats: { games: 1 } });
+ const r = render(); await login(r);
+ expect(u.token).toBe('jwt');
+ expect(u.profile).toEqual({ id: 'u1', stats: { games: 1 } });
+ });
+
+ it('does not set profile on fetch error', async () => {
+ w.walletType = 'freighter'; w.address = 'GFRE';
+ (api.fetchUserProfile as jest.Mock).mockRejected(new Error('profile'));
+ const r = render(); await login(r);
+ expect(u.profile).toBe(null);
+ });
+
+ it('preserves stats on partial profile', async () => {
+ w.walletType = 'freighter'; w.address = 'GFRE';
+ u.profile = { id: 'u1', stats: { games: 10, wins: 5 } };
+ (api.fetchUserProfile as jest.Mock).mockResolved({ id: 'u1', stats: { games: 10 } });
+ const r = render(); await login(r);
+ expect(u.profile.stats).toEqual({ games: 10, wins: 5 });
+ });
+
+ it('syncProfile fetches profile', async () => {
+ u.token = 'token';
+ (api.fetchUserProfile as jest.Mock).mockResolved({ id: 'x', stats: { games: 2 } });
+ const r = render(); await act(async () => { await r.current.syncProfile(); });
+ expect(api.fetchUserProfile).toHaveBeenCalledWith('token');
+ expect(u.profile).toEqual({ id: 'x', stats: { games: 2 } });
+ });
+
+ it('syncProfile preserves stats on partial data', async () => {
+ u.token = 'token';
+ u.profile = { id: 'x', stats: { games: 10, wins: 3 } };
+ (api.fetchUserProfile as jest.Mock).mockResolved({ id: 'x', stats: { games: 10 } });
+ const r = render(); await act(async () => { await r.current.syncProfile(); });
+ expect(u.profile.stats).toEqual({ games: 10, wins: 3 });
+ });
+
+ it('syncProfile does nothing without token', async () => {
+ const r = render(); await act(async () => { await r.current.syncProfile(); });
+ expect(api.fetchUserProfile).not.toHaveBeenCalled();
+ });
+
+ it('syncProfile rejects on fetch error', async () => {
+ u.token = 'token';
+ (api.fetchUserProfile as jest.Mock).mockRejected(new Error('profile'));
+ const r = render();
+ await expect(r.current.syncProfile()).rejects.toThrow('profile');
+ });
+});
\ No newline at end of file