mcp4openapi 0.2.0 → 0.2.1

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.
@@ -7,8 +7,16 @@
7
7
  import { http, HttpResponse } from 'msw';
8
8
  import { setupServer } from 'msw/node';
9
9
  import * as fixtures from './fixtures.js';
10
- import { parsePaginationParams, parseSearchParam, parseBranchParams, parseScopeParam } from './mock-utils.js';
11
- const BASE_URL = 'https://gitlab.com/api/v4';
10
+ import { parsePaginationParams, parseSearchParam, parseBranchParams, parseScopeParam, } from './mock-utils.js';
11
+ /** Default BASE_URL for GitLab API (used by MSW interceptor) */
12
+ export const DEFAULT_BASE_URL = 'https://gitlab.com/api/v4';
13
+ /** Default OAuth config */
14
+ const DEFAULT_OAUTH_CONFIG = {
15
+ oauthBaseUrl: 'http://localhost:4000',
16
+ accessToken: 'mock-access-token-12345',
17
+ refreshToken: 'mock-refresh-token-67890',
18
+ expiresIn: 3600,
19
+ };
12
20
  /**
13
21
  * Helper: Extract and validate IID from URL
14
22
  *
@@ -18,7 +26,6 @@ const BASE_URL = 'https://gitlab.com/api/v4';
18
26
  function extractIidFromUrl(url) {
19
27
  const parts = url.split('/');
20
28
  const iidStr = parts[parts.length - 1];
21
- // Validate it's a positive integer
22
29
  if (!iidStr || !/^\d+$/.test(iidStr)) {
23
30
  return null;
24
31
  }
@@ -33,7 +40,6 @@ function extractIidFromUrl(url) {
33
40
  * URL format: /projects/{project}/merge_requests/{iid}/notes
34
41
  */
35
42
  function extractMrIidFromNotesUrl(url) {
36
- // Remove query parameters for matching
37
43
  const urlWithoutQuery = url.split('?')[0];
38
44
  const match = urlWithoutQuery.match(/\/merge_requests\/(\d+)\/notes/);
39
45
  if (!match) {
@@ -46,436 +52,563 @@ function extractMrIidFromNotesUrl(url) {
46
52
  return iid;
47
53
  }
48
54
  /**
49
- * Mock GitLab API endpoints
50
- *
51
- * Why ordered by resource: Mirrors actual GitLab API structure for maintainability
52
- * Why wildcard patterns: GitLab accepts URL-encoded paths like my-org/my-project
55
+ * Create OAuth handlers for mock OAuth server
56
+ */
57
+ export function createOAuthHandlers(config = DEFAULT_OAUTH_CONFIG) {
58
+ const { oauthBaseUrl, accessToken, refreshToken, expiresIn } = { ...DEFAULT_OAUTH_CONFIG, ...config };
59
+ return [
60
+ // OAuth Discovery endpoint
61
+ http.get(`${oauthBaseUrl}/.well-known/oauth-authorization-server`, () => {
62
+ return HttpResponse.json({
63
+ issuer: oauthBaseUrl,
64
+ authorization_endpoint: `${oauthBaseUrl}/oauth/authorize`,
65
+ token_endpoint: `${oauthBaseUrl}/oauth/token`,
66
+ response_types_supported: ['code'],
67
+ grant_types_supported: ['authorization_code', 'refresh_token'],
68
+ code_challenge_methods_supported: ['S256'],
69
+ });
70
+ }),
71
+ // OAuth Authorization endpoint - redirects with code
72
+ http.get(`${oauthBaseUrl}/oauth/authorize`, ({ request }) => {
73
+ const url = new URL(request.url);
74
+ const redirectUri = url.searchParams.get('redirect_uri');
75
+ const state = url.searchParams.get('state');
76
+ if (!redirectUri) {
77
+ return HttpResponse.json({ error: 'missing_redirect_uri' }, { status: 400 });
78
+ }
79
+ const code = 'mock-code-' + Math.random().toString(36).substring(7);
80
+ const redirectUrl = new URL(redirectUri);
81
+ redirectUrl.searchParams.set('code', code);
82
+ if (state) {
83
+ redirectUrl.searchParams.set('state', state);
84
+ }
85
+ return new HttpResponse(null, {
86
+ status: 302,
87
+ headers: { Location: redirectUrl.toString() },
88
+ });
89
+ }),
90
+ // OAuth Token endpoint
91
+ http.post(`${oauthBaseUrl}/oauth/token`, async ({ request }) => {
92
+ const contentType = request.headers.get('content-type') || '';
93
+ let params;
94
+ if (contentType.includes('application/json')) {
95
+ params = await request.json();
96
+ }
97
+ else {
98
+ const body = await request.text();
99
+ params = Object.fromEntries(new URLSearchParams(body));
100
+ }
101
+ const grantType = params.grant_type;
102
+ if (grantType === 'authorization_code') {
103
+ if (!params.code) {
104
+ return HttpResponse.json({ error: 'invalid_grant' }, { status: 400 });
105
+ }
106
+ return HttpResponse.json({
107
+ access_token: accessToken,
108
+ refresh_token: refreshToken,
109
+ token_type: 'Bearer',
110
+ expires_in: expiresIn,
111
+ scope: 'api',
112
+ });
113
+ }
114
+ if (grantType === 'refresh_token') {
115
+ if (!params.refresh_token) {
116
+ return HttpResponse.json({ error: 'invalid_grant' }, { status: 400 });
117
+ }
118
+ return HttpResponse.json({
119
+ access_token: `${accessToken}-refreshed`,
120
+ refresh_token: `${refreshToken}-new`,
121
+ token_type: 'Bearer',
122
+ expires_in: expiresIn,
123
+ scope: 'api',
124
+ });
125
+ }
126
+ return HttpResponse.json({ error: 'unsupported_grant_type' }, { status: 400 });
127
+ }),
128
+ ];
129
+ }
130
+ /**
131
+ * Create GitLab API handlers with configurable base URL
53
132
  */
54
- export const handlers = [
55
- // Project Badges
56
- http.get(`${BASE_URL}/projects/*/badges`, ({ request, params }) => {
57
- const { page } = parsePaginationParams(request);
58
- // Simple pagination
59
- if (page === 1) {
133
+ export function createGitLabHandlers(baseUrl = DEFAULT_BASE_URL) {
134
+ return [
135
+ // Groups
136
+ http.get(`${baseUrl}/groups`, ({ request }) => {
137
+ const { page } = parsePaginationParams(request);
138
+ const search = parseSearchParam(request);
139
+ let groups = fixtures.mockGroupsList;
140
+ if (search) {
141
+ groups = groups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()) ||
142
+ g.path.toLowerCase().includes(search.toLowerCase()));
143
+ }
144
+ if (page > 1) {
145
+ return HttpResponse.json([]);
146
+ }
147
+ return HttpResponse.json(groups);
148
+ }),
149
+ http.get(`${baseUrl}/groups/:id`, ({ params }) => {
150
+ const groupId = params.id;
151
+ if (groupId === '36173' || groupId === 'davidruzicka') {
152
+ return HttpResponse.json(fixtures.mockGroup);
153
+ }
154
+ return HttpResponse.json({ message: 'Group Not Found' }, { status: 404 });
155
+ }),
156
+ http.get(`${baseUrl}/groups/:id/projects`, ({ request, params }) => {
157
+ const groupId = params.id;
158
+ const { page } = parsePaginationParams(request);
159
+ if (groupId === '36173' || groupId === 'davidruzicka') {
160
+ if (page > 1) {
161
+ return HttpResponse.json([]);
162
+ }
163
+ return HttpResponse.json(fixtures.mockProjectsList);
164
+ }
165
+ return HttpResponse.json({ message: 'Group Not Found' }, { status: 404 });
166
+ }),
167
+ http.get(`${baseUrl}/groups/:id/subgroups`, ({ request, params }) => {
168
+ const groupId = params.id;
169
+ const { page } = parsePaginationParams(request);
170
+ if (groupId === '36173' || groupId === 'davidruzicka') {
171
+ if (page > 1) {
172
+ return HttpResponse.json([]);
173
+ }
174
+ return HttpResponse.json(fixtures.mockSubgroupsList);
175
+ }
176
+ return HttpResponse.json({ message: 'Group Not Found' }, { status: 404 });
177
+ }),
178
+ // Projects
179
+ http.get(`${baseUrl}/projects`, ({ request }) => {
180
+ const { page } = parsePaginationParams(request);
181
+ const search = parseSearchParam(request);
182
+ let projects = fixtures.mockProjectsList;
183
+ if (search) {
184
+ projects = projects.filter(p => p.name.toLowerCase().includes(search.toLowerCase()) ||
185
+ p.description?.toLowerCase().includes(search.toLowerCase()));
186
+ }
187
+ if (page > 1) {
188
+ return HttpResponse.json([]);
189
+ }
190
+ return HttpResponse.json(projects);
191
+ }),
192
+ http.get(`${baseUrl}/projects/:id`, ({ params }) => {
193
+ const projectId = params.id;
194
+ if (projectId === '12345' || projectId === 'davidruzicka%2Fmcp4openapi') {
195
+ return HttpResponse.json(fixtures.mockProject);
196
+ }
197
+ return HttpResponse.json({ message: 'Project Not Found' }, { status: 404 });
198
+ }),
199
+ // Project Badges
200
+ http.get(`${baseUrl}/projects/*/badges`, ({ request }) => {
201
+ const { page } = parsePaginationParams(request);
202
+ if (page === 1) {
203
+ return HttpResponse.json(fixtures.mockBadgesList);
204
+ }
205
+ return HttpResponse.json([]);
206
+ }),
207
+ http.get(`${baseUrl}/projects/*/badges/*`, ({ request }) => {
208
+ const badgeId = extractIidFromUrl(request.url);
209
+ if (badgeId === null) {
210
+ return HttpResponse.json({ error: 'Invalid badge ID' }, { status: 400 });
211
+ }
212
+ if (badgeId === 1) {
213
+ return HttpResponse.json(fixtures.mockBadge);
214
+ }
215
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
216
+ }),
217
+ http.post(`${baseUrl}/projects/*/badges`, async ({ request }) => {
218
+ const body = await request.json();
219
+ if (!body.link_url || !body.image_url) {
220
+ return HttpResponse.json({ error: 'link_url and image_url are required' }, { status: 400 });
221
+ }
222
+ return HttpResponse.json({
223
+ ...fixtures.mockBadge,
224
+ id: 3,
225
+ name: body.name || 'New Badge',
226
+ link_url: body.link_url,
227
+ image_url: body.image_url,
228
+ }, { status: 201 });
229
+ }),
230
+ http.put(`${baseUrl}/projects/*/badges/*`, async ({ request }) => {
231
+ const badgeId = extractIidFromUrl(request.url);
232
+ if (badgeId === null) {
233
+ return HttpResponse.json({ error: 'Invalid badge ID' }, { status: 400 });
234
+ }
235
+ const body = await request.json();
236
+ if (badgeId === 1) {
237
+ return HttpResponse.json({
238
+ ...fixtures.mockBadge,
239
+ name: body.name || fixtures.mockBadge.name,
240
+ link_url: body.link_url || fixtures.mockBadge.link_url,
241
+ image_url: body.image_url || fixtures.mockBadge.image_url,
242
+ });
243
+ }
244
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
245
+ }),
246
+ http.delete(`${baseUrl}/projects/*/badges/*`, ({ request }) => {
247
+ const badgeId = extractIidFromUrl(request.url);
248
+ if (badgeId === null) {
249
+ return HttpResponse.json({ error: 'Invalid badge ID' }, { status: 400 });
250
+ }
251
+ if (badgeId === 1) {
252
+ return new HttpResponse(null, { status: 204 });
253
+ }
254
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
255
+ }),
256
+ // Group Badges
257
+ http.get(`${baseUrl}/groups/*/badges`, () => {
60
258
  return HttpResponse.json(fixtures.mockBadgesList);
61
- }
62
- return HttpResponse.json([]);
63
- }),
64
- http.get(`${BASE_URL}/projects/*/badges/*`, ({ request }) => {
65
- const badgeId = extractIidFromUrl(request.url);
66
- if (badgeId === null) {
67
- return HttpResponse.json({ error: 'Invalid badge ID' }, { status: 400 });
68
- }
69
- if (badgeId === 1) {
70
- return HttpResponse.json(fixtures.mockBadge);
71
- }
72
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
73
- }),
74
- http.post(`${BASE_URL}/projects/*/badges`, async ({ request }) => {
75
- const body = await request.json();
76
- if (!body.link_url || !body.image_url) {
77
- return HttpResponse.json({ error: 'link_url and image_url are required' }, { status: 400 });
78
- }
79
- return HttpResponse.json({
80
- ...fixtures.mockBadge,
81
- id: 3,
82
- name: body.name || 'New Badge',
83
- link_url: body.link_url,
84
- image_url: body.image_url,
85
- }, { status: 201 });
86
- }),
87
- http.put(`${BASE_URL}/projects/*/badges/*`, async ({ request }) => {
88
- const badgeId = extractIidFromUrl(request.url);
89
- if (badgeId === null) {
90
- return HttpResponse.json({ error: 'Invalid badge ID' }, { status: 400 });
91
- }
92
- const body = await request.json();
93
- if (badgeId === 1) {
259
+ }),
260
+ http.post(`${baseUrl}/groups/*/badges`, async ({ request }) => {
261
+ const body = await request.json();
94
262
  return HttpResponse.json({
95
263
  ...fixtures.mockBadge,
96
- name: body.name || fixtures.mockBadge.name,
97
- link_url: body.link_url || fixtures.mockBadge.link_url,
98
- image_url: body.image_url || fixtures.mockBadge.image_url,
264
+ id: 4,
265
+ kind: 'group',
266
+ name: body.name || 'Group Badge',
267
+ }, { status: 201 });
268
+ }),
269
+ // Branches
270
+ http.get(`${baseUrl}/projects/*/repository/branches`, ({ request }) => {
271
+ const search = parseSearchParam(request);
272
+ if (search) {
273
+ return HttpResponse.json(fixtures.mockBranchesList.filter(b => b.name.includes(search)));
274
+ }
275
+ return HttpResponse.json(fixtures.mockBranchesList);
276
+ }),
277
+ http.get(`${baseUrl}/projects/*/repository/branches/*`, ({ request }) => {
278
+ const branch = decodeURIComponent(request.url.split('/').pop() || '');
279
+ const found = fixtures.mockBranchesList.find(b => b.name === branch);
280
+ if (found) {
281
+ return HttpResponse.json(found);
282
+ }
283
+ return HttpResponse.json({ message: 'Branch Not Found' }, { status: 404 });
284
+ }),
285
+ http.post(`${baseUrl}/projects/*/repository/branches`, async ({ request }) => {
286
+ const { branch, ref } = parseBranchParams(request);
287
+ if (!branch || !ref) {
288
+ return HttpResponse.json({ error: 'branch and ref parameters are required' }, { status: 400 });
289
+ }
290
+ return HttpResponse.json({
291
+ name: branch,
292
+ commit: fixtures.mockBranch.commit,
293
+ merged: false,
294
+ protected: false,
295
+ default: false,
296
+ }, { status: 201 });
297
+ }),
298
+ http.delete(`${baseUrl}/projects/*/repository/branches/*`, ({ request }) => {
299
+ const branch = decodeURIComponent(request.url.split('/').pop() || '');
300
+ if (branch !== 'main') {
301
+ return new HttpResponse(null, { status: 204 });
302
+ }
303
+ return HttpResponse.json({ message: 'Cannot delete default branch' }, { status: 403 });
304
+ }),
305
+ http.put(`${baseUrl}/projects/*/repository/branches/*/protect`, ({ request }) => {
306
+ const parts = request.url.split('/');
307
+ const branch = decodeURIComponent(parts[parts.length - 2]);
308
+ return HttpResponse.json({
309
+ ...fixtures.mockBranch,
310
+ name: branch,
311
+ protected: true,
99
312
  });
100
- }
101
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
102
- }),
103
- http.delete(`${BASE_URL}/projects/*/badges/*`, ({ request }) => {
104
- const badgeId = extractIidFromUrl(request.url);
105
- if (badgeId === null) {
106
- return HttpResponse.json({ error: 'Invalid badge ID' }, { status: 400 });
107
- }
108
- if (badgeId === 1) {
109
- return new HttpResponse(null, { status: 204 });
110
- }
111
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
112
- }),
113
- // Group Badges (similar structure)
114
- http.get(`${BASE_URL}/groups/*/badges`, () => {
115
- return HttpResponse.json(fixtures.mockBadgesList);
116
- }),
117
- http.post(`${BASE_URL}/groups/*/badges`, async ({ request }) => {
118
- const body = await request.json();
119
- return HttpResponse.json({
120
- ...fixtures.mockBadge,
121
- id: 4,
122
- kind: 'group',
123
- name: body.name || 'Group Badge',
124
- }, { status: 201 });
125
- }),
126
- // Branches
127
- http.get(`${BASE_URL}/projects/*/repository/branches`, ({ request }) => {
128
- const search = parseSearchParam(request);
129
- if (search) {
130
- return HttpResponse.json(fixtures.mockBranchesList.filter(b => b.name.includes(search)));
131
- }
132
- return HttpResponse.json(fixtures.mockBranchesList);
133
- }),
134
- http.get(`${BASE_URL}/projects/*/repository/branches/*`, ({ request }) => {
135
- const branch = decodeURIComponent(request.url.split('/').pop() || '');
136
- const found = fixtures.mockBranchesList.find(b => b.name === branch);
137
- if (found) {
138
- return HttpResponse.json(found);
139
- }
140
- return HttpResponse.json({ message: 'Branch Not Found' }, { status: 404 });
141
- }),
142
- http.post(`${BASE_URL}/projects/*/repository/branches`, async ({ request }) => {
143
- const { branch, ref } = parseBranchParams(request);
144
- if (!branch || !ref) {
145
- return HttpResponse.json({ error: 'branch and ref parameters are required' }, { status: 400 });
146
- }
147
- return HttpResponse.json({
148
- name: branch,
149
- commit: fixtures.mockBranch.commit,
150
- merged: false,
151
- protected: false,
152
- default: false,
153
- }, { status: 201 });
154
- }),
155
- http.delete(`${BASE_URL}/projects/*/repository/branches/*`, ({ request }) => {
156
- const branch = decodeURIComponent(request.url.split('/').pop() || '');
157
- if (branch !== 'main') {
313
+ }),
314
+ http.put(`${baseUrl}/projects/*/repository/branches/*/unprotect`, ({ request }) => {
315
+ const parts = request.url.split('/');
316
+ const branch = decodeURIComponent(parts[parts.length - 2]);
317
+ return HttpResponse.json({
318
+ ...fixtures.mockBranch,
319
+ name: branch,
320
+ protected: false,
321
+ });
322
+ }),
323
+ // Access Requests
324
+ http.get(`${baseUrl}/projects/*/access_requests`, () => {
325
+ return HttpResponse.json(fixtures.mockAccessRequestsList);
326
+ }),
327
+ http.get(`${baseUrl}/groups/*/access_requests`, () => {
328
+ return HttpResponse.json(fixtures.mockAccessRequestsList);
329
+ }),
330
+ http.post(`${baseUrl}/projects/*/access_requests`, () => {
331
+ return HttpResponse.json(fixtures.mockAccessRequest, { status: 201 });
332
+ }),
333
+ http.put(`${baseUrl}/projects/*/access_requests/*/approve`, async ({ request }) => {
334
+ const body = await request.json();
335
+ const parts = request.url.split('/');
336
+ const userId = parseInt(parts[parts.length - 2], 10);
337
+ return HttpResponse.json({
338
+ ...fixtures.mockAccessRequest,
339
+ id: userId,
340
+ access_level: body.access_level || 30,
341
+ });
342
+ }),
343
+ http.delete(`${baseUrl}/projects/*/access_requests/*`, () => {
158
344
  return new HttpResponse(null, { status: 204 });
159
- }
160
- return HttpResponse.json({ message: 'Cannot delete default branch' }, { status: 403 });
161
- }),
162
- http.put(`${BASE_URL}/projects/*/repository/branches/*/protect`, ({ request }) => {
163
- const parts = request.url.split('/');
164
- const branch = decodeURIComponent(parts[parts.length - 2]); // second-to-last part
165
- return HttpResponse.json({
166
- ...fixtures.mockBranch,
167
- name: branch,
168
- protected: true,
169
- });
170
- }),
171
- http.put(`${BASE_URL}/projects/*/repository/branches/*/unprotect`, ({ request }) => {
172
- const parts = request.url.split('/');
173
- const branch = decodeURIComponent(parts[parts.length - 2]); // second-to-last part
174
- return HttpResponse.json({
175
- ...fixtures.mockBranch,
176
- name: branch,
177
- protected: false,
178
- });
179
- }),
180
- // Access Requests
181
- http.get(`${BASE_URL}/projects/*/access_requests`, () => {
182
- return HttpResponse.json(fixtures.mockAccessRequestsList);
183
- }),
184
- http.get(`${BASE_URL}/groups/*/access_requests`, () => {
185
- return HttpResponse.json(fixtures.mockAccessRequestsList);
186
- }),
187
- http.post(`${BASE_URL}/projects/*/access_requests`, () => {
188
- return HttpResponse.json(fixtures.mockAccessRequest, { status: 201 });
189
- }),
190
- http.put(`${BASE_URL}/projects/*/access_requests/*/approve`, async ({ request }) => {
191
- const body = await request.json();
192
- const parts = request.url.split('/');
193
- const userId = parseInt(parts[parts.length - 2], 10); // second-to-last part
194
- return HttpResponse.json({
195
- ...fixtures.mockAccessRequest,
196
- id: userId,
197
- access_level: body.access_level || 30,
198
- });
199
- }),
200
- http.delete(`${BASE_URL}/projects/*/access_requests/*`, () => {
201
- return new HttpResponse(null, { status: 204 });
202
- }),
203
- // Jobs
204
- http.get(`${BASE_URL}/projects/*/jobs`, ({ request }) => {
205
- const scope = parseScopeParam(request);
206
- if (scope.length > 0 && scope.includes('failed')) {
207
- return HttpResponse.json(fixtures.mockJobsList.filter(j => j.status === 'failed'));
208
- }
209
- return HttpResponse.json(fixtures.mockJobsList);
210
- }),
211
- http.get(`${BASE_URL}/projects/*/jobs/*`, ({ request }) => {
212
- const jobId = extractIidFromUrl(request.url);
213
- if (jobId === null) {
214
- return HttpResponse.json({ error: 'Invalid job ID' }, { status: 400 });
215
- }
216
- if (jobId === 1234) {
217
- return HttpResponse.json(fixtures.mockJob);
218
- }
219
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
220
- }),
221
- http.post(`${BASE_URL}/projects/*/jobs/*/play`, ({ request }) => {
222
- const parts = request.url.split('/');
223
- const jobId = parseInt(parts[parts.length - 2], 10); // second-to-last part
224
- return HttpResponse.json({
225
- ...fixtures.mockJob,
226
- id: jobId,
227
- status: 'pending',
228
- });
229
- }),
230
- // Rate limiting simulation
231
- http.get(`${BASE_URL}/rate-limit-test`, () => {
232
- return HttpResponse.json({ message: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': '60' } });
233
- }),
234
- // Merge Request Notes (MUST be before generic merge_requests/* handlers)
235
- // Why order matters: MSW matches first handler that fits, more specific patterns must come first
236
- http.get(`${BASE_URL}/projects/*/merge_requests/*/notes`, ({ request }) => {
237
- // Try multiple parsing strategies for URL-encoded paths
238
- let mergeRequestIid = extractMrIidFromNotesUrl(request.url);
239
- if (mergeRequestIid === null) {
240
- // Try with URL-decoded path
241
- const decodedUrl = decodeURIComponent(request.url);
242
- mergeRequestIid = extractMrIidFromNotesUrl(decodedUrl);
243
- }
244
- if (mergeRequestIid === null) {
245
- // Try alternative pattern matching
246
- const altMatch = request.url.match(/merge_requests[\/%2F](\d+)[\/%2F]notes/);
247
- if (altMatch) {
248
- mergeRequestIid = parseInt(altMatch[1], 10);
249
- }
250
- }
251
- if (mergeRequestIid === null || isNaN(mergeRequestIid)) {
252
- return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
253
- }
254
- if (mergeRequestIid === 1) {
255
- return HttpResponse.json(fixtures.mockNotesList);
256
- }
257
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
258
- }),
259
- http.post(`${BASE_URL}/projects/*/merge_requests/*/notes`, async ({ request }) => {
260
- let mergeRequestIid = extractMrIidFromNotesUrl(request.url);
261
- if (mergeRequestIid === null) {
262
- const decodedUrl = decodeURIComponent(request.url);
263
- mergeRequestIid = extractMrIidFromNotesUrl(decodedUrl);
264
- }
265
- if (mergeRequestIid === null) {
266
- const altMatch = request.url.match(/merge_requests[\/%2F](\d+)[\/%2F]notes/);
267
- if (altMatch) {
268
- mergeRequestIid = parseInt(altMatch[1], 10);
269
- }
270
- }
271
- if (mergeRequestIid === null || isNaN(mergeRequestIid)) {
272
- return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
273
- }
274
- const body = await request.json();
275
- if (!body.body) {
276
- return HttpResponse.json({ error: 'body is required' }, { status: 400 });
277
- }
278
- const createdNote = {
279
- ...fixtures.mockNote,
280
- id: 3,
281
- body: body.body,
282
- confidential: body.confidential || false,
283
- created_at: new Date().toISOString(),
284
- };
285
- return HttpResponse.json(createdNote, { status: 201 });
286
- }),
287
- http.put(`${BASE_URL}/projects/*/merge_requests/*/notes/*`, async ({ request }) => {
288
- // Parse note ID from URL - handle both /notes/1 and /notes/1?params
289
- const urlWithoutQuery = request.url.split('?')[0];
290
- const urlParts = urlWithoutQuery.split('/notes/');
291
- if (urlParts.length < 2) {
292
- return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
293
- }
294
- const noteIdStr = urlParts[1].split('?')[0].split('/')[0];
295
- const noteId = parseInt(noteIdStr, 10);
296
- if (isNaN(noteId)) {
297
- return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
298
- }
299
- if (noteId === 1) {
345
+ }),
346
+ // Jobs
347
+ http.get(`${baseUrl}/projects/*/jobs`, ({ request }) => {
348
+ const scope = parseScopeParam(request);
349
+ if (scope.length > 0 && scope.includes('failed')) {
350
+ return HttpResponse.json(fixtures.mockJobsList.filter(j => j.status === 'failed'));
351
+ }
352
+ return HttpResponse.json(fixtures.mockJobsList);
353
+ }),
354
+ http.get(`${baseUrl}/projects/*/jobs/*`, ({ request }) => {
355
+ const jobId = extractIidFromUrl(request.url);
356
+ if (jobId === null) {
357
+ return HttpResponse.json({ error: 'Invalid job ID' }, { status: 400 });
358
+ }
359
+ if (jobId === 1234) {
360
+ return HttpResponse.json(fixtures.mockJob);
361
+ }
362
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
363
+ }),
364
+ http.post(`${baseUrl}/projects/*/jobs/*/play`, ({ request }) => {
365
+ const parts = request.url.split('/');
366
+ const jobId = parseInt(parts[parts.length - 2], 10);
367
+ return HttpResponse.json({
368
+ ...fixtures.mockJob,
369
+ id: jobId,
370
+ status: 'pending',
371
+ });
372
+ }),
373
+ // Rate limiting simulation
374
+ http.get(`${baseUrl}/rate-limit-test`, () => {
375
+ return HttpResponse.json({ message: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': '60' } });
376
+ }),
377
+ // Merge Request Notes (MUST be before generic merge_requests/* handlers)
378
+ http.get(`${baseUrl}/projects/*/merge_requests/*/notes`, ({ request }) => {
379
+ let mergeRequestIid = extractMrIidFromNotesUrl(request.url);
380
+ if (mergeRequestIid === null) {
381
+ const decodedUrl = decodeURIComponent(request.url);
382
+ mergeRequestIid = extractMrIidFromNotesUrl(decodedUrl);
383
+ }
384
+ if (mergeRequestIid === null) {
385
+ const altMatch = request.url.match(/merge_requests[\/%2F](\d+)[\/%2F]notes/);
386
+ if (altMatch) {
387
+ mergeRequestIid = parseInt(altMatch[1], 10);
388
+ }
389
+ }
390
+ if (mergeRequestIid === null || isNaN(mergeRequestIid)) {
391
+ return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
392
+ }
393
+ if (mergeRequestIid === 1) {
394
+ return HttpResponse.json(fixtures.mockNotesList);
395
+ }
396
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
397
+ }),
398
+ http.post(`${baseUrl}/projects/*/merge_requests/*/notes`, async ({ request }) => {
399
+ let mergeRequestIid = extractMrIidFromNotesUrl(request.url);
400
+ if (mergeRequestIid === null) {
401
+ const decodedUrl = decodeURIComponent(request.url);
402
+ mergeRequestIid = extractMrIidFromNotesUrl(decodedUrl);
403
+ }
404
+ if (mergeRequestIid === null) {
405
+ const altMatch = request.url.match(/merge_requests[\/%2F](\d+)[\/%2F]notes/);
406
+ if (altMatch) {
407
+ mergeRequestIid = parseInt(altMatch[1], 10);
408
+ }
409
+ }
410
+ if (mergeRequestIid === null || isNaN(mergeRequestIid)) {
411
+ return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
412
+ }
300
413
  const body = await request.json();
301
414
  if (!body.body) {
302
415
  return HttpResponse.json({ error: 'body is required' }, { status: 400 });
303
416
  }
304
- const updatedNote = {
417
+ const createdNote = {
305
418
  ...fixtures.mockNote,
306
- id: noteId,
419
+ id: 3,
307
420
  body: body.body,
308
- confidential: body.confidential !== undefined ? body.confidential : fixtures.mockNote.confidential,
309
- updated_at: new Date().toISOString(),
421
+ confidential: body.confidential || false,
422
+ created_at: new Date().toISOString(),
310
423
  };
311
- return HttpResponse.json(updatedNote, { status: 200 });
312
- }
313
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
314
- }),
315
- http.delete(`${BASE_URL}/projects/*/merge_requests/*/notes/*`, ({ request }) => {
316
- const urlParts = request.url.split('/notes/');
317
- if (urlParts.length < 2) {
318
- return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
319
- }
320
- const noteId = parseInt(urlParts[1], 10);
321
- if (isNaN(noteId)) {
322
- return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
323
- }
324
- if (noteId === 1) {
325
- return new HttpResponse(null, { status: 204 });
326
- }
327
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
328
- }),
329
- // Merge Requests (generic handlers after more specific /notes handlers)
330
- http.get(`${BASE_URL}/projects/*/merge_requests`, ({ request, params }) => {
331
- const { page } = parsePaginationParams(request);
332
- // Simple pagination
333
- if (page === 1) {
334
- return HttpResponse.json(fixtures.mockMergeRequestsList);
335
- }
336
- return HttpResponse.json([]);
337
- }),
338
- http.get(`${BASE_URL}/projects/*/merge_requests/*`, ({ request }) => {
339
- const mergeRequestIid = extractIidFromUrl(request.url);
340
- if (mergeRequestIid === null) {
341
- return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
342
- }
343
- if (mergeRequestIid === 1) {
344
- return HttpResponse.json(fixtures.mockMergeRequest);
345
- }
346
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
347
- }),
348
- http.post(`${BASE_URL}/projects/*/merge_requests`, async ({ request }) => {
349
- const body = await request.json();
350
- if (!body.source_branch || !body.target_branch || !body.title) {
351
- return HttpResponse.json({ error: 'source_branch, target_branch, and title are required' }, { status: 400 });
352
- }
353
- // Return created merge request
354
- const createdMR = {
355
- ...fixtures.mockMergeRequest,
356
- iid: 3,
357
- id: 3,
358
- title: body.title,
359
- source_branch: body.source_branch,
360
- target_branch: body.target_branch,
361
- description: body.description,
362
- web_url: 'https://gitlab.com/my-org/my-project/-/merge_requests/3',
363
- };
364
- return HttpResponse.json(createdMR, { status: 201 });
365
- }),
366
- http.put(`${BASE_URL}/projects/*/merge_requests/*`, async ({ request }) => {
367
- const mergeRequestIid = extractIidFromUrl(request.url);
368
- if (mergeRequestIid === null) {
369
- return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
370
- }
371
- if (mergeRequestIid === 1) {
424
+ return HttpResponse.json(createdNote, { status: 201 });
425
+ }),
426
+ http.put(`${baseUrl}/projects/*/merge_requests/*/notes/*`, async ({ request }) => {
427
+ const urlWithoutQuery = request.url.split('?')[0];
428
+ const urlParts = urlWithoutQuery.split('/notes/');
429
+ if (urlParts.length < 2) {
430
+ return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
431
+ }
432
+ const noteIdStr = urlParts[1].split('?')[0].split('/')[0];
433
+ const noteId = parseInt(noteIdStr, 10);
434
+ if (isNaN(noteId)) {
435
+ return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
436
+ }
437
+ if (noteId === 1) {
438
+ const body = await request.json();
439
+ if (!body.body) {
440
+ return HttpResponse.json({ error: 'body is required' }, { status: 400 });
441
+ }
442
+ const updatedNote = {
443
+ ...fixtures.mockNote,
444
+ id: noteId,
445
+ body: body.body,
446
+ confidential: body.confidential !== undefined ? body.confidential : fixtures.mockNote.confidential,
447
+ updated_at: new Date().toISOString(),
448
+ };
449
+ return HttpResponse.json(updatedNote, { status: 200 });
450
+ }
451
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
452
+ }),
453
+ http.delete(`${baseUrl}/projects/*/merge_requests/*/notes/*`, ({ request }) => {
454
+ const urlParts = request.url.split('/notes/');
455
+ if (urlParts.length < 2) {
456
+ return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
457
+ }
458
+ const noteId = parseInt(urlParts[1], 10);
459
+ if (isNaN(noteId)) {
460
+ return HttpResponse.json({ error: 'Invalid note ID' }, { status: 400 });
461
+ }
462
+ if (noteId === 1) {
463
+ return new HttpResponse(null, { status: 204 });
464
+ }
465
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
466
+ }),
467
+ // Merge Requests (generic handlers after more specific /notes handlers)
468
+ http.get(`${baseUrl}/projects/*/merge_requests`, ({ request }) => {
469
+ const { page } = parsePaginationParams(request);
470
+ if (page === 1) {
471
+ return HttpResponse.json(fixtures.mockMergeRequestsList);
472
+ }
473
+ return HttpResponse.json([]);
474
+ }),
475
+ http.get(`${baseUrl}/projects/*/merge_requests/*`, ({ request }) => {
476
+ const mergeRequestIid = extractIidFromUrl(request.url);
477
+ if (mergeRequestIid === null) {
478
+ return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
479
+ }
480
+ if (mergeRequestIid === 1) {
481
+ return HttpResponse.json(fixtures.mockMergeRequest);
482
+ }
483
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
484
+ }),
485
+ http.post(`${baseUrl}/projects/*/merge_requests`, async ({ request }) => {
372
486
  const body = await request.json();
373
- const updatedMR = {
487
+ if (!body.source_branch || !body.target_branch || !body.title) {
488
+ return HttpResponse.json({ error: 'source_branch, target_branch, and title are required' }, { status: 400 });
489
+ }
490
+ const createdMR = {
374
491
  ...fixtures.mockMergeRequest,
375
- title: body.title || fixtures.mockMergeRequest.title,
376
- description: body.description !== undefined ? body.description : fixtures.mockMergeRequest.description,
377
- state: body.state_event === 'close' ? 'closed' : fixtures.mockMergeRequest.state,
378
- updated_at: new Date().toISOString(),
492
+ iid: 3,
493
+ id: 3,
494
+ title: body.title,
495
+ source_branch: body.source_branch,
496
+ target_branch: body.target_branch,
497
+ description: body.description,
498
+ web_url: 'https://gitlab.com/my-org/my-project/-/merge_requests/3',
379
499
  };
380
- return HttpResponse.json(updatedMR, { status: 200 });
381
- }
382
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
383
- }),
384
- http.delete(`${BASE_URL}/projects/*/merge_requests/*`, ({ request }) => {
385
- // Check for forbidden project
386
- if (request.url.includes('/forbidden-project/')) {
387
- return HttpResponse.json({ message: 'Forbidden', error: 'You do not have permission to delete this merge request' }, { status: 403 });
388
- }
389
- const mergeRequestIid = extractIidFromUrl(request.url);
390
- if (mergeRequestIid === null) {
391
- return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
392
- }
393
- if (mergeRequestIid === 1) {
394
- return new HttpResponse(null, { status: 204 });
395
- }
396
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
397
- }),
398
- // Issues
399
- http.get(`${BASE_URL}/projects/*/issues`, ({ request }) => {
400
- const { page } = parsePaginationParams(request);
401
- // Simple pagination
402
- if (page === 1) {
403
- return HttpResponse.json(fixtures.mockIssuesList);
404
- }
405
- return HttpResponse.json([]);
406
- }),
407
- http.get(`${BASE_URL}/projects/*/issues/*`, ({ request }) => {
408
- const issueIid = extractIidFromUrl(request.url);
409
- if (issueIid === null) {
410
- return HttpResponse.json({ error: 'Invalid issue IID' }, { status: 400 });
411
- }
412
- if (issueIid === 1) {
413
- return HttpResponse.json(fixtures.mockIssue);
414
- }
415
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
416
- }),
417
- http.post(`${BASE_URL}/projects/*/issues`, async ({ request }) => {
418
- const body = await request.json();
419
- // Validate required fields
420
- if (!body.title) {
421
- return HttpResponse.json({ error: 'title is required' }, { status: 400 });
422
- }
423
- // Return created issue
424
- const createdIssue = {
425
- ...fixtures.mockIssue,
426
- iid: 3,
427
- id: 3,
428
- title: body.title,
429
- description: body.description || '',
430
- state: 'opened',
431
- web_url: 'https://gitlab.com/my-org/my-project/-/issues/3',
432
- created_at: new Date().toISOString(),
433
- };
434
- return HttpResponse.json(createdIssue, { status: 201 });
435
- }),
436
- http.delete(`${BASE_URL}/projects/*/issues/*`, ({ request }) => {
437
- // Check for forbidden project
438
- if (request.url.includes('/forbidden-project/')) {
439
- return HttpResponse.json({ message: 'Forbidden', error: 'You do not have permission to delete this issue' }, { status: 403 });
440
- }
441
- const issueIid = extractIidFromUrl(request.url);
442
- if (issueIid === null) {
443
- return HttpResponse.json({ error: 'Invalid issue IID' }, { status: 400 });
444
- }
445
- if (issueIid === 1) {
446
- return new HttpResponse(null, { status: 204 });
447
- }
448
- return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
449
- }),
450
- // Server error simulation
451
- http.get(`${BASE_URL}/server-error-test`, () => {
452
- return HttpResponse.json({ message: 'Internal Server Error' }, { status: 503 });
453
- }),
454
- ];
500
+ return HttpResponse.json(createdMR, { status: 201 });
501
+ }),
502
+ http.put(`${baseUrl}/projects/*/merge_requests/*`, async ({ request }) => {
503
+ const mergeRequestIid = extractIidFromUrl(request.url);
504
+ if (mergeRequestIid === null) {
505
+ return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
506
+ }
507
+ if (mergeRequestIid === 1) {
508
+ const body = await request.json();
509
+ const updatedMR = {
510
+ ...fixtures.mockMergeRequest,
511
+ title: body.title || fixtures.mockMergeRequest.title,
512
+ description: body.description !== undefined ? body.description : fixtures.mockMergeRequest.description,
513
+ state: body.state_event === 'close' ? 'closed' : fixtures.mockMergeRequest.state,
514
+ updated_at: new Date().toISOString(),
515
+ };
516
+ return HttpResponse.json(updatedMR, { status: 200 });
517
+ }
518
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
519
+ }),
520
+ http.delete(`${baseUrl}/projects/*/merge_requests/*`, ({ request }) => {
521
+ if (request.url.includes('/forbidden-project/')) {
522
+ return HttpResponse.json({ message: 'Forbidden', error: 'You do not have permission to delete this merge request' }, { status: 403 });
523
+ }
524
+ const mergeRequestIid = extractIidFromUrl(request.url);
525
+ if (mergeRequestIid === null) {
526
+ return HttpResponse.json({ error: 'Invalid merge request IID' }, { status: 400 });
527
+ }
528
+ if (mergeRequestIid === 1) {
529
+ return new HttpResponse(null, { status: 204 });
530
+ }
531
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
532
+ }),
533
+ // Issues
534
+ http.get(`${baseUrl}/projects/*/issues`, ({ request }) => {
535
+ const { page } = parsePaginationParams(request);
536
+ if (page === 1) {
537
+ return HttpResponse.json(fixtures.mockIssuesList);
538
+ }
539
+ return HttpResponse.json([]);
540
+ }),
541
+ http.get(`${baseUrl}/projects/*/issues/*`, ({ request }) => {
542
+ const issueIid = extractIidFromUrl(request.url);
543
+ if (issueIid === null) {
544
+ return HttpResponse.json({ error: 'Invalid issue IID' }, { status: 400 });
545
+ }
546
+ if (issueIid === 1) {
547
+ return HttpResponse.json(fixtures.mockIssue);
548
+ }
549
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
550
+ }),
551
+ http.post(`${baseUrl}/projects/*/issues`, async ({ request }) => {
552
+ const body = await request.json();
553
+ if (!body.title) {
554
+ return HttpResponse.json({ error: 'title is required' }, { status: 400 });
555
+ }
556
+ const createdIssue = {
557
+ ...fixtures.mockIssue,
558
+ iid: 3,
559
+ id: 3,
560
+ title: body.title,
561
+ description: body.description || '',
562
+ state: 'opened',
563
+ web_url: 'https://gitlab.com/my-org/my-project/-/issues/3',
564
+ created_at: new Date().toISOString(),
565
+ };
566
+ return HttpResponse.json(createdIssue, { status: 201 });
567
+ }),
568
+ http.delete(`${baseUrl}/projects/*/issues/*`, ({ request }) => {
569
+ if (request.url.includes('/forbidden-project/')) {
570
+ return HttpResponse.json({ message: 'Forbidden', error: 'You do not have permission to delete this issue' }, { status: 403 });
571
+ }
572
+ const issueIid = extractIidFromUrl(request.url);
573
+ if (issueIid === null) {
574
+ return HttpResponse.json({ error: 'Invalid issue IID' }, { status: 400 });
575
+ }
576
+ if (issueIid === 1) {
577
+ return new HttpResponse(null, { status: 204 });
578
+ }
579
+ return HttpResponse.json({ message: 'Not Found' }, { status: 404 });
580
+ }),
581
+ // Server error simulation
582
+ http.get(`${baseUrl}/server-error-test`, () => {
583
+ return HttpResponse.json({ message: 'Internal Server Error' }, { status: 503 });
584
+ }),
585
+ ];
586
+ }
455
587
  /**
456
- * Create and configure mock server
457
- *
458
- * Why setupServer: MSW's node integration for testing environments
588
+ * Create all handlers (GitLab API + OAuth) with configurable URLs
459
589
  */
590
+ export function createAllHandlers(gitlabBaseUrl = DEFAULT_BASE_URL, oauthConfig) {
591
+ const gitlabHandlers = createGitLabHandlers(gitlabBaseUrl);
592
+ const oauthHandlers = oauthConfig ? createOAuthHandlers(oauthConfig) : [];
593
+ return [...oauthHandlers, ...gitlabHandlers];
594
+ }
595
+ // Legacy exports for backward compatibility with existing unit tests
596
+ export const handlers = createGitLabHandlers(DEFAULT_BASE_URL);
460
597
  export const mockServer = setupServer(...handlers);
461
- /**
462
- * Helper: start server before tests
463
- */
464
598
  export function startMockServer() {
465
599
  mockServer.listen({ onUnhandledRequest: 'error' });
466
600
  }
467
- /**
468
- * Helper: reset handlers between tests
469
- *
470
- * Why: Prevents test pollution from runtime handler modifications
471
- */
472
601
  export function resetMockServer() {
473
602
  mockServer.resetHandlers();
474
603
  }
475
- /**
476
- * Helper: stop server after tests
477
- */
478
604
  export function stopMockServer() {
479
605
  mockServer.close();
480
606
  }
607
+ /**
608
+ * Create a new MSW server instance with custom handlers
609
+ */
610
+ export function createMockServer(gitlabBaseUrl = DEFAULT_BASE_URL, oauthConfig) {
611
+ const allHandlers = createAllHandlers(gitlabBaseUrl, oauthConfig);
612
+ return setupServer(...allHandlers);
613
+ }
481
614
  //# sourceMappingURL=mock-gitlab-server.js.map