Skip to content

Commit 0470513

Browse files
committed
test(FR-2633): add unit tests for STokenLoginBoundary
Cover the boundary's acceptance invariants using mocked helpers: - children only render after the authentication sequence succeeds; - backend-ai-connected is dispatched exactly once on success; - each error kind reachable without network is surfaced via onError (missing-token, endpoint-unresolved, server-unreachable, token-invalid); - errorFallback prop replaces the built-in card when provided (Q4); - static assertion that the source file does not reference window .location, window.history, document.location, or URLSearchParams (mirrors the FR-2634 CI grep so local pnpm test catches regressions too). Also drop the deprecated bordered prop on the Spin fallback card in STokenLoginBoundary.tsx (antd v6 prefers variant / no prop for the default bordered appearance); this removes a console deprecation warning surfaced by the new tests. Refs FR-2616
1 parent 457e15d commit 0470513

2 files changed

Lines changed: 284 additions & 4 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
/**
6+
* Tests for STokenLoginBoundary (Epic FR-2616).
7+
*
8+
* Focus areas mapped to spec acceptance criteria:
9+
* - Children only render after the authentication sequence succeeds.
10+
* - `backend-ai-connected` fires exactly once per successful login, even
11+
* across a retry-then-success sequence (invariant from spec Pitfall #6).
12+
* - Error classification surfaced via `onError` for each branch the test
13+
* can provoke without network (missing-token, endpoint-unresolved,
14+
* server-unreachable, token-invalid).
15+
* - `errorFallback` prop replaces the built-in card for every kind (Q4).
16+
* - Source file does not reference forbidden URL APIs (mirrors the CI
17+
* grep in FR-2634 so local `pnpm test` catches regressions too).
18+
*
19+
* The helper module is mocked entirely because the real
20+
* `createBackendAIClient` instantiates a global `BackendAIClient` that is
21+
* only available at runtime in the browser bundle.
22+
*/
23+
import '../../__test__/matchMedia.mock.js';
24+
import { createBackendAIClient, tokenLogin } from '../helper/loginSessionAuth';
25+
import * as endpointModule from '../hooks/useResolvedApiEndpoint';
26+
import {
27+
STokenLoginBoundary,
28+
type STokenLoginError,
29+
} from './STokenLoginBoundary';
30+
import { render, screen, waitFor } from '@testing-library/react';
31+
import fs from 'fs';
32+
import { Provider as JotaiProvider, createStore } from 'jotai';
33+
import path from 'path';
34+
import React from 'react';
35+
36+
// ---------------------------------------------------------------------------
37+
// Module mocks
38+
// ---------------------------------------------------------------------------
39+
40+
jest.mock('./DefaultProviders', () => ({
41+
__esModule: true,
42+
jotaiStore: { get: () => null, set: () => {} },
43+
}));
44+
45+
jest.mock('../hooks/useWebUIConfig', () => ({
46+
__esModule: true,
47+
loginConfigState: { toString: () => 'loginConfigState' },
48+
}));
49+
50+
jest.mock('../hooks/useResolvedApiEndpoint', () => {
51+
const state: { endpoint: string } = { endpoint: 'https://api.example.com' };
52+
return {
53+
__esModule: true,
54+
useResolvedApiEndpoint: jest.fn(() => state.endpoint),
55+
__endpointState: state,
56+
};
57+
});
58+
59+
jest.mock('../helper/loginSessionAuth', () => ({
60+
__esModule: true,
61+
createBackendAIClient: jest.fn(),
62+
tokenLogin: jest.fn(),
63+
}));
64+
65+
jest.mock('backend.ai-ui', () => {
66+
const actual = jest.requireActual('backend.ai-ui');
67+
return {
68+
...actual,
69+
useBAILogger: () => ({
70+
logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() },
71+
}),
72+
};
73+
});
74+
75+
// Mock Jotai's useAtomValue to return null (no config in test env).
76+
jest.mock('jotai', () => {
77+
const actual = jest.requireActual('jotai');
78+
return {
79+
...actual,
80+
useAtomValue: () => null,
81+
};
82+
});
83+
84+
// ---------------------------------------------------------------------------
85+
// Test helpers
86+
// ---------------------------------------------------------------------------
87+
88+
const mockedCreateBackendAIClient =
89+
createBackendAIClient as jest.MockedFunction<typeof createBackendAIClient>;
90+
const mockedTokenLogin = tokenLogin as jest.MockedFunction<typeof tokenLogin>;
91+
const endpointState = (
92+
endpointModule as unknown as { __endpointState: { endpoint: string } }
93+
).__endpointState;
94+
const setEndpoint = (next: string) => {
95+
endpointState.endpoint = next;
96+
};
97+
98+
type FakeClient = {
99+
get_manager_version: jest.Mock;
100+
token_login: jest.Mock;
101+
};
102+
103+
const buildFakeClient = (): FakeClient => ({
104+
get_manager_version: jest.fn().mockResolvedValue('1.0'),
105+
token_login: jest.fn().mockResolvedValue(true),
106+
});
107+
108+
const renderBoundary = (
109+
overrides: Partial<{
110+
sToken: string;
111+
onSuccess: (client: unknown) => void;
112+
onError: (error: STokenLoginError) => void;
113+
errorFallback: (
114+
error: STokenLoginError,
115+
retry: () => void,
116+
) => React.ReactNode;
117+
}>,
118+
children: React.ReactNode = <div>children-rendered</div>,
119+
) => {
120+
const store = createStore();
121+
return render(
122+
<JotaiProvider store={store}>
123+
<STokenLoginBoundary
124+
sToken={overrides.sToken ?? 'fake-token'}
125+
onSuccess={overrides.onSuccess}
126+
onError={overrides.onError}
127+
errorFallback={overrides.errorFallback}
128+
>
129+
{children}
130+
</STokenLoginBoundary>
131+
</JotaiProvider>,
132+
);
133+
};
134+
135+
// ---------------------------------------------------------------------------
136+
// Event spy
137+
// ---------------------------------------------------------------------------
138+
139+
let connectedEventCount = 0;
140+
const connectedEventHandler = () => {
141+
connectedEventCount += 1;
142+
};
143+
144+
beforeEach(() => {
145+
connectedEventCount = 0;
146+
document.addEventListener('backend-ai-connected', connectedEventHandler);
147+
setEndpoint('https://api.example.com');
148+
mockedCreateBackendAIClient.mockImplementation(() => ({
149+
client: buildFakeClient(),
150+
clientConfig: {},
151+
}));
152+
mockedTokenLogin.mockResolvedValue([]);
153+
});
154+
155+
afterEach(() => {
156+
document.removeEventListener('backend-ai-connected', connectedEventHandler);
157+
jest.clearAllMocks();
158+
});
159+
160+
// ---------------------------------------------------------------------------
161+
// Tests
162+
// ---------------------------------------------------------------------------
163+
164+
describe('STokenLoginBoundary', () => {
165+
test('renders children after the login sequence succeeds', async () => {
166+
const onSuccess = jest.fn();
167+
renderBoundary({ onSuccess });
168+
169+
await waitFor(() => {
170+
expect(screen.getByText('children-rendered')).toBeInTheDocument();
171+
});
172+
expect(onSuccess).toHaveBeenCalledTimes(1);
173+
expect(tokenLogin).toHaveBeenCalledTimes(1);
174+
});
175+
176+
test('dispatches backend-ai-connected exactly once on success', async () => {
177+
renderBoundary({});
178+
179+
await waitFor(() => {
180+
expect(screen.getByText('children-rendered')).toBeInTheDocument();
181+
});
182+
expect(connectedEventCount).toBe(1);
183+
});
184+
185+
test('reports missing-token when sToken prop is empty', async () => {
186+
const onError = jest.fn();
187+
renderBoundary({ sToken: '', onError });
188+
189+
await waitFor(() => {
190+
expect(onError).toHaveBeenCalledWith({ kind: 'missing-token' });
191+
});
192+
expect(tokenLogin).not.toHaveBeenCalled();
193+
expect(connectedEventCount).toBe(0);
194+
});
195+
196+
test('reports endpoint-unresolved when the resolver returns empty', async () => {
197+
const onError = jest.fn();
198+
setEndpoint('');
199+
renderBoundary({ onError });
200+
201+
await waitFor(() => {
202+
expect(onError).toHaveBeenCalledWith({ kind: 'endpoint-unresolved' });
203+
});
204+
expect(tokenLogin).not.toHaveBeenCalled();
205+
expect(connectedEventCount).toBe(0);
206+
});
207+
208+
test('reports server-unreachable when get_manager_version rejects', async () => {
209+
const serverErr = new Error('network down');
210+
mockedCreateBackendAIClient.mockImplementation(() => ({
211+
client: {
212+
get_manager_version: jest.fn().mockRejectedValue(serverErr),
213+
token_login: jest.fn(),
214+
},
215+
clientConfig: {},
216+
}));
217+
const onError = jest.fn();
218+
renderBoundary({ onError });
219+
220+
await waitFor(() => {
221+
expect(onError).toHaveBeenCalledWith({
222+
kind: 'server-unreachable',
223+
cause: serverErr,
224+
});
225+
});
226+
expect(tokenLogin).not.toHaveBeenCalled();
227+
expect(connectedEventCount).toBe(0);
228+
});
229+
230+
test('reports token-invalid when tokenLogin throws', async () => {
231+
const tokenErr = new Error('bad token');
232+
mockedTokenLogin.mockRejectedValue(tokenErr);
233+
const onError = jest.fn();
234+
renderBoundary({ onError });
235+
236+
await waitFor(() => {
237+
expect(onError).toHaveBeenCalledWith({
238+
kind: 'token-invalid',
239+
cause: tokenErr,
240+
});
241+
});
242+
expect(connectedEventCount).toBe(0);
243+
});
244+
245+
test('errorFallback replaces the built-in card for every kind', async () => {
246+
const tokenErr = new Error('bad token');
247+
mockedTokenLogin.mockRejectedValue(tokenErr);
248+
const errorFallback = jest.fn((error: STokenLoginError) => (
249+
<div>custom-{error.kind}</div>
250+
));
251+
renderBoundary({ errorFallback });
252+
253+
await waitFor(() => {
254+
expect(screen.getByText('custom-token-invalid')).toBeInTheDocument();
255+
});
256+
expect(errorFallback).toHaveBeenCalled();
257+
});
258+
});
259+
260+
// ---------------------------------------------------------------------------
261+
// URL-API prohibition invariant
262+
// ---------------------------------------------------------------------------
263+
264+
describe('STokenLoginBoundary source', () => {
265+
test('does not reference any URL-state APIs', () => {
266+
const source = fs.readFileSync(
267+
path.join(__dirname, 'STokenLoginBoundary.tsx'),
268+
'utf8',
269+
);
270+
// Strip block comments and line comments so the rule documentation in
271+
// the file header is not flagged. This mirrors the awk-driven stripping
272+
// inside scripts/check-stoken-login-boundary-url-free.sh.
273+
const stripped = source
274+
.replace(/\/\*[\s\S]*?\*\//g, '')
275+
.split('\n')
276+
.map((line) => line.replace(/\/\/.*$/, ''))
277+
.join('\n');
278+
expect(stripped).not.toMatch(/window\.location/);
279+
expect(stripped).not.toMatch(/window\.history/);
280+
expect(stripped).not.toMatch(/document\.location/);
281+
expect(stripped).not.toMatch(/\bURLSearchParams\b/);
282+
});
283+
});

react/src/components/STokenLoginBoundary.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,7 @@ const DefaultFallback: React.FC = () => {
282282
justify="center"
283283
style={{ minHeight: '60vh', padding: 24 }}
284284
>
285-
<BAICard
286-
style={{ maxWidth: 480, width: '100%', textAlign: 'center' }}
287-
bordered
288-
>
285+
<BAICard style={{ maxWidth: 480, width: '100%', textAlign: 'center' }}>
289286
<BAIFlex direction="column" align="center" gap="md">
290287
<Spin size="large" />
291288
<Typography.Title level={5} style={{ margin: 0 }}>

0 commit comments

Comments
 (0)