Skip to content
Open
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
38 changes: 38 additions & 0 deletions src/__tests__/OnboardingScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<OnboardingScreen />);
});

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(<OnboardingScreen />);
});

expect(authenticate).toHaveBeenCalledWith('GNEW');
});

it('displays wallet connection errors', async () => {
walletHookState.error = 'Freighter extension not detected';

Expand Down
138 changes: 138 additions & 0 deletions src/__tests__/useAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});