claude-remote-cli 3.9.5 → 3.10.0

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.
@@ -0,0 +1,302 @@
1
+ import { test, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import express from 'express';
4
+ import { createIntegrationJiraRouter } from '../server/integration-jira.js';
5
+ // ─── Constants ────────────────────────────────────────────────────────────────
6
+ const FAKE_BASE_URL = 'https://fake-jira.atlassian.net';
7
+ const FAKE_TOKEN = 'test-api-token';
8
+ const FAKE_EMAIL = 'test@example.com';
9
+ // ─── Globals ──────────────────────────────────────────────────────────────────
10
+ let server;
11
+ let baseUrl;
12
+ /**
13
+ * The real Node.js fetch, saved before any test replaces globalThis.fetch.
14
+ * All HTTP calls from the test itself to the local Express server must use this.
15
+ */
16
+ let realFetch;
17
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
18
+ /**
19
+ * Builds a minimal Jira REST API issue shape for use in mock responses.
20
+ */
21
+ function makeJiraApiIssue(overrides) {
22
+ const { key = 'TEST-1', summary = 'Test issue', status = 'In Progress', priority = 'Medium', storyPoints = null, sprint = null, assignee = 'Jane Doe', updated = '2026-03-21T00:00:00.000+0000', } = overrides;
23
+ return {
24
+ key,
25
+ self: `${FAKE_BASE_URL}/rest/api/3/issue/${key}`,
26
+ fields: {
27
+ summary,
28
+ status: { name: status },
29
+ priority: priority !== null ? { name: priority } : null,
30
+ customfield_10016: storyPoints,
31
+ customfield_10020: sprint !== null ? [{ name: sprint }] : null,
32
+ assignee: assignee !== null ? { displayName: assignee } : null,
33
+ updated,
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * Builds a real Response with a given status and JSON body.
39
+ */
40
+ function makeJsonResponse(body, status = 200) {
41
+ return new Response(JSON.stringify(body), {
42
+ status,
43
+ headers: { 'Content-Type': 'application/json' },
44
+ });
45
+ }
46
+ // ─── Server lifecycle ─────────────────────────────────────────────────────────
47
+ /**
48
+ * Starts a fresh Express server with a new Jira router instance (and therefore
49
+ * a fresh in-memory cache). Call this AFTER setting globalThis.fetch so the
50
+ * router uses the mock when handling requests.
51
+ */
52
+ function startServer() {
53
+ return new Promise((resolve) => {
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use('/integration-jira', createIntegrationJiraRouter({ configPath: '' }));
57
+ server = app.listen(0, '127.0.0.1', () => {
58
+ const addr = server.address();
59
+ if (typeof addr === 'object' && addr) {
60
+ baseUrl = `http://127.0.0.1:${addr.port}`;
61
+ }
62
+ resolve();
63
+ });
64
+ });
65
+ }
66
+ function stopServer() {
67
+ return new Promise((resolve) => {
68
+ if (server)
69
+ server.close(() => resolve());
70
+ else
71
+ resolve();
72
+ });
73
+ }
74
+ // ─── Env var helpers ──────────────────────────────────────────────────────────
75
+ function setEnvVars() {
76
+ process.env.JIRA_API_TOKEN = FAKE_TOKEN;
77
+ process.env.JIRA_EMAIL = FAKE_EMAIL;
78
+ process.env.JIRA_BASE_URL = FAKE_BASE_URL;
79
+ }
80
+ function clearEnvVars() {
81
+ delete process.env.JIRA_API_TOKEN;
82
+ delete process.env.JIRA_EMAIL;
83
+ delete process.env.JIRA_BASE_URL;
84
+ }
85
+ // ─── Suite setup / teardown ───────────────────────────────────────────────────
86
+ before(() => {
87
+ realFetch = globalThis.fetch;
88
+ clearEnvVars();
89
+ });
90
+ after(async () => {
91
+ globalThis.fetch = realFetch;
92
+ clearEnvVars();
93
+ await stopServer();
94
+ });
95
+ // ─── Tests ────────────────────────────────────────────────────────────────────
96
+ test('GET /configured returns true when all env vars are set', async () => {
97
+ setEnvVars();
98
+ globalThis.fetch = realFetch;
99
+ await stopServer();
100
+ await startServer();
101
+ const res = await realFetch(`${baseUrl}/integration-jira/configured`);
102
+ assert.equal(res.status, 200);
103
+ const body = (await res.json());
104
+ assert.equal(body.configured, true);
105
+ });
106
+ test('GET /configured returns false when any env var is missing', async () => {
107
+ clearEnvVars();
108
+ // Set only two of the three required vars; JIRA_BASE_URL intentionally omitted
109
+ process.env.JIRA_API_TOKEN = FAKE_TOKEN;
110
+ process.env.JIRA_EMAIL = FAKE_EMAIL;
111
+ globalThis.fetch = realFetch;
112
+ await stopServer();
113
+ await startServer();
114
+ const res = await realFetch(`${baseUrl}/integration-jira/configured`);
115
+ assert.equal(res.status, 200);
116
+ const body = (await res.json());
117
+ assert.equal(body.configured, false);
118
+ clearEnvVars();
119
+ });
120
+ test('GET /issues returns mapped JiraIssue[] from mocked Jira search response', async () => {
121
+ setEnvVars();
122
+ const mockSearchPayload = {
123
+ issues: [
124
+ makeJiraApiIssue({
125
+ key: 'PROJ-42',
126
+ summary: 'Fix the login bug',
127
+ status: 'In Progress',
128
+ priority: 'High',
129
+ storyPoints: 3,
130
+ sprint: 'Sprint 5',
131
+ assignee: 'Alice',
132
+ updated: '2026-03-21T12:00:00.000+0000',
133
+ }),
134
+ makeJiraApiIssue({
135
+ key: 'PROJ-10',
136
+ summary: 'Update docs',
137
+ status: 'To Do',
138
+ priority: null,
139
+ storyPoints: null,
140
+ sprint: null,
141
+ assignee: null,
142
+ updated: '2026-03-20T08:00:00.000+0000',
143
+ }),
144
+ ],
145
+ };
146
+ globalThis.fetch = async (input) => {
147
+ const url = typeof input === 'string' ? input : input.toString();
148
+ if (url.includes('/rest/api/3/search')) {
149
+ return makeJsonResponse(mockSearchPayload);
150
+ }
151
+ return makeJsonResponse({ error: 'unexpected url' }, 500);
152
+ };
153
+ await stopServer();
154
+ await startServer();
155
+ const res = await realFetch(`${baseUrl}/integration-jira/issues`);
156
+ assert.equal(res.status, 200);
157
+ const data = (await res.json());
158
+ assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
159
+ assert.equal(data.issues.length, 2);
160
+ const issue42 = data.issues.find((i) => i.key === 'PROJ-42');
161
+ assert.ok(issue42, 'Should contain PROJ-42');
162
+ assert.equal(issue42.title, 'Fix the login bug');
163
+ assert.equal(issue42.status, 'In Progress');
164
+ assert.equal(issue42.priority, 'High');
165
+ assert.equal(issue42.storyPoints, 3);
166
+ assert.equal(issue42.sprint, 'Sprint 5');
167
+ assert.equal(issue42.assignee, 'Alice');
168
+ assert.equal(issue42.url, `${FAKE_BASE_URL}/browse/PROJ-42`);
169
+ assert.equal(issue42.projectKey, 'PROJ');
170
+ const issue10 = data.issues.find((i) => i.key === 'PROJ-10');
171
+ assert.ok(issue10, 'Should contain PROJ-10');
172
+ assert.equal(issue10.priority, null);
173
+ assert.equal(issue10.storyPoints, null);
174
+ assert.equal(issue10.sprint, null);
175
+ assert.equal(issue10.assignee, null);
176
+ // Verify sorted descending by updatedAt — PROJ-42 (newer) should come first
177
+ assert.equal(data.issues[0]?.key, 'PROJ-42');
178
+ assert.equal(data.issues[1]?.key, 'PROJ-10');
179
+ });
180
+ test('GET /issues caches results within TTL — fetch called only once for two requests', async () => {
181
+ setEnvVars();
182
+ let fetchCallCount = 0;
183
+ const mockPayload = {
184
+ issues: [makeJiraApiIssue({ key: 'CACHE-1', summary: 'Cached issue' })],
185
+ };
186
+ globalThis.fetch = async (input) => {
187
+ const url = typeof input === 'string' ? input : input.toString();
188
+ if (url.includes('/rest/api/3/search')) {
189
+ fetchCallCount++;
190
+ return makeJsonResponse(mockPayload);
191
+ }
192
+ return makeJsonResponse({}, 500);
193
+ };
194
+ await stopServer();
195
+ await startServer();
196
+ // First request — populates cache
197
+ const firstRes = await realFetch(`${baseUrl}/integration-jira/issues`);
198
+ const first = (await firstRes.json());
199
+ assert.equal(first.error, undefined, `Unexpected error: ${first.error}`);
200
+ assert.equal(first.issues.length, 1);
201
+ assert.equal(fetchCallCount, 1, 'fetch should be called once on first request');
202
+ // Second request — should be served from cache, no additional fetch
203
+ const secondRes = await realFetch(`${baseUrl}/integration-jira/issues`);
204
+ const second = (await secondRes.json());
205
+ assert.equal(second.error, undefined, `Unexpected error: ${second.error}`);
206
+ assert.equal(second.issues.length, 1);
207
+ assert.equal(fetchCallCount, 1, 'fetch should not be called again within TTL (cache hit)');
208
+ });
209
+ test('GET /issues returns jira_not_configured when env vars missing', async () => {
210
+ clearEnvVars();
211
+ // fetch should never be called — use a mock that throws to verify early return
212
+ globalThis.fetch = async () => {
213
+ throw new Error('fetch should not be called when not configured');
214
+ };
215
+ await stopServer();
216
+ await startServer();
217
+ const res = await realFetch(`${baseUrl}/integration-jira/issues`);
218
+ assert.equal(res.status, 200);
219
+ const data = (await res.json());
220
+ assert.equal(data.error, 'jira_not_configured');
221
+ assert.equal(data.issues.length, 0);
222
+ });
223
+ test('GET /statuses?projectKey=TEST returns deduplicated statuses from Jira project API', async () => {
224
+ setEnvVars();
225
+ // Jira returns one entry per issue type; statuses may overlap across types
226
+ const mockProjectStatuses = [
227
+ {
228
+ statuses: [
229
+ { id: '1', name: 'To Do' },
230
+ { id: '2', name: 'In Progress' },
231
+ ],
232
+ },
233
+ {
234
+ statuses: [
235
+ { id: '2', name: 'In Progress' }, // duplicate — should be filtered
236
+ { id: '3', name: 'Done' },
237
+ ],
238
+ },
239
+ ];
240
+ globalThis.fetch = async (input) => {
241
+ const url = typeof input === 'string' ? input : input.toString();
242
+ if (url.includes('/rest/api/3/project/TEST/statuses')) {
243
+ return makeJsonResponse(mockProjectStatuses);
244
+ }
245
+ return makeJsonResponse({}, 500);
246
+ };
247
+ await stopServer();
248
+ await startServer();
249
+ const res = await realFetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST`);
250
+ assert.equal(res.status, 200);
251
+ const data = (await res.json());
252
+ assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
253
+ assert.equal(data.statuses.length, 3, 'Should deduplicate statuses by id');
254
+ const ids = data.statuses.map((s) => s.id);
255
+ assert.deepEqual(ids, ['1', '2', '3']);
256
+ const names = data.statuses.map((s) => s.name);
257
+ assert.deepEqual(names, ['To Do', 'In Progress', 'Done']);
258
+ });
259
+ test('GET /issues returns jira_auth_failed on 401 from Jira API', async () => {
260
+ setEnvVars();
261
+ globalThis.fetch = async (input) => {
262
+ const url = typeof input === 'string' ? input : input.toString();
263
+ if (url.includes('/rest/api/3/search')) {
264
+ return makeJsonResponse({ message: 'Unauthorized' }, 401);
265
+ }
266
+ return makeJsonResponse({}, 500);
267
+ };
268
+ await stopServer();
269
+ await startServer();
270
+ const res = await realFetch(`${baseUrl}/integration-jira/issues`);
271
+ assert.equal(res.status, 200);
272
+ const data = (await res.json());
273
+ assert.equal(data.error, 'jira_auth_failed');
274
+ assert.equal(data.issues.length, 0);
275
+ });
276
+ test('GET /statuses returns jira_auth_failed on 401 from Jira project API', async () => {
277
+ setEnvVars();
278
+ globalThis.fetch = async (input) => {
279
+ const url = typeof input === 'string' ? input : input.toString();
280
+ if (url.includes('/rest/api/3/project')) {
281
+ return makeJsonResponse({ message: 'Unauthorized' }, 401);
282
+ }
283
+ return makeJsonResponse({}, 500);
284
+ };
285
+ await stopServer();
286
+ await startServer();
287
+ const res = await realFetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST`);
288
+ assert.equal(res.status, 200);
289
+ const data = (await res.json());
290
+ assert.equal(data.error, 'jira_auth_failed');
291
+ assert.equal(data.statuses.length, 0);
292
+ });
293
+ test('GET /statuses returns 400 when projectKey query param is missing', async () => {
294
+ setEnvVars();
295
+ globalThis.fetch = realFetch;
296
+ await stopServer();
297
+ await startServer();
298
+ const res = await realFetch(`${baseUrl}/integration-jira/statuses`);
299
+ assert.equal(res.status, 400);
300
+ const data = (await res.json());
301
+ assert.equal(data.error, 'missing_project_key');
302
+ });
@@ -0,0 +1,293 @@
1
+ import { test, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import express from 'express';
4
+ import { createIntegrationLinearRouter } from '../server/integration-linear.js';
5
+ // ─── State ───────────────────────────────────────────────────────────────────
6
+ let server;
7
+ let baseUrl;
8
+ // Saved before any mock replaces globalThis.fetch so test HTTP calls
9
+ // to the local Express server always reach it even when fetch is mocked.
10
+ let httpFetch;
11
+ let originalApiKey;
12
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
13
+ /** Builds a single Linear issue node as returned by the GraphQL API. */
14
+ function makeIssueNode(overrides = {}) {
15
+ return {
16
+ id: overrides.id ?? 'issue-1',
17
+ identifier: overrides.identifier ?? 'ENG-1',
18
+ title: overrides.title ?? 'Test Issue',
19
+ url: overrides.url ?? 'https://linear.app/team/issue/ENG-1',
20
+ state: overrides.stateName != null ? { name: overrides.stateName } : { name: 'In Progress' },
21
+ priority: overrides.priority ?? 2,
22
+ priorityLabel: overrides.priorityLabel ?? 'Medium',
23
+ cycle: (overrides.cycle !== undefined)
24
+ ? (overrides.cycle !== null ? { name: overrides.cycle } : null)
25
+ : null,
26
+ estimate: overrides.estimate ?? null,
27
+ assignee: overrides.assigneeName != null ? { name: overrides.assigneeName } : null,
28
+ updatedAt: overrides.updatedAt ?? '2026-03-21T00:00:00Z',
29
+ team: overrides.teamId != null ? { id: overrides.teamId } : { id: 'team-abc' },
30
+ };
31
+ }
32
+ /** Builds a GraphQL issues response envelope. */
33
+ function makeIssuesGqlResponse(nodes) {
34
+ return {
35
+ data: {
36
+ viewer: {
37
+ assignedIssues: {
38
+ nodes,
39
+ },
40
+ },
41
+ },
42
+ };
43
+ }
44
+ /** Builds a GraphQL workflow states response envelope. */
45
+ function makeStatesGqlResponse(nodes) {
46
+ return {
47
+ data: {
48
+ workflowStates: {
49
+ nodes,
50
+ },
51
+ },
52
+ };
53
+ }
54
+ /**
55
+ * Returns a function suitable for replacing globalThis.fetch.
56
+ * The returned mock always resolves with a minimal Response-shaped object.
57
+ */
58
+ function makeMockFetch(gqlBody, opts = {}) {
59
+ const status = opts.status ?? 200;
60
+ const ok = opts.ok !== undefined ? opts.ok : (status >= 200 && status < 300);
61
+ return (async () => ({
62
+ ok,
63
+ status,
64
+ json: async () => gqlBody,
65
+ }));
66
+ }
67
+ function startServer() {
68
+ return new Promise((resolve) => {
69
+ const app = express();
70
+ app.use(express.json());
71
+ // configPath is unused by the Linear router at runtime; pass a dummy.
72
+ app.use('/integration-linear', createIntegrationLinearRouter({ configPath: '/dev/null' }));
73
+ server = app.listen(0, '127.0.0.1', () => {
74
+ const addr = server.address();
75
+ if (typeof addr === 'object' && addr) {
76
+ baseUrl = `http://127.0.0.1:${addr.port}`;
77
+ }
78
+ resolve();
79
+ });
80
+ });
81
+ }
82
+ function stopServer() {
83
+ return new Promise((resolve) => {
84
+ if (server)
85
+ server.close(() => resolve());
86
+ else
87
+ resolve();
88
+ });
89
+ }
90
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
91
+ // Each test gets a fresh server instance so the module-level issuesCache starts
92
+ // at null. This is the only reliable way to clear cache state without exposing
93
+ // internals, since issuesCache lives in the router closure.
94
+ beforeEach(async () => {
95
+ await startServer();
96
+ // Capture real fetch AFTER server starts (so baseUrl is set) but BEFORE any
97
+ // test mock replaces it. Used for all test-to-server HTTP calls.
98
+ httpFetch = globalThis.fetch;
99
+ originalApiKey = process.env['LINEAR_API_KEY'];
100
+ });
101
+ afterEach(async () => {
102
+ await stopServer();
103
+ globalThis.fetch = httpFetch;
104
+ if (originalApiKey === undefined) {
105
+ delete process.env['LINEAR_API_KEY'];
106
+ }
107
+ else {
108
+ process.env['LINEAR_API_KEY'] = originalApiKey;
109
+ }
110
+ });
111
+ // ─── GET /configured ─────────────────────────────────────────────────────────
112
+ test('GET /configured — returns { configured: true } when LINEAR_API_KEY is set', async () => {
113
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
114
+ const res = await httpFetch(`${baseUrl}/integration-linear/configured`);
115
+ assert.equal(res.ok, true);
116
+ const body = await res.json();
117
+ assert.deepEqual(body, { configured: true });
118
+ });
119
+ test('GET /configured — returns { configured: false } when LINEAR_API_KEY is not set', async () => {
120
+ delete process.env['LINEAR_API_KEY'];
121
+ const res = await httpFetch(`${baseUrl}/integration-linear/configured`);
122
+ assert.equal(res.ok, true);
123
+ const body = await res.json();
124
+ assert.deepEqual(body, { configured: false });
125
+ });
126
+ // ─── GET /issues ──────────────────────────────────────────────────────────────
127
+ test('GET /issues — returns linear_not_configured error when API key is missing', async () => {
128
+ delete process.env['LINEAR_API_KEY'];
129
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
130
+ assert.equal(res.ok, true);
131
+ const body = await res.json();
132
+ assert.equal(body.error, 'linear_not_configured');
133
+ assert.deepEqual(body.issues, []);
134
+ });
135
+ test('GET /issues — returns mapped LinearIssue[] from mocked GraphQL response', async () => {
136
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
137
+ const nodes = [
138
+ makeIssueNode({
139
+ id: 'issue-abc',
140
+ identifier: 'ENG-42',
141
+ title: 'Build something',
142
+ url: 'https://linear.app/team/issue/ENG-42',
143
+ stateName: 'In Progress',
144
+ priority: 1,
145
+ priorityLabel: 'Urgent',
146
+ cycle: 'Sprint 5',
147
+ estimate: 3,
148
+ assigneeName: 'Alice',
149
+ updatedAt: '2026-03-21T12:00:00Z',
150
+ teamId: 'team-xyz',
151
+ }),
152
+ ];
153
+ globalThis.fetch = makeMockFetch(makeIssuesGqlResponse(nodes));
154
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
155
+ assert.equal(res.ok, true);
156
+ const body = await res.json();
157
+ assert.equal(body.error, undefined, `Unexpected error: ${body.error}`);
158
+ assert.equal(body.issues.length, 1);
159
+ const issue = body.issues[0];
160
+ assert.equal(issue.id, 'issue-abc');
161
+ assert.equal(issue.identifier, 'ENG-42');
162
+ assert.equal(issue.title, 'Build something');
163
+ assert.equal(issue.url, 'https://linear.app/team/issue/ENG-42');
164
+ assert.equal(issue.state, 'In Progress');
165
+ assert.equal(issue.priority, 1);
166
+ assert.equal(issue.priorityLabel, 'Urgent');
167
+ assert.equal(issue.cycle, 'Sprint 5');
168
+ assert.equal(issue.estimate, 3);
169
+ assert.equal(issue.assignee, 'Alice');
170
+ assert.equal(issue.updatedAt, '2026-03-21T12:00:00Z');
171
+ assert.equal(issue.teamId, 'team-xyz');
172
+ });
173
+ test('GET /issues — caches results within TTL (fetch called only once for two requests)', async () => {
174
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
175
+ let fetchCallCount = 0;
176
+ const nodes = [makeIssueNode({ id: 'cached-issue', identifier: 'ENG-99' })];
177
+ const mockBody = makeIssuesGqlResponse(nodes);
178
+ globalThis.fetch = (async () => {
179
+ fetchCallCount++;
180
+ return { ok: true, status: 200, json: async () => mockBody };
181
+ });
182
+ // First request — populates cache
183
+ const first = await httpFetch(`${baseUrl}/integration-linear/issues`);
184
+ const firstBody = await first.json();
185
+ assert.equal(firstBody.error, undefined, `Unexpected error on first request: ${firstBody.error}`);
186
+ assert.equal(firstBody.issues.length, 1);
187
+ assert.equal(fetchCallCount, 1, 'fetch should be called once on the first request');
188
+ // Second request — should be served from cache, no additional fetch calls
189
+ const second = await httpFetch(`${baseUrl}/integration-linear/issues`);
190
+ const secondBody = await second.json();
191
+ assert.equal(secondBody.error, undefined, `Unexpected error on second request: ${secondBody.error}`);
192
+ assert.equal(secondBody.issues.length, 1);
193
+ assert.equal(fetchCallCount, 1, 'fetch should not be called again within TTL (cache hit)');
194
+ });
195
+ // ─── GET /states ──────────────────────────────────────────────────────────────
196
+ test('GET /states — returns workflow states from mocked GraphQL response', async () => {
197
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
198
+ const stateNodes = [
199
+ { id: 'state-1', name: 'Backlog' },
200
+ { id: 'state-2', name: 'In Progress' },
201
+ { id: 'state-3', name: 'Done' },
202
+ ];
203
+ globalThis.fetch = makeMockFetch(makeStatesGqlResponse(stateNodes));
204
+ const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
205
+ assert.equal(res.ok, true);
206
+ const body = await res.json();
207
+ assert.equal(body.error, undefined, `Unexpected error: ${body.error}`);
208
+ assert.equal(body.states.length, 3);
209
+ assert.deepEqual(body.states, stateNodes);
210
+ });
211
+ test('GET /states — returns linear_not_configured error when API key is missing', async () => {
212
+ delete process.env['LINEAR_API_KEY'];
213
+ const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
214
+ assert.equal(res.ok, true);
215
+ const body = await res.json();
216
+ assert.equal(body.error, 'linear_not_configured');
217
+ assert.deepEqual(body.states, []);
218
+ });
219
+ test('GET /states — returns 400 missing_team_id when teamId query param is absent', async () => {
220
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
221
+ const res = await httpFetch(`${baseUrl}/integration-linear/states`);
222
+ assert.equal(res.status, 400);
223
+ const body = await res.json();
224
+ assert.equal(body.error, 'missing_team_id');
225
+ });
226
+ // ─── Error handling ───────────────────────────────────────────────────────────
227
+ test('auth failure (HTTP 401) returns linear_auth_failed for /issues', async () => {
228
+ process.env['LINEAR_API_KEY'] = 'lin_bad_key';
229
+ globalThis.fetch = makeMockFetch({}, { status: 401, ok: false });
230
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
231
+ assert.equal(res.ok, true);
232
+ const body = await res.json();
233
+ assert.equal(body.error, 'linear_auth_failed');
234
+ assert.deepEqual(body.issues, []);
235
+ });
236
+ test('auth failure (HTTP 403) returns linear_auth_failed for /issues', async () => {
237
+ process.env['LINEAR_API_KEY'] = 'lin_bad_key';
238
+ globalThis.fetch = makeMockFetch({}, { status: 403, ok: false });
239
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
240
+ assert.equal(res.ok, true);
241
+ const body = await res.json();
242
+ assert.equal(body.error, 'linear_auth_failed');
243
+ assert.deepEqual(body.issues, []);
244
+ });
245
+ test('non-ok response (HTTP 500) returns linear_fetch_failed for /issues', async () => {
246
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
247
+ globalThis.fetch = makeMockFetch({}, { status: 500, ok: false });
248
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
249
+ assert.equal(res.ok, true);
250
+ const body = await res.json();
251
+ assert.equal(body.error, 'linear_fetch_failed');
252
+ assert.deepEqual(body.issues, []);
253
+ });
254
+ test('GraphQL-level authentication error returns linear_auth_failed', async () => {
255
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
256
+ const gqlAuthError = {
257
+ errors: [{ extensions: { type: 'authentication' } }],
258
+ data: null,
259
+ };
260
+ globalThis.fetch = makeMockFetch(gqlAuthError);
261
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
262
+ assert.equal(res.ok, true);
263
+ const body = await res.json();
264
+ assert.equal(body.error, 'linear_auth_failed');
265
+ assert.deepEqual(body.issues, []);
266
+ });
267
+ test('network error (fetch throws) returns linear_fetch_failed for /issues', async () => {
268
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
269
+ globalThis.fetch = (async () => { throw new Error('Network failure'); });
270
+ const res = await httpFetch(`${baseUrl}/integration-linear/issues`);
271
+ assert.equal(res.ok, true);
272
+ const body = await res.json();
273
+ assert.equal(body.error, 'linear_fetch_failed');
274
+ assert.deepEqual(body.issues, []);
275
+ });
276
+ test('auth failure (HTTP 401) returns linear_auth_failed for /states', async () => {
277
+ process.env['LINEAR_API_KEY'] = 'lin_bad_key';
278
+ globalThis.fetch = makeMockFetch({}, { status: 401, ok: false });
279
+ const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
280
+ assert.equal(res.ok, true);
281
+ const body = await res.json();
282
+ assert.equal(body.error, 'linear_auth_failed');
283
+ assert.deepEqual(body.states, []);
284
+ });
285
+ test('non-ok response (HTTP 500) returns linear_fetch_failed for /states', async () => {
286
+ process.env['LINEAR_API_KEY'] = 'lin_test_key';
287
+ globalThis.fetch = makeMockFetch({}, { status: 500, ok: false });
288
+ const res = await httpFetch(`${baseUrl}/integration-linear/states?teamId=team-abc`);
289
+ assert.equal(res.ok, true);
290
+ const body = await res.json();
291
+ assert.equal(body.error, 'linear_fetch_failed');
292
+ assert.deepEqual(body.states, []);
293
+ });