@hellcoder/companion 0.96.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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,927 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
2
+
3
+ // ─── Mock linear-connections ────────────────────────────────────────────────
4
+ // Each function is declared as a vi.fn() so we can control return values per test.
5
+ const mockListConnections = vi.fn(() => [] as any[]);
6
+ const mockGetConnection = vi.fn((_id: string) => null as any);
7
+ const mockCreateConnection = vi.fn((data: { name: string; apiKey: string }) => ({
8
+ id: "new-conn-id",
9
+ name: data.name,
10
+ apiKey: data.apiKey,
11
+ workspaceName: "",
12
+ workspaceId: "",
13
+ viewerName: "",
14
+ viewerEmail: "",
15
+ connected: false,
16
+ autoTransition: false,
17
+ autoTransitionStateId: "",
18
+ autoTransitionStateName: "",
19
+ archiveTransition: false,
20
+ archiveTransitionStateId: "",
21
+ archiveTransitionStateName: "",
22
+ createdAt: 1000,
23
+ updatedAt: 1000,
24
+ }));
25
+ const mockUpdateConnection = vi.fn((_id: string, _patch: any) => null as any);
26
+ const mockDeleteConnection = vi.fn((_id: string) => false);
27
+
28
+ vi.mock("../linear-connections.js", () => ({
29
+ listConnections: () => mockListConnections(),
30
+ getConnection: (id: string) => mockGetConnection(id),
31
+ createConnection: (data: any) => mockCreateConnection(data),
32
+ updateConnection: (id: string, patch: any) => mockUpdateConnection(id, patch),
33
+ deleteConnection: (id: string) => mockDeleteConnection(id),
34
+ }));
35
+
36
+ // ─── Mock linear-cache ──────────────────────────────────────────────────────
37
+ // The routes use linearCache.invalidate() on update/delete; we mock it as a no-op spy.
38
+ vi.mock("../linear-cache.js", () => ({
39
+ linearCache: {
40
+ getOrFetch: vi.fn(async (_key: string, _ttl: number, fetcher: () => Promise<unknown>) => fetcher()),
41
+ invalidate: vi.fn(),
42
+ clear: vi.fn(),
43
+ },
44
+ }));
45
+
46
+ // ─── Imports (after mocks are declared) ─────────────────────────────────────
47
+ import { Hono } from "hono";
48
+ import { linearCache } from "../linear-cache.js";
49
+ import { registerLinearConnectionRoutes } from "./linear-connection-routes.js";
50
+
51
+ // ─── Test setup ─────────────────────────────────────────────────────────────
52
+
53
+ let app: Hono;
54
+
55
+ // Save original global fetch so we can restore it after each test.
56
+ const originalFetch = globalThis.fetch;
57
+
58
+ /** Helper to mock globalThis.fetch without TS errors about missing properties */
59
+ function mockFetch() {
60
+ const fn = vi.fn();
61
+ globalThis.fetch = fn as any;
62
+ return fn;
63
+ }
64
+
65
+ /** Build a successful Linear GraphQL response (for verification calls). */
66
+ function linearVerifyOk(viewer: Record<string, unknown>, org: Record<string, unknown>) {
67
+ return new Response(
68
+ JSON.stringify({
69
+ data: {
70
+ viewer,
71
+ organization: org,
72
+ },
73
+ }),
74
+ { status: 200, headers: { "Content-Type": "application/json" } },
75
+ );
76
+ }
77
+
78
+ /** Build a Linear GraphQL error response. */
79
+ function linearError(message: string, status = 200) {
80
+ return new Response(
81
+ JSON.stringify({ errors: [{ message }] }),
82
+ { status, headers: { "Content-Type": "application/json" } },
83
+ );
84
+ }
85
+
86
+ /** Build a non-ok HTTP response. */
87
+ function linearHttpError(statusText = "Internal Server Error", status = 500) {
88
+ return new Response(JSON.stringify({}), {
89
+ status,
90
+ statusText,
91
+ headers: { "Content-Type": "application/json" },
92
+ });
93
+ }
94
+
95
+ /** Create a full connection object for use in mocks. */
96
+ function makeConnection(overrides: Record<string, unknown> = {}) {
97
+ return {
98
+ id: "conn-1",
99
+ name: "My Workspace",
100
+ apiKey: "lin_api_testapikey1234",
101
+ workspaceName: "TestOrg",
102
+ workspaceId: "org-1",
103
+ viewerName: "Test User",
104
+ viewerEmail: "test@example.com",
105
+ connected: true,
106
+ autoTransition: false,
107
+ autoTransitionStateId: "",
108
+ autoTransitionStateName: "",
109
+ archiveTransition: false,
110
+ archiveTransitionStateId: "",
111
+ archiveTransitionStateName: "",
112
+ createdAt: 1000,
113
+ updatedAt: 1000,
114
+ ...overrides,
115
+ };
116
+ }
117
+
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+
121
+ // Restore global fetch to prevent leaks between tests
122
+ globalThis.fetch = originalFetch;
123
+
124
+ // Reset mock defaults
125
+ mockListConnections.mockReturnValue([]);
126
+ mockGetConnection.mockReturnValue(null);
127
+ mockDeleteConnection.mockReturnValue(false);
128
+ mockUpdateConnection.mockReturnValue(null);
129
+
130
+ app = new Hono();
131
+ const api = new Hono();
132
+ registerLinearConnectionRoutes(api);
133
+ app.route("/api", api);
134
+ });
135
+
136
+ afterEach(() => {
137
+ globalThis.fetch = originalFetch;
138
+ });
139
+
140
+ // =============================================================================
141
+ // GET /api/linear/connections
142
+ // =============================================================================
143
+
144
+ describe("GET /api/linear/connections", () => {
145
+ it("returns an empty array when no connections exist", async () => {
146
+ // Validates that listing connections with an empty store returns { connections: [] }
147
+ mockListConnections.mockReturnValue([]);
148
+
149
+ const res = await app.request("/api/linear/connections");
150
+ expect(res.status).toBe(200);
151
+ const json = await res.json();
152
+ expect(json.connections).toEqual([]);
153
+ });
154
+
155
+ it("returns connections with API keys masked, showing only last 4 chars", async () => {
156
+ // Validates that the maskApiKey helper correctly hides all but the last 4 characters
157
+ const conn = makeConnection({ apiKey: "lin_api_supersecretkey1234" });
158
+ mockListConnections.mockReturnValue([conn]);
159
+
160
+ const res = await app.request("/api/linear/connections");
161
+ expect(res.status).toBe(200);
162
+ const json = await res.json();
163
+
164
+ expect(json.connections).toHaveLength(1);
165
+ expect(json.connections[0].apiKeyLast4).toBe("****1234");
166
+ // The raw apiKey should NOT appear in the response
167
+ expect(json.connections[0].apiKey).toBeUndefined();
168
+ });
169
+
170
+ it("masks short API keys (4 chars or fewer) as '****'", async () => {
171
+ // Validates the edge case in maskApiKey where key.length <= 4 returns "****"
172
+ const conn = makeConnection({ apiKey: "abcd" });
173
+ mockListConnections.mockReturnValue([conn]);
174
+
175
+ const res = await app.request("/api/linear/connections");
176
+ expect(res.status).toBe(200);
177
+ const json = await res.json();
178
+
179
+ expect(json.connections[0].apiKeyLast4).toBe("****");
180
+ });
181
+
182
+ it("masks very short API keys (fewer than 4 chars) as '****'", async () => {
183
+ // Validates the edge case in maskApiKey where key.length < 4 returns "****"
184
+ const conn = makeConnection({ apiKey: "ab" });
185
+ mockListConnections.mockReturnValue([conn]);
186
+
187
+ const res = await app.request("/api/linear/connections");
188
+ expect(res.status).toBe(200);
189
+ const json = await res.json();
190
+
191
+ expect(json.connections[0].apiKeyLast4).toBe("****");
192
+ });
193
+
194
+ it("returns all connection fields (except raw apiKey) for multiple connections", async () => {
195
+ // Validates that all mapped fields are correctly returned for each connection
196
+ const conns = [
197
+ makeConnection({
198
+ id: "conn-1",
199
+ name: "Workspace A",
200
+ apiKey: "lin_api_aaaabbbb",
201
+ workspaceName: "OrgA",
202
+ workspaceId: "org-a",
203
+ viewerName: "Alice",
204
+ viewerEmail: "alice@example.com",
205
+ connected: true,
206
+ autoTransition: true,
207
+ autoTransitionStateId: "state-1",
208
+ autoTransitionStateName: "In Progress",
209
+ archiveTransition: true,
210
+ archiveTransitionStateId: "state-2",
211
+ archiveTransitionStateName: "Done",
212
+ }),
213
+ makeConnection({
214
+ id: "conn-2",
215
+ name: "Workspace B",
216
+ apiKey: "lin_api_ccccdddd",
217
+ connected: false,
218
+ }),
219
+ ];
220
+ mockListConnections.mockReturnValue(conns);
221
+
222
+ const res = await app.request("/api/linear/connections");
223
+ expect(res.status).toBe(200);
224
+ const json = await res.json();
225
+
226
+ expect(json.connections).toHaveLength(2);
227
+
228
+ // First connection: verify all fields
229
+ const c1 = json.connections[0];
230
+ expect(c1.id).toBe("conn-1");
231
+ expect(c1.name).toBe("Workspace A");
232
+ expect(c1.apiKeyLast4).toBe("****bbbb");
233
+ expect(c1.workspaceName).toBe("OrgA");
234
+ expect(c1.workspaceId).toBe("org-a");
235
+ expect(c1.viewerName).toBe("Alice");
236
+ expect(c1.viewerEmail).toBe("alice@example.com");
237
+ expect(c1.connected).toBe(true);
238
+ expect(c1.autoTransition).toBe(true);
239
+ expect(c1.autoTransitionStateId).toBe("state-1");
240
+ expect(c1.autoTransitionStateName).toBe("In Progress");
241
+ expect(c1.archiveTransition).toBe(true);
242
+ expect(c1.archiveTransitionStateId).toBe("state-2");
243
+ expect(c1.archiveTransitionStateName).toBe("Done");
244
+
245
+ // Second connection: spot check
246
+ const c2 = json.connections[1];
247
+ expect(c2.id).toBe("conn-2");
248
+ expect(c2.connected).toBe(false);
249
+ });
250
+ });
251
+
252
+ // =============================================================================
253
+ // POST /api/linear/connections
254
+ // =============================================================================
255
+
256
+ describe("POST /api/linear/connections", () => {
257
+ it("returns 400 when name is missing", async () => {
258
+ // Validates that the route rejects requests without a name field
259
+ const res = await app.request("/api/linear/connections", {
260
+ method: "POST",
261
+ headers: { "Content-Type": "application/json" },
262
+ body: JSON.stringify({ apiKey: "lin_api_somekey1234" }),
263
+ });
264
+ expect(res.status).toBe(400);
265
+ const json = await res.json();
266
+ expect(json.error).toBe("name is required");
267
+ });
268
+
269
+ it("returns 400 when name is empty string", async () => {
270
+ // Validates that whitespace-only names are treated as empty
271
+ const res = await app.request("/api/linear/connections", {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify({ name: " ", apiKey: "lin_api_somekey1234" }),
275
+ });
276
+ expect(res.status).toBe(400);
277
+ const json = await res.json();
278
+ expect(json.error).toBe("name is required");
279
+ });
280
+
281
+ it("returns 400 when apiKey is missing", async () => {
282
+ // Validates that the route rejects requests without an apiKey field
283
+ const res = await app.request("/api/linear/connections", {
284
+ method: "POST",
285
+ headers: { "Content-Type": "application/json" },
286
+ body: JSON.stringify({ name: "My Connection" }),
287
+ });
288
+ expect(res.status).toBe(400);
289
+ const json = await res.json();
290
+ expect(json.error).toBe("apiKey is required");
291
+ });
292
+
293
+ it("returns 400 when apiKey is empty string", async () => {
294
+ // Validates that whitespace-only API keys are treated as empty
295
+ const res = await app.request("/api/linear/connections", {
296
+ method: "POST",
297
+ headers: { "Content-Type": "application/json" },
298
+ body: JSON.stringify({ name: "My Connection", apiKey: " " }),
299
+ });
300
+ expect(res.status).toBe(400);
301
+ const json = await res.json();
302
+ expect(json.error).toBe("apiKey is required");
303
+ });
304
+
305
+ it("creates a connection and verifies the API key successfully (201)", async () => {
306
+ // Validates the happy path: API key is verified, connection is created with workspace info
307
+ const fetchMock = mockFetch();
308
+ fetchMock.mockResolvedValue(
309
+ linearVerifyOk(
310
+ { id: "user-1", name: "Test User", email: "test@example.com" },
311
+ { id: "org-1", name: "TestOrg" },
312
+ ),
313
+ );
314
+
315
+ const createdConn = makeConnection({
316
+ id: "new-conn-id",
317
+ name: "New Workspace",
318
+ apiKey: "lin_api_newkey1234",
319
+ connected: false,
320
+ });
321
+ mockCreateConnection.mockReturnValue(createdConn);
322
+
323
+ // After updateConnection is called with verified info, getConnection returns the updated version
324
+ const updatedConn = makeConnection({
325
+ id: "new-conn-id",
326
+ name: "New Workspace",
327
+ apiKey: "lin_api_newkey1234",
328
+ connected: true,
329
+ workspaceName: "TestOrg",
330
+ workspaceId: "org-1",
331
+ viewerName: "Test User",
332
+ viewerEmail: "test@example.com",
333
+ });
334
+ mockGetConnection.mockReturnValue(updatedConn);
335
+
336
+ const res = await app.request("/api/linear/connections", {
337
+ method: "POST",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({ name: "New Workspace", apiKey: "lin_api_newkey1234" }),
340
+ });
341
+
342
+ expect(res.status).toBe(201);
343
+ const json = await res.json();
344
+ expect(json.verified).toBe(true);
345
+ expect(json.error).toBeUndefined();
346
+ expect(json.connection.id).toBe("new-conn-id");
347
+ expect(json.connection.name).toBe("New Workspace");
348
+ expect(json.connection.apiKeyLast4).toBe("****1234");
349
+ expect(json.connection.workspaceName).toBe("TestOrg");
350
+ expect(json.connection.connected).toBe(true);
351
+
352
+ // Verify that updateConnection was called with workspace info
353
+ expect(mockUpdateConnection).toHaveBeenCalledWith("new-conn-id", {
354
+ connected: true,
355
+ workspaceName: "TestOrg",
356
+ workspaceId: "org-1",
357
+ viewerName: "Test User",
358
+ viewerEmail: "test@example.com",
359
+ });
360
+ });
361
+
362
+ it("does not create a connection when verification fails (422)", async () => {
363
+ // Validates that no connection is persisted when the API key verification fails
364
+ const fetchMock = mockFetch();
365
+ fetchMock.mockResolvedValue(linearError("Authentication failed"));
366
+
367
+ const res = await app.request("/api/linear/connections", {
368
+ method: "POST",
369
+ headers: { "Content-Type": "application/json" },
370
+ body: JSON.stringify({ name: "Bad Key Workspace", apiKey: "lin_api_badkey1234" }),
371
+ });
372
+
373
+ expect(res.status).toBe(422);
374
+ const json = await res.json();
375
+ expect(json.verified).toBe(false);
376
+ expect(json.error).toBe("Authentication failed");
377
+ expect(json.connection).toBeNull();
378
+
379
+ // createConnection should NOT have been called because verification failed
380
+ expect(mockCreateConnection).not.toHaveBeenCalled();
381
+ expect(mockUpdateConnection).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it("does not create a connection on non-ok HTTP response (422)", async () => {
385
+ // Validates that a non-200 HTTP response from Linear results in no connection being created
386
+ const fetchMock = mockFetch();
387
+ fetchMock.mockResolvedValue(linearHttpError("Unauthorized", 401));
388
+
389
+ const res = await app.request("/api/linear/connections", {
390
+ method: "POST",
391
+ headers: { "Content-Type": "application/json" },
392
+ body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
393
+ });
394
+
395
+ expect(res.status).toBe(422);
396
+ const json = await res.json();
397
+ expect(json.verified).toBe(false);
398
+ expect(json.error).toBe("Unauthorized");
399
+ expect(json.connection).toBeNull();
400
+ expect(mockCreateConnection).not.toHaveBeenCalled();
401
+ });
402
+
403
+ it("does not create a connection on network error (422)", async () => {
404
+ // Validates that a network error (fetch throws) results in no connection being created
405
+ const fetchMock = mockFetch();
406
+ fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
407
+
408
+ const res = await app.request("/api/linear/connections", {
409
+ method: "POST",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
412
+ });
413
+
414
+ expect(res.status).toBe(422);
415
+ const json = await res.json();
416
+ expect(json.verified).toBe(false);
417
+ expect(json.error).toBe("ECONNREFUSED");
418
+ expect(json.connection).toBeNull();
419
+ expect(mockCreateConnection).not.toHaveBeenCalled();
420
+ });
421
+
422
+ it("handles malformed JSON body gracefully", async () => {
423
+ // Validates that the route handles non-JSON body without crashing
424
+ const res = await app.request("/api/linear/connections", {
425
+ method: "POST",
426
+ headers: { "Content-Type": "application/json" },
427
+ body: "not-valid-json",
428
+ });
429
+ // Body parse fails silently (returns {}), so name is missing
430
+ expect(res.status).toBe(400);
431
+ const json = await res.json();
432
+ expect(json.error).toBe("name is required");
433
+ });
434
+
435
+ it("does not create a connection on non-Error throw during verification", async () => {
436
+ // Validates the catch block handles non-Error thrown values without creating a connection
437
+ const fetchMock = mockFetch();
438
+ fetchMock.mockRejectedValue("string error");
439
+
440
+ const res = await app.request("/api/linear/connections", {
441
+ method: "POST",
442
+ headers: { "Content-Type": "application/json" },
443
+ body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
444
+ });
445
+
446
+ expect(res.status).toBe(422);
447
+ const json = await res.json();
448
+ expect(json.verified).toBe(false);
449
+ expect(json.connection).toBeNull();
450
+ expect(json.error).toBe("Verification failed");
451
+ expect(mockCreateConnection).not.toHaveBeenCalled();
452
+ });
453
+
454
+ it("handles verification response with null viewer/organization fields", async () => {
455
+ // Validates that null viewer and organization fields default to empty strings
456
+ const fetchMock = mockFetch();
457
+ fetchMock.mockResolvedValue(
458
+ new Response(
459
+ JSON.stringify({
460
+ data: {
461
+ viewer: null,
462
+ organization: null,
463
+ },
464
+ }),
465
+ { status: 200, headers: { "Content-Type": "application/json" } },
466
+ ),
467
+ );
468
+
469
+ const createdConn = makeConnection({ id: "new-conn-id", connected: false });
470
+ mockCreateConnection.mockReturnValue(createdConn);
471
+
472
+ const updatedConn = makeConnection({ id: "new-conn-id", connected: true });
473
+ mockGetConnection.mockReturnValue(updatedConn);
474
+
475
+ const res = await app.request("/api/linear/connections", {
476
+ method: "POST",
477
+ headers: { "Content-Type": "application/json" },
478
+ body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
479
+ });
480
+
481
+ expect(res.status).toBe(201);
482
+ const json = await res.json();
483
+ expect(json.verified).toBe(true);
484
+
485
+ // Should update with empty strings for null fields
486
+ expect(mockUpdateConnection).toHaveBeenCalledWith("new-conn-id", {
487
+ connected: true,
488
+ workspaceName: "",
489
+ workspaceId: "",
490
+ viewerName: "",
491
+ viewerEmail: "",
492
+ });
493
+ });
494
+
495
+ it("handles verification response where json parsing fails (returns {})", async () => {
496
+ // Validates that if response.json() fails (e.g., invalid JSON body from Linear),
497
+ // the route handles it gracefully by treating it as a verification failure
498
+ const fetchMock = mockFetch();
499
+ fetchMock.mockResolvedValue(
500
+ new Response("not json at all", {
501
+ status: 200,
502
+ headers: { "Content-Type": "application/json" },
503
+ }),
504
+ );
505
+
506
+ const createdConn = makeConnection({ id: "new-conn-id", connected: false });
507
+ mockCreateConnection.mockReturnValue(createdConn);
508
+
509
+ const updatedConn = makeConnection({ id: "new-conn-id", connected: true });
510
+ mockGetConnection.mockReturnValue(updatedConn);
511
+
512
+ const res = await app.request("/api/linear/connections", {
513
+ method: "POST",
514
+ headers: { "Content-Type": "application/json" },
515
+ body: JSON.stringify({ name: "Workspace", apiKey: "lin_api_key12345" }),
516
+ });
517
+
518
+ expect(res.status).toBe(201);
519
+ const json = await res.json();
520
+ // response.ok is true, but json parses to {} so no errors array
521
+ // This means verification succeeds with empty strings for fields
522
+ expect(json.verified).toBe(true);
523
+ });
524
+ });
525
+
526
+ // =============================================================================
527
+ // PUT /api/linear/connections/:id
528
+ // =============================================================================
529
+
530
+ describe("PUT /api/linear/connections/:id", () => {
531
+ it("returns 404 when connection is not found", async () => {
532
+ // Validates that updating a nonexistent connection returns 404
533
+ mockGetConnection.mockReturnValue(null);
534
+
535
+ const res = await app.request("/api/linear/connections/nonexistent-id", {
536
+ method: "PUT",
537
+ headers: { "Content-Type": "application/json" },
538
+ body: JSON.stringify({ name: "Updated" }),
539
+ });
540
+ expect(res.status).toBe(404);
541
+ const json = await res.json();
542
+ expect(json.error).toBe("Connection not found");
543
+ });
544
+
545
+ it("updates connection name successfully", async () => {
546
+ // Validates that updating just the name field works and returns the updated connection
547
+ const existing = makeConnection({ id: "conn-1" });
548
+ mockGetConnection.mockReturnValue(existing);
549
+
550
+ const updated = makeConnection({ id: "conn-1", name: "Updated Name" });
551
+ mockUpdateConnection.mockReturnValue(updated);
552
+
553
+ const res = await app.request("/api/linear/connections/conn-1", {
554
+ method: "PUT",
555
+ headers: { "Content-Type": "application/json" },
556
+ body: JSON.stringify({ name: "Updated Name" }),
557
+ });
558
+
559
+ expect(res.status).toBe(200);
560
+ const json = await res.json();
561
+ expect(json.connection.name).toBe("Updated Name");
562
+ expect(json.connection.apiKeyLast4).toBe("****1234");
563
+
564
+ // Cache should be invalidated for this connection
565
+ expect(vi.mocked(linearCache.invalidate)).toHaveBeenCalledWith("conn-1:");
566
+ });
567
+
568
+ it("updates apiKey and sets connected to false", async () => {
569
+ // Validates that changing the API key marks the connection as needing re-verification
570
+ const existing = makeConnection({ id: "conn-1", connected: true });
571
+ mockGetConnection.mockReturnValue(existing);
572
+
573
+ const updated = makeConnection({
574
+ id: "conn-1",
575
+ apiKey: "lin_api_newkey5678",
576
+ connected: false,
577
+ });
578
+ mockUpdateConnection.mockReturnValue(updated);
579
+
580
+ const res = await app.request("/api/linear/connections/conn-1", {
581
+ method: "PUT",
582
+ headers: { "Content-Type": "application/json" },
583
+ body: JSON.stringify({ apiKey: "lin_api_newkey5678" }),
584
+ });
585
+
586
+ expect(res.status).toBe(200);
587
+ const json = await res.json();
588
+ expect(json.connection.connected).toBe(false);
589
+ expect(json.connection.apiKeyLast4).toBe("****5678");
590
+
591
+ // Verify the patch includes connected: false when apiKey changes
592
+ expect(mockUpdateConnection).toHaveBeenCalledWith(
593
+ "conn-1",
594
+ expect.objectContaining({
595
+ apiKey: "lin_api_newkey5678",
596
+ connected: false,
597
+ }),
598
+ );
599
+ });
600
+
601
+ it("ignores empty apiKey (whitespace only)", async () => {
602
+ // Validates that an empty/whitespace apiKey is not included in the patch
603
+ const existing = makeConnection({ id: "conn-1" });
604
+ mockGetConnection.mockReturnValue(existing);
605
+
606
+ const updated = makeConnection({ id: "conn-1", name: "Same" });
607
+ mockUpdateConnection.mockReturnValue(updated);
608
+
609
+ const res = await app.request("/api/linear/connections/conn-1", {
610
+ method: "PUT",
611
+ headers: { "Content-Type": "application/json" },
612
+ body: JSON.stringify({ name: "Same", apiKey: " " }),
613
+ });
614
+
615
+ expect(res.status).toBe(200);
616
+ // The patch should contain name but NOT apiKey since it was whitespace
617
+ expect(mockUpdateConnection).toHaveBeenCalledWith(
618
+ "conn-1",
619
+ expect.not.objectContaining({ apiKey: expect.anything() }),
620
+ );
621
+ });
622
+
623
+ it("updates autoTransition and archiveTransition boolean fields", async () => {
624
+ // Validates that boolean fields like autoTransition and archiveTransition are accepted
625
+ const existing = makeConnection({ id: "conn-1" });
626
+ mockGetConnection.mockReturnValue(existing);
627
+
628
+ const updated = makeConnection({
629
+ id: "conn-1",
630
+ autoTransition: true,
631
+ autoTransitionStateId: "state-1",
632
+ autoTransitionStateName: "In Progress",
633
+ archiveTransition: true,
634
+ archiveTransitionStateId: "state-2",
635
+ archiveTransitionStateName: "Done",
636
+ });
637
+ mockUpdateConnection.mockReturnValue(updated);
638
+
639
+ const res = await app.request("/api/linear/connections/conn-1", {
640
+ method: "PUT",
641
+ headers: { "Content-Type": "application/json" },
642
+ body: JSON.stringify({
643
+ autoTransition: true,
644
+ autoTransitionStateId: "state-1",
645
+ autoTransitionStateName: "In Progress",
646
+ archiveTransition: true,
647
+ archiveTransitionStateId: "state-2",
648
+ archiveTransitionStateName: "Done",
649
+ }),
650
+ });
651
+
652
+ expect(res.status).toBe(200);
653
+ const json = await res.json();
654
+ expect(json.connection.autoTransition).toBe(true);
655
+ expect(json.connection.autoTransitionStateName).toBe("In Progress");
656
+ expect(json.connection.archiveTransition).toBe(true);
657
+ expect(json.connection.archiveTransitionStateName).toBe("Done");
658
+
659
+ expect(mockUpdateConnection).toHaveBeenCalledWith(
660
+ "conn-1",
661
+ expect.objectContaining({
662
+ autoTransition: true,
663
+ autoTransitionStateId: "state-1",
664
+ autoTransitionStateName: "In Progress",
665
+ archiveTransition: true,
666
+ archiveTransitionStateId: "state-2",
667
+ archiveTransitionStateName: "Done",
668
+ }),
669
+ );
670
+ });
671
+
672
+ it("returns 500 when updateConnection returns null (update failed)", async () => {
673
+ // Validates the error path when updateConnection returns null (unexpected failure)
674
+ const existing = makeConnection({ id: "conn-1" });
675
+ mockGetConnection.mockReturnValue(existing);
676
+ mockUpdateConnection.mockReturnValue(null);
677
+
678
+ const res = await app.request("/api/linear/connections/conn-1", {
679
+ method: "PUT",
680
+ headers: { "Content-Type": "application/json" },
681
+ body: JSON.stringify({ name: "Updated" }),
682
+ });
683
+
684
+ expect(res.status).toBe(500);
685
+ const json = await res.json();
686
+ expect(json.error).toBe("Update failed");
687
+ });
688
+
689
+ it("handles malformed JSON body gracefully", async () => {
690
+ // Validates that the route handles non-JSON body without crashing
691
+ const existing = makeConnection({ id: "conn-1" });
692
+ mockGetConnection.mockReturnValue(existing);
693
+
694
+ // Empty patch should still work (no fields to update)
695
+ const updated = makeConnection({ id: "conn-1" });
696
+ mockUpdateConnection.mockReturnValue(updated);
697
+
698
+ const res = await app.request("/api/linear/connections/conn-1", {
699
+ method: "PUT",
700
+ headers: { "Content-Type": "application/json" },
701
+ body: "not-valid-json",
702
+ });
703
+
704
+ // Body parse fails silently to {}, so an empty patch is applied
705
+ expect(res.status).toBe(200);
706
+ });
707
+ });
708
+
709
+ // =============================================================================
710
+ // DELETE /api/linear/connections/:id
711
+ // =============================================================================
712
+
713
+ describe("DELETE /api/linear/connections/:id", () => {
714
+ it("returns 404 when connection is not found", async () => {
715
+ // Validates that deleting a nonexistent connection returns 404
716
+ mockDeleteConnection.mockReturnValue(false);
717
+
718
+ const res = await app.request("/api/linear/connections/nonexistent-id", {
719
+ method: "DELETE",
720
+ });
721
+ expect(res.status).toBe(404);
722
+ const json = await res.json();
723
+ expect(json.error).toBe("Connection not found");
724
+ });
725
+
726
+ it("deletes a connection successfully and invalidates cache", async () => {
727
+ // Validates the happy path: connection is deleted and cache is invalidated
728
+ mockDeleteConnection.mockReturnValue(true);
729
+
730
+ const res = await app.request("/api/linear/connections/conn-1", {
731
+ method: "DELETE",
732
+ });
733
+ expect(res.status).toBe(200);
734
+ const json = await res.json();
735
+ expect(json.ok).toBe(true);
736
+
737
+ // deleteConnection should have been called with the connection ID
738
+ expect(mockDeleteConnection).toHaveBeenCalledWith("conn-1");
739
+
740
+ // Cache should be invalidated for this connection's prefix
741
+ expect(vi.mocked(linearCache.invalidate)).toHaveBeenCalledWith("conn-1:");
742
+ });
743
+ });
744
+
745
+ // =============================================================================
746
+ // POST /api/linear/connections/:id/verify
747
+ // =============================================================================
748
+
749
+ describe("POST /api/linear/connections/:id/verify", () => {
750
+ it("returns 404 when connection is not found", async () => {
751
+ // Validates that verifying a nonexistent connection returns 404
752
+ mockGetConnection.mockReturnValue(null);
753
+
754
+ const res = await app.request("/api/linear/connections/nonexistent-id/verify", {
755
+ method: "POST",
756
+ });
757
+ expect(res.status).toBe(404);
758
+ const json = await res.json();
759
+ expect(json.error).toBe("Connection not found");
760
+ });
761
+
762
+ it("re-verifies a connection successfully", async () => {
763
+ // Validates the happy path: the connection's stored API key is used for verification
764
+ // and the connection is updated with workspace info
765
+ const conn = makeConnection({
766
+ id: "conn-1",
767
+ apiKey: "lin_api_existingkey1234",
768
+ connected: false,
769
+ workspaceName: "",
770
+ viewerName: "",
771
+ });
772
+ mockGetConnection
773
+ .mockReturnValueOnce(conn) // first call: find existing connection
774
+ .mockReturnValueOnce(
775
+ makeConnection({
776
+ id: "conn-1",
777
+ apiKey: "lin_api_existingkey1234",
778
+ connected: true,
779
+ workspaceName: "Verified Org",
780
+ workspaceId: "org-v",
781
+ viewerName: "Verified User",
782
+ viewerEmail: "verified@example.com",
783
+ }),
784
+ ); // second call: return updated connection after updateConnection
785
+
786
+ const fetchMock = mockFetch();
787
+ fetchMock.mockResolvedValue(
788
+ linearVerifyOk(
789
+ { id: "user-v", name: "Verified User", email: "verified@example.com" },
790
+ { id: "org-v", name: "Verified Org" },
791
+ ),
792
+ );
793
+
794
+ const res = await app.request("/api/linear/connections/conn-1/verify", {
795
+ method: "POST",
796
+ });
797
+
798
+ expect(res.status).toBe(200);
799
+ const json = await res.json();
800
+ expect(json.verified).toBe(true);
801
+ expect(json.error).toBeUndefined();
802
+ expect(json.connection.id).toBe("conn-1");
803
+ expect(json.connection.connected).toBe(true);
804
+ expect(json.connection.workspaceName).toBe("Verified Org");
805
+ expect(json.connection.viewerName).toBe("Verified User");
806
+ expect(json.connection.viewerEmail).toBe("verified@example.com");
807
+ expect(json.connection.apiKeyLast4).toBe("****1234");
808
+
809
+ // updateConnection should be called with connected: true and workspace info
810
+ expect(mockUpdateConnection).toHaveBeenCalledWith("conn-1", {
811
+ connected: true,
812
+ workspaceName: "Verified Org",
813
+ workspaceId: "org-v",
814
+ viewerName: "Verified User",
815
+ viewerEmail: "verified@example.com",
816
+ });
817
+ });
818
+
819
+ it("marks connection as disconnected when verification fails", async () => {
820
+ // Validates that a failed verification sets connected to false but preserves
821
+ // existing workspace info
822
+ const existingConn = makeConnection({
823
+ id: "conn-1",
824
+ apiKey: "lin_api_badkey1234",
825
+ connected: true,
826
+ workspaceName: "OldOrg",
827
+ workspaceId: "org-old",
828
+ viewerName: "OldUser",
829
+ viewerEmail: "old@example.com",
830
+ });
831
+ mockGetConnection
832
+ .mockReturnValueOnce(existingConn) // first call
833
+ .mockReturnValueOnce(
834
+ makeConnection({
835
+ ...existingConn,
836
+ connected: false,
837
+ }),
838
+ ); // second call after updateConnection
839
+
840
+ const fetchMock = mockFetch();
841
+ fetchMock.mockResolvedValue(linearError("Invalid token"));
842
+
843
+ const res = await app.request("/api/linear/connections/conn-1/verify", {
844
+ method: "POST",
845
+ });
846
+
847
+ expect(res.status).toBe(200);
848
+ const json = await res.json();
849
+ expect(json.verified).toBe(false);
850
+ expect(json.error).toBe("Invalid token");
851
+ expect(json.connection.connected).toBe(false);
852
+
853
+ // When verification fails, updateConnection should preserve existing workspace info
854
+ expect(mockUpdateConnection).toHaveBeenCalledWith("conn-1", {
855
+ connected: false,
856
+ workspaceName: "OldOrg",
857
+ workspaceId: "org-old",
858
+ viewerName: "OldUser",
859
+ viewerEmail: "old@example.com",
860
+ });
861
+ });
862
+
863
+ it("handles network error during verification", async () => {
864
+ // Validates that a network error during verification (fetch throws)
865
+ // results in the connection being marked as disconnected
866
+ const existingConn = makeConnection({
867
+ id: "conn-1",
868
+ apiKey: "lin_api_key12345678",
869
+ connected: true,
870
+ workspaceName: "PrevOrg",
871
+ workspaceId: "org-prev",
872
+ viewerName: "PrevUser",
873
+ viewerEmail: "prev@example.com",
874
+ });
875
+ mockGetConnection
876
+ .mockReturnValueOnce(existingConn)
877
+ .mockReturnValueOnce(makeConnection({ ...existingConn, connected: false }));
878
+
879
+ const fetchMock = mockFetch();
880
+ fetchMock.mockRejectedValue(new Error("DNS resolution failed"));
881
+
882
+ const res = await app.request("/api/linear/connections/conn-1/verify", {
883
+ method: "POST",
884
+ });
885
+
886
+ expect(res.status).toBe(200);
887
+ const json = await res.json();
888
+ expect(json.verified).toBe(false);
889
+ expect(json.error).toBe("DNS resolution failed");
890
+
891
+ // Should mark as disconnected but keep existing workspace info
892
+ expect(mockUpdateConnection).toHaveBeenCalledWith("conn-1", {
893
+ connected: false,
894
+ workspaceName: "PrevOrg",
895
+ workspaceId: "org-prev",
896
+ viewerName: "PrevUser",
897
+ viewerEmail: "prev@example.com",
898
+ });
899
+ });
900
+
901
+ it("handles HTTP error response during verification", async () => {
902
+ // Validates that a non-ok HTTP status from Linear results in failed verification
903
+ const existingConn = makeConnection({
904
+ id: "conn-1",
905
+ apiKey: "lin_api_key12345678",
906
+ workspaceName: "Existing",
907
+ workspaceId: "org-e",
908
+ viewerName: "ExUser",
909
+ viewerEmail: "ex@example.com",
910
+ });
911
+ mockGetConnection
912
+ .mockReturnValueOnce(existingConn)
913
+ .mockReturnValueOnce(makeConnection({ ...existingConn, connected: false }));
914
+
915
+ const fetchMock = mockFetch();
916
+ fetchMock.mockResolvedValue(linearHttpError("Service Unavailable", 503));
917
+
918
+ const res = await app.request("/api/linear/connections/conn-1/verify", {
919
+ method: "POST",
920
+ });
921
+
922
+ expect(res.status).toBe(200);
923
+ const json = await res.json();
924
+ expect(json.verified).toBe(false);
925
+ expect(json.error).toBe("Service Unavailable");
926
+ });
927
+ });