librechat-data-provider 0.8.300 → 0.8.301

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "librechat-data-provider",
3
- "version": "0.8.300",
3
+ "version": "0.8.301",
4
4
  "description": "data services for librechat apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.es.js",
@@ -62,8 +62,8 @@
62
62
  "jest": "^30.2.0",
63
63
  "jest-junit": "^16.0.0",
64
64
  "openapi-types": "^12.1.3",
65
- "rimraf": "^6.1.2",
66
- "rollup": "^4.22.4",
65
+ "rimraf": "^6.1.3",
66
+ "rollup": "^4.34.9",
67
67
  "rollup-plugin-peer-deps-external": "^2.2.4",
68
68
  "rollup-plugin-typescript2": "^0.35.0",
69
69
  "typescript": "^5.0.4"
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { buildLoginRedirectUrl } from '../src/api-endpoints';
5
+
6
+ describe('buildLoginRedirectUrl', () => {
7
+ let savedLocation: Location;
8
+
9
+ beforeEach(() => {
10
+ savedLocation = window.location;
11
+ Object.defineProperty(window, 'location', {
12
+ value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' },
13
+ writable: true,
14
+ });
15
+ });
16
+
17
+ afterEach(() => {
18
+ Object.defineProperty(window, 'location', { value: savedLocation, writable: true });
19
+ });
20
+
21
+ it('builds a login URL from explicit args', () => {
22
+ const result = buildLoginRedirectUrl('/c/new', '?q=hello', '');
23
+ expect(result).toBe('/login?redirect_to=%2Fc%2Fnew%3Fq%3Dhello');
24
+ });
25
+
26
+ it('encodes complex paths with query and hash', () => {
27
+ const result = buildLoginRedirectUrl('/c/new', '?q=hello&submit=true', '#section');
28
+ expect(result).toContain('redirect_to=');
29
+ const encoded = result.split('redirect_to=')[1];
30
+ expect(decodeURIComponent(encoded)).toBe('/c/new?q=hello&submit=true#section');
31
+ });
32
+
33
+ it('falls back to window.location when no args provided', () => {
34
+ const result = buildLoginRedirectUrl();
35
+ const encoded = result.split('redirect_to=')[1];
36
+ expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5');
37
+ });
38
+
39
+ it('falls back to "/" when all location parts are empty', () => {
40
+ Object.defineProperty(window, 'location', {
41
+ value: { pathname: '', search: '', hash: '' },
42
+ writable: true,
43
+ });
44
+ const result = buildLoginRedirectUrl();
45
+ expect(result).toBe('/login?redirect_to=%2F');
46
+ });
47
+
48
+ it('returns plain /login when pathname is /login (prevents recursive redirect)', () => {
49
+ const result = buildLoginRedirectUrl('/login', '?redirect_to=%2Fc%2Fnew', '');
50
+ expect(result).toBe('/login');
51
+ });
52
+
53
+ it('returns plain /login when window.location is already /login', () => {
54
+ Object.defineProperty(window, 'location', {
55
+ value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
56
+ writable: true,
57
+ });
58
+ const result = buildLoginRedirectUrl();
59
+ expect(result).toBe('/login');
60
+ });
61
+
62
+ it('returns plain /login for /login sub-paths', () => {
63
+ const result = buildLoginRedirectUrl('/login/2fa', '', '');
64
+ expect(result).toBe('/login');
65
+ });
66
+
67
+ it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => {
68
+ Object.defineProperty(window, 'location', {
69
+ value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
70
+ writable: true,
71
+ });
72
+ const result = buildLoginRedirectUrl();
73
+ expect(result).toBe('/login');
74
+ });
75
+
76
+ it('returns plain /login for basename-prefixed /login sub-paths', () => {
77
+ const result = buildLoginRedirectUrl('/librechat/login/2fa', '', '');
78
+ expect(result).toBe('/login');
79
+ });
80
+
81
+ it('does NOT match paths where "login" is a substring of a segment', () => {
82
+ const result = buildLoginRedirectUrl('/c/loginhistory', '', '');
83
+ expect(result).toContain('redirect_to=');
84
+ expect(decodeURIComponent(result.split('redirect_to=')[1])).toBe('/c/loginhistory');
85
+ });
86
+ });
@@ -688,5 +688,175 @@ describe('bedrockInputParser', () => {
688
688
  expect(amrf.anthropic_beta).toBeDefined();
689
689
  expect(Array.isArray(amrf.anthropic_beta)).toBe(true);
690
690
  });
691
+
692
+ test('should strip stale reasoning_config when switching to Anthropic model', () => {
693
+ const staleConversationData = {
694
+ model: 'anthropic.claude-sonnet-4-6',
695
+ additionalModelRequestFields: {
696
+ reasoning_config: 'high',
697
+ },
698
+ };
699
+ const result = bedrockInputParser.parse(staleConversationData) as Record<string, unknown>;
700
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
701
+ expect(amrf.reasoning_config).toBeUndefined();
702
+ });
703
+
704
+ test('should strip stale reasoning_config when switching from Moonshot to Meta model', () => {
705
+ const staleData = {
706
+ model: 'meta.llama-3-1-70b',
707
+ additionalModelRequestFields: {
708
+ reasoning_config: 'high',
709
+ },
710
+ };
711
+ const result = bedrockInputParser.parse(staleData) as Record<string, unknown>;
712
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
713
+ expect(amrf?.reasoning_config).toBeUndefined();
714
+ });
715
+
716
+ test('should strip stale reasoning_config when switching from ZAI to DeepSeek model', () => {
717
+ const staleData = {
718
+ model: 'deepseek.deepseek-r1',
719
+ additionalModelRequestFields: {
720
+ reasoning_config: 'medium',
721
+ },
722
+ };
723
+ const result = bedrockInputParser.parse(staleData) as Record<string, unknown>;
724
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
725
+ expect(amrf?.reasoning_config).toBeUndefined();
726
+ });
727
+ });
728
+
729
+ describe('Bedrock reasoning_effort → reasoning_config for Moonshot/ZAI models', () => {
730
+ test('should map reasoning_effort to reasoning_config for moonshotai.kimi-k2.5', () => {
731
+ const input = {
732
+ model: 'moonshotai.kimi-k2.5',
733
+ reasoning_effort: 'high',
734
+ };
735
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
736
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
737
+ expect(amrf.reasoning_config).toBe('high');
738
+ expect(amrf.reasoning_effort).toBeUndefined();
739
+ });
740
+
741
+ test('should map reasoning_effort to reasoning_config for moonshot.kimi-k2.5', () => {
742
+ const input = {
743
+ model: 'moonshot.kimi-k2.5',
744
+ reasoning_effort: 'medium',
745
+ };
746
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
747
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
748
+ expect(amrf.reasoning_config).toBe('medium');
749
+ });
750
+
751
+ test('should map reasoning_effort to reasoning_config for zai.glm-4.7', () => {
752
+ const input = {
753
+ model: 'zai.glm-4.7',
754
+ reasoning_effort: 'high',
755
+ };
756
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
757
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
758
+ expect(amrf.reasoning_config).toBe('high');
759
+ });
760
+
761
+ test('should map reasoning_effort "low" to reasoning_config for Moonshot model', () => {
762
+ const input = {
763
+ model: 'moonshotai.kimi-k2.5',
764
+ reasoning_effort: 'low',
765
+ };
766
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
767
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
768
+ expect(amrf.reasoning_config).toBe('low');
769
+ });
770
+
771
+ test('should not include reasoning_config when reasoning_effort is unset (empty string)', () => {
772
+ const input = {
773
+ model: 'moonshotai.kimi-k2.5',
774
+ reasoning_effort: '',
775
+ };
776
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
777
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
778
+ expect(amrf?.reasoning_config).toBeUndefined();
779
+ });
780
+
781
+ test('should not include reasoning_config when reasoning_effort is not provided', () => {
782
+ const input = {
783
+ model: 'moonshotai.kimi-k2.5',
784
+ };
785
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
786
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
787
+ expect(amrf?.reasoning_config).toBeUndefined();
788
+ });
789
+
790
+ test('should not forward reasoning_effort "none" to reasoning_config', () => {
791
+ const result = bedrockInputParser.parse({
792
+ model: 'moonshotai.kimi-k2.5',
793
+ reasoning_effort: 'none',
794
+ }) as Record<string, unknown>;
795
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
796
+ expect(amrf?.reasoning_config).toBeUndefined();
797
+ });
798
+
799
+ test('should not forward reasoning_effort "minimal" to reasoning_config', () => {
800
+ const result = bedrockInputParser.parse({
801
+ model: 'moonshotai.kimi-k2.5',
802
+ reasoning_effort: 'minimal',
803
+ }) as Record<string, unknown>;
804
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
805
+ expect(amrf?.reasoning_config).toBeUndefined();
806
+ });
807
+
808
+ test('should not forward reasoning_effort "xhigh" to reasoning_config', () => {
809
+ const result = bedrockInputParser.parse({
810
+ model: 'zai.glm-4.7',
811
+ reasoning_effort: 'xhigh',
812
+ }) as Record<string, unknown>;
813
+ const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
814
+ expect(amrf?.reasoning_config).toBeUndefined();
815
+ });
816
+
817
+ test('should not add reasoning_config to Anthropic models', () => {
818
+ const input = {
819
+ model: 'anthropic.claude-sonnet-4-6',
820
+ reasoning_effort: 'high',
821
+ };
822
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
823
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
824
+ expect(amrf.reasoning_config).toBeUndefined();
825
+ expect(amrf.reasoning_effort).toBeUndefined();
826
+ });
827
+
828
+ test('should not add thinking or anthropic_beta to Moonshot models with reasoning_effort', () => {
829
+ const input = {
830
+ model: 'moonshotai.kimi-k2.5',
831
+ reasoning_effort: 'high',
832
+ };
833
+ const result = bedrockInputParser.parse(input) as Record<string, unknown>;
834
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
835
+ expect(amrf.thinking).toBeUndefined();
836
+ expect(amrf.thinkingBudget).toBeUndefined();
837
+ expect(amrf.anthropic_beta).toBeUndefined();
838
+ });
839
+
840
+ test('should pass reasoning_config through bedrockOutputParser', () => {
841
+ const parsed = bedrockInputParser.parse({
842
+ model: 'moonshotai.kimi-k2.5',
843
+ reasoning_effort: 'high',
844
+ }) as Record<string, unknown>;
845
+ const output = bedrockOutputParser(parsed);
846
+ const amrf = output.additionalModelRequestFields as Record<string, unknown>;
847
+ expect(amrf.reasoning_config).toBe('high');
848
+ });
849
+
850
+ test('should strip stale reasoning_config from additionalModelRequestFields for Anthropic models', () => {
851
+ const staleData = {
852
+ model: 'anthropic.claude-opus-4-6-v1',
853
+ additionalModelRequestFields: {
854
+ reasoning_config: 'high',
855
+ },
856
+ };
857
+ const result = bedrockInputParser.parse(staleData) as Record<string, unknown>;
858
+ const amrf = result.additionalModelRequestFields as Record<string, unknown>;
859
+ expect(amrf.reasoning_config).toBeUndefined();
860
+ });
691
861
  });
692
862
  });
@@ -0,0 +1,24 @@
1
+ import axios from 'axios';
2
+ import { setTokenHeader } from '../src/headers-helpers';
3
+
4
+ describe('setTokenHeader', () => {
5
+ afterEach(() => {
6
+ delete axios.defaults.headers.common['Authorization'];
7
+ });
8
+
9
+ it('sets the Authorization header with a Bearer token', () => {
10
+ setTokenHeader('my-token');
11
+ expect(axios.defaults.headers.common['Authorization']).toBe('Bearer my-token');
12
+ });
13
+
14
+ it('deletes the Authorization header when called with undefined', () => {
15
+ axios.defaults.headers.common['Authorization'] = 'Bearer old-token';
16
+ setTokenHeader(undefined);
17
+ expect(axios.defaults.headers.common['Authorization']).toBeUndefined();
18
+ });
19
+
20
+ it('is a no-op when clearing an already absent header', () => {
21
+ setTokenHeader(undefined);
22
+ expect(axios.defaults.headers.common['Authorization']).toBeUndefined();
23
+ });
24
+ });
@@ -7,22 +7,24 @@ import type { TUser, TConversation } from '../src/types';
7
7
 
8
8
  // Mock dayjs module with consistent date/time values regardless of environment
9
9
  jest.mock('dayjs', () => {
10
- // Create a mock implementation that returns fixed values
11
10
  const mockDayjs = () => ({
12
11
  format: (format: string) => {
13
12
  if (format === 'YYYY-MM-DD') {
14
13
  return '2024-04-29';
15
14
  }
16
- if (format === 'YYYY-MM-DD HH:mm:ss') {
17
- return '2024-04-29 12:34:56';
15
+ if (format === 'YYYY-MM-DD HH:mm:ss Z') {
16
+ return '2024-04-29 12:34:56 -04:00';
18
17
  }
19
- return format; // fallback
18
+ if (format === 'dddd') {
19
+ return 'Monday';
20
+ }
21
+ throw new Error(
22
+ `Unhandled dayjs().format() call in mock: "${format}". Update the mock in parsers.spec.ts`,
23
+ );
20
24
  },
21
- day: () => 1, // 1 = Monday
22
25
  toISOString: () => '2024-04-29T16:34:56.000Z',
23
26
  });
24
27
 
25
- // Add any static methods needed
26
28
  mockDayjs.extend = jest.fn();
27
29
 
28
30
  return mockDayjs;
@@ -47,13 +49,12 @@ describe('replaceSpecialVars', () => {
47
49
 
48
50
  test('should replace {{current_date}} with the current date', () => {
49
51
  const result = replaceSpecialVars({ text: 'Today is {{current_date}}' });
50
- // dayjs().day() returns 1 for Monday (April 29, 2024 is a Monday)
51
- expect(result).toBe('Today is 2024-04-29 (1)');
52
+ expect(result).toBe('Today is 2024-04-29 (Monday)');
52
53
  });
53
54
 
54
55
  test('should replace {{current_datetime}} with the current datetime', () => {
55
56
  const result = replaceSpecialVars({ text: 'Now is {{current_datetime}}' });
56
- expect(result).toBe('Now is 2024-04-29 12:34:56 (1)');
57
+ expect(result).toBe('Now is 2024-04-29 12:34:56 -04:00 (Monday)');
57
58
  });
58
59
 
59
60
  test('should replace {{iso_datetime}} with the ISO datetime', () => {
@@ -90,7 +91,7 @@ describe('replaceSpecialVars', () => {
90
91
  user: mockUser,
91
92
  });
92
93
  expect(result).toBe(
93
- 'Hello Test User! Today is 2024-04-29 (1) and the time is 2024-04-29 12:34:56 (1). ISO: 2024-04-29T16:34:56.000Z',
94
+ 'Hello Test User! Today is 2024-04-29 (Monday) and the time is 2024-04-29 12:34:56 -04:00 (Monday). ISO: 2024-04-29T16:34:56.000Z',
94
95
  );
95
96
  });
96
97
 
@@ -99,7 +100,7 @@ describe('replaceSpecialVars', () => {
99
100
  text: 'Date: {{CURRENT_DATE}}, User: {{Current_User}}',
100
101
  user: mockUser,
101
102
  });
102
- expect(result).toBe('Date: 2024-04-29 (1), User: Test User');
103
+ expect(result).toBe('Date: 2024-04-29 (Monday), User: Test User');
103
104
  });
104
105
 
105
106
  test('should confirm all specialVariables from config.ts get parsed', () => {
@@ -120,8 +121,8 @@ describe('replaceSpecialVars', () => {
120
121
  });
121
122
 
122
123
  // Verify the expected replacements
123
- expect(result).toContain('2024-04-29 (1)'); // current_date
124
- expect(result).toContain('2024-04-29 12:34:56 (1)'); // current_datetime
124
+ expect(result).toContain('2024-04-29 (Monday)'); // current_date
125
+ expect(result).toContain('2024-04-29 12:34:56 -04:00 (Monday)'); // current_datetime
125
126
  expect(result).toContain('2024-04-29T16:34:56.000Z'); // iso_datetime
126
127
  expect(result).toContain('Test User'); // current_user
127
128
  });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import axios from 'axios';
5
+ import { setTokenHeader } from '../src/headers-helpers';
6
+
7
+ /**
8
+ * The response interceptor in request.ts registers at import time when
9
+ * `typeof window !== 'undefined'` (jsdom provides window).
10
+ *
11
+ * We use axios's built-in request adapter mock to avoid real HTTP calls,
12
+ * and verify the interceptor's behavior by observing whether a 401 triggers
13
+ * a refresh POST or is immediately rejected.
14
+ */
15
+
16
+ const mockAdapter = jest.fn();
17
+ let originalAdapter: typeof axios.defaults.adapter;
18
+ let savedLocation: Location;
19
+
20
+ beforeAll(async () => {
21
+ originalAdapter = axios.defaults.adapter;
22
+ axios.defaults.adapter = mockAdapter;
23
+
24
+ await import('../src/request');
25
+ });
26
+
27
+ beforeEach(() => {
28
+ mockAdapter.mockReset();
29
+ savedLocation = window.location;
30
+ });
31
+
32
+ afterAll(() => {
33
+ axios.defaults.adapter = originalAdapter;
34
+ });
35
+
36
+ afterEach(() => {
37
+ delete axios.defaults.headers.common['Authorization'];
38
+ Object.defineProperty(window, 'location', {
39
+ value: savedLocation,
40
+ writable: true,
41
+ });
42
+ });
43
+
44
+ function setWindowLocation(overrides: Partial<Location>) {
45
+ Object.defineProperty(window, 'location', {
46
+ value: { ...window.location, ...overrides },
47
+ writable: true,
48
+ });
49
+ }
50
+
51
+ describe('axios 401 interceptor — Authorization header guard', () => {
52
+ it('skips refresh and rejects when Authorization header is cleared', async () => {
53
+ expect.assertions(1);
54
+ setTokenHeader(undefined);
55
+
56
+ mockAdapter.mockRejectedValueOnce({
57
+ response: { status: 401 },
58
+ config: { url: '/api/messages', headers: {} },
59
+ });
60
+
61
+ try {
62
+ await axios.get('/api/messages');
63
+ } catch {
64
+ // expected rejection
65
+ }
66
+
67
+ expect(mockAdapter).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it('attempts refresh on shared link page even without Authorization header', async () => {
71
+ expect.assertions(2);
72
+ setTokenHeader(undefined);
73
+
74
+ setWindowLocation({
75
+ href: 'http://localhost/share/abc123',
76
+ pathname: '/share/abc123',
77
+ search: '',
78
+ hash: '',
79
+ } as Partial<Location>);
80
+
81
+ mockAdapter.mockRejectedValueOnce({
82
+ response: { status: 401 },
83
+ config: { url: '/api/share/abc123', headers: {} },
84
+ });
85
+
86
+ mockAdapter.mockResolvedValueOnce({
87
+ data: { token: 'new-token' },
88
+ status: 200,
89
+ headers: {},
90
+ config: {},
91
+ });
92
+
93
+ mockAdapter.mockResolvedValueOnce({
94
+ data: { sharedLink: {} },
95
+ status: 200,
96
+ headers: {},
97
+ config: {},
98
+ });
99
+
100
+ try {
101
+ await axios.get('/api/share/abc123');
102
+ } catch {
103
+ // may reject depending on exact flow
104
+ }
105
+
106
+ expect(mockAdapter.mock.calls.length).toBe(3);
107
+
108
+ const refreshCall = mockAdapter.mock.calls[1];
109
+ expect(refreshCall[0].url).toContain('api/auth/refresh');
110
+ });
111
+
112
+ it('does not bypass guard when share/ appears only in query params', async () => {
113
+ expect.assertions(1);
114
+ setTokenHeader(undefined);
115
+
116
+ setWindowLocation({
117
+ href: 'http://localhost/c/chat?ref=share/token',
118
+ pathname: '/c/chat',
119
+ search: '?ref=share/token',
120
+ hash: '',
121
+ } as Partial<Location>);
122
+
123
+ mockAdapter.mockRejectedValueOnce({
124
+ response: { status: 401 },
125
+ config: { url: '/api/messages', headers: {} },
126
+ });
127
+
128
+ try {
129
+ await axios.get('/api/messages');
130
+ } catch {
131
+ // expected rejection
132
+ }
133
+
134
+ expect(mockAdapter).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it('redirects to login with redirect_to when unauthenticated on share page and refresh fails', async () => {
138
+ expect.assertions(1);
139
+ setTokenHeader(undefined);
140
+
141
+ setWindowLocation({
142
+ href: 'http://localhost/share/abc123',
143
+ pathname: '/share/abc123',
144
+ search: '',
145
+ hash: '',
146
+ } as Partial<Location>);
147
+
148
+ mockAdapter.mockRejectedValueOnce({
149
+ response: { status: 401 },
150
+ config: { url: '/api/share/abc123', headers: {} },
151
+ });
152
+
153
+ mockAdapter.mockResolvedValueOnce({
154
+ data: { token: '' },
155
+ status: 200,
156
+ headers: {},
157
+ config: {},
158
+ });
159
+
160
+ try {
161
+ await axios.get('/api/share/abc123');
162
+ } catch {
163
+ // expected rejection
164
+ }
165
+
166
+ expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
167
+ });
168
+
169
+ it('redirects to login with redirect_to when authenticated and refresh returns no token on share page', async () => {
170
+ expect.assertions(1);
171
+ setTokenHeader('some-token');
172
+
173
+ setWindowLocation({
174
+ href: 'http://localhost/share/abc123',
175
+ pathname: '/share/abc123',
176
+ search: '',
177
+ hash: '',
178
+ } as Partial<Location>);
179
+
180
+ mockAdapter.mockRejectedValueOnce({
181
+ response: { status: 401 },
182
+ config: { url: '/api/share/abc123', headers: {} },
183
+ });
184
+
185
+ mockAdapter.mockResolvedValueOnce({
186
+ data: { token: '' },
187
+ status: 200,
188
+ headers: {},
189
+ config: {},
190
+ });
191
+
192
+ try {
193
+ await axios.get('/api/share/abc123');
194
+ } catch {
195
+ // expected rejection
196
+ }
197
+
198
+ expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
199
+ });
200
+
201
+ it('redirects to login with redirect_to when refresh returns no token on regular page', async () => {
202
+ expect.assertions(1);
203
+ setTokenHeader('some-token');
204
+
205
+ setWindowLocation({
206
+ href: 'http://localhost/c/some-conversation',
207
+ pathname: '/c/some-conversation',
208
+ search: '',
209
+ hash: '',
210
+ } as Partial<Location>);
211
+
212
+ mockAdapter.mockRejectedValueOnce({
213
+ response: { status: 401 },
214
+ config: { url: '/api/messages', headers: {} },
215
+ });
216
+
217
+ mockAdapter.mockResolvedValueOnce({
218
+ data: { token: '' },
219
+ status: 200,
220
+ headers: {},
221
+ config: {},
222
+ });
223
+
224
+ try {
225
+ await axios.get('/api/messages');
226
+ } catch {
227
+ // expected rejection
228
+ }
229
+
230
+ expect(window.location.href).toBe('/login?redirect_to=%2Fc%2Fsome-conversation');
231
+ });
232
+
233
+ it('redirects to plain /login without redirect_to when already on a login path', async () => {
234
+ expect.assertions(1);
235
+ setTokenHeader('some-token');
236
+
237
+ setWindowLocation({
238
+ href: 'http://localhost/login/2fa',
239
+ pathname: '/login/2fa',
240
+ search: '',
241
+ hash: '',
242
+ } as Partial<Location>);
243
+
244
+ mockAdapter.mockRejectedValueOnce({
245
+ response: { status: 401 },
246
+ config: { url: '/api/messages', headers: {} },
247
+ });
248
+
249
+ mockAdapter.mockResolvedValueOnce({
250
+ data: { token: '' },
251
+ status: 200,
252
+ headers: {},
253
+ config: {},
254
+ });
255
+
256
+ try {
257
+ await axios.get('/api/messages');
258
+ } catch {
259
+ // expected rejection
260
+ }
261
+
262
+ expect(window.location.href).toBe('/login');
263
+ });
264
+
265
+ it('attempts refresh when Authorization header is present', async () => {
266
+ expect.assertions(2);
267
+ setTokenHeader('valid-token');
268
+
269
+ mockAdapter.mockRejectedValueOnce({
270
+ response: { status: 401 },
271
+ config: { url: '/api/messages', headers: {}, _retry: false },
272
+ });
273
+
274
+ mockAdapter.mockResolvedValueOnce({
275
+ data: { token: 'new-token' },
276
+ status: 200,
277
+ headers: {},
278
+ config: {},
279
+ });
280
+
281
+ mockAdapter.mockResolvedValueOnce({
282
+ data: { messages: [] },
283
+ status: 200,
284
+ headers: {},
285
+ config: {},
286
+ });
287
+
288
+ try {
289
+ await axios.get('/api/messages');
290
+ } catch {
291
+ // may reject depending on exact flow
292
+ }
293
+
294
+ expect(mockAdapter.mock.calls.length).toBe(3);
295
+
296
+ const refreshCall = mockAdapter.mock.calls[1];
297
+ expect(refreshCall[0].url).toContain('api/auth/refresh');
298
+ });
299
+ });